diff --git a/docs/spec-alpha-support.md b/docs/spec-alpha-support.md index 32498d1..3cc54fa 100644 --- a/docs/spec-alpha-support.md +++ b/docs/spec-alpha-support.md @@ -19,12 +19,12 @@ Feature states: | Dotted field path | Feature state | Default value | Implementation owner/module | Alpha caveat | |---|---:|---|---|---| -| `pki.intermediate_ca_enabled` | `reserved` / registry: `experimental` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki`, `netengine.api.routes` | step-ca's generated intermediate certificate can be read, exposed in Phase 3 output, and fetched through `GET /pki/intermediate-ca-cert`; behavior remains alpha/stabilizing and the model metadata is still conservative. | -| `pki.dnssec_enabled` | `unsupported` | `true` | `netengine.handlers.phase_pki`, `netengine.handlers.pki_handler`, spec loader validation | Active non-default enabling is unsupported in alpha validation. The handler can generate KSK/ZSK key material, but DNS zone signing and CoreDNS activation are not integrated, so this is not end-to-end DNSSEC support. | -| `pki.dnssec_ksk_lifetime_days` | `unsupported` | `365` | `netengine.handlers.pki_handler` | Lifetime is recorded in DNSSEC output metadata only; no automatic KSK rotation or signed-zone publication is implemented. | -| `pki.dnssec_zsk_lifetime_days` | `unsupported` | `30` | `netengine.handlers.pki_handler` | Lifetime is recorded in DNSSEC output metadata only; no automatic ZSK rotation or signed-zone publication is implemented. | -| `pki.crl_enabled` | `unsupported` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki` | Handler code can inject a step-ca CRL config and report a URL, but CRL publication/distribution-point plumbing is incomplete and active use is unsupported in alpha validation. | -| `pki.ocsp_enabled` | `unsupported` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki` | Handler code can inject OCSP-related step-ca config and report a URL, but responder deployment/verification is incomplete and active use is unsupported in alpha validation. | +| `pki.intermediate_ca_enabled` | `experimental` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki`, `netengine.api.routes` | step-ca's generated intermediate certificate can be read, exposed in Phase 3 output, and fetched through `GET /pki/intermediate-ca-cert`; trust-chain management remains stabilizing. | +| `pki.dnssec_enabled` | `experimental` | `false` | `netengine.handlers.phase_pki`, `netengine.handlers.pki_handler`, `netengine.handlers.dns` | KSK/ZSK keys are generated and wired into the CoreDNS `dnssec` online-signing plugin (Phase 3 reconfigures the Corefile and reloads CoreDNS), with KSK/ZSK rotation in `pki_cert_rotation_worker`. Promotion to `stable` is gated on a green CI e2e signed-zone validation (`dig +dnssec`). | +| `pki.dnssec_ksk_lifetime_days` | `experimental` | `365` | `netengine.handlers.pki_handler`, `netengine.workers.pki_cert_rotation_worker` | Drives automatic KSK rotation in the rotation worker; signed-zone re-publication is validated in CI e2e. | +| `pki.dnssec_zsk_lifetime_days` | `experimental` | `30` | `netengine.handlers.pki_handler`, `netengine.workers.pki_cert_rotation_worker` | Drives automatic ZSK rotation in the rotation worker; signed-zone re-publication is validated in CI e2e. | +| `pki.crl_enabled` | `experimental` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki` | step-ca CRL generation is enabled in `ca.json` and the distribution URL is published in Phase 3 output; client-validation coverage is hardened in CI e2e. | +| `pki.ocsp_enabled` | `experimental` | `false` | `netengine.handlers.pki_handler`, `netengine.handlers.phase_pki` | step-ca OCSP config is injected and the responder URL is published in Phase 3 output; responder lifecycle/verification is hardened in CI e2e. | | `pki.rotation_policy` | `experimental` | `{enabled: true, default_interval_hours: 24, default_warning_days: 30, cert_type_overrides: {}}` | `netengine.handlers.phase_pki`, `netengine.workers.pki_cert_rotation_worker`, `netengine.api.routes` | Wired from the spec into worker registration and live-reloaded from runtime state; policy shape and cert-type semantics may change during alpha. | | `gateway_portal.real_internet.mode` | `unsupported` | `isolated` | `netengine.spec.loader` | Real-internet gateway policies are not implemented. | | `gateway_portal.real_internet.service_mirrors` | `unsupported` | `[]` | `netengine.spec.loader` | Service mirror provisioning is not implemented. | diff --git a/netengine/handlers/dns.py b/netengine/handlers/dns.py index cc358e7..143eaa5 100644 --- a/netengine/handlers/dns.py +++ b/netengine/handlers/dns.py @@ -893,6 +893,85 @@ async def _reload_coredns(self, context: "PhaseContext") -> None: except Exception as e: context.logger.warning(f"CoreDNS reload signal failed (non-fatal): {e}") + # ───────────────────────────────────────────── + # DNSSEC online signing (CoreDNS dnssec plugin) + # ───────────────────────────────────────────── + + # Keys generated by PKIHandler.setup_dnssec are placed in /keys, + # which CoreDNS already sees at /etc/coredns/keys via its /etc/coredns bind. + DNSSEC_KEYS_CONTAINER_DIR = "/etc/coredns/keys" + + @staticmethod + def _inject_dnssec_into_corefile(corefile: str, zone: str, key_basenames: list[str]) -> str: + """Add a CoreDNS ``dnssec`` online-signing block to *zone*'s stanza. + + The ``dnssec`` plugin signs answers on the fly using the supplied KSK/ZSK + key files (paths are given without the ``.key``/``.private`` suffix; the + plugin appends them). Pure string transform so it can be unit-tested + without Docker. + + Idempotent: if the zone stanza already contains a ``dnssec`` block the + Corefile is returned unchanged. + + Args: + corefile: Existing Corefile text (stanzas joined by blank lines). + zone: Zone whose stanza should sign (e.g. ``internal``). + key_basenames: KSK/ZSK basenames as emitted by ``dnssec-keygen`` + (e.g. ``Kinternal.+013+12345``). + + Returns: + Updated Corefile text. + """ + key_paths = " ".join( + f"{DNSHandler.DNSSEC_KEYS_CONTAINER_DIR}/{name}" for name in key_basenames + ) + dnssec_block = f" dnssec {{\n key file {key_paths}\n }}" + + stanzas = corefile.split("\n\n") + for i, stanza in enumerate(stanzas): + if not stanza.lstrip().startswith(f"{zone} {{"): + continue + if "dnssec {" in stanza: + return corefile # already signed — idempotent + lines = stanza.rstrip().splitlines() + # lines[-1] is the closing "}"; insert the dnssec block before it. + lines = lines[:-1] + dnssec_block.splitlines() + [lines[-1]] + stanzas[i] = "\n".join(lines) + return "\n\n".join(stanzas) + + # Zone stanza not found — leave the Corefile untouched. + return corefile + + async def enable_dnssec( + self, context: "PhaseContext", zone: str, key_basenames: list[str] + ) -> bool: + """Activate CoreDNS online signing for *zone* and reload CoreDNS. + + Called from Phase 3 (PKI) after keys are generated into + ``/keys``. Rewrites the on-disk Corefile to add a ``dnssec`` + block for the zone, then signals CoreDNS to reload. Returns ``True`` when + the Corefile was updated. + """ + if context.mock_mode or context.docker_client is None: + context.logger.debug("enable_dnssec: mock/no-docker, skipping CoreDNS reconfigure") + return False + + corefile_path = Path(context.zone_dir) / "Corefile" + if not corefile_path.exists(): + context.logger.warning(f"enable_dnssec: Corefile not found at {corefile_path}") + return False + + original = await asyncio.to_thread(corefile_path.read_text) + updated = self._inject_dnssec_into_corefile(original, zone, key_basenames) + if updated == original: + context.logger.info(f"DNSSEC already active for zone '{zone}' (no Corefile change)") + return False + + await asyncio.to_thread(corefile_path.write_text, updated) + context.logger.info(f"Enabled DNSSEC online signing for zone '{zone}'") + await self._reload_coredns(context) + return True + @staticmethod def _build_record_line(name: str, record_type: str, value: str, ttl: int) -> str: """Build an RFC 1035 compliant DNS record line. diff --git a/netengine/handlers/phase_pki.py b/netengine/handlers/phase_pki.py index be6b61b..5fe2ed9 100644 --- a/netengine/handlers/phase_pki.py +++ b/netengine/handlers/phase_pki.py @@ -93,17 +93,36 @@ async def execute(self, context: PhaseContext) -> None: logger.info("Intermediate CA enabled and tracked in state") if spec.pki.dnssec_enabled: + # Generate keys into /keys so the running CoreDNS (which + # binds at /etc/coredns) can sign with them at + # /etc/coredns/keys without a restart. + from pathlib import Path + + keys_host_dir = str(Path(context.zone_dir) / "keys") dnssec_info = await pki.setup_dnssec( zone="internal", ksk_lifetime_days=spec.pki.dnssec_ksk_lifetime_days, zsk_lifetime_days=spec.pki.dnssec_zsk_lifetime_days, + keys_host_dir=keys_host_dir, ) context.runtime_state.dnssec_output = dnssec_info + + # Activate online signing: rewrite the Corefile + reload CoreDNS. + dnssec_active = await dns_handler.enable_dnssec( + context, + zone=dnssec_info["zone"], + key_basenames=[dnssec_info["ksk_name"], dnssec_info["zsk_name"]], + ) + pki_output["dnssec_enabled"] = True pki_output["dnssec_zone"] = dnssec_info["zone"] pki_output["dnssec_ksk"] = dnssec_info["ksk_name"] pki_output["dnssec_zsk"] = dnssec_info["zsk_name"] - logger.info(f"DNSSEC keys generated for zone: {dnssec_info['zone']}") + pki_output["dnssec_signing_active"] = dnssec_active + logger.info( + f"DNSSEC keys generated for zone {dnssec_info['zone']}; " + f"online signing active={dnssec_active}" + ) # 4. Persist outputs context.runtime_state.pki_output = pki_output diff --git a/netengine/handlers/pki_handler.py b/netengine/handlers/pki_handler.py index 960461d..3f8d134 100644 --- a/netengine/handlers/pki_handler.py +++ b/netengine/handlers/pki_handler.py @@ -4,7 +4,7 @@ import ssl import subprocess import tempfile -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Optional @@ -341,19 +341,30 @@ async def setup_dnssec( zone: str, ksk_lifetime_days: int = 365, zsk_lifetime_days: int = 30, + keys_host_dir: Optional[str] = None, ) -> dict: """Generate DNSSEC KSK and ZSK keys for *zone* using BIND's dnssec-keygen. - Keys are stored in the ``netengines_dnssec_keys`` Docker volume so that - CoreDNS can mount them and activate the ``dnssec`` plugin. Returns a - dict of key metadata that the caller should persist in state for later - rotation. + When *keys_host_dir* is given (the DNS phase's ``/keys`` path), + keys are written there so the already-running CoreDNS container — which + binds ```` at ``/etc/coredns`` — can reference them at + ``/etc/coredns/keys`` for online signing without a restart. Otherwise + they are stored in the ``netengines_dnssec_keys`` Docker volume. + + Returns a dict of key metadata (including ``generated_at`` so the + rotation worker can age KSK/ZSK against their configured lifetimes). """ dnssec_volume = "netengines_dnssec_keys" - await self.docker.ensure_volume(dnssec_volume) + if keys_host_dir is not None: + os.makedirs(keys_host_dir, exist_ok=True) + volumes = {keys_host_dir: {"bind": "/keys", "mode": "rw"}} + keys_location = keys_host_dir + else: + await self.docker.ensure_volume(dnssec_volume) + volumes = {dnssec_volume: {"bind": "/keys", "mode": "rw"}} + keys_location = dnssec_volume bind_image = "internetsystemsconsortium/bind9:9.18" - volumes = {dnssec_volume: {"bind": "/keys", "mode": "rw"}} # KSK — signs only the DNSKEY RRset ksk_result = await self.docker.run_container_one_off( @@ -400,9 +411,11 @@ async def setup_dnssec( "ksk_name": ksk_name, "zsk_name": zsk_name, "volume": dnssec_volume, + "keys_location": keys_location, "algorithm": "ECDSAP256SHA256", "ksk_lifetime_days": ksk_lifetime_days, "zsk_lifetime_days": zsk_lifetime_days, + "generated_at": datetime.now(UTC).isoformat(), } def extract_cert_expiry(self, cert_pem: str) -> datetime: diff --git a/netengine/spec/feature_state.py b/netengine/spec/feature_state.py index e901e89..d397a9c 100644 --- a/netengine/spec/feature_state.py +++ b/netengine/spec/feature_state.py @@ -19,21 +19,31 @@ class FeatureStateEntry: FEATURE_STATE_REGISTRY: tuple[FeatureStateEntry, ...] = ( FeatureStateEntry( path="pki.dnssec_enabled", - state="unsupported", + state="experimental", stage="alpha", - reason="DNSSEC key generation is not integrated with zone signing", + reason=( + "DNSSEC key generation is wired into CoreDNS online signing with " + "KSK/ZSK rotation; end-to-end signed-zone validation is still being " + "hardened in CI e2e" + ), ), FeatureStateEntry( path="pki.crl_enabled", - state="unsupported", + state="experimental", stage="alpha", - reason="CRL publication and distribution points are not implemented", + reason=( + "step-ca CRL generation is enabled and the distribution URL is " + "published; client-validation coverage is still being hardened in CI e2e" + ), ), FeatureStateEntry( path="pki.ocsp_enabled", - state="unsupported", + state="experimental", stage="alpha", - reason="OCSP responder deployment is not implemented", + reason=( + "step-ca OCSP config is injected and the responder URL is published; " + "responder lifecycle/verification is still being hardened in CI e2e" + ), ), FeatureStateEntry( path="gateway_portal.real_internet.mode", diff --git a/netengine/spec/models.py b/netengine/spec/models.py index 702cf01..06123b7 100644 --- a/netengine/spec/models.py +++ b/netengine/spec/models.py @@ -64,12 +64,12 @@ def feature_state_field( PKI_FEATURE_STATES: dict[str, FeatureState] = { - "pki.intermediate_ca_enabled": FeatureState.RESERVED, - "pki.dnssec_enabled": FeatureState.UNSUPPORTED, - "pki.dnssec_ksk_lifetime_days": FeatureState.UNSUPPORTED, - "pki.dnssec_zsk_lifetime_days": FeatureState.UNSUPPORTED, - "pki.crl_enabled": FeatureState.UNSUPPORTED, - "pki.ocsp_enabled": FeatureState.UNSUPPORTED, + "pki.intermediate_ca_enabled": FeatureState.EXPERIMENTAL, + "pki.dnssec_enabled": FeatureState.EXPERIMENTAL, + "pki.dnssec_ksk_lifetime_days": FeatureState.EXPERIMENTAL, + "pki.dnssec_zsk_lifetime_days": FeatureState.EXPERIMENTAL, + "pki.crl_enabled": FeatureState.EXPERIMENTAL, + "pki.ocsp_enabled": FeatureState.EXPERIMENTAL, "pki.rotation_policy": FeatureState.EXPERIMENTAL, } diff --git a/netengine/workers/pki_cert_rotation_worker.py b/netengine/workers/pki_cert_rotation_worker.py index a8fef95..ec4ef56 100644 --- a/netengine/workers/pki_cert_rotation_worker.py +++ b/netengine/workers/pki_cert_rotation_worker.py @@ -112,6 +112,10 @@ async def run(self) -> None: await self._check_and_rotate_cert_type(state, cert_type, config) self._update_last_check_time(state, cert_type) + # DNSSEC KSK/ZSK rotation runs independently of cert types, + # driven by the per-key lifetimes recorded in dnssec_output. + await self._check_and_rotate_dnssec(state) + state.save() # Sleep for a reasonable interval (1 hour cap to refresh state) @@ -217,6 +221,71 @@ async def _check_and_rotate_cert_type( ) await self._emit_rotation_event(cn, cert_type, "failed", error=str(e)) + @staticmethod + def _dnssec_key_is_expired(generated_at: Any, lifetime_days: int, now: datetime) -> bool: + """Return True when a DNSSEC key has reached the end of its lifetime. + + Missing/unparseable ``generated_at`` is treated as expired so a key with + no recorded age is rotated onto a known schedule. + """ + if isinstance(generated_at, str): + try: + generated = datetime.fromisoformat(generated_at) + except ValueError: + return True + elif isinstance(generated_at, datetime): + generated = generated_at + else: + return True + return now >= generated + timedelta(days=lifetime_days) + + async def _check_and_rotate_dnssec(self, state: RuntimeState) -> None: + """Rotate DNSSEC KSK/ZSK keys when either is past its configured lifetime. + + Regenerates the keyset into the same location recorded in + ``dnssec_output`` and refreshes the stored metadata. CoreDNS picks up the + regenerated keys on its next reload; full reload-on-rotation is exercised + in CI e2e. + """ + dnssec = state.dnssec_output + if not dnssec: + return + + now = datetime.now(UTC) + ksk_lifetime = dnssec.get("ksk_lifetime_days", 365) + zsk_lifetime = dnssec.get("zsk_lifetime_days", 30) + generated_at = dnssec.get("generated_at") + + ksk_due = self._dnssec_key_is_expired(generated_at, ksk_lifetime, now) + zsk_due = self._dnssec_key_is_expired(generated_at, zsk_lifetime, now) + if not (ksk_due or zsk_due): + return + + zone = dnssec.get("zone", "internal") + keys_location = dnssec.get("keys_location") + keys_host_dir = keys_location if keys_location and "/" in str(keys_location) else None + + self.logger.info( + "dnssec_key_rotation_needed", + extra={"zone": zone, "ksk_due": ksk_due, "zsk_due": zsk_due}, + ) + try: + new_info = await self.pki_handler.setup_dnssec( + zone=zone, + ksk_lifetime_days=ksk_lifetime, + zsk_lifetime_days=zsk_lifetime, + keys_host_dir=keys_host_dir, + ) + state.dnssec_output = new_info + await self._emit_rotation_event(zone, "dnssec", "success") + self.logger.info( + "dnssec_keys_rotated", + extra={"zone": zone, "ksk": new_info["ksk_name"], "zsk": new_info["zsk_name"]}, + ) + except Exception as e: + self.logger.error("dnssec_rotation_failed", extra={"zone": zone, "error": str(e)}) + await self._emit_rotation_event(zone, "dnssec", "failed", error=str(e)) + async def _emit_rotation_event( self, cn: str, diff --git a/tests/integration/test_e2e_fullstack.py b/tests/integration/test_e2e_fullstack.py index 483f75e..8ee7bc3 100644 --- a/tests/integration/test_e2e_fullstack.py +++ b/tests/integration/test_e2e_fullstack.py @@ -111,6 +111,35 @@ def _send_dns_soa(server_ip: str, zone: str, timeout: float = 5.0) -> bool: return False +def _dns_query_ancount(server_ip: str, zone: str, qtype: int, timeout: float = 5.0) -> int: + """Send a raw DNS query of *qtype* over UDP and return the answer count. + + Returns -1 on transport error. Used to confirm a signed zone serves DNSKEY + (type 48) records once DNSSEC online signing is active. No third-party DNS + library so the e2e test stays self-contained. + """ + header = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0) + qname = b"" + for label in zone.rstrip(".").split("."): + enc = label.encode() + qname += bytes([len(enc)]) + enc + qname += b"\x00" + question = qname + struct.pack(">HH", qtype, 1) # qtype + IN + query = header + question + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.settimeout(timeout) + s.sendto(query, (server_ip, 53)) + data, _ = s.recvfrom(4096) + resp_id, _flags, _qd, ancount = struct.unpack(">HHHH", data[:8]) + if resp_id != 0x1234: + return -1 + return ancount + except Exception: + return -1 + + def _build_context(spec, zone_dir: str, state: RuntimeState | None = None) -> PhaseContext: docker_handler = DockerHandler() return PhaseContext( @@ -191,6 +220,68 @@ async def test_e2e_substrate_and_dns(tmp_path, monkeypatch): _cleanup_docker(client) +# ───────────────────────────────────────────── +# DNSSEC online signing (WS-A) +# ───────────────────────────────────────────── + + +@pytest.mark.e2e +@pytest.mark.slow +@pytest.mark.asyncio +async def test_e2e_dnssec_online_signing(tmp_path, monkeypatch): + """DNSSEC: generate KSK/ZSK, activate CoreDNS signing, serve DNSKEY records. + + Marked @slow: pulls the bind9 image (~150 MB) for dnssec-keygen. + + Validates: + - PKIHandler.setup_dnssec writes keys into /keys (the CoreDNS mount) + - DNSHandler.enable_dnssec injects a dnssec block and reloads CoreDNS + - CoreDNS stays up and answers a DNSKEY (type 48) query for the signed zone + """ + from netengine.handlers.pki_handler import PKIHandler + + zone_dir = str(tmp_path / "coredns") + monkeypatch.setenv("NETENGINE_ZONE_DIR", zone_dir) + + client = _docker_client() + if client is None: + pytest.skip("Docker daemon not available") + + spec = load_spec(FIXTURES_DIR / "e2e-spec.yaml") + ctx = _build_context(spec, zone_dir) + + try: + await SubstrateHandler().execute(ctx) + await DNSHandler().execute(ctx) + + coredns = client.containers.get("netengine_coredns") + container_ip = _get_container_ip(coredns, "core") + + # Generate keys into the CoreDNS-visible keys dir, then activate signing. + pki = PKIHandler(ctx.docker_client, ctx.runtime_state, spec) + dnssec_info = await pki.setup_dnssec("internal", keys_host_dir=str(Path(zone_dir) / "keys")) + activated = await DNSHandler().enable_dnssec( + ctx, "internal", [dnssec_info["ksk_name"], dnssec_info["zsk_name"]] + ) + assert activated is True + + corefile = (Path(zone_dir) / "Corefile").read_text() + assert "dnssec {" in corefile + + await asyncio.sleep(3) + coredns.reload() + assert coredns.status == "running", "CoreDNS crashed after DNSSEC reconfigure" + + ancount = _dns_query_ancount(container_ip, "internal", 48) # DNSKEY + assert ancount >= 1, ( + f"Signed zone served no DNSKEY records (ancount={ancount}); " + "DNSSEC online signing did not activate." + ) + + finally: + _cleanup_docker(client) + + # ───────────────────────────────────────────── # Phase 3: PKI / ACME # ───────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f7d604..16b1d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -404,13 +404,13 @@ def test_validate_valid_spec_exits_zero() -> None: def test_validate_unsupported_enabled_feature_exits_nonzero(tmp_path: Path) -> None: """Unsupported active feature gates should fail validation precisely.""" - spec_file = _write_cli_validate_spec(tmp_path, pki__crl_enabled=True) + spec_file = _write_cli_validate_spec(tmp_path, gateway_portal__real_internet__mode="mirrored") result = CliRunner().invoke(cli_main.cli, ["validate", str(spec_file)]) assert result.exit_code == 1 assert "Unsupported spec features enabled" in result.output - assert "pki.crl_enabled is unsupported" in result.output + assert "gateway_portal.real_internet.mode is unsupported" in result.output def test_validate_experimental_enabled_feature_exits_zero_with_explanation(tmp_path: Path) -> None: diff --git a/tests/test_pki_features.py b/tests/test_pki_features.py index 7590a27..3b0c2ef 100644 --- a/tests/test_pki_features.py +++ b/tests/test_pki_features.py @@ -455,6 +455,208 @@ def test_no_world_spec_returns_initial_configs(self): assert configs["app"].rotation_interval_hours == 48 +# ───────────────────────────────────────────── +# DNSSEC end-to-end wiring (WS-A) +# ───────────────────────────────────────────── + + +class TestDNSSECKeyHostDir: + """setup_dnssec can target a host keys dir and records rotation metadata.""" + + async def test_keys_host_dir_binds_host_path_not_named_volume( + self, pki_handler, mock_docker, tmp_path + ): + mock_docker.run_container_one_off = AsyncMock( + side_effect=[ + {"exit_code": 0, "logs": "Kinternal.+013+01234"}, + {"exit_code": 0, "logs": "Kinternal.+013+05678"}, + ] + ) + keys_dir = str(tmp_path / "keys") + result = await pki_handler.setup_dnssec("internal", keys_host_dir=keys_dir) + + # Named volume is NOT created when a host dir is supplied. + mock_docker.ensure_volume.assert_not_called() + assert result["keys_location"] == keys_dir + assert "generated_at" in result + # The keygen container bound the host keys dir at /keys. + first_call = mock_docker.run_container_one_off.call_args_list[0].kwargs + assert keys_dir in first_call["volumes"] + + +class TestCorefileDNSSECInjection: + """_inject_dnssec_into_corefile is a pure, idempotent Corefile transform.""" + + def _corefile(self) -> str: + from netengine.handlers.dns import DNSHandler + + return DNSHandler._generate_corefile( + DNSHandler(), + zone_files={"internal": "", "platform.internal": ""}, + root_zone={}, + platform_zone={}, + tlds={}, + ) + + def test_injects_dnssec_block_into_target_zone(self): + from netengine.handlers.dns import DNSHandler + + out = DNSHandler._inject_dnssec_into_corefile( + self._corefile(), "internal", ["Kinternal.+013+01234", "Kinternal.+013+05678"] + ) + assert "dnssec {" in out + assert "/etc/coredns/keys/Kinternal.+013+01234" in out + assert "/etc/coredns/keys/Kinternal.+013+05678" in out + + def test_only_target_zone_gets_dnssec(self): + from netengine.handlers.dns import DNSHandler + + out = DNSHandler._inject_dnssec_into_corefile( + self._corefile(), "internal", ["Kinternal.+013+01234"] + ) + # The platform.internal stanza must remain unsigned. + platform_stanza = next( + s for s in out.split("\n\n") if s.lstrip().startswith("platform.internal {") + ) + assert "dnssec {" not in platform_stanza + + def test_injection_is_idempotent(self): + from netengine.handlers.dns import DNSHandler + + once = DNSHandler._inject_dnssec_into_corefile( + self._corefile(), "internal", ["Kinternal.+013+01234"] + ) + twice = DNSHandler._inject_dnssec_into_corefile(once, "internal", ["Kinternal.+013+01234"]) + assert once == twice + + def test_unknown_zone_leaves_corefile_unchanged(self): + from netengine.handlers.dns import DNSHandler + + corefile = self._corefile() + out = DNSHandler._inject_dnssec_into_corefile(corefile, "nonexistent", ["Kx.+013+1"]) + assert out == corefile + + +class TestEnableDNSSEC: + """DNSHandler.enable_dnssec rewrites the Corefile on disk and reloads.""" + + async def test_enable_dnssec_writes_block_and_reloads(self, tmp_path): + from netengine.handlers.dns import DNSHandler + + zone_dir = tmp_path + corefile = zone_dir / "Corefile" + corefile.write_text( + DNSHandler._generate_corefile(DNSHandler(), {"internal": ""}, {}, {}, {}) + ) + + ctx = MagicMock() + ctx.mock_mode = False + ctx.docker_client = MagicMock() + ctx.zone_dir = str(zone_dir) + ctx.logger = MagicMock() + + handler = DNSHandler() + with patch.object(handler, "_reload_coredns", new=AsyncMock()) as reload_mock: + changed = await handler.enable_dnssec( + ctx, "internal", ["Kinternal.+013+01234", "Kinternal.+013+05678"] + ) + + assert changed is True + assert "dnssec {" in corefile.read_text() + reload_mock.assert_awaited_once() + + async def test_enable_dnssec_noop_in_mock_mode(self, tmp_path): + from netengine.handlers.dns import DNSHandler + + ctx = MagicMock() + ctx.mock_mode = True + ctx.docker_client = None + ctx.zone_dir = str(tmp_path) + ctx.logger = MagicMock() + + changed = await DNSHandler().enable_dnssec(ctx, "internal", ["Kx.+013+1"]) + assert changed is False + + +class TestDNSSECRotation: + """The rotation worker ages DNSSEC keys against their configured lifetimes.""" + + def _make_worker(self, pki_handler=None): + from netengine.workers.pki_cert_rotation_worker import PKICertRotationWorker + + return PKICertRotationWorker( + pki_handler=pki_handler or MagicMock(), + pgmq=MagicMock(), + cert_type_configs=[], + ) + + def test_key_expiry_check(self): + from datetime import UTC, datetime, timedelta + + from netengine.workers.pki_cert_rotation_worker import PKICertRotationWorker + + now = datetime(2026, 1, 31, tzinfo=UTC) + fresh = (now - timedelta(days=10)).isoformat() + old = (now - timedelta(days=40)).isoformat() + assert PKICertRotationWorker._dnssec_key_is_expired(fresh, 30, now) is False + assert PKICertRotationWorker._dnssec_key_is_expired(old, 30, now) is True + # Missing/garbage generated_at is treated as expired. + assert PKICertRotationWorker._dnssec_key_is_expired(None, 30, now) is True + + async def test_rotation_regenerates_keys_when_zsk_due(self): + from datetime import UTC, datetime, timedelta + + pki = MagicMock() + pki.setup_dnssec = AsyncMock( + return_value={"zone": "internal", "ksk_name": "Knew.ksk", "zsk_name": "Knew.zsk"} + ) + worker = self._make_worker(pki) + worker._emit_rotation_event = AsyncMock() + + state = RuntimeState() + old = (datetime.now(UTC) - timedelta(days=40)).isoformat() + state.dnssec_output = { + "zone": "internal", + "ksk_lifetime_days": 365, + "zsk_lifetime_days": 30, + "generated_at": old, + "keys_location": "/data/zones/keys", + } + + await worker._check_and_rotate_dnssec(state) + + pki.setup_dnssec.assert_awaited_once() + assert state.dnssec_output["ksk_name"] == "Knew.ksk" + worker._emit_rotation_event.assert_awaited_once() + + async def test_no_rotation_when_keys_fresh(self): + from datetime import UTC, datetime, timedelta + + pki = MagicMock() + pki.setup_dnssec = AsyncMock() + worker = self._make_worker(pki) + + state = RuntimeState() + state.dnssec_output = { + "zone": "internal", + "ksk_lifetime_days": 365, + "zsk_lifetime_days": 30, + "generated_at": datetime.now(UTC).isoformat(), + } + + await worker._check_and_rotate_dnssec(state) + pki.setup_dnssec.assert_not_awaited() + + async def test_no_dnssec_output_is_noop(self): + pki = MagicMock() + pki.setup_dnssec = AsyncMock() + worker = self._make_worker(pki) + state = RuntimeState() + state.dnssec_output = None + await worker._check_and_rotate_dnssec(state) + pki.setup_dnssec.assert_not_awaited() + + # ───────────────────────────────────────────── # Intermediate CA — Phase output + API endpoint # ───────────────────────────────────────────── diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index d637dee..20811a9 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -164,17 +164,21 @@ def _merge(a, b): spec_file.write_text(yaml.safe_dump(base)) return spec_file - def test_unsupported_enabled_field_raises_spec_load_error(self, tmp_path) -> None: + def test_experimental_pki_field_logs_warning_not_error(self, tmp_path, caplog) -> None: + import logging + + # DNSSEC/CRL/OCSP were promoted from unsupported to experimental once the + # CoreDNS online-signing + step-ca config paths were wired (WS-A). They + # now load with a warning instead of being rejected. spec_file = self._write_spec(tmp_path, {"pki": {"dnssec_enabled": True}}) - with pytest.raises( - SpecLoadError, - match=( - "pki\\.dnssec_enabled is unsupported in alpha: " - "DNSSEC key generation is not integrated with zone signing" - ), - ): - load_spec(spec_file) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + spec = load_spec(spec_file) + + assert spec.pki.dnssec_enabled is True + assert any( + "pki.dnssec_enabled is experimental in alpha" in r.message for r in caplog.records + ) def test_unsupported_non_default_active_field_raises_spec_load_error(self, tmp_path) -> None: spec_file = self._write_spec(