初始化 Panda RPKI v0.1.0 开源候选版本
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled

This commit is contained in:
Panda RPKI OSS Local
2026-09-09 18:01:15 +08:00
commit 277cbca878
203 changed files with 92495 additions and 0 deletions

9
tests/daemon_e2e.rs Normal file
View File

@@ -0,0 +1,9 @@
#[test]
fn real_daemon_lifecycle() {
let status = std::process::Command::new("python3")
.arg("tests/support/test_daemon.py")
.arg(env!("CARGO_BIN_EXE_panda-rpki"))
.status()
.expect("start daemon integration driver");
assert!(status.success(), "real daemon integration failed: {status}");
}

13
tests/support/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Synthetic RPKI test data
`generate_repository.py` creates a fresh RFC-shaped RPKI hierarchy with new
keys for each test process. It provides TAL/TA bootstrap files, CA/CRL,
manifest, ROA, ASPA, RRDP snapshot and delta documents, and a locally issued
HTTPS certificate. `serve_repository.py` is the deliberately minimal HTTPS
server used by the deterministic runtime and Docker tests.
No RIR production TAL, certificate, publication-point object, cache, or live
network request is included in this directory. The generator requires Python
`cryptography`, OpenSSL's `cms` command, and Python 3; CI installs those test
tools explicitly. Test output is created under a temporary directory and is
not part of a release artifact.

View File

