diff --git a/docs/gateway.md b/docs/gateway.md index 952c6cce..313101ff 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -280,18 +280,21 @@ dashboard reach by design — the surface is privileged (approve / deny / destroy) and must not be exposed without a real reverse proxy in front. -## `HIVE_FORGE_URL`: agents reach the forge via the gateway by domain +## `HIVE_FORGE_URL`: domain via gateway for isolated agents, loopback for shared-netns Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all -`hive-forge` calls against it. Network isolation is always on (the -shared-netns mode was removed), so agents run in a private netns and -can never reach the host's loopback. `hive-c0re.nix` sets -`HIVE_FORGE_URL` to `http://` (default -`forge.`; `services.hyperhive.domain` is required). Agents -get the bridge dnsmasq as their resolver, resolve the hostname → -bridge IP, then reach nginx on port 80 (the bridge firewall opens -80+443). nginx proxies to forgejo — the same path an operator browser -takes, no raw port exposure needed. +`hive-forge` calls against it. `hive-c0re.nix` sets this based on the +network isolation mode: + +- **`network.isolateContainers = true`**: agents run in private netns and + get the bridge dnsmasq as their resolver. `HIVE_FORGE_URL` is set to + `http://` (default `forge.`). Agents resolve + the hostname via dnsmasq → bridge IP, then reach nginx on port 80 (bridge + firewall opens 80+443 when isolation is on). nginx proxies to forgejo — the + same path an operator browser takes, no raw port exposure needed. +- **`network.isolateContainers = false`** (default): agents share the host's + network namespace, so loopback reaches forgejo directly. `HIVE_FORGE_URL` + is `http://127.0.0.1:`. ## hive-forge container shape @@ -303,13 +306,10 @@ the operator already runs on the host — separate systemd namespace, separate state dir, separate port unless the operator deliberately collides. -The forge container shares the host network namespace -(`privateNetwork = false`), so forgejo's listeners look like a -host-side service — nixos-container is here for state + systemd-unit -isolation, not network isolation. Note this is the FORGE container; -agent containers are network-isolated and reach the forge through the -gateway by `forge.` (see `HIVE_FORGE_URL` above), not via the -host's loopback. +Container shares the host network namespace +(`privateNetwork = false`) so agents reach the forge at +`http://localhost:` without extra plumbing — nixos-container +is here for state + systemd-unit isolation, not network isolation. State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` and survives container restart / host reboot. To wipe, destroy the @@ -336,9 +336,9 @@ via `-p 2222`). Port 22 is left alone on the host for openssh. `openFirewall` (default **false**) controls whether `httpPort` and `sshPort` are opened in the host firewall. Off by default (secure by -default): agents reach Forgejo through the gateway (`forge.` on -the bridge), not the raw port, so no firewall hole is needed. Flip to -`true` when you need: +default): every agent container reaches Forgejo at `localhost:` +via the shared host netns without a firewall hole. Flip to `true` when +you need: - The operator's browser to reach `http://:/` directly (not behind the gateway). - External git clients that push/pull via SSH directly to the host. diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 368a3a04..5bf0af7c 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -112,9 +112,8 @@ in # The forge is mandatory — hive-c0re mirrors every agent's applied # config repo into it and it's the canonical store for the meta flake # + `internal/*` repos, so there's no enable toggle; it deploys with - # hyperhive itself. hive-matrix is opt-in (off by default). All - # subsystems rely on `services.hyperhive.domain`, which is required - # (asserted in hive-network.nix) whenever hyperhive is enabled. + # hyperhive itself. hive-matrix is opt-in (off by default) and asserts + # that `services.hyperhive.domain` is set before it can be enabled. imports = [ ./hive-ci.nix ./hive-forge.nix @@ -129,11 +128,9 @@ in options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator"; # Canonical hive DNS domain shared by every subsystem that needs a - # stable hostname. Typed nullOr (default null) so the option always - # exists, but it's REQUIRED whenever hyperhive is enabled — an - # assertion in hive-network.nix fails eval when it's unset, since - # matrix bakes it in on first boot and the gateway/forge/agent URLs all - # derive from it (no safe default). Full identity-surface + # stable hostname. Nullable + default null so existing configs + # evaluate unchanged; subsystems that need it (matrix) assert + # non-null in their own config block. Full identity-surface # context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var # chain → identity.rs → claude prompt): docs/conventions.md:: # Hive identity (label + domain + display names). @@ -146,10 +143,9 @@ in stable name (currently: `services.hyperhive.matrix.serverName` derives from this, defaulting to `matrix.''${services.hyperhive.domain}` when `serverName` is - null). **Required** when `services.hyperhive.enable` — eval fails - with a helpful message if it's unset (it's baked into matrix on - first boot and drives the gateway/forge/agent URLs, with no safe - default; changing it later is destructive). Exposed to agents as + null). No default — subsystems that opt to require it assert + non-null in their own config and fail eval with a helpful + message if it's missing. Exposed to agents as `HYPERHIVE_HIVE_DOMAIN`; consumed by `hive-ag3nt::identity::hive_domain()` for `@` qualified labels. @@ -832,11 +828,6 @@ in config.services.hyperhive.swarm.wireguard.listenPort ]; - # NB: `services.hyperhive.domain` is required when hyperhive is - # enabled — the canonical assertion lives in `hive-network.nix` (the - # hive resolver is authoritative for `` and agents reach the - # forge/matrix through the gateway by it). So everything below can - # treat `config.services.hyperhive.domain` as non-null. assertions = lib.optionals config.services.hyperhive.swarm.wireguard.enable [ { @@ -906,11 +897,10 @@ in # behaviour (root auto-managed); true makes the sweep a no-op. HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless; } - // { + // lib.optionalAttrs (config.services.hyperhive.domain != null) { # Identity env vars threaded into c0re's own service env and # forwarded by meta.rs into every sub-agent's harness env — - # full chain in docs/conventions.md::Hive identity. `domain` is - # required (asserted in hive-network.nix), so it's always set. + # full chain in docs/conventions.md::Hive identity. HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; } // lib.optionalAttrs (config.services.hyperhive.hiveName != null) { diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index cc99f7bc..ca30c11f 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -69,8 +69,13 @@ in domain = lib.mkOption { type = lib.types.str; - default = "forge.${hyperhiveDomain}"; - defaultText = lib.literalExpression ''"forge.''${services.hyperhive.domain}"''; + default = if hyperhiveDomain != null then "forge.${hyperhiveDomain}" else "localhost"; + defaultText = lib.literalExpression '' + if services.hyperhive.domain != null then + "forge.''${services.hyperhive.domain}" + else + "localhost" + ''; example = "git.example.com"; description = '' Public hostname for the forge. Doubles as both the forgejo @@ -78,10 +83,10 @@ in gateway vhost server-name when `behindGateway = true` (sub-domain routing — see `docs/gateway.md`). - Defaults to `forge.''${services.hyperhive.domain}` (idiomatic - sub-domain shape — `forge` labelled under the hive's bare - domain). `services.hyperhive.domain` is required, so there's - always a domain to derive from. + Defaults to `forge.''${services.hyperhive.domain}` when the + hive-domain is set (idiomatic sub-domain shape — `forge` + labelled under the hive's bare domain), falling back to + `localhost` otherwise (direct-on-port behaviour). Set to a full hostname (`git.example.com`, `forge.internal.lan`, etc.) for a bespoke vhost shape — the @@ -194,17 +199,18 @@ in { # `cfg.domain` can't be empty — would render `.` shaped # garbage as both server_name (nginx wildcard catch-all) and - # /etc/hosts entry (invalid). The default derives a non-empty - # `forge.`, but an operator-set empty string should fail - # loud. + # /etc/hosts entry (invalid). Default rejects this case (lands + # `"localhost"` when hive-domain is unset), but operator-set + # empty strings should fail loud. assertion = cfg.domain != ""; message = '' services.hyperhive.forge.domain = "" is rejected. The rendered URLs would be invalid (nginx wildcard catch-all for an empty server_name, /etc/hosts rejects empty entries). Either leave at default (auto-derives to - "forge."), or set a non-empty - hostname like "forge.example.com" or "git.internal". + "forge." when set, else + "localhost"), or set a non-empty hostname like "forge.example.com" + or "git.internal". ''; } ]; diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index dd0310af..bde98ac3 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -385,6 +385,14 @@ in config = lib.mkIf config.services.hyperhive.enable { assertions = [ + { + assertion = !cfg.localHostsEntry || hyperhiveDomain != null; + message = '' + services.hyperhive.gateway.localHostsEntry = true requires + services.hyperhive.domain to be set. Either pin a hostname + or leave `localHostsEntry` at its default of false. + ''; + } { assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); message = '' @@ -624,11 +632,10 @@ in ''; }; } - // { + // lib.optionalAttrs (hyperhiveDomain != null) { # FluffyChat boot-config pre-fill so the client's # `.well-known/matrix/client` lookup hits the - # right delegation endpoint. `domain` is required, so - # this is always present. + # right delegation endpoint. "= /config.json" = { extraConfig = '' default_type application/json; @@ -671,7 +678,7 @@ in # clients at `matrixCfg.gatewayHost` when set; falls back to direct # `:`. CORS `*` per matrix spec. The `m.server` # port-8448 carve-out is documented inline. See `docs/gateway.md`. - wellKnownLocations = lib.optionalAttrs matrixCfg.enable ( + wellKnownLocations = lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) ( let clientBaseUrl = if matrixCfg.gatewayHost != null then @@ -1013,7 +1020,7 @@ in # sub-domain modules that are on. `lib.unique` dedupes if any # sub-domain happens to equal another. See `docs/gateway.md` # ("Local dev"). - networking.hosts = lib.mkIf cfg.localHostsEntry { + networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) { "127.0.0.1" = lib.unique ( [ hyperhiveDomain ] ++ lib.optional (config.services.hyperhive.forge.behindGateway or false diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index ed120fac..838bd08e 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -149,8 +149,13 @@ in gatewayHost = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = "matrix.${hyperhiveDomain}"; - defaultText = lib.literalExpression ''"matrix.''${services.hyperhive.domain}"''; + default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null; + defaultText = lib.literalExpression '' + if services.hyperhive.domain != null then + "matrix.''${services.hyperhive.domain}" + else + null + ''; example = "matrix.example.com"; description = '' Public hostname for the matrix homeserver behind the gateway. @@ -289,13 +294,25 @@ in }; config = lib.mkIf cfg.enable { - # `serverName` is irrevocably embedded in user/room IDs; it derives - # from `services.hyperhive.domain` (required, asserted in - # hive-network.nix) when not set explicitly, so no separate - # domain/serverName assertion is needed here. gatewayHost may not be - # "" (same footgun as forge.domain — nginx rejects an empty - # server_name). docs/matrix.md::Assertion rationale. + # serverName must exist (irrevocably embedded in user/room IDs); + # gatewayHost may not be "" (same footgun as forge.domain — + # nginx rejects an empty server_name). docs/matrix.md::Assertion + # rationale. assertions = [ + { + assertion = hyperhiveDomain != null || cfg.serverName != null; + message = '' + services.hyperhive.matrix.enable = true requires either: + - services.hyperhive.domain set to your host's canonical domain + (recommended; shared with forge / dashboard), or + - services.hyperhive.matrix.serverName set explicitly. + + The matrix server_name is embedded into every user ID and + room ID on this homeserver — it cannot be changed later + without losing every account and chat history. Pick a + stable hostname before enabling. + ''; + } { assertion = cfg.gatewayHost == null || cfg.gatewayHost != ""; message = '' diff --git a/nix/modules/hive-tls.nix b/nix/modules/hive-tls.nix index 0931e035..2f38bdd6 100644 --- a/nix/modules/hive-tls.nix +++ b/nix/modules/hive-tls.nix @@ -12,12 +12,11 @@ let # The host-managed hive CA is the trust anchor for self-signed mode. # It is only stood up when the gateway actually serves a self-signed - # cert: the gateway must be in self-signed mode. `domain` is required - # (asserted in hive-network.nix), so the leaf SANs always have a - # domain to derive from. The self-signed condition is the gateway - # module's single source of truth (`gateway.useSelfSigned`): true when - # neither an operator cert (`tls.certDir`) nor ACME is set. - active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned; + # cert: a domain must be set (the leaf SANs derive from it) and the + # gateway must be in self-signed mode. The self-signed condition is the + # gateway module's single source of truth (`gateway.useSelfSigned`): + # true when neither an operator cert (`tls.certDir`) nor ACME is set. + active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned && domain != null; in { # Host-side TLS trust root for the self-signed gateway mode.