初始化 Panda RPKI v0.1.0 开源候选版本
This commit is contained in:
9
tests/daemon_e2e.rs
Normal file
9
tests/daemon_e2e.rs
Normal 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
13
tests/support/README.md
Normal 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.
|
||||
717
tests/support/generate_repository.py
Normal file
717
tests/support/generate_repository.py
Normal 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()
|
||||
15
tests/support/serve_repository.py
Normal file
15
tests/support/serve_repository.py
Normal 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()
|
||||
158
tests/support/test_daemon.py
Normal file
158
tests/support/test_daemon.py
Normal 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
139
tests/synthetic_docker_e2e.sh
Executable 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
|
||||
Reference in New Issue
Block a user