@@ -0,0 +1,717 @@
#!/usr/bin/env python3
"""Generate synthetic RFC RPKI fixtures using cryptography and OpenSSL.
Outputs use new test keys and documentation resources; no downloaded RIR objects.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import ipaddress
import shutil
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
from cryptography.x509 import AccessDescription, AuthorityInformationAccess, DistributionPoint, UniformResourceIdentifier
from cryptography.x509.oid import AuthorityInformationAccessOID, NameOID, ObjectIdentifier
RRDP_HOST = "rpki-local-rrdp"
FOREIGN_RRDP_HOST = "rpki-local-foreign"
RRDP_PORT = 8443
RSYNC_HOST = "rpki-local-rsync"
RSYNC_PORT = 873
TAL_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/ta.cer"
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
ROOT_REPO_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/root/"
ROOT_MFT_URI = f"{ROOT_REPO_URI}root.mft"
CHILD_REPO_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/child/"
CHILD_MFT_URI = f"{CHILD_REPO_URI}child.mft"
OID_SIA = ObjectIdentifier("1.3.6.1.5.5.7.1.11")
OID_RESOURCES_IP = ObjectIdentifier("1.3.6.1.5.5.7.1.7")
OID_RESOURCES_AS = ObjectIdentifier("1.3.6.1.5.5.7.1.8")
OID_POLICY_IP_AS = ObjectIdentifier("1.3.6.1.5.5.7.14.2")
OID_ROA_ECONTENT = "1.2.840.113549.1.9.16.1.24"
OID_MANIFEST_ECONTENT = "1.2.840.113549.1.9.16.1.26"
OID_ASPA_ECONTENT = "1.2.840.113549.1.9.16.1.49"
OID_SHA256 = "2.16.840.1.101.3.4.2.1"
SIA_CA_REPOSITORY = "1.3.6.1.5.5.7.48.5"
SIA_RPKI_MANIFEST = "1.3.6.1.5.5.7.48.10"
SIA_SIGNED_OBJECT = "1.3.6.1.5.5.7.48.11"
SIA_RPKI_NOTIFY = "1.3.6.1.5.5.7.48.13"
UTC = timezone.utc
VALID_FROM = datetime(2026, 1, 1, tzinfo=UTC)
VALID_TO = datetime(2027, 12, 31, 23, 59, 59, tzinfo=UTC)
CHILD_PREFIX = ipaddress.ip_network("203.0.113.0/24")
SECOND_PREFIX = ipaddress.ip_network("203.0.113.128/25")
OUTSIDE_PREFIX = ipaddress.ip_network("203.0.114.0/24")
TEST_ASN = 64496
SESSION_ID = "11111111-2222-4333-8444-555555555555"
def tlv(tag: int, value: bytes) -> bytes:
length = len(value)
if length < 128:
encoded_length = bytes([length])
else:
raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
encoded_length = bytes([0x80 | len(raw)]) + raw
return bytes([tag]) + encoded_length + value
def seq(*values: bytes) -> bytes:
return tlv(0x30, b"".join(values))
def integer(value: int) -> bytes:
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return tlv(0x02, raw)
def oid(oid_value: str) -> bytes:
parts = [int(part) for part in oid_value.split(".")]
encoded = bytearray([40 * parts[0] + parts[1]])
for part in parts[2:]:
chunks = [part & 0x7F]
part >>= 7
while part:
chunks.append(0x80 | (part & 0x7F))
part >>= 7
encoded.extend(reversed(chunks))
return tlv(0x06, bytes(encoded))
def octet(value: bytes) -> bytes:
return tlv(0x04, value)
def uri(value: str) -> bytes:
return tlv(0x86, value.encode("ascii"))
def bit_string(value: bytes, unused: int = 0) -> bytes:
return tlv(0x03, bytes([unused]) + value)
def generalized_time(value: datetime) -> bytes:
return tlv(0x18, value.astimezone(UTC).strftime("%Y%m%d%H%M%SZ").encode("ascii"))
def context_zero(value: bytes) -> bytes:
return tlv(0xA0, value)
def ip_choice(networks: list[ipaddress._BaseNetwork]) -> bytes:
entries = []
for network in networks:
length = (network.prefixlen + 7) // 8
raw = network.network_address.packed[:length]
entries.append(bit_string(raw, length * 8 - network.prefixlen))
return seq(*entries)
def ip_resources(networks: list[ipaddress._BaseNetwork]) -> bytes:
families: dict[int, list[ipaddress._BaseNetwork]] = {1: [], 2: []}
for network in networks:
families[1 if network.version == 4 else 2].append(network)
return seq(
*(
seq(octet(afi.to_bytes(2, "big")), ip_choice(items))
for afi, items in families.items()
if items
)
)
def ip_inherit() -> bytes:
return seq(
seq(octet(b"\x00\x01"), tlv(0x05, b"")),
seq(octet(b"\x00\x02"), tlv(0x05, b"")),
)
def as_resources(asns: list[int] | None) -> bytes:
if asns is None:
return seq(context_zero(tlv(0x05, b"")))
return seq(context_zero(seq(*(integer(asn) for asn in asns))))
def sia(entries: list[tuple[str, str]]) -> bytes:
return seq(*(seq(oid(method), uri(location)) for method, location in entries))
def write(path: Path, data: bytes, mode: int | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
if mode is not None:
path.chmod(mode)
def pem_cert(cert: x509.Certificate) -> bytes:
return cert.public_bytes(Encoding.PEM)
def pem_key(key: rsa.RSAPrivateKey) -> bytes:
return key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())
def write_key(path: Path, key: rsa.RSAPrivateKey) -> None:
write(path, pem_key(key), 0o600)
def sign_cert(
subject: x509.Name,
issuer: x509.Name,
public_key,
signing_key: rsa.RSAPrivateKey,
serial: int,
is_ca: bool,
ski_key: rsa.RSAPrivateKey,
aki_key: rsa.RSAPrivateKey | None = None,
ip_ext: bytes | None = None,
as_ext: bytes | None = None,
ca_repository: str | None = None,
manifest_uri: str | None = None,
notify_uri: str | None = None,
signed_object_uri: str | None = None,
crl_uri: str | None = None,
ca_issuer_uri: str | None = None,
not_before: datetime = VALID_FROM,
not_after: datetime = VALID_TO,
) -> x509.Certificate:
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(public_key)
.serial_number(serial)
.not_valid_before(not_before)
.not_valid_after(not_after)
)
if is_ca:
builder = builder.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
if is_ca:
usage = x509.KeyUsage(False, False, False, False, False, True, True, False, False)
else:
usage = x509.KeyUsage(True, False, False, False, False, False, False, False, False)
builder = builder.add_extension(usage, critical=True)
builder = builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(ski_key.public_key()), critical=False)
if aki_key is not None:
builder = builder.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(aki_key.public_key()), critical=False)
builder = builder.add_extension(
x509.CertificatePolicies([x509.PolicyInformation(OID_POLICY_IP_AS, None)]),
critical=True,
)
if crl_uri is not None:
builder = builder.add_extension(
x509.CRLDistributionPoints(
[DistributionPoint([UniformResourceIdentifier(crl_uri)], None, None, None)]
),
critical=False,
)
if ca_issuer_uri is not None:
builder = builder.add_extension(
AuthorityInformationAccess(
[AccessDescription(AuthorityInformationAccessOID.CA_ISSUERS, UniformResourceIdentifier(ca_issuer_uri))]
),
critical=False,
)
sia_entries: list[tuple[str, str]] = []
if ca_repository is not None:
sia_entries.append((SIA_CA_REPOSITORY, ca_repository))
if manifest_uri is not None:
sia_entries.append((SIA_RPKI_MANIFEST, manifest_uri))
if notify_uri is not None:
sia_entries.append((SIA_RPKI_NOTIFY, notify_uri))
if signed_object_uri is not None:
sia_entries.append((SIA_SIGNED_OBJECT, signed_object_uri))
if sia_entries:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_SIA, sia(sia_entries)), critical=False)
if ip_ext is not None:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_RESOURCES_IP, ip_ext), critical=True)
if as_ext is not None:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_RESOURCES_AS, as_ext), critical=True)
return builder.sign(signing_key, hashes.SHA256())
def make_crl(subject: x509.Name, key: rsa.RSAPrivateKey, number: int) -> x509.CertificateRevocationList:
return (
x509.CertificateRevocationListBuilder()
.issuer_name(subject)
.last_update(VALID_FROM + timedelta(days=30))
.next_update(VALID_TO - timedelta(days=30))
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False)
.add_extension(x509.CRLNumber(number), critical=False)
.sign(key, hashes.SHA256())
)
def roa_content(prefix: ipaddress._BaseNetwork, max_length: int) -> bytes:
length = (prefix.prefixlen + 7) // 8
raw = prefix.network_address.packed[:length]
address = seq(bit_string(raw, length * 8 - prefix.prefixlen), integer(max_length))
family = seq(octet((1 if prefix.version == 4 else 2).to_bytes(2, "big")), seq(address))
return seq(integer(TEST_ASN), seq(family))
def aspa_content(customer_asn: int, providers: list[int]) -> bytes:
"""RFC 9237/ASPA-profile test payload with explicit version 1."""
return seq(context_zero(integer(1)), integer(customer_asn), seq(*(integer(provider) for provider in providers)))
def manifest_content(number: int, files: list[tuple[str, bytes]]) -> bytes:
entries = [
seq(tlv(0x16, name.encode("ascii")), bit_string(hashlib.sha256(content).digest()))
for name, content in files
]
return seq(
integer(number),
generalized_time(VALID_FROM + timedelta(days=30, seconds=number)),
generalized_time(VALID_TO - timedelta(days=30)),
oid(OID_SHA256),
seq(*entries),
)
def write_cms(content: bytes, content_type: str, signer_cert: x509.Certificate, signer_key: rsa.RSAPrivateKey, output: Path, work: Path) -> None:
content_path = work / f"{output.name}.content.der"
signer_path = work / f"{output.name}.signer.pem"
key_path = work / f"{output.name}.key.pem"
write(content_path, content)
write(signer_path, pem_cert(signer_cert))
write_key(key_path, signer_key)
subprocess.run(
[
"openssl", "cms", "-sign", "-binary", "-in", str(content_path),
"-signer", str(signer_path), "-inkey", str(key_path),
"-outform", "DER", "-nodetach", "-nosmimecap", "-keyid", "-md", "sha256",
"-econtent_type", content_type, "-out", str(output),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
def make_roa(
name: str,
prefix,
max_length: int,
not_before: datetime,
not_after: datetime,
child_name,
child_key,
common: Path,
work: Path,
certificate_prefix=None,
) -> None:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = sign_cert(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test EE {name}")]),
child_name,
key.public_key(),
child_key,
x509.random_serial_number(),
False,
key,
child_key,
ip_ext=ip_resources([certificate_prefix or prefix]),
signed_object_uri=f"{CHILD_REPO_URI}{name}",
crl_uri=f"{CHILD_REPO_URI}child.crl",
ca_issuer_uri=f"{CHILD_REPO_URI}child.cer",
not_before=not_before,
not_after=not_after,
)
write_cms(roa_content(prefix, max_length), OID_ROA_ECONTENT, cert, key, common / name, work)
def make_aspa(name: str, child_name, child_key, common: Path, work: Path) -> None:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = sign_cert(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test EE {name}")]),
child_name,
key.public_key(),
child_key,
x509.random_serial_number(),
False,
key,
child_key,
as_ext=as_resources([TEST_ASN]),
signed_object_uri=f"{CHILD_REPO_URI}{name}",
crl_uri=f"{CHILD_REPO_URI}child.crl",
ca_issuer_uri=f"{CHILD_REPO_URI}child.cer",
not_before=VALID_FROM + timedelta(days=31),
not_after=VALID_TO - timedelta(days=31),
)
write_cms(aspa_content(TEST_ASN, [64497, 64498]), OID_ASPA_ECONTENT, cert, key, common / name, work)
def make_manifest(name: str, number: int, files: list[tuple[str, bytes]], child_name, child_key, common: Path, work: Path) -> bytes:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = sign_cert(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test Manifest EE {name}")]),
child_name,
key.public_key(),
child_key,
x509.random_serial_number(),
False,
key,
child_key,
ip_ext=ip_inherit(),
as_ext=as_resources(None),
signed_object_uri=CHILD_MFT_URI if name != "root" else ROOT_MFT_URI,
crl_uri=f"{CHILD_REPO_URI}child.crl" if name != "root" else f"{ROOT_REPO_URI}root.crl",
ca_issuer_uri=f"{CHILD_REPO_URI}child.cer" if name != "root" else f"{ROOT_REPO_URI}ta.cer",
)
output = common / f"{name}.mft"
write_cms(manifest_content(number, files), OID_MANIFEST_ECONTENT, cert, key, output, work)
return output.read_bytes()
def publish(uri: str, path: Path) -> str:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f' <publish uri="{uri}">{encoded}</publish>\n'
def snapshot(case_root: Path, names: list[str], serial: int, extra_root_names: list[str] | None = None) -> bytes:
repository = case_root / "repository"
objects = [
(f"{ROOT_REPO_URI}root.crl", repository / "root/root.crl"),
(f"{ROOT_REPO_URI}child.cer", repository / "root/child.cer"),
*[
(f"{ROOT_REPO_URI}{name}", repository / f"root/{name}")
for name in extra_root_names or []
],
(f"{CHILD_REPO_URI}child.crl", repository / "child/child.crl"),
*[(f"{CHILD_REPO_URI}{name}", repository / f"child/{name}") for name in names],
(ROOT_MFT_URI, repository / "root/root.mft"),
(CHILD_MFT_URI, repository / "child/child.mft"),
]
body = "".join(publish(uri, path) for uri, path in objects)
return (
# RFC 8182 carries the protocol version on every RRDP XML document,
# not just on notification.xml. Keeping the fixture strict here
# prevents a snapshot-only path from hiding a parser regression.
f'<snapshot xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{SESSION_ID}" serial="{serial}">\n'
f"{body}</snapshot>\n"
).encode()
def notification(
serial: int,
snap: bytes,
delta: bytes | None,
snapshot_uri: str | None = None,
) -> bytes:
lines = [
f'<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{SESSION_ID}" serial="{serial}">',
f' <snapshot uri="{snapshot_uri or f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/snapshot.xml"}" hash="{hashlib.sha256(snap).hexdigest()}"/>',
]
if delta is not None:
lines.append(
f' <delta serial="{serial}" uri="https://{RRDP_HOST}:{RRDP_PORT}/rrdp/delta-{serial}.xml" hash="{hashlib.sha256(delta).hexdigest()}"/>'
)
return ("\n".join(lines) + "\n</notification>\n").encode()
def corrupt_first_hash(xml: bytes) -> bytes:
marker = b'hash="'
start = xml.index(marker) + len(marker)
end = xml.index(b'"', start)
value = bytearray(xml[start:end])
value[0] = ord("0") if value[0] != ord("0") else ord("1")
return xml[:start] + bytes(value) + xml[end:]
def delta(add_name: str, add_path: Path, new_mft: Path, old_mft: bytes) -> bytes:
body = publish(CHILD_MFT_URI, new_mft).replace('<publish uri=', f'<publish hash="{hashlib.sha256(old_mft).hexdigest()}" uri=')
body += publish(f"{CHILD_REPO_URI}{add_name}", add_path)
return f'<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{SESSION_ID}" serial="2">\n{body}</delta>\n'.encode()
def build_case(
output: Path,
common: Path,
tal: bytes,
ta: bytes,
name: str,
number: int,
names: list[str],
child_mft: bytes,
change: bytes | None = None,
root_mft: bytes | None = None,
extra_root_objects: list[tuple[str, bytes]] | None = None,
notification_hash_mismatch: bool = False,
snapshot_uri: str | None = None,
) -> None:
case_root = output / "cases" / name
repo = case_root / "repository"
(repo / "root").mkdir(parents=True, exist_ok=True)
(repo / "child").mkdir(parents=True, exist_ok=True)
shutil.copy2(common / "ta.cer", repo / "ta.cer")
for item in ("root.crl", "child.cer"):
shutil.copy2(common / item, repo / "root" / item)
(repo / "root/root.mft").write_bytes(root_mft if root_mft is not None else (common / "root.mft").read_bytes())
for item, content in extra_root_objects or []:
(repo / "root" / item).write_bytes(content)
for item in (
"child.crl", "valid.roa", "valid2.roa", "valid.asa", "expired.roa", "max-length.roa",
"max-length-invalid.roa", "roa-prefix-outside.roa", "out-of-resource.roa", "bad-format.roa",
):
if (common / item).exists():
shutil.copy2(common / item, repo / "child" / item)
(repo / "child/child.mft").write_bytes(child_mft)
snap = snapshot(case_root, names, number, [item for item, _ in extra_root_objects or []])
(case_root / "http/rrdp").mkdir(parents=True, exist_ok=True)
(case_root / "http/tal").mkdir(parents=True, exist_ok=True)
(case_root / "http/ta").mkdir(parents=True, exist_ok=True)
(case_root / "foreign-http/rrdp").mkdir(parents=True, exist_ok=True)
(case_root / "http/rrdp/snapshot.xml").write_bytes(snap)
# A complete copy is deliberately made available on the foreign service
# for the cross-origin case. A conformant RP must reject its URI before
# attempting this request; the service-side request log proves that.
(case_root / "foreign-http/rrdp/snapshot.xml").write_bytes(snap)
(case_root / "foreign-requests.log").write_text("", encoding="utf-8")
notification_xml = notification(number, snap, change, snapshot_uri=snapshot_uri)
if notification_hash_mismatch:
notification_xml = corrupt_first_hash(notification_xml)
(case_root / "http/rrdp/notification.xml").write_bytes(notification_xml)
if change is not None:
(case_root / "http/rrdp/delta-2.xml").write_bytes(change)
(case_root / "http/tal/custom.tal").write_bytes(tal)
(case_root / "http/ta/custom-ta.cer").write_bytes(ta)
(case_root / "CASE-MANIFEST.txt").write_text(
f"case={name}\nserial={number}\nobjects={','.join(names)}\nsnapshot_sha256={hashlib.sha256(snap).hexdigest()}\n",
encoding="utf-8",
)
def make_https_certificates(output: Path) -> None:
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RRDP CA")])
ca_ski = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name).issuer_name(ca_name).public_key(ca_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(VALID_FROM).not_valid_after(VALID_TO)
.add_extension(x509.BasicConstraints(ca=True, path_length=1), critical=True)
.add_extension(x509.KeyUsage(False, False, False, False, False, True, True, False, False), critical=True)
.add_extension(ca_ski, critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, RRDP_HOST)])
try:
server_host = x509.IPAddress(ipaddress.ip_address(RRDP_HOST))
except ValueError:
server_host = x509.DNSName(RRDP_HOST)
san_entries = [server_host, x509.DNSName(FOREIGN_RRDP_HOST)]
if RRDP_HOST != "localhost":
san_entries.append(x509.DNSName("localhost"))
server_cert = (
x509.CertificateBuilder()
.subject_name(server_name).issuer_name(ca_cert.subject).public_key(server_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(VALID_FROM).not_valid_after(VALID_TO)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, False, False, False, False), critical=True)
.add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
write(output / "certs/rrdp-ca.pem", pem_cert(ca_cert))
write_key(output / "certs/rrdp-ca.key", ca_key)
write(output / "certs/rrdp-server.pem", pem_cert(server_cert))
write_key(output / "certs/rrdp-server.key", server_key)
def main() -> None:
global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT
global TAL_URI, TAL_HTTPS_URI, RRDP_URI
global ROOT_REPO_URI, ROOT_MFT_URI, CHILD_REPO_URI, CHILD_MFT_URI
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--rrdp-host",
default=RRDP_HOST,
help="host embedded in RRDP URIs and the HTTPS server certificate SAN "
"(default: rpki-local-rrdp, the compose service name)",
)
parser.add_argument(
"--rrdp-port",
type=int,
default=RRDP_PORT,
help="port embedded in RRDP/HTTPS URIs (default: 8443)",
)
parser.add_argument(
"--foreign-rrdp-host",
default=FOREIGN_RRDP_HOST,
help="separate HTTPS service host used only by the cross-origin RRDP test case",
)
parser.add_argument(
"--rsync-host",
default=RSYNC_HOST,
help="host embedded in the TAL rsync URI and certificate SIA rsync URIs "
"(default: rpki-local-rsync, the compose service name)",
)
parser.add_argument(
"--rsync-port",
type=int,
default=RSYNC_PORT,
help="port embedded in rsync URIs (default: 873)",
)
args = parser.parse_args()
RRDP_HOST = args.rrdp_host
FOREIGN_RRDP_HOST = args.foreign_rrdp_host
RRDP_PORT = args.rrdp_port
RSYNC_HOST = args.rsync_host
RSYNC_PORT = args.rsync_port
TAL_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/ta.cer"
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
ROOT_REPO_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/root/"
ROOT_MFT_URI = f"{ROOT_REPO_URI}root.mft"
CHILD_REPO_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/child/"
CHILD_MFT_URI = f"{CHILD_REPO_URI}child.mft"
output = args.output.resolve()
if output.exists() and any(output.iterdir()):
raise SystemExit("fixture output must be new or empty")
for directory in ("tal", "ta", "certs", "services", "keys"):
(output / directory).mkdir(parents=True)
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
child_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Root")])
child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Child")])
root_cert = sign_cert(
root_name, root_name, root_key.public_key(), root_key, 0x14700001, True, root_key,
ip_ext=ip_resources([CHILD_PREFIX]),
as_ext=as_resources([TEST_ASN]), ca_repository=ROOT_REPO_URI, manifest_uri=ROOT_MFT_URI,
notify_uri=RRDP_URI,
)
child_cert = sign_cert(
child_name, root_name, child_key.public_key(), root_key, 0x14700002, True, child_key, root_key,
ip_ext=ip_resources([CHILD_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=CHILD_REPO_URI, manifest_uri=CHILD_MFT_URI, notify_uri=RRDP_URI,
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
)
bad_child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Overclaiming Child")])
bad_child_repo_uri = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/bad-child/"
bad_child_cert = sign_cert(
bad_child_name, root_name, child_key.public_key(), root_key, 0x14700003, True, child_key, root_key,
ip_ext=ip_resources([OUTSIDE_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=bad_child_repo_uri, manifest_uri=f"{bad_child_repo_uri}bad-child.mft", notify_uri=RRDP_URI,
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
)
root_crl = make_crl(root_name, root_key, 1)
child_crl = make_crl(child_name, child_key, 1)
tal = (
f"{TAL_URI}\n{TAL_HTTPS_URI}\n\n"
+ base64.b64encode(root_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)).decode()
+ "\n"
).encode()
write(output / "tal/custom.tal", tal)
write(output / "ta/custom-ta.cer", root_cert.public_bytes(Encoding.DER))
with tempfile.TemporaryDirectory(prefix="custom-rpki-cms-") as temp:
work = Path(temp)
common = output / ".common"
common.mkdir()
write(common / "ta.cer", root_cert.public_bytes(Encoding.DER))
write(common / "child.cer", child_cert.public_bytes(Encoding.DER))
write(common / "bad-child.cer", bad_child_cert.public_bytes(Encoding.DER))
write(common / "root.crl", root_crl.public_bytes(Encoding.DER))
write(common / "child.crl", child_crl.public_bytes(Encoding.DER))
for name, prefix, max_length, before, after, certificate_prefix in (
("valid.roa", CHILD_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("valid2.roa", SECOND_PREFIX, 25, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("expired.roa", CHILD_PREFIX, 24, VALID_FROM - timedelta(days=30), VALID_FROM - timedelta(days=1), None),
("max-length.roa", CHILD_PREFIX, 25, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("max-length-invalid.roa", CHILD_PREFIX, 33, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("roa-prefix-outside.roa", OUTSIDE_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), CHILD_PREFIX),
("out-of-resource.roa", OUTSIDE_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
):
make_roa(
name, prefix, max_length, before, after, child_name, child_key, common, work,
certificate_prefix=certificate_prefix,
)
make_aspa("valid.asa", child_name, child_key, common, work)
bad = bytearray((common / "valid.roa").read_bytes())
bad[-1] ^= 1
write(common / "bad-format.roa", bytes(bad))
root_mft = make_manifest(
"root", 1, [
("root.crl", (common / "root.crl").read_bytes()),
("child.cer", (common / "child.cer").read_bytes()),
], root_name, root_key, common, work,
)
write(common / "root.mft", root_mft)
bad_root_mft = make_manifest(
"root", 1, [
("root.crl", (common / "root.crl").read_bytes()),
("child.cer", (common / "child.cer").read_bytes()),
("bad-child.cer", (common / "bad-child.cer").read_bytes()),
], root_name, root_key, common, work,
)
write(common / "root.mft", root_mft)
case_specs = (
("baseline-v1", 1, ["child.crl", "valid.roa", "valid.asa"]),
("sync-hash-mismatch", 1, ["child.crl", "valid.roa", "valid.asa"]),
("rrdp-cross-origin-snapshot", 1, ["child.crl", "valid.roa", "valid.asa"]),
("baseline-v2", 2, ["child.crl", "valid.roa", "valid2.roa", "valid.asa"]),
("validation-expired", 1, ["child.crl", "expired.roa", "valid.asa"]),
("validation-max-length", 1, ["child.crl", "max-length.roa", "valid.asa"]),
("validation-max-length-invalid", 1, ["child.crl", "max-length-invalid.roa", "valid.asa"]),
("validation-roa-prefix-outside", 1, ["child.crl", "roa-prefix-outside.roa", "valid.asa"]),
("validation-out-of-resource", 1, ["child.crl", "out-of-resource.roa", "valid.asa"]),
("validation-ca-over-resource", 1, ["child.crl", "valid.roa", "valid.asa"]),
("validation-nonstandard", 1, ["child.crl", "bad-format.roa", "valid.asa"]),
)
child_mfts = {}
for name, number, names in case_specs:
child_mfts[name] = make_manifest(
name, number, [(item, (common / item).read_bytes()) for item in names],
child_name, child_key, common, work,
)
for name, number, names in case_specs:
change = None
if name == "baseline-v2":
change = delta("valid2.roa", common / "valid2.roa", common / "baseline-v2.mft", child_mfts["baseline-v1"])
build_case(
output, common, tal, root_cert.public_bytes(Encoding.DER), name, number, names,
child_mfts[name], change,
root_mft=bad_root_mft if name == "validation-ca-over-resource" else None,
extra_root_objects=[("bad-child.cer", (common / "bad-child.cer").read_bytes())]
if name == "validation-ca-over-resource" else None,
notification_hash_mismatch=name == "sync-hash-mismatch",
snapshot_uri=f"https://{FOREIGN_RRDP_HOST}:{RRDP_PORT}/rrdp/snapshot.xml"
if name == "rrdp-cross-origin-snapshot" else None,
)
write_key(output / "keys/root.key", root_key)
write_key(output / "keys/child.key", child_key)
make_https_certificates(output)
shutil.rmtree(output / ".common")
(output / "FIXTURE-MANIFEST.txt").write_text(
"fixture_schema_version=1\nfixture_kind=stack-local-test-rpki-repo\n"
f"tal_uri={TAL_URI}\ntal_https_uri={TAL_HTTPS_URI}\nrrdp_uri={RRDP_URI}\nrsync_module=custom\n"
f"rrdp_host={RRDP_HOST}\nforeign_rrdp_host={FOREIGN_RRDP_HOST}\nrrdp_port={RRDP_PORT}\nrsync_host={RSYNC_HOST}\nrsync_port={RSYNC_PORT}\n"
"cases=baseline-v1,sync-hash-mismatch,rrdp-cross-origin-snapshot,baseline-v2,validation-expired,validation-max-length,validation-max-length-invalid,"
"validation-roa-prefix-outside,validation-out-of-resource,validation-ca-over-resource,validation-nonstandard\n",
encoding="utf-8",
)
print(f"generated custom fixture: {output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""HTTPS fixture server, with request log for forbidden-origin assertions."""
import functools
import http.server
import ssl
import sys
root, port, cert, key = sys.argv[1:]
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
server = http.server.ThreadingHTTPServer(('127.0.0.1', int(port)), handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
server.socket = context.wrap_socket(server.socket, server_side=True)
print('ready', flush=True)
server.serve_forever()

View File

@@ -0,0 +1,158 @@
"""Real CLI regression: HTTPS snapshot/delta, restart, interval, lock and signals."""
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import time
def read(path):
return json.loads(path.read_text())
def wait_for(check, label, timeout=30):
start = time.monotonic()
while time.monotonic() - start < timeout:
if check():
return
time.sleep(0.05)
raise AssertionError(f"timed out waiting for {label}")
def state_is(root, state):
path = root / "daemon-status.json"
return path.exists() and read(path)["state"] == state
def run(binary):
support = Path(__file__).resolve().parent
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
fixture = base / "fixture"
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
subprocess.run([sys.executable, str(support / "generate_repository.py"),
"--rrdp-host", "127.0.0.1", "--rrdp-port", str(port),
# Key generation is CPU-heavy under instrumented CI builds.
"--output", str(fixture)], check=True, timeout=120)
active = fixture / "active"
active.symlink_to("cases/baseline-v1/http")
server = subprocess.Popen([sys.executable, str(support / "serve_repository.py"),
str(active), str(port), str(fixture / "certs/rrdp-server.pem"),
str(fixture / "certs/rrdp-server.key")],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
processes = []
logs = []
try:
assert server.stdout.readline().strip() == "ready"
child_args = ["--tal", str(fixture / "tal/custom.tal"), "--ta", str(fixture / "ta/custom-ta.cer"),
"--http-root-cert", str(fixture / "certs/rrdp-ca.pem"),
"--http-timeout-secs", "2", "--log-format", "json"]
def launch(root, options, inputs=None):
log = (base / f"controller-{len(logs)}.log").open("w")
logs.append(log)
proc = subprocess.Popen([binary, "daemon", "--state-root", str(root), *options,
"--", *(child_args if inputs is None else inputs)],
stdout=log, stderr=log)
processes.append(proc)
return proc
root = base / "normal"
proc = launch(root, ["--interval-secs", "2", "--max-runs", "2", "--retain-runs", "2"])
wait_for(lambda: state_is(root, "sleeping"), "snapshot completion")
first = read(root / "runs/run_000001/run-summary.json")
assert first["status"] == "success" and first["sync_mode"] == "snapshot", first
assert first["summary"]["vrps"] == 1, first
# Single controller even in the interval when RocksDB itself is closed.
rival = launch(root, ["--max-runs", "1"])
assert rival.wait(timeout=10) == 2
active.unlink()
active.symlink_to("cases/baseline-v2/http")
assert proc.wait(timeout=30) == 0
second = read(root / "runs/run_000002/run-summary.json")
assert second["status"] == "success" and second["sync_mode"] == "delta", second
assert second["summary"]["vrps"] == 2, second
timing = read(root / "runs/run_000002/stage-timing.json")
assert timing["analysis_counts"]["rrdp_delta_ops_applied_total"] > 0
from datetime import datetime
gap = (datetime.fromisoformat(second["started_at"].replace("Z", "+00:00")) -
datetime.fromisoformat(first["finished_at"].replace("Z", "+00:00"))).total_seconds()
assert gap >= 1.9, gap
for seq in (1, 2):
out = root / f"runs/run_{seq:06}"
assert len((out / "vrps.csv").read_text().splitlines()) - 1 == seq
subprocess.run(["openssl", "asn1parse", "-inform", "DER", "-in",
str(out / "result.ccr"), "-noout"], check=True)
# Restart appends sequence and keeps protocol state; retention only drops completed runs.
(root / "runs/operator-notes").mkdir()
(root / "runs/run_000003").mkdir() # Simulated interrupted run evidence.
restart = launch(root, ["--interval-secs", "0", "--max-runs", "1", "--retain-runs", "1"])
assert restart.wait(timeout=30) == 0
fourth = read(root / "runs/run_000004/run-summary.json")
assert fourth["sync_mode"] == "auto" and fourth["summary"]["vrps"] == 2
assert not (root / "runs/run_000001").exists()
assert (root / "runs/operator-notes").is_dir() and (root / "runs/run_000003").is_dir()
sleeper = launch(root, ["--interval-secs", "600"])
wait_for(lambda: state_is(root, "sleeping"), "daemon sleep")
sleeper.send_signal(signal.SIGTERM)
assert sleeper.wait(timeout=5) == 0
assert state_is(root, "exited")
# A real validator failure is recorded, retried, and returned as nonzero at max-runs.
failed = base / "failed"
bad_inputs = ["--tal", str(base / "missing.tal"), "--ta", str(base / "missing.cer")]
failure = launch(failed, ["--max-runs", "2", "--interval-secs", "0"], bad_inputs)
assert failure.wait(timeout=20) == 2
for seq in (1, 2):
assert read(failed / f"runs/run_{seq:06}/run-summary.json")["status"] == "failed"
# Suspend the HTTPS server so the actual validator cannot complete a request.
server.send_signal(signal.SIGSTOP)
hung = base / "timeout"
timeout = launch(hung, ["--max-runs", "1", "--run-timeout-secs", "1"])
assert timeout.wait(timeout=10) == 2
assert read(hung / "runs/run_000001/run-summary.json")["error"] == "timeout"
interrupted = base / "interrupted"
active_proc = launch(interrupted, ["--shutdown-grace-secs", "0"])
wait_for(lambda: state_is(interrupted, "running"), "active validator")
time.sleep(0.2)
active_proc.send_signal(signal.SIGTERM)
assert active_proc.wait(timeout=10) == 2
assert read(interrupted / "runs/run_000001/run-summary.json")["error"] == "interrupted"
server.send_signal(signal.SIGCONT)
recovered = launch(interrupted, ["--max-runs", "1", "--interval-secs", "0"])
assert recovered.wait(timeout=30) == 0
resumed = read(interrupted / "runs/run_000002/run-summary.json")
assert resumed["status"] == "success" and resumed["summary"]["vrps"] == 2
assert resumed["sync_mode"] in ("auto", "snapshot")
print("Real daemon E2E passed: snapshot/delta/restart/interval/retention/lock/failure/timeout/SIGTERM")
except BaseException:
for log in logs:
log.flush()
print(Path(log.name).read_text(), file=sys.stderr)
raise
finally:
server.send_signal(signal.SIGCONT)
for proc in processes:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=35)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
server.terminate()
server.wait(timeout=5)
for log in logs:
log.close()
if __name__ == "__main__":
run(str(Path(sys.argv[1]).resolve()))

139
tests/synthetic_docker_e2e.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Self-contained Docker deployment regression. Requires Linux Docker because
# the validator uses the host network to reach the ephemeral HTTPS RRDP server.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work_root="$(mktemp -d)"
server_pid=""
daemon_container=""
cleanup() {
[[ -n "$daemon_container" ]] && docker stop --time 5 "$daemon_container" >/dev/null 2>&1 || true
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null || true
[[ -n "$server_pid" ]] && wait "$server_pid" 2>/dev/null || true
rm -rf "$work_root"
}
trap cleanup EXIT
port="$(python3 - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
)"
fixture="$work_root/fixture"
python3 "$repo_root/tests/support/generate_repository.py" \
--rrdp-host 127.0.0.1 --rrdp-port "$port" --output "$fixture"
ln -s "cases/baseline-v1/http" "$fixture/active"
mkdir -p "$work_root/output-snapshot" "$work_root/output-delta" "$work_root/state"
PYTHONUNBUFFERED=1 python3 "$repo_root/tests/support/serve_repository.py" \
"$fixture/active" "$port" "$fixture/certs/rrdp-server.pem" "$fixture/certs/rrdp-server.key" \
>"$work_root/server.log" 2>&1 &
server_pid="$!"
for _ in $(seq 1 50); do
rg -qx 'ready' "$work_root/server.log" && break
sleep 0.1
done
rg -qx 'ready' "$work_root/server.log"
docker build -f "$repo_root/docker/Dockerfile" -t panda-rpki:synthetic-e2e "$repo_root"
run_validator() {
local out="$1"
local mode="$2"
docker run --rm --network host --user "$(id -u):$(id -g)" \
-v "$fixture:/fixture:ro" \
-v "$work_root/state:/state" \
-v "$out:/output" \
panda-rpki:synthetic-e2e validate \
--tal /fixture/tal/custom.tal --ta /fixture/ta/custom-ta.cer \
--http-root-cert /fixture/certs/rrdp-ca.pem \
--http-timeout-secs 5 \
--parallel-max-repo-sync-workers-global 2 \
--parallel-phase2-object-workers 2 \
--rrdp-state-dir /state --rrdp-sync-mode "$mode" \
--ccr-out /output/result.ccr --out /output --log-level info --log-format json
}
run_validator "$work_root/output-snapshot" snapshot
rm "$fixture/active"
ln -s "cases/baseline-v2/http" "$fixture/active"
run_validator "$work_root/output-delta" delta
for output in "$work_root/output-snapshot" "$work_root/output-delta"; do
test -s "$output/summary.json"
test -s "$output/vrps.csv"
test -s "$output/result.ccr"
openssl asn1parse -inform DER -in "$output/result.ccr" -noout
done
test -s "$work_root/state/repository-db/CURRENT"
python3 - "$work_root/output-snapshot" "$work_root/output-delta" <<'PY'
import json
import pathlib
import sys
snapshot, delta = (pathlib.Path(item) for item in sys.argv[1:])
first = json.loads((snapshot / "summary.json").read_text(encoding="utf-8"))
second = json.loads((delta / "summary.json").read_text(encoding="utf-8"))
assert first["vrps"] == 1, first
assert second["vrps"] == 2, second
assert len((snapshot / "vaps.csv").read_text(encoding="utf-8").splitlines()) == 2
assert len((delta / "vaps.csv").read_text(encoding="utf-8").splitlines()) == 2
timing = json.loads((delta / "stage-timing.json").read_text(encoding="utf-8"))
assert timing["analysis_counts"]["rrdp_delta_ops_applied_total"] > 0, timing
PY
echo "Synthetic Docker snapshot+delta E2E passed"
# Exercise the actual public daemon in the image with a fresh protocol root.
mkdir "$work_root/daemon"
rm "$fixture/active"
ln -s "cases/baseline-v1/http" "$fixture/active"
daemon_container="panda-rpki-daemon-$(basename "$work_root")"
docker run --rm --name "$daemon_container" --network host --read-only --tmpfs /tmp \
--user "$(id -u):$(id -g)" \
-v "$fixture:/fixture:ro" -v "$work_root/daemon:/data" \
panda-rpki:synthetic-e2e daemon \
--state-root /data --max-runs 3 --interval-secs 2 --retain-runs 3 -- \
--tal /fixture/tal/custom.tal --ta /fixture/ta/custom-ta.cer \
--http-root-cert /fixture/certs/rrdp-ca.pem --log-format json \
>"$work_root/daemon-container.log" 2>&1 &
daemon_job=$!
python3 - "$work_root/daemon" "$fixture" <<'PY'
import json
import pathlib
import sys
import time
root, fixture = map(pathlib.Path, sys.argv[1:])
for _ in range(400):
status = root / "daemon-status.json"
if status.exists() and json.loads(status.read_text())["state"] == "sleeping":
(fixture / "active").unlink()
(fixture / "active").symlink_to("cases/baseline-v2/http")
break
time.sleep(0.05)
else:
raise AssertionError("Docker daemon did not finish snapshot")
PY
wait "$daemon_job"
daemon_container=""
python3 - "$work_root/daemon" <<'PY'
import json
import pathlib
import subprocess
import sys
root = pathlib.Path(sys.argv[1])
assert json.loads((root / "daemon-status.json").read_text())["runs_completed"] == 3
for seq, mode, vrps in [(1, "snapshot", 1), (2, "delta", 2), (3, "delta", 2)]:
out = root / f"runs/run_{seq:06}"
run = json.loads((out / "run-summary.json").read_text())
assert run["status"] == "success" and run["sync_mode"] == mode, run
assert run["summary"]["vrps"] == vrps, run
assert run["process_metrics"]["maxRssKb"] > 0, run
assert len((out / "vrps.csv").read_text().splitlines()) - 1 == vrps
subprocess.run(["openssl", "asn1parse", "-inform", "DER", "-in", str(out / "result.ccr"), "-noout"], check=True)
timing = json.loads((root / "runs/run_000002/stage-timing.json").read_text())
assert timing["analysis_counts"]["rrdp_delta_ops_applied_total"] > 0
print("Synthetic Docker daemon 3-run E2E passed")
PY