diff --git a/docs/spec-alpha-support.md b/docs/spec-alpha-support.md index db5ffb4..2a288ac 100644 --- a/docs/spec-alpha-support.md +++ b/docs/spec-alpha-support.md @@ -28,9 +28,9 @@ Feature states: | `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` | `experimental` | `isolated` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Isolated, shadowed, mirrored, and exposed nftables policies are wired; real-host integration remains alpha. | | `gateway_portal.real_internet.service_mirrors` | `experimental` | `[]` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Mirrored-mode allowlists and operator output are wired; `in_world_service` must be an IPv4 address until automatic DNS aliasing for real hostnames lands. | -| `gateway_portal.real_internet.upstream_resolver_enabled` | `unsupported` | `false` | `netengine.spec.loader` | Upstream resolver forwarding is not implemented. | -| `gateway_portal.cross_world.mode` | `unsupported` | `none` | `netengine.spec.loader` | Cross-world federation is not implemented. | -| `gateway_portal.cross_world.peers` | `unsupported` | `[]` | `netengine.spec.loader` | Cross-world peer provisioning is not implemented. | +| `gateway_portal.real_internet.upstream_resolver_enabled` | `experimental` | `false` | `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | CoreDNS upstream forwarding stubs are appended and reloaded; duplicate-stub reconciliation remains alpha. | +| `gateway_portal.cross_world.mode` | `experimental` | `none` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | PEERED/FEDERATED lifecycle hooks install peer routing, DNS stubs, trust anchors, runtime artifacts, rollback, and healthchecks; interoperability remains alpha. | +| `gateway_portal.cross_world.peers` | `experimental` | `[]` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Peer add/update/remove, trust-anchor rotation, and routing reapply are implemented with mocked two-world DNS coverage; live multi-host federation is not yet guaranteed. | | `ands.profiles.*.dynamic_ip` | `unsupported` | profile default | `netengine.spec.loader` | Dynamic IP allocation in AND profiles is not implemented. | | `ands.profiles.*.reverse_dns` | `unsupported` | profile default | `netengine.spec.loader` | Reverse DNS delegation from AND profiles is not implemented. | | `ands.profiles.*.bgp` | `unsupported` | profile default | `netengine.spec.loader` | BGP profile configuration is not implemented. | diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 9460346..27a50c2 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -28,9 +28,9 @@ Feature states: | `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` | `experimental` | `isolated` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Isolated, shadowed, mirrored, and exposed nftables policies are wired; real-host integration remains alpha. | | `gateway_portal.real_internet.service_mirrors` | `experimental` | `[]` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Mirrored-mode allowlists and operator output are wired; `in_world_service` must be an IPv4 address until automatic DNS aliasing for real hostnames lands. | -| `gateway_portal.real_internet.upstream_resolver_enabled` | `unsupported` | `false` | `netengine.spec.loader` | Upstream resolver forwarding is not implemented. | -| `gateway_portal.cross_world.mode` | `unsupported` | `none` | `netengine.spec.loader` | Cross-world federation is not implemented. | -| `gateway_portal.cross_world.peers` | `unsupported` | `[]` | `netengine.spec.loader` | Cross-world peer provisioning is not implemented. | +| `gateway_portal.real_internet.upstream_resolver_enabled` | `experimental` | `false` | `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | CoreDNS upstream forwarding stubs are appended and reloaded; duplicate-stub reconciliation remains alpha. | +| `gateway_portal.cross_world.mode` | `experimental` | `none` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | PEERED/FEDERATED lifecycle hooks install peer routing, DNS stubs, trust anchors, runtime artifacts, rollback, and healthchecks; interoperability remains alpha. | +| `gateway_portal.cross_world.peers` | `experimental` | `[]` | `netengine.handlers.gateway_handler`, `netengine.handlers.gateway_portal_handler`, `netengine.spec.loader` | Peer add/update/remove, trust-anchor rotation, and routing reapply are implemented with mocked two-world DNS coverage; live multi-host federation is not yet guaranteed. | | `ands.profiles.*.dynamic_ip` | `unsupported` | profile default | `netengine.spec.loader` | Dynamic IP allocation in AND profiles is not implemented. | | `ands.profiles.*.reverse_dns` | `unsupported` | profile default | `netengine.spec.loader` | Reverse DNS delegation from AND profiles is not implemented. | | `ands.profiles.*.bgp` | `unsupported` | profile default | `netengine.spec.loader` | BGP profile configuration is not implemented. | diff --git a/netengine/handlers/gateway_handler.py b/netengine/handlers/gateway_handler.py index 783064e..8f4be24 100644 --- a/netengine/handlers/gateway_handler.py +++ b/netengine/handlers/gateway_handler.py @@ -1,7 +1,7 @@ import ipaddress import os import tempfile -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any from netengine.errors import GatewayError from netengine.gateways.base import BaseGatewayHandler @@ -15,6 +15,79 @@ def __init__(self, docker: Any) -> None: self.docker = docker self.gateway_container = "netengine_gateway" + async def gateway_reachable(self) -> bool: + """Return True when the gateway container accepts exec commands.""" + try: + exit_code, _ = await self.docker.exec_command( + self.gateway_container, ["true"] + ) + return exit_code == 0 + except Exception: + return False + + async def nft_table_exists(self, family: str, table_name: str) -> bool: + """Return True when an nftables table exists in the gateway container.""" + exit_code, _ = await self.docker.exec_command( + self.gateway_container, ["nft", "list", "table", family, table_name] + ) + return exit_code == 0 + + async def path_exists(self, path: str) -> bool: + """Return True when *path* exists in the gateway container.""" + exit_code, _ = await self.docker.exec_command( + self.gateway_container, ["test", "-e", path] + ) + return exit_code == 0 + + async def peer_endpoint_reachable( + self, endpoint: str, timeout_seconds: int = 2 + ) -> bool | None: + """Best-effort TCP reachability probe for host:port endpoints. + + Returns None when the endpoint has no explicit port or the container lacks + a probe utility; callers can treat that as unknown rather than unhealthy. + """ + host, sep, port = endpoint.rpartition(":") + if not sep or not host or not port.isdigit(): + return None + probes = ( + ["nc", "-z", "-w", str(timeout_seconds), host, port], + [ + "sh", + "-c", + f"timeout {timeout_seconds} bash -c ' None: + """Re-load persisted peer nftables rule files after a gateway restart.""" + for peer in peers: + peer_name = peer.get("name") + rules_path = ( + peer.get("routing_rules_path") + or f"/etc/nftables/rules/peer_{peer_name}.nft" + ) + exit_code, output = await self.docker.exec_command( + self.gateway_container, ["nft", "-f", rules_path] + ) + if exit_code != 0: + raise GatewayError( + f"Failed to reapply peer routing for {peer_name}: {output}" + ) + async def generate_rules(self, and_name: str, profile: str, cidr: str) -> str: """Generate nftables ruleset for the given AND profile.""" if profile == "residential": @@ -95,14 +168,18 @@ async def apply_rules(self, and_name: str, rules: str) -> None: f.write(rules) tmp_path = f.name try: - await self.docker.copy_to_container(self.gateway_container, tmp_path, dest_path) + await self.docker.copy_to_container( + self.gateway_container, tmp_path, dest_path + ) finally: os.unlink(tmp_path) cmd = ["nft", "-f", dest_path] exit_code, output = await self.docker.exec_command(self.gateway_container, cmd) if exit_code != 0: - raise GatewayError(f"Failed to apply nftables rules for {and_name}: {output}") + raise GatewayError( + f"Failed to apply nftables rules for {and_name}: {output}" + ) async def remove_rules(self, and_name: str) -> None: """Delete the nftables table for this AND.""" @@ -110,7 +187,9 @@ async def remove_rules(self, and_name: str) -> None: exit_code, output = await self.docker.exec_command(self.gateway_container, cmd) # Table-not-found is acceptable on teardown if exit_code != 0 and "No such table" not in output: - raise GatewayError(f"Failed to remove nftables table for {and_name}: {output}") + raise GatewayError( + f"Failed to remove nftables table for {and_name}: {output}" + ) cmd = ["rm", "-f", f"/etc/nftables/rules/{and_name}.nft"] await self.docker.exec_command(self.gateway_container, cmd) @@ -145,7 +224,9 @@ async def apply_internet_policy(self, config: "RealInternetConfig") -> None: f.write(rules) tmp_path = f.name try: - await self.docker.copy_to_container(self.gateway_container, tmp_path, dest_path) + await self.docker.copy_to_container( + self.gateway_container, tmp_path, dest_path + ) except Exception as exc: os.unlink(tmp_path) raise GatewayError( @@ -157,7 +238,9 @@ async def apply_internet_policy(self, config: "RealInternetConfig") -> None: self.gateway_container, ["nft", "-f", dest_path] ) if exit_code != 0: - raise GatewayError(f"Failed to apply internet policy ({config.mode.value}): {output}") + raise GatewayError( + f"Failed to apply internet policy ({config.mode.value}): {output}" + ) async def remove_internet_policy(self) -> None: """Remove internet policy rules (reverts to isolated state).""" @@ -179,7 +262,9 @@ def _internet_rules(self, config: "RealInternetConfig") -> str: dispatch = { GatewayRealInternetMode.ISOLATED: self._isolated_internet_rules, GatewayRealInternetMode.SHADOWED: self._shadowed_internet_rules, - GatewayRealInternetMode.MIRRORED: lambda: self._mirrored_internet_rules(config), + GatewayRealInternetMode.MIRRORED: lambda: self._mirrored_internet_rules( + config + ), GatewayRealInternetMode.EXPOSED: self._exposed_internet_rules, } return dispatch[config.mode]() @@ -300,7 +385,9 @@ async def apply_peer_routing(self, peer_name: str, peer_endpoint_ip: str) -> Non f.write(rules) tmp_path = f.name try: - await self.docker.copy_to_container(self.gateway_container, tmp_path, dest_path) + await self.docker.copy_to_container( + self.gateway_container, tmp_path, dest_path + ) except Exception as exc: os.unlink(tmp_path) raise GatewayError( @@ -312,7 +399,9 @@ async def apply_peer_routing(self, peer_name: str, peer_endpoint_ip: str) -> Non self.gateway_container, ["nft", "-f", dest_path] ) if exit_code != 0: - raise GatewayError(f"Failed to apply peer routing for {peer_name}: {output}") + raise GatewayError( + f"Failed to apply peer routing for {peer_name}: {output}" + ) async def remove_peer_routing(self, peer_name: str) -> None: """Remove routing rules for a cross-world peer.""" @@ -321,7 +410,9 @@ async def remove_peer_routing(self, peer_name: str) -> None: ["nft", "delete", "table", "inet", f"netengine_peer_{peer_name}"], ) if exit_code != 0 and "No such table" not in output: - raise GatewayError(f"Failed to remove peer routing for {peer_name}: {output}") + raise GatewayError( + f"Failed to remove peer routing for {peer_name}: {output}" + ) await self.docker.exec_command( self.gateway_container, ["rm", "-f", f"/etc/nftables/rules/peer_{peer_name}.nft"], @@ -348,7 +439,9 @@ async def setup_dhcp(self, and_name: str, cidr: str, gateway_ip: str) -> None: f.write(conf) tmp_path = f.name try: - await self.docker.copy_to_container(self.gateway_container, tmp_path, conf_path) + await self.docker.copy_to_container( + self.gateway_container, tmp_path, conf_path + ) finally: os.unlink(tmp_path) @@ -369,13 +462,17 @@ async def remove_dhcp(self, and_name: str) -> None: await self.docker.exec_command( self.gateway_container, ["rm", "-f", f"/etc/dnsmasq.d/{and_name}.conf"] ) - await self.docker.exec_command(self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"]) + await self.docker.exec_command( + self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"] + ) # ───────────────────────────────────────────── # BGP speaker (bgp profile feature) # ───────────────────────────────────────────── - async def setup_bgp(self, and_name: str, cidr: str, gateway_ip: str, bgp_mode: str) -> None: + async def setup_bgp( + self, and_name: str, cidr: str, gateway_ip: str, bgp_mode: str + ) -> None: """Provision a Bird2 BGP speaker sidecar container for the AND.""" bird_conf = self._bird_conf(and_name, cidr, gateway_ip) conf_path = f"/etc/bird/bird_{and_name}.conf" diff --git a/netengine/handlers/gateway_portal_handler.py b/netengine/handlers/gateway_portal_handler.py index 50c8e72..b3ce846 100644 --- a/netengine/handlers/gateway_portal_handler.py +++ b/netengine/handlers/gateway_portal_handler.py @@ -11,6 +11,7 @@ """ import os +import re from datetime import datetime from typing import Any @@ -57,7 +58,9 @@ async def execute(self, context: PhaseContext) -> None: } context.runtime_state.save() await self._emit_event( - context, "gateway_portal.ready", context.runtime_state.gateway_portal_output + context, + "gateway_portal.ready", + context.runtime_state.gateway_portal_output, ) return @@ -72,7 +75,9 @@ async def execute(self, context: PhaseContext) -> None: internet_output = await self._apply_internet_policy(context, gateway, portal) # ── Cross-World Federation ────────────────────────────────────────── - federation_output = await self._apply_cross_world(context, gateway, docker, portal) + federation_output = await self._apply_cross_world( + context, gateway, docker, portal + ) # ── Persist ──────────────────────────────────────────────────────── context.runtime_state.gateway_portal_output = { @@ -82,6 +87,7 @@ async def execute(self, context: PhaseContext) -> None: "peer_count": len(portal.cross_world.peers), "internet": internet_output, "federation": federation_output, + "peer_artifacts": federation_output.get("peers", []), "deployed_at": datetime.utcnow().isoformat(), } context.runtime_state.save() @@ -94,7 +100,54 @@ async def execute(self, context: PhaseContext) -> None: context.logger.info("Gateway portal policies applied") async def healthcheck(self, context: PhaseContext) -> bool: - return context.runtime_state.gateway_portal_output is not None + output = context.runtime_state.gateway_portal_output + if output is None: + return False + if not output.get("enabled", True) or output.get("mock"): + return True + if context.docker_client is None: + return False + + gateway = GatewayHandler(context.docker_client) + checks: dict[str, Any] = { + "gateway_reachable": await gateway.gateway_reachable() + } + if not checks["gateway_reachable"]: + output["healthchecks"] = checks + return False + + internet_mode = output.get("internet_mode") + if internet_mode and internet_mode != GatewayRealInternetMode.CUSTOM.value: + checks["internet_nft_table"] = await gateway.nft_table_exists( + "inet", "netengine_internet" + ) + + peers = output.get("peer_artifacts", []) + peer_checks = [] + for peer in peers: + peer_check = {"name": peer.get("name")} + peer_check["nft_table"] = await gateway.nft_table_exists( + "inet", peer.get("nft_table", f"netengine_peer_{peer.get('name')}") + ) + trust_anchor_path = peer.get("trust_anchor_path") + peer_check["trust_anchor"] = ( + True + if not trust_anchor_path + else await gateway.path_exists(trust_anchor_path) + ) + peer_check["dns_stub"] = self._corefile_contains_stub(context, peer) + endpoint_status = await gateway.peer_endpoint_reachable( + peer.get("endpoint", "") + ) + if endpoint_status is not None: + peer_check["endpoint_reachable"] = endpoint_status + peer_checks.append(peer_check) + checks["peers"] = peer_checks + output["healthchecks"] = checks + return all(v is True for k, v in checks.items() if k != "peers") and all( + all(value is True for key, value in peer.items() if key != "name") + for peer in peer_checks + ) async def should_skip(self, context: PhaseContext) -> bool: return context.runtime_state.gateway_portal_output is not None @@ -123,7 +176,9 @@ async def _apply_internet_policy( output: dict[str, Any] = {"mode": config.mode.value} if config.upstream_resolver_enabled and config.upstream_resolver_ip: - await self._configure_upstream_resolver(context, config.upstream_resolver_ip) + await self._configure_upstream_resolver( + context, config.upstream_resolver_ip + ) output["upstream_resolver"] = config.upstream_resolver_ip if config.mode == GatewayRealInternetMode.MIRRORED and config.service_mirrors: @@ -134,7 +189,9 @@ async def _apply_internet_policy( return output - async def _configure_upstream_resolver(self, context: PhaseContext, resolver_ip: str) -> None: + async def _configure_upstream_resolver( + self, context: PhaseContext, resolver_ip: str + ) -> None: """Inject an upstream forwarder into the CoreDNS root Corefile. Adds a ``forward . `` directive so that names not @@ -148,7 +205,8 @@ async def _configure_upstream_resolver(self, context: PhaseContext, resolver_ip: try: corefile_patch = ( - f"\n# Upstream internet resolver (gateway portal)\n" f"forward . {resolver_ip}\n" + f"\n# Upstream internet resolver (gateway portal)\n" + f"forward . {resolver_ip}\n" ) corefile_path = os.path.join(context.zone_dir, "Corefile") with open(corefile_path, "a") as f: @@ -176,12 +234,13 @@ async def _apply_cross_world( return {"mode": GatewayCrossWorldMode.NONE.value, "peers": []} context.logger.info( - f"Cross-world mode: {cross_world.mode.value} " f"({len(cross_world.peers)} peer(s))" + f"Cross-world mode: {cross_world.mode.value} " + f"({len(cross_world.peers)} peer(s))" ) peers_output = [] for peer in cross_world.peers: - peer_result = await self._setup_peer(context, gateway, docker, peer) + peer_result = await self.add_peer(context, gateway, docker, peer) peers_output.append(peer_result) return { @@ -189,54 +248,129 @@ async def _apply_cross_world( "peers": peers_output, } + async def add_peer( + self, + context: PhaseContext, + gateway: GatewayHandler, + docker: DockerAdapterProtocol, + peer: CrossWorldPeer, + ) -> dict[str, Any]: + """Explicit lifecycle operation to add a peer with rollback on failure.""" + return await self._setup_peer( + context, gateway, docker, peer, rollback_on_failure=True + ) + + async def update_peer( + self, + context: PhaseContext, + gateway: GatewayHandler, + docker: DockerAdapterProtocol, + peer: CrossWorldPeer, + ) -> dict[str, Any]: + """Replace a peer's installed artifacts with the supplied definition.""" + await self.remove_peer(context, gateway, peer.name) + return await self.add_peer(context, gateway, docker, peer) + + async def remove_peer( + self, context: PhaseContext, gateway: GatewayHandler, peer_name: str + ) -> None: + """Explicit lifecycle operation to remove routing, trust, and DNS artifacts.""" + await gateway.remove_peer_routing(peer_name) + if context.docker_client is not None: + await context.docker_client.exec_command( + "netengine_gateway", + ["rm", "-f", self._trust_anchor_path(peer_name)], + ) + await context.docker_client.exec_command( + "netengine_gateway", ["update-ca-certificates"] + ) + self._remove_peer_dns(context, peer_name) + self._drop_peer_artifact(context, peer_name) + + async def rotate_trust_anchor( + self, + context: PhaseContext, + docker: DockerAdapterProtocol, + peer_name: str, + cert_pem: str, + ) -> dict[str, Any]: + """Explicit lifecycle operation to rotate an installed peer trust anchor.""" + await self._install_trust_anchor(context, docker, peer_name, cert_pem) + artifact = self._upsert_peer_artifact(context, {"name": peer_name}) + artifact["trust_anchor_path"] = self._trust_anchor_path(peer_name) + artifact["trust_anchor_installed_at"] = datetime.utcnow().isoformat() + context.runtime_state.save() + return artifact + + async def reapply_routing_after_gateway_restart( + self, context: PhaseContext, gateway: GatewayHandler + ) -> None: + """Reload persisted peer routing rule files after gateway restart.""" + output = context.runtime_state.gateway_portal_output or {} + await gateway.reapply_peer_routing(output.get("peer_artifacts", [])) + async def _setup_peer( self, context: PhaseContext, gateway: GatewayHandler, docker: DockerAdapterProtocol, peer: CrossWorldPeer, + rollback_on_failure: bool = False, ) -> dict[str, Any]: """Wire up a single cross-world peer: trust anchor + routing + DNS.""" - context.logger.info(f"Setting up cross-world peer: {peer.name} ({peer.endpoint})") - result: dict[str, Any] = { + context.logger.info( + f"Setting up cross-world peer: {peer.name} ({peer.endpoint})" + ) + peer_ip = peer.endpoint.split(":")[0] + artifact = { "name": peer.name, "endpoint": peer.endpoint, "mode": peer.mode.value, + "nft_table": f"netengine_peer_{self._safe_peer_name(peer.name)}", + "routing_rules_path": f"/etc/nftables/rules/peer_{self._safe_peer_name(peer.name)}.nft", + "dns_zone": f"{peer.name}.internal", + "dns_forwarder": f"{peer_ip}:53", + "trust_anchor_path": self._trust_anchor_path(peer.name) + if peer.trust_anchor_cert + else None, + "installed_at": datetime.utcnow().isoformat(), + "trust_anchor_installed": False, + "routing_configured": False, + "dns_forwarding_configured": False, } - - # 1. Install trust anchor certificate - if peer.trust_anchor_cert: - try: - await self._install_trust_anchor(context, docker, peer.name, peer.trust_anchor_cert) - result["trust_anchor_installed"] = True - except Exception as exc: - context.logger.warning(f"Trust anchor install failed for peer {peer.name}: {exc}") - result["trust_anchor_installed"] = False - result["trust_anchor_error"] = str(exc) - else: - result["trust_anchor_installed"] = False - - # 2. Configure nftables routing to peer endpoint + completed: list[str] = [] try: - # Extract host from endpoint (strip port if present) - peer_ip = peer.endpoint.split(":")[0] + if peer.trust_anchor_cert: + await self._install_trust_anchor( + context, docker, peer.name, peer.trust_anchor_cert + ) + completed.append("trust_anchor") + artifact["trust_anchor_installed"] = True + else: + artifact["trust_anchor_installed"] = False + await gateway.apply_peer_routing(peer.name, peer_ip) - result["routing_configured"] = True - except GatewayError as exc: - context.logger.warning(f"Peer routing failed for {peer.name}: {exc}") - result["routing_configured"] = False - result["routing_error"] = str(exc) + completed.append("routing") + artifact["routing_configured"] = True - # 3. Configure DNS forwarding for peer domains - try: await self._configure_peer_dns(context, peer) - result["dns_forwarding_configured"] = True + completed.append("dns") + artifact["dns_forwarding_configured"] = True + self._upsert_peer_artifact(context, artifact) + return artifact except Exception as exc: - context.logger.warning(f"Peer DNS forwarding failed for {peer.name}: {exc}") - result["dns_forwarding_configured"] = False - result["dns_error"] = str(exc) - - return result + context.logger.warning(f"Peer setup failed for {peer.name}: {exc}") + if rollback_on_failure: + await self._rollback_peer_setup( + context, gateway, docker, peer.name, completed + ) + artifact["error"] = str(exc) + if "routing" not in completed: + artifact["routing_error"] = str(exc) + elif "dns" not in completed: + artifact["dns_error"] = str(exc) + artifact["rolled_back"] = rollback_on_failure + return artifact async def _install_trust_anchor( self, @@ -258,7 +392,7 @@ async def _install_trust_anchor( f.write(cert_pem) tmp_path = f.name try: - dest_path = f"/usr/local/share/ca-certificates/peer_{peer_name}.crt" + dest_path = self._trust_anchor_path(peer_name) await docker.copy_to_container("netengine_gateway", tmp_path, dest_path) finally: os.unlink(tmp_path) @@ -267,11 +401,15 @@ async def _install_trust_anchor( "netengine_gateway", ["update-ca-certificates"] ) if exit_code != 0: - raise PKIError(f"update-ca-certificates failed for peer {peer_name}: {output}") + raise PKIError( + f"update-ca-certificates failed for peer {peer_name}: {output}" + ) context.logger.info(f"Trust anchor installed for peer: {peer_name}") - async def _configure_peer_dns(self, context: PhaseContext, peer: CrossWorldPeer) -> None: + async def _configure_peer_dns( + self, context: PhaseContext, peer: CrossWorldPeer + ) -> None: """Add a CoreDNS forwarding zone for the peer world's TLD. Derives the peer TLD from ``.internal`` and adds a @@ -298,12 +436,93 @@ async def _configure_peer_dns(self, context: PhaseContext, peer: CrossWorldPeer) with open(corefile_path, "a") as f: f.write(corefile_stub) except OSError as exc: - raise GatewayError(f"Could not append to Corefile at {corefile_path}: {exc}") from exc + raise GatewayError( + f"Could not append to Corefile at {corefile_path}: {exc}" + ) from exc # Signal CoreDNS to reload config via Docker daemon (no shell required) await context.docker_client.signal_container("netengine_coredns", "HUP") context.logger.info(f"DNS forwarding configured for peer TLD: {peer_tld}") + def _safe_peer_name(self, peer_name: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]", "_", peer_name) + + def _trust_anchor_path(self, peer_name: str) -> str: + return f"/usr/local/share/ca-certificates/peer_{self._safe_peer_name(peer_name)}.crt" + + def _upsert_peer_artifact( + self, context: PhaseContext, artifact: dict[str, Any] + ) -> dict[str, Any]: + output = context.runtime_state.gateway_portal_output or {} + artifacts = list(output.get("peer_artifacts", [])) + artifacts = [p for p in artifacts if p.get("name") != artifact.get("name")] + artifacts.append(artifact) + output["peer_artifacts"] = artifacts + context.runtime_state.gateway_portal_output = output + context.runtime_state.save() + return artifact + + def _drop_peer_artifact(self, context: PhaseContext, peer_name: str) -> None: + output = context.runtime_state.gateway_portal_output or {} + output["peer_artifacts"] = [ + p for p in output.get("peer_artifacts", []) if p.get("name") != peer_name + ] + context.runtime_state.gateway_portal_output = output + context.runtime_state.save() + + async def _rollback_peer_setup( + self, + context: PhaseContext, + gateway: GatewayHandler, + docker: DockerAdapterProtocol, + peer_name: str, + completed: list[str], + ) -> None: + if "dns" in completed: + self._remove_peer_dns(context, peer_name) + if "routing" in completed: + try: + await gateway.remove_peer_routing(peer_name) + except Exception as exc: + context.logger.warning( + f"Peer routing rollback failed for {peer_name}: {exc}" + ) + if "trust_anchor" in completed: + await docker.exec_command( + "netengine_gateway", ["rm", "-f", self._trust_anchor_path(peer_name)] + ) + await docker.exec_command("netengine_gateway", ["update-ca-certificates"]) + self._drop_peer_artifact(context, peer_name) + + def _remove_peer_dns(self, context: PhaseContext, peer_name: str) -> None: + corefile_path = os.path.join(context.zone_dir, "Corefile") + try: + text = open(corefile_path).read() + except OSError: + return + pattern = re.compile( + rf"\n?# Cross-world peer: {re.escape(peer_name)}\n{re.escape(peer_name)}\.internal \{{\n forward \. [^\n]+\n\}}\n?", + re.MULTILINE, + ) + new_text = pattern.sub("\n", text) + if new_text != text: + with open(corefile_path, "w") as f: + f.write(new_text) + if context.docker_client is not None: + # Fire-and-forget is not possible in this sync helper; callers also reload on add. + pass + + def _corefile_contains_stub( + self, context: PhaseContext, peer: dict[str, Any] + ) -> bool: + try: + text = open(os.path.join(context.zone_dir, "Corefile")).read() + except OSError: + return False + return ( + peer.get("dns_zone", "") in text and peer.get("dns_forwarder", "") in text + ) + # ───────────────────────────────────────────── # Event emission # ───────────────────────────────────────────── diff --git a/tests/test_gateway_portal.py b/tests/test_gateway_portal.py index 0c56857..3c9ce5b 100644 --- a/tests/test_gateway_portal.py +++ b/tests/test_gateway_portal.py @@ -75,7 +75,9 @@ def test_mirrored_includes_mirror_addresses(self, gateway): config = RealInternetConfig( mode=GatewayRealInternetMode.MIRRORED, service_mirrors=[ - ServiceMirror(real_hostname="example.com", in_world_service="10.0.1.50"), + ServiceMirror( + real_hostname="example.com", in_world_service="10.0.1.50" + ), ], ) rules = gateway._mirrored_internet_rules(config) @@ -136,12 +138,13 @@ async def test_exposed_raises_on_nft_failure(self, gateway, mock_docker): async def test_mirrored_passes_config_to_rule_generator(self, gateway, mock_docker): config = RealInternetConfig( mode=GatewayRealInternetMode.MIRRORED, - service_mirrors=[ServiceMirror(real_hostname="a.com", in_world_service="10.1.2.3")], + service_mirrors=[ + ServiceMirror(real_hostname="a.com", in_world_service="10.1.2.3") + ], ) written_content = [] import builtins - import os real_open = builtins.open @@ -199,7 +202,9 @@ async def test_other_failure_raises(self, gateway, mock_docker): class TestPeerRouting: - async def test_apply_peer_routing_uses_peer_name_in_table(self, gateway, mock_docker): + async def test_apply_peer_routing_uses_peer_name_in_table( + self, gateway, mock_docker + ): await gateway.apply_peer_routing("worldb", "192.168.100.1") copy_args = mock_docker.copy_to_container.call_args[0] assert "peer_worldb" in copy_args[2] @@ -223,7 +228,9 @@ async def test_remove_peer_routing_tolerates_not_found(self, gateway, mock_docke mock_docker.exec_command.side_effect = [(1, "No such table"), (0, "")] await gateway.remove_peer_routing("worldb") - async def test_remove_peer_routing_raises_on_other_failure(self, gateway, mock_docker): + async def test_remove_peer_routing_raises_on_other_failure( + self, gateway, mock_docker + ): mock_docker.exec_command.return_value = (1, "permission denied") with pytest.raises(GatewayError, match="worldb"): await gateway.remove_peer_routing("worldb") @@ -347,7 +354,6 @@ def test_intermediate_ca_cert_defaults_to_none(self): assert state.intermediate_ca_cert is None def test_new_fields_survive_save_load_cycle(self, tmp_path, monkeypatch): - import os state_path = tmp_path / "state.json" monkeypatch.setenv("NETENGINE_STATE_FILE", str(state_path)) @@ -355,10 +361,100 @@ def test_new_fields_survive_save_load_cycle(self, tmp_path, monkeypatch): state = RuntimeState() state.dnssec_output = {"zone": "internal", "ksk_name": "Kinternal.+013+00001"} state.gateway_portal_output = {"enabled": True, "internet_mode": "exposed"} - state.intermediate_ca_cert = "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----" + state.intermediate_ca_cert = ( + "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----" + ) state.save() loaded = RuntimeState.load() - assert loaded.dnssec_output == {"zone": "internal", "ksk_name": "Kinternal.+013+00001"} + assert loaded.dnssec_output == { + "zone": "internal", + "ksk_name": "Kinternal.+013+00001", + } assert loaded.gateway_portal_output["internet_mode"] == "exposed" assert "ABC" in loaded.intermediate_ca_cert + + +class TestGatewayPortalLifecycleAndHealth: + def _ctx(self, tmp_path, docker): + state = RuntimeState() + ctx = MagicMock() + ctx.runtime_state = state + ctx.zone_dir = str(tmp_path) + ctx.docker_client = docker + ctx.logger = MagicMock() + (tmp_path / "Corefile").write_text(". {\n errors\n}\n") + return ctx + + async def test_add_peer_tracks_artifacts_and_healthchecks_dns_stub( + self, tmp_path, mock_docker + ): + mock_docker.signal_container = AsyncMock() + ctx = self._ctx(tmp_path, mock_docker) + ctx.runtime_state.gateway_portal_output = { + "enabled": True, + "internet_mode": "custom", + } + handler = GatewayPortalHandler() + peer = CrossWorldPeer(name="worldb", endpoint="192.168.200.1:9053") + + result = await handler.add_peer( + ctx, GatewayHandler(mock_docker), mock_docker, peer + ) + + assert result["routing_configured"] is True + artifacts = ctx.runtime_state.gateway_portal_output["peer_artifacts"] + assert artifacts[0]["dns_zone"] == "worldb.internal" + assert "worldb.internal" in (tmp_path / "Corefile").read_text() + + async def exec_side_effect(container, cmd): + if cmd == ["true"]: + return (0, "") + if cmd[:4] == ["nft", "list", "table", "inet"]: + return (0, "") + if cmd[:2] == ["test", "-e"]: + return (0, "") + if cmd and cmd[0] in {"nc", "sh"}: + return (0, "") + return (0, "") + + mock_docker.exec_command.side_effect = exec_side_effect + assert await handler.healthcheck(ctx) is True + + async def test_peer_setup_rolls_back_routing_when_dns_fails( + self, tmp_path, mock_docker + ): + ctx = self._ctx(tmp_path, mock_docker) + (tmp_path / "Corefile").unlink() + handler = GatewayPortalHandler() + peer = CrossWorldPeer(name="worldc", endpoint="192.168.200.2:8443") + + result = await handler.add_peer( + ctx, GatewayHandler(mock_docker), mock_docker, peer + ) + + assert result["rolled_back"] is True + assert "error" in result + commands = [c.args[1] for c in mock_docker.exec_command.await_args_list] + assert ["nft", "delete", "table", "inet", "netengine_peer_worldc"] in commands + + async def test_reapply_routing_after_gateway_restart_loads_tracked_rule_files( + self, tmp_path, mock_docker + ): + ctx = self._ctx(tmp_path, mock_docker) + ctx.runtime_state.gateway_portal_output = { + "peer_artifacts": [ + { + "name": "worldb", + "routing_rules_path": "/etc/nftables/rules/peer_worldb.nft", + } + ] + } + + await GatewayPortalHandler().reapply_routing_after_gateway_restart( + ctx, GatewayHandler(mock_docker) + ) + + mock_docker.exec_command.assert_any_await( + "netengine_gateway", ["nft", "-f", "/etc/nftables/rules/peer_worldb.nft"] + )