diff --git a/nix/modules/hive-ci.nix b/nix/modules/hive-ci.nix index 7d07ec06..72072620 100644 --- a/nix/modules/hive-ci.nix +++ b/nix/modules/hive-ci.nix @@ -8,6 +8,7 @@ let cfg = config.services.hyperhive.forge.ci; forgeCfg = config.services.hyperhive.forge; gatewayCfg = config.services.hyperhive.gateway; + networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.tls; # Self-signed TLS is the gateway default (no operator cert / ACME). When @@ -166,8 +167,14 @@ let in { # Forgejo Actions runner in a `hive-ci` nixos-container. - # Shares host netns (same as hive-forge), so the runner reaches - # the forge at `http://127.0.0.1:` without extra plumbing. + # Uses a private network namespace (bridge-connected, not host netns) + # so CI build scripts cannot reach host-loopback services (dashboard, + # forge internal port, etc.) — a key defence against prompt-injection + # via PR nix builds. The runner reaches the forge via the + # gateway at `http://${forgeCfg.domain}` (resolved to the bridge IP + # via `networking.extraHosts`; gateway port 80 is always open on the + # bridge; `addSSL = true` means HTTP is served alongside HTTPS without + # a redirect). See docs/network.md. # Container is non-ephemeral: the runner's registered credentials # survive restarts (gitea-actions-runner writes them to its stateDir # on first registration and reuses them on every subsequent start). @@ -265,9 +272,24 @@ in }; config = lib.mkIf cfg.enable { - # No forge-presence assertion needed: the internal forge is mandatory - # (deploys with hyperhive), so the runner always has an instance to - # register against. + # `forge.behindGateway = true` (the default) is required because the + # CI container uses private networking and reaches the forge through + # the gateway vhost. Without the gateway vhost there is no HTTP + # listener for `forgeCfg.domain` on the bridge that the runner can + # connect to. + assertions = [ + { + assertion = forgeCfg.behindGateway; + message = '' + services.hyperhive.forge.ci.enable requires + services.hyperhive.forge.behindGateway = true. + The CI container runs with a private network namespace and + reaches the forge through the gateway vhost on the bridge IP. + Set behindGateway = true (it defaults to true alongside + services.hyperhive.enable). + ''; + } + ]; # Create /run/hive-ci/ on the host and seed runner-token with a # placeholder. hive-ci-prefetch.service overwrites it with the real @@ -337,8 +359,13 @@ in containers.hive-ci = { autoStart = true; ephemeral = false; - # Shared host netns: runner reaches hive-forge at localhost. - privateNetwork = false; + # Private network namespace, attached to the hive bridge so the + # runner reaches the forge via the gateway — and cannot reach + # host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.). + # Requires `forge.behindGateway = true` (asserted in the options + # block above). See docs/network.md. + privateNetwork = true; + hostBridge = networkCfg.bridgeName; bindMounts = { # Pre-filled by hive-ci-prefetch.service (host-side) before the @@ -367,23 +394,27 @@ in { system.stateVersion = "26.05"; - # Resolve the hive's own forge domain to loopback inside the - # runner. The Forgejo Actions artifact API hands the runner an - # upload URL built from forgejo's ROOT_URL — the *public* forge - # domain (`forge.`), not the runner's - # `127.0.0.1:` registration URL. This container shares - # host netns but carries no resolver entry for the hive's own - # domains (the gateway's `localHostsEntry` only touches the host), - # so `actions/upload-artifact` dies with - # Error: getaddrinfo ENOTFOUND forge. - # while normal CI is unaffected (checkout uses the localhost - # registration URL). Map the forge domain to loopback so the - # upload reaches the local forge — via the gateway on :80 when - # behindGateway, or forge directly on :httpPort otherwise. The - # gateway proxies all of `/` (artifact endpoints included), and a - # `localhost` domain (no hive-domain set) is already loopback, so - # this is a harmless no-op in that case. - networking.extraHosts = "127.0.0.1 ${forgeCfg.domain}"; + # Point the forge domain at the bridge IP so the runner can + # reach the forge through the gateway — both for registration / + # polling (runner URL below) and for artifact uploads (the + # Forgejo Actions artifact API uses ROOT_URL, i.e. the public + # forge domain, not a localhost URL). The gateway vhost for + # `forgeCfg.domain` proxies all `/` → forge; `addSSL = true` + # means HTTP:80 is served without redirect alongside HTTPS:443. + # Ports 80 and 443 are always open on the bridge firewall (see + # hive-network.nix). No DNS lookup needed — /etc/hosts wins. + networking.extraHosts = "${networkCfg.bridgeIp} ${forgeCfg.domain}"; + # DNS: use the hive resolver on the bridge IP (dnsmasq in + # hive-gateway) for external lookups (git checkout, crate + # registries, etc.). The bridge→loopback DROP rule does not + # affect traffic destined for the bridge IP itself. + networking.nameservers = [ networkCfg.bridgeIp ]; + # With privateNetwork=true + hostBridge the container's veth + # is bridge-attached. Enable DHCP so the container gets an IP + # from dnsmasq on the bridge (hive-gateway serves the bridge + # subnet). Matches how normal hive agent containers acquire + # their addresses. + networking.useDHCP = true; # nspawn containers can't create user-namespaces, so nix # sandboxing always fails. Fall back to unsandboxed builds. @@ -407,7 +438,12 @@ in services.gitea-actions-runner.instances.hive = { enable = true; name = cfg.name; - url = "http://127.0.0.1:${toString forgeCfg.httpPort}"; + # Route through the gateway (bridge IP, port 80) so the + # runner never touches host-loopback. The forge domain + # resolves to the bridge IP via networking.extraHosts above; + # the gateway vhost `forgeCfg.domain` proxies to the forge + # on HTTP:80 (addSSL=true, no HTTP→HTTPS redirect). + url = "http://${forgeCfg.domain}"; # EnvironmentFile providing TOKEN= — pre-filled by the # host-side hive-ci-prefetch.service before the container # starts; bind-mounted read-only from /run/hive-ci/runner-token