diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 0bdaaa5c..3ad677f2 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -123,7 +123,7 @@ in imports = [ ./hive-ci.nix ./hive-forge.nix - ./hive-gateway.nix + ./hive-gateway ./hive-matrix.nix ./hive-network.nix ./hive-tls.nix diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix deleted file mode 100644 index 71dcb87b..00000000 --- a/nix/modules/hive-gateway.nix +++ /dev/null @@ -1,1074 +0,0 @@ -{ - pkgs, - lib, - config, - ... -}: -let - cfg = config.services.hyperhive.gateway; - hyperhiveDomain = config.services.hyperhive.domain; - matrixCfg = config.services.hyperhive.matrix; - forgeCfg = config.services.hyperhive.forge; - networkCfg = config.services.hyperhive.network; - - # DHCP pool covering all usable host addresses on the bridge subnet. - # All containers (agents and service containers such as hive-ci) receive - # their IPs dynamically; there are no hash-derived static assignments. - # Range: .2 to .(hostCount-2) — skipping .0 (network), .1 (gateway/host - # bridge), and the broadcast address. - # - # IPv4 helpers — nix integers are 64-bit so all /0-/32 values are safe. - ipToInt = - ip: - builtins.foldl' (acc: x: acc * 256 + x) 0 ( - map lib.strings.toIntBase10 (lib.strings.splitString "." ip) - ); - intToIp = - n: - let - a = n / 16777216; - b = (n - a * 16777216) / 65536; - c = (n - a * 16777216 - b * 65536) / 256; - d = n - a * 16777216 - b * 65536 - c * 256; - in - "${toString a}.${toString b}.${toString c}.${toString d}"; - # 2^n via recursion (nix has no pow builtin). - pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); - hostCount = pow2 (32 - networkCfg.bridgePrefixLength); - # Mask off host bits to get the network base address. - networkBase = builtins.bitAnd (ipToInt networkCfg.bridgeIp) (4294967295 - hostCount + 1); - # DHCP range: .2 (first usable after gateway) to .(hostCount-2) (last usable). - dhcpStart = intToIp (networkBase + 2); # skip .0 (network) and .1 (gateway) - dhcpEnd = intToIp (networkBase + hostCount - 2); # last usable = broadcast - 1 - - # Dashboard SPA dist, static-served by nginx below. Read in OUTER scope so - # `config` is the host's (inside the container block it'd be the container's). - dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; - - # Self-signed TLS is the implicit floor: when neither an operator cert - # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway - # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no - # explicit toggle and no http-only mode — matrix discovery requires https, - # so the gateway always terminates TLS. The deprecated `selfSignedTls` - # option is a no-op kept only so existing configs eval (see warnings). - useSelfSigned = cfg.tls.certDir == null && !cfg.tls.acme.enable; - - # Static error pages for `/agent//` mishaps. - # Useful pages of nginx's default 404/502 for routes - # we've already special-cased. See `docs/gateway.md::Per-agent - # error pages` for the design rationale + page-vs-status semantics. - agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } '' - mkdir -p $out - cat > $out/not-found.html <<'EOF' - - - - - agent not found ◆ hyperhive - - - -

◆ agent not found

-

No agent matches the requested /agent/<name>/ path on this hive.

-

Operator: check the agent name in the dashboard.

- - - EOF - cat > $out/unreachable.html <<'EOF' - - - - - agent unreachable ◆ hyperhive - - - -

◆ agent unreachable

-

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

-

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

- - - EOF - ''; -in -{ - # Single nginx in front of every hyperhive web surface — dashboard, - # per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known - # delegations. Container `hive-gateway`, shared host netns, - # state-free. Full vhost map + discovery flow + design rationale in - # `docs/gateway.md`. - - options.services.hyperhive.gateway = { - # The gateway is always run alongside hyperhive (it's the single nginx - # in front of every surface and the only thing exposed to the outside); - # there is no enable flag. An operator who wants their own reverse proxy - # in front points it at the gateway's `port`. The gateway config below - # is gated on the top-level `services.hyperhive.enable`. - - port = lib.mkOption { - type = lib.types.port; - default = 80; - example = 8080; - description = '' - TCP port the gateway listens on. Default 80 (canonical web - port). nginx inside the container binds <1024 because the - container's init runs as root; if 80 is already taken on the - host (existing nginx, traefik, etc.) override to an unused - port like 8080 or move the conflicting service. - ''; - }; - - upstreamHost = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = '' - Host the gateway proxies non-static requests to. Defaults to - `127.0.0.1` because the gateway container shares the host - netns, so loopback resolves directly to hive-c0re. - ''; - }; - - upstreamPort = lib.mkOption { - type = lib.types.port; - default = 7000; - description = '' - TCP port the gateway proxies non-static requests to. Defaults - to `7000` (hive-c0re's out-of-the-box dashboard port). Operators - who change `services.hyperhive.c0re.dashboardPort` should set - `upstreamPort` to match — kept as a hardcoded default rather - than a cross-reference to keep this module's options eval - independent of c0re's option tree shape. - ''; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Open `port` in the host firewall. Off by default (secure-by-default). - Flip to `true` to expose the gateway to - the operator's browser / external clients — required for any - out-of-host reach, since the agents themselves talk to - hive-c0re via the per-agent unix sockets and don't need the - nginx vhost. Leave off when running behind another reverse - proxy (e.g. caddy / traefik on the host) that handles TLS - termination + forwards to `port`. - - **Note**: this used to default to `true`. Add - `services.hyperhive.gateway.openFirewall = true;` to your host - config if external reach stopped working after a recent upgrade. - ''; - }; - - localHostsEntry = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Add an `/etc/hosts` entry mapping `services.hyperhive.domain` - to `127.0.0.1` on the host. Useful for local deployments + - tests where there's no real DNS for `services.hyperhive.domain` - but the operator (or browser-based tests) want to hit - `http://''${services.hyperhive.domain}` to exercise the - gateway shape. Off by default — operators running with real - DNS shouldn't have a stale `/etc/hosts` entry sticking - around. Requires `services.hyperhive.domain` to be set. - ''; - }; - - selfSignedTls = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - **DEPRECATED — ignored.** Self-signed TLS is now the implicit - default: when neither `tls.certDir` nor `tls.acme.enable` is - configured, the gateway generates and serves a hive-CA-signed - leaf (see the `hive-tls` module). There is no explicit toggle and - no http-only mode — matrix discovery hardcodes - `https:///.well-known/matrix/client`, so the gateway always - terminates TLS. This option is retained as a no-op so existing - configs eval; setting it (to either value) warns and has no - effect, and it will be removed in a future release. Use - `tls.certDir` or `tls.acme` to override the self-signed default. - - See `docs/gateway.md` ("Self-signed TLS"). - ''; - }; - - useSelfSigned = lib.mkOption { - type = lib.types.bool; - internal = true; - readOnly = true; - default = useSelfSigned; - defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; - description = '' - Read-only derived flag: `true` when the gateway serves the - self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor - `tls.acme.enable` is configured. Single source of truth for the - self-signed condition; consumed by the `hive-tls` and `hive-ci` - modules so the derivation isn't duplicated. Internal — not meant to - be set by operators (use `tls.certDir` / `tls.acme` to override the - self-signed default). - ''; - }; - - httpsPort = lib.mkOption { - type = lib.types.port; - default = 443; - example = 8443; - description = '' - TCP port for the TLS-terminated vhosts. Default 443. The gateway - always terminates TLS (self-signed is the implicit floor when no - `tls.certDir` / ACME is configured), so this port is always active - alongside the plain-http `port`. - ''; - }; - - tls = { - certDir = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - example = lib.literalExpression ''"/var/lib/acme/example.com"''; - description = '' - Path to a host directory containing a TLS certificate and - private key for nginx. When set, nginx listens on `httpsPort` - and uses this cert, overriding the self-signed default — the - auto-generated hive-CA-signed leaf is skipped entirely. - - The directory is bind-mounted read-only into the gateway - container at `/run/hive-tls/`. nginx reads - `/` and `/`. - Default filenames (`cert.pem` / `key.pem`) match the output - layout of nixpkgs's `security.acme` module. - - Typical ACME setup: - ```nix - security.acme.certs."example.com" = { ... }; - services.hyperhive.gateway.tls.certDir = - config.security.acme.certs."example.com".directory; - ``` - - When using an external CA cert, peer hives can declare this - hive in `services.hyperhive.swarm.peers` without - `certFingerprint` — the standard CA bundle validates. - - Mutual exclusion with `tls.acme.enable` — set one or the other, - not both. - ''; - }; - - certName = lib.mkOption { - type = lib.types.str; - default = "cert.pem"; - description = '' - Filename of the TLS certificate within `tls.certDir`. Defaults - to `cert.pem` which matches nixpkgs's `security.acme` output. - ''; - }; - - keyName = lib.mkOption { - type = lib.types.str; - default = "key.pem"; - description = '' - Filename of the TLS private key within `tls.certDir`. Defaults - to `key.pem` which matches nixpkgs's `security.acme` output. - ''; - }; - - acme = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Let nginx inside the gateway container obtain and renew TLS - certificates automatically via ACME (Let's Encrypt). When - enabled, each vhost calls out to Let's Encrypt using the - HTTP-01 challenge on `port` (default 80) and stores certs - inside the gateway container's persistent state dir. - - Requirements: - - `services.hyperhive.domain` must be set and publicly - DNS-resolvable to this host. - - `services.hyperhive.gateway.openFirewall = true` so - Let's Encrypt can reach `/.well-known/acme-challenge/`. - - `tls.acme.email` must be set (ACME account contact). - - Mutual exclusion: `tls.certDir` set together with - `tls.acme.enable = true` fails at eval — pick one TLS source. - - Typical setup: - ```nix - services.hyperhive.gateway = { - openFirewall = true; - tls.acme = { - enable = true; - email = "admin@example.com"; - }; - }; - ``` - - After enabling, peer hives can omit `certFingerprint` in - `swarm.peers` — Let's Encrypt certs are CA-trusted - by default. - ''; - }; - - email = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "admin@example.com"; - description = '' - Email address for the ACME account registration with - Let's Encrypt. Required when `tls.acme.enable = true`. - Let's Encrypt sends expiry warnings to this address. - ''; - }; - }; - }; - - auth = { - enable = lib.mkEnableOption '' - HTTP basic auth on the gateway using an htpasswd file. When - enabled, every request to the gateway's main vhost requires a - valid username and password. nginx's built-in `auth_basic` - module validates credentials against - `/var/lib/hyperhive/gateway/gateway.htpasswd` on the host - (exposed as `/run/hive-state/gateway.htpasswd` inside the - container via the existing gateway state bind-mount). Off by default. - - Manage users with `hivectl gateway create-user`, `delete-user`, - and `list-users` — see `hivectl gateway --help` for usage. - The htpasswd file is created automatically when auth is enabled; - add at least one user before enabling to avoid locking everyone out. - ''; - - realm = lib.mkOption { - type = lib.types.strMatching "[^\"$]*"; - default = "hyperhive"; - example = "my-hive"; - description = '' - HTTP Basic auth `realm` value sent in the `WWW-Authenticate` - header when credentials are absent or rejected. Must not - contain `"` or `$` (nginx string metacharacters). - ''; - }; - }; - - hsts = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Add `Strict-Transport-Security` to all gateway vhosts. - - Disabled by default: HSTS pins HTTPS in the browser's HSTS - preload list; enabling it on a deployment that later loses TLS - will lock browsers out until the max-age expires. Only enable - this when you are certain TLS is permanent. - - The gateway always terminates TLS now (self-signed floor), so - HSTS is always served over https when enabled — but mind the - warning above: HSTS pins https in the browser, so only enable it - when TLS is permanent for this deployment. - ''; - }; - - maxAge = lib.mkOption { - type = lib.types.ints.positive; - default = 31536000; - example = 86400; - description = '' - Value for the `max-age` directive in seconds. - Default: 31536000 (1 year), which is the value required for - HSTS preload list submission. Use a shorter value (e.g. 86400) - while testing so browsers forget the pin quickly. - ''; - }; - - includeSubDomains = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether to include `includeSubDomains` in the HSTS header. - Only disable this if the gateway host has sub-domains that - intentionally serve plain HTTP. - ''; - }; - }; - - }; - - config = lib.mkIf config.services.hyperhive.enable { - assertions = [ - { - assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); - message = '' - services.hyperhive.gateway.tls.acme.enable = true and - tls.certDir are mutually exclusive. Pick one TLS mode. - ''; - } - { - assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; - message = '' - services.hyperhive.gateway.tls.acme.enable = true requires - services.hyperhive.gateway.tls.acme.email to be set — - Let's Encrypt needs a contact address for the ACME account. - ''; - } - ]; - - # Deprecation surface for the removed `selfSignedTls` toggle. Self-signed - # is now the implicit floor (used whenever neither `tls.certDir` nor - # `tls.acme` is set), so the toggle no longer does anything. Warn only - # when it's set to `false` — that's the case that previously meant - # "http-only / external-only", which no longer exists; `= true` matches - # the effective behaviour and stays silent to avoid noise. - warnings = lib.optional (!cfg.selfSignedTls) '' - services.hyperhive.gateway.selfSignedTls = false is deprecated and - ignored — self-signed TLS is now the default whenever no other TLS - source is configured, and there is no http-only mode. Remove the - setting; configure `tls.certDir` or `tls.acme` to override the - self-signed default. - ''; - - # Ensure bind-mount sources exist at host boot before the gateway - # container's first start. nspawn would auto-create missing dirs - # tmpfiles rules make the intent explicit - # and cover the fresh-boot window before c0re has run. - # - # /run/hive-agent — per-agent UDS socket dir, written by c0re's - # set_nspawn_flags when agents start. Owned by `hive-core` (the - # unprivileged coordinator user): c0re does the - # `create_dir_all(/run/hive-agent/)` itself, so a root-owned - # parent would EACCES on the very first agent create on a fresh host - # (hive-priv only chowns the subdir afterwards, it doesn't make it). - # /var/lib/hyperhive — hyperhive state dir, created by c0re on - # first run. Also pre-seed agents.conf with an empty-but-valid - # header so nginx can start + include the file before c0re writes - # its first real content (f = create-if-absent, no overwrite). - systemd.tmpfiles.rules = [ - "d /run/hive-agent 0755 hive-core hive-core - -" - "d /var/lib/hyperhive 0755 root root - -" - "d /var/lib/hyperhive/gateway 0755 root root - -" - "f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n" - # Pre-create the htpasswd file so nginx can open it even before any - # users have been added. An empty file causes all auth checks to - # return 401 (no valid credentials), which is the correct no-users - # behaviour. `f` = create-if-absent, never overwrite. - "f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -" - ]; - - containers.hive-gateway = { - autoStart = true; - ephemeral = false; - # Share host netns — nginx then binds host-level ports directly, - # `localhost` upstream resolution reaches hive-c0re without any - # port-forward dance, and the firewall config below is the only - # layer that matters. - privateNetwork = false; - # dnsmasq refuses to start once a dhcp-range is configured unless it - # holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network - # containers retain NET_ADMIN implicitly, but this container shares the - # host netns (above), so nspawn's default bounding set drops it — grant - # it explicitly. Note this is NET_ADMIN over the *host* netns; the - # gateway container is trusted infra (it already terminates TLS and - # fronts every vhost), so no new trust boundary is crossed. - additionalCapabilities = [ "CAP_NET_ADMIN" ]; - # Bind-mount the per-agent socket dir so nginx inside the gateway - # container can `connect(2)` to the UDS upstreams. - # Read-only (we just connect; harness writes the socket inside - # the agent's own container). Host-side dir is pre-created by a - # tmpfiles rule so nspawn always finds a source at boot. - bindMounts."/run/hive-agent" = { - hostPath = "/run/hive-agent"; - isReadOnly = true; - }; - # Bind-mount ONLY the gateway-specific subdir of the hyperhive - # state dir. Scoped to /var/lib/hyperhive/gateway/ rather than - # the whole parent so the gateway container can't read forge - # tokens or other files that may live at the parent level. - # c0re writes agents.conf under this subdir and triggers an nginx - # reload from the host via systemd-run after each write. - # Pre-created by a tmpfiles rule. - bindMounts."/run/hive-state" = { - hostPath = "/var/lib/hyperhive/gateway"; - isReadOnly = true; - }; - # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). - # Only mounted when `tls.certDir` is set; when it is, the self-signed - # floor is off (so the `/run/hive-ca` mount above is absent). nginx - # reads cert + key from `/run/hive-tls/` and ``. - bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { - hostPath = cfg.tls.certDir; - isReadOnly = true; - }; - # Self-signed mode: the host `hive-tls-ca` service generates a hive - # CA + a leaf signed by it under `services.hyperhive.tls.stateDir`. - # Bind-mount that dir read-only so the in-container import service - # (below) can copy the leaf into nginx's state dir with the right - # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. - bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { - hostPath = config.services.hyperhive.tls.stateDir; - isReadOnly = true; - }; - config = - { pkgs, ... }: - let - tlsDir = "/var/lib/hive-gateway/tls"; - # TLS cert + key paths inside the container. - # - self-signed (default): imported hive-CA-signed leaf in the - # persistent state dir. - # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. - tlsCert = - if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; - tlsKey = - if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; - # The gateway always terminates TLS now: self-signed is the - # implicit floor (`useSelfSigned`) when neither `tls.certDir` nor - # ACME is set, so there is no http-only mode. Kept as a named - # binding for the vhost listen/ssl wiring below. - hasTls = true; - # Listen addresses every vhost shares. Plain http on `cfg.port` - # always; `cfg.httpsPort` with TLS sits beside it when TLS is - # active (any mode). See `docs/gateway.md` ("TLS modes"). - vhostListen = [ - { - addr = "0.0.0.0"; - port = cfg.port; - } - ] - ++ lib.optional hasTls { - addr = "0.0.0.0"; - port = cfg.httpsPort; - ssl = true; - }; - # nixos `services.nginx.virtualHosts.` ssl attrs merged - # into each vhost. For ACME mode: `enableACME` + `addSSL` — - # NixOS's ACME integration manages the cert lifecycle and sets - # ssl_certificate automatically. For self-signed / certDir: - # explicit cert paths. Empty for http-only. - vhostTls = - if cfg.tls.acme.enable then - { - addSSL = true; - enableACME = true; - } - else - lib.optionalAttrs hasTls { - addSSL = true; - sslCertificate = tlsCert; - sslCertificateKey = tlsKey; - }; - - # Public-facing scheme + port-suffix for URLs the gateway - # mints into responses (well-known JSON, the deprecated - # `/matrix/*` 301 redirect, future absolute-URL needs). - # When TLS is active (self-signed OR operator cert), prefer - # `https://` (matrix-spec compliance) — 443 elides the - # port. Otherwise fall back to the plain-http listen with the - # bare port. See `docs/gateway.md` ("Self-signed TLS"). - publicScheme = if hasTls then "https" else "http"; - publicPort = if hasTls then cfg.httpsPort else cfg.port; - publicPortDefault = if hasTls then 443 else 80; - publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; - - # Security headers added at the server scope on every vhost. - # nginx's add_header inheritance rule: a location that defines its - # own add_header does NOT inherit the server-level ones. Any - # location with its own add_header (e.g. CORS on /.well-known or - # /_matrix/) must repeat the security headers explicitly — see those - # locations below. HTML-serving and proxy locations that carry no - # add_header of their own pick these up from the server scope - # automatically. - hstsDirectives = lib.concatStringsSep "; " ( - [ "max-age=${toString cfg.hsts.maxAge}" ] - ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" - ); - securityHeaders = '' - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} - ''; - - # Forge sub-domain vhost. `server_name = forge.domain`, proxies - # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, - # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on - # `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the - # forge isn't behind the gateway. - forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { - "${forgeCfg.domain}" = vhostTls // { - listen = vhostListen; - extraConfig = securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 1G; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ''; - }; - }; - }; - - # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. - # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll - # timeout). `/` serves fluffychat or 404 if GUI off. nginx - # longer-prefix-wins puts `/_matrix/` ahead of `/`. See - # `docs/gateway.md`. Empty attrset when matrix has no gateway host. - matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { - "${matrixCfg.gatewayHost}" = vhostTls // { - listen = vhostListen; - extraConfig = securityHeaders; - locations = { - "/_matrix/" = { - proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 50M; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ${securityHeaders} - add_header Access-Control-Allow-Origin *; - ''; - }; - } - // lib.optionalAttrs (matrixCfg.gui.enable) ( - { - # fluffychat at sub-domain root, SPA-fallback via - # the Accept-header `$matrix_spa_target` map. - "/" = { - alias = "${matrixCfg.gui.package}/"; - extraConfig = '' - try_files $uri $uri/ $matrix_spa_target =404; - ''; - }; - } - // { - # 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. - "= /config.json" = { - extraConfig = '' - default_type application/json; - return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; - ''; - }; - } - ) - // lib.optionalAttrs (!matrixCfg.gui.enable) { - "/" = { - return = "404"; - }; - }; - }; - }; - - # `_` (default) server location groups, lifted out of the inline - # `//`-chain so each conditional group reads on its own. Composed - # into the `_` vhost's `locations` below alongside the still-inline - # auth-401 group (a self-contained `lib.optionalAttrs`). - - # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link - # shim during the fluffychat sub-domain move). See `docs/gateway.md`. - matrixRedirectLocations = - lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) - ( - let - target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; - in - { - "/matrix/" = { - extraConfig = '' - rewrite ^/matrix/(.*)$ ${target}/$1 permanent; - ''; - }; - } - ); - - # `.well-known/matrix/{client,server}` discovery JSON. Points - # 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 ( - let - clientBaseUrl = - if matrixCfg.gatewayHost != null then - "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" - else - "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; - # `m.server` is NOT a URL: per the matrix server-server spec - # (Resolving Server Names) a delegated host with NO port resolves - # to the federation default 8448 (after the SRV check) — the - # https-implies-443 rule does NOT apply here. So the port must be - # explicit even when it's the HTTPS default; `publicPortSuffix` - # (which drops :443) is right for the client base_url above but - # wrong for federation delegation. Without this, peers federate to - # :8448 (closed) while the endpoint actually lives on - # the gateway's 443 vhost. See docs/gateway.md discovery flow. - serverHostPort = - if matrixCfg.gatewayHost != null then - "${matrixCfg.gatewayHost}:${toString publicPort}" - else - "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; - in - { - "= /.well-known/matrix/client" = { - extraConfig = '' - default_type application/json; - ${securityHeaders} - add_header Access-Control-Allow-Origin *; - return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}'; - ''; - }; - "= /.well-known/matrix/server" = { - extraConfig = '' - default_type application/json; - return 200 '{"m.server":"${serverHostPort}"}'; - ''; - }; - } - ); - - # `/agent/` catch-all 404 + the two internal error-page targets it - # points at. Per-agent `location /agent//` blocks live in the - # runtime-generated `/run/hive-state/agents.conf` (included via - # `extraConfig` on the vhost); nginx longest-prefix-match makes a - # real `/agent//` beat this catch-all. `internal` keeps the - # error pages reachable only through nginx's error handling. - agentLocations = { - "/agent/" = { - extraConfig = '' - error_page 404 = /__hive_agent_not_found; - return 404; - ''; - }; - "= /__hive_agent_not_found" = { - extraConfig = '' - internal; - alias ${agentErrorPagesDir}/not-found.html; - default_type text/html; - ''; - }; - "= /__hive_agent_unreachable" = { - extraConfig = '' - internal; - alias ${agentErrorPagesDir}/unreachable.html; - default_type text/html; - ''; - }; - }; - - # Shared auth block — separate locations don't inherit auth_basic, so - # each dashboard location (`/`, `/api/`) needs it or that surface is - # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot - # send HTTP Basic credentials with webhook deliveries, and the HMAC - # secret (`X-Hub-Signature-256`) protects those endpoints instead. - dashboardAuth = lib.optionalString cfg.auth.enable '' - auth_basic "${cfg.auth.realm}"; - auth_basic_user_file /run/hive-state/gateway.htpasswd; - # `=401` keeps the status 401 so the login dialog shows; the - # internal page explains `hivectl gateway create-user`. - error_page 401 =401 /__hive_auth_unauthorized; - ''; - - # Dashboard: nginx static-serves the dist, c0re is API-only. Routing - # is by PATH, never content-type. c0re serves exactly two prefixes — - # `/api/` (all dashboard data + actions + the SSE streams) and - # `/webhook/` (knowledge push + config-PR approval triggers, HMAC- - # guarded) — so those proxy to c0re and everything else serves the - # dist with an SPA fallback to index.html. - # The earlier `map $http_accept` Accept-header split made the SAME - # url behave differently by content-type (e.g. `/api/state` fetched - # with `Accept: text/html` wrongly got index.html); path routing is - # deterministic. A new top-level c0re route prefix (beyond /api + - # /webhook) would need a matching location added here. - dashboardProxyLocation = { - "/" = { - root = dashboardDist; - extraConfig = '' - try_files $uri /index.html; - ${dashboardAuth} - ''; - }; - "/api/" = { - proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; - proxyWebsockets = true; - extraConfig = '' - # off + 1d keep the SSE streams (/api/dashboard/stream, - # /api/build-logs/id/{id}/stream) live. - proxy_buffering off; - proxy_read_timeout 1d; - ${dashboardAuth} - ''; - }; - "/webhook/" = { - # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials - # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth - # for these endpoints; hive-c0re verifies it in the handler. - proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; - }; - }; - in - { - system.stateVersion = "26.05"; - - # ACME (Let's Encrypt) integration. nginx vhosts set - # `enableACME = true` via `vhostTls`; this provides the - # shared ACME config (acceptTerms + email). The gateway - # container has shared host netns so outbound ACME requests - # work without extra routing config. Certs are stored in the - # container's persistent state (`ephemeral = false`). - security.acme = lib.mkIf cfg.tls.acme.enable { - acceptTerms = true; - defaults.email = cfg.tls.acme.email; - }; - - # Import the host-generated leaf cert before nginx starts. - # The hive CA + gateway leaf are generated on the HOST by - # `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only - # at `/run/hive-ca`; this service copies the leaf into nginx's - # state dir with the owner/mode nginx needs, rather than reading - # the bind-mount directly (the host key is 0600 root:root and a - # cross-namespace bind-mount can't be relaxed in place). nginx - # `Requires=` this via `requiredBy`, so it refuses to start until - # the copy succeeds. ALWAYS runs (no ConditionPathExists) and is - # idempotent — necessary to reconcile broken state from prior - # failed boots (a 0700 dir from a stale UMask, a truncated copy - # from an interrupted oneshot, etc.). The leaf covers the bare - # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` - # so all sub-domains validate under the same cert + the hive CA. - # See `docs/gateway.md` ("Self-signed TLS"). - systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { - description = "Import host-generated TLS leaf for hive-gateway"; - wantedBy = [ "multi-user.target" ]; - before = [ "nginx.service" ]; - requiredBy = [ "nginx.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "hive-gateway-self-signed-cert"; - }; - path = [ pkgs.coreutils ]; - script = '' - set -eu - mkdir -p ${tlsDir} - # 0755 on BOTH the cert dir and its parent so the nginx - # user can traverse the full path. The parent - # `/var/lib/hive-gateway` lands at 0700 by default (systemd - # StateDirectory / mkdir umask depending on which service - # created it first), which on its own blocks traversal. - # Re-applied every boot in case a prior run left a tighter - # mode behind. - chmod 0755 ${builtins.dirOf tlsDir} - chmod 0755 ${tlsDir} - # Copy the host leaf in. `install` writes atomically with the - # target mode; run as root (container root == host root, - # privateUsers=false) so the 0600 root:root host key is - # readable. Key ends up root:nginx 0640 so nginx-pre-start - # (which runs `nginx -t` as the nginx user, not root) can - # read it — a 0600 root:root key passes the master load but - # fails the pre-start config test with `BIO_new_file() … - # Permission denied`, blocking the unit. Cert is world-read. - install -m 0644 /run/hive-ca/gateway.pem ${tlsCert} - install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey} - ''; - }; - - # nginx reload is triggered from the HOST side by hive-c0re - # via `systemctl -M hive-gateway reload nginx` after each - # agents.conf write — letting systemd resolve the nginx binary - # path avoids exit-203 EXEC failures. A path unit watching the - # bind-mounted file inside the container was tried first but - # doesn't work: an IN_MOVED_TO from an atomic rename on the host - # does not propagate across the nspawn mount-namespace boundary. - # The host-side trigger is the correct approach. - - services.nginx = { - enable = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - recommendedGzipSettings = true; - recommendedOptimisation = true; - # Accept-header SPA map for the matrix GUI only (see docs/gateway.md - # "SPA fallback"): text/html → index.html, else a sentinel so - # try_files falls through to 404. The dashboard no longer uses an - # Accept-header map — it routes by path (see dashboardProxyLocation). - appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' - map $http_accept $matrix_spa_target { - default "/__matrix_spa_no_html_fallback"; - "~*text/html" "/index.html"; - } - ''; - virtualHosts = { - "_" = vhostTls // { - listen = vhostListen; - locations = - matrixRedirectLocations - // wellKnownLocations - // agentLocations - // dashboardProxyLocation - // lib.optionalAttrs cfg.auth.enable { - # Internal-only target for the 401 error_page above. - # `internal` prevents direct client access; `alias` serves - # the pre-built HTML from the Nix store. - "= /__hive_auth_unauthorized" = - let - page = pkgs.writeText "hive-gateway-unauthorized.html" '' - - - - - unauthorized ◆ hyperhive - - - -

◆ unauthorized

-

This hive is protected by HTTP Basic auth. Valid credentials are required.

-

Operator: add a user with hivectl gateway create-user:

-
hivectl gateway create-user \
-                          <username> --password-stdin
-

Then reload your browser and enter the credentials when prompted.

- - - ''; - in - { - extraConfig = '' - internal; - alias ${page}; - default_type text/html; - ''; - }; - }; - # Per-agent location blocks, generated at runtime by - # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf - # on the host. The bind-mount at /run/hive-state/ exposes - # that file here. nginx parses `include` at config-load - # time so a reload (triggered by c0re via systemd-run - # after each agents.conf write) picks up new or removed - # agents without a nixos-rebuild. nginx's longest-prefix- - # match rule ensures `/agent//` from this file beats - # the `/agent/` catch-all above. - extraConfig = securityHeaders + '' - include /run/hive-state/agents.conf; - ''; - }; - } - // forgeVhost - // matrixVhost; - }; - - # Hive-internal DNS resolver, co-located in the - # gateway container — single - # front-door for both DNS and HTTP, saves a sibling - # container. Listens on the bridge interface from - # `services.hyperhive.network`; authoritative for the hive - # domain + sub-domains, forwards everything else upstream. - services.dnsmasq = lib.mkIf networkCfg.enable { - enable = true; - # Don't substitute the container's /etc/resolv.conf — - # the gateway uses the host's resolver for its own - # outbound traffic; dnsmasq is purely for incoming - # queries from agent containers. - resolveLocalQueries = false; - settings = { - # Bind only on the bridge interface (and lo for - # health-checks). Outside hosts can't even see the - # listener. - interface = [ - networkCfg.bridgeName - "lo" - ]; - bind-interfaces = true; - port = 53; - # Don't read /etc/resolv.conf — we control upstream - # explicitly to dodge dependency on the gateway - # container's own resolver state. - no-resolv = true; - server = networkCfg.upstreamDns; - # Hive authoritative records — answer queries for the - # hive domain + its sub-domains with the bridge IP - # (where nginx is reachable from container netns once - # per-agent netns isolation lands; today it's the host - # loopback alias and works in either shape). - # - # The forge / matrix entries are redundant in the - # common case where `forge.domain` / - # `matrix.gatewayHost` are sub-domains of - # `hyperhive.domain` — dnsmasq's `//` rule - # already matches sub-domains. - # Kept explicit because operators can override either - # to a cross-domain hostname (e.g. - # `forge.domain = "git.example.com"`); listing them - # explicitly keeps that case routed without needing - # an extra config block. - address = [ - "/${hyperhiveDomain}/${networkCfg.bridgeIp}" - ] - ++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" - ++ lib.optional ( - matrixCfg.enable && matrixCfg.gatewayHost != null - ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; - # DHCP pool covering all usable host addresses on the bridge subnet. - # Range is computed from bridgeIp/bridgePrefixLength at eval time: - # .2 (first after gateway) to .(hostCount-2) (last usable before - # broadcast). All containers — agents and service containers alike — - # receive their IPs dynamically from this pool. - dhcp-range = "${dhcpStart},${dhcpEnd},1h"; - dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; - }; - }; - }; - }; - - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ - cfg.port - # The gateway always terminates TLS now (self-signed floor), so - # `httpsPort` is always opened alongside the plain-http `port`. - cfg.httpsPort - ]; - }; - - # `/etc/hosts` entries for local dev — bare hive domain + any - # 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 { - "127.0.0.1" = lib.unique ( - [ hyperhiveDomain ] - ++ lib.optional (config.services.hyperhive.forge.behindGateway or false - ) config.services.hyperhive.forge.domain - ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost - ); - }; - }; -} diff --git a/nix/modules/hive-gateway/default.nix b/nix/modules/hive-gateway/default.nix new file mode 100644 index 00000000..757f02fa --- /dev/null +++ b/nix/modules/hive-gateway/default.nix @@ -0,0 +1,298 @@ +# Single nginx in front of every hyperhive web surface — dashboard, +# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known +# delegations — plus the hive-internal dnsmasq resolver, co-located in +# the same `hive-gateway` container (shared host netns, state-free). +# Full vhost map + discovery flow + design rationale in +# `docs/gateway.md`. Layout: ./options.nix (option declarations), +# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix +# (styled static pages), ./dnsmasq.nix (resolver + DHCP config). +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.gateway; + hyperhiveDomain = config.services.hyperhive.domain; + matrixCfg = config.services.hyperhive.matrix; + forgeCfg = config.services.hyperhive.forge; + networkCfg = config.services.hyperhive.network; + + # Dashboard SPA dist, static-served by nginx. Read in OUTER scope so + # `config` is the host's (inside the container block it'd be the + # container's). + dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; + + # Self-signed TLS is the implicit floor: when neither an operator cert + # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway + # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no + # explicit toggle and no http-only mode — matrix discovery requires https, + # so the gateway always terminates TLS. The deprecated `selfSignedTls` + # option is a no-op kept only so existing configs eval (see warnings). + # `cfg.useSelfSigned` (options.nix) is the derived single source of truth. + useSelfSigned = cfg.useSelfSigned; +in +{ + imports = [ ./options.nix ]; + + config = lib.mkIf config.services.hyperhive.enable { + assertions = [ + { + assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); + message = '' + services.hyperhive.gateway.tls.acme.enable = true and + tls.certDir are mutually exclusive. Pick one TLS mode. + ''; + } + { + assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; + message = '' + services.hyperhive.gateway.tls.acme.enable = true requires + services.hyperhive.gateway.tls.acme.email to be set — + Let's Encrypt needs a contact address for the ACME account. + ''; + } + ]; + + # Deprecation surface for the removed `selfSignedTls` toggle. Self-signed + # is now the implicit floor (used whenever neither `tls.certDir` nor + # `tls.acme` is set), so the toggle no longer does anything. Warn only + # when it's set to `false` — that's the case that previously meant + # "http-only / external-only", which no longer exists; `= true` matches + # the effective behaviour and stays silent to avoid noise. + warnings = lib.optional (!cfg.selfSignedTls) '' + services.hyperhive.gateway.selfSignedTls = false is deprecated and + ignored — self-signed TLS is now the default whenever no other TLS + source is configured, and there is no http-only mode. Remove the + setting; configure `tls.certDir` or `tls.acme` to override the + self-signed default. + ''; + + # Ensure bind-mount sources exist at host boot before the gateway + # container's first start. nspawn would auto-create missing dirs; + # tmpfiles rules make the intent explicit and cover the fresh-boot + # window before c0re has run. + # + # /run/hive-agent — per-agent UDS socket dir, written by c0re's + # set_nspawn_flags when agents start. Owned by `hive-core` (the + # unprivileged coordinator user): c0re does the + # `create_dir_all(/run/hive-agent/)` itself, so a root-owned + # parent would EACCES on the very first agent create on a fresh host + # (hive-priv only chowns the subdir afterwards, it doesn't make it). + # /var/lib/hyperhive — hyperhive state dir, created by c0re on + # first run. Also pre-seed agents.conf with an empty-but-valid + # header so nginx can start + include the file before c0re writes + # its first real content (f = create-if-absent, no overwrite). + systemd.tmpfiles.rules = [ + "d /run/hive-agent 0755 hive-core hive-core - -" + "d /var/lib/hyperhive 0755 root root - -" + "d /var/lib/hyperhive/gateway 0755 root root - -" + "f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n" + # Pre-create the htpasswd file so nginx can open it even before any + # users have been added. An empty file causes all auth checks to + # return 401 (no valid credentials), which is the correct no-users + # behaviour. `f` = create-if-absent, never overwrite. + "f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -" + ]; + + containers.hive-gateway = { + autoStart = true; + ephemeral = false; + # Share host netns — nginx then binds host-level ports directly, + # `localhost` upstream resolution reaches hive-c0re without any + # port-forward dance, and the firewall config below is the only + # layer that matters. + privateNetwork = false; + # dnsmasq refuses to start once a dhcp-range is configured unless it + # holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network + # containers retain NET_ADMIN implicitly, but this container shares the + # host netns (above), so nspawn's default bounding set drops it — grant + # it explicitly. Note this is NET_ADMIN over the *host* netns; the + # gateway container is trusted infra (it already terminates TLS and + # fronts every vhost), so no new trust boundary is crossed. + additionalCapabilities = [ "CAP_NET_ADMIN" ]; + # Bind-mount the per-agent socket dir so nginx inside the gateway + # container can `connect(2)` to the UDS upstreams. + # Read-only (we just connect; harness writes the socket inside + # the agent's own container). Host-side dir is pre-created by a + # tmpfiles rule so nspawn always finds a source at boot. + bindMounts."/run/hive-agent" = { + hostPath = "/run/hive-agent"; + isReadOnly = true; + }; + # Bind-mount ONLY the gateway-specific subdir of the hyperhive + # state dir. Scoped to /var/lib/hyperhive/gateway/ rather than + # the whole parent so the gateway container can't read forge + # tokens or other files that may live at the parent level. + # c0re writes agents.conf under this subdir and triggers an nginx + # reload from the host via systemd-run after each write. + # Pre-created by a tmpfiles rule. + bindMounts."/run/hive-state" = { + hostPath = "/var/lib/hyperhive/gateway"; + isReadOnly = true; + }; + # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). + # Only mounted when `tls.certDir` is set; when it is, the self-signed + # floor is off (so the `/run/hive-ca` mount below is absent). nginx + # reads cert + key from `/run/hive-tls/` and ``. + bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { + hostPath = cfg.tls.certDir; + isReadOnly = true; + }; + # Self-signed mode: the host `hive-tls-ca` service generates a hive + # CA + a leaf signed by it under `services.hyperhive.tls.stateDir`. + # Bind-mount that dir read-only so the in-container import service + # (below) can copy the leaf into nginx's state dir with the right + # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. + bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { + hostPath = config.services.hyperhive.tls.stateDir; + isReadOnly = true; + }; + config = + { pkgs, ... }: + let + tlsDir = "/var/lib/hive-gateway/tls"; + # TLS cert + key paths inside the container. + # - self-signed (default): imported hive-CA-signed leaf in the + # persistent state dir. + # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. + tlsCert = + if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; + tlsKey = + if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; + nginxTree = import ./vhosts.nix { + inherit + lib + cfg + forgeCfg + matrixCfg + hyperhiveDomain + dashboardDist + tlsCert + tlsKey + ; + errorPages = import ./error-pages.nix { inherit pkgs; }; + }; + in + { + system.stateVersion = "26.05"; + + # ACME (Let's Encrypt) integration. nginx vhosts set + # `enableACME = true` via the vhost builder; this provides the + # shared ACME config (acceptTerms + email). The gateway + # container has shared host netns so outbound ACME requests + # work without extra routing config. Certs are stored in the + # container's persistent state (`ephemeral = false`). + security.acme = lib.mkIf cfg.tls.acme.enable { + acceptTerms = true; + defaults.email = cfg.tls.acme.email; + }; + + # Import the host-generated leaf cert before nginx starts. + # The hive CA + gateway leaf are generated on the HOST by + # `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only + # at `/run/hive-ca`; this service copies the leaf into nginx's + # state dir with the owner/mode nginx needs, rather than reading + # the bind-mount directly (the host key is 0600 root:root and a + # cross-namespace bind-mount can't be relaxed in place). nginx + # `Requires=` this via `requiredBy`, so it refuses to start until + # the copy succeeds. ALWAYS runs (no ConditionPathExists) and is + # idempotent — necessary to reconcile broken state from prior + # failed boots (a 0700 dir from a stale UMask, a truncated copy + # from an interrupted oneshot, etc.). The leaf covers the bare + # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` + # so all sub-domains validate under the same cert + the hive CA. + # See `docs/gateway.md` ("Self-signed TLS"). + systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { + description = "Import host-generated TLS leaf for hive-gateway"; + wantedBy = [ "multi-user.target" ]; + before = [ "nginx.service" ]; + requiredBy = [ "nginx.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "hive-gateway-self-signed-cert"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -eu + mkdir -p ${tlsDir} + # 0755 on BOTH the cert dir and its parent so the nginx + # user can traverse the full path. The parent + # `/var/lib/hive-gateway` lands at 0700 by default (systemd + # StateDirectory / mkdir umask depending on which service + # created it first), which on its own blocks traversal. + # Re-applied every boot in case a prior run left a tighter + # mode behind. + chmod 0755 ${builtins.dirOf tlsDir} + chmod 0755 ${tlsDir} + # Copy the host leaf in. `install` writes atomically with the + # target mode; run as root (container root == host root, + # privateUsers=false) so the 0600 root:root host key is + # readable. Key ends up root:nginx 0640 so nginx-pre-start + # (which runs `nginx -t` as the nginx user, not root) can + # read it — a 0600 root:root key passes the master load but + # fails the pre-start config test with `BIO_new_file() … + # Permission denied`, blocking the unit. Cert is world-read. + install -m 0644 /run/hive-ca/gateway.pem ${tlsCert} + install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey} + ''; + }; + + # nginx reload is triggered from the HOST side by hive-c0re + # via `systemctl -M hive-gateway reload nginx` after each + # agents.conf write — letting systemd resolve the nginx binary + # path avoids exit-203 EXEC failures. A path unit watching the + # bind-mounted file inside the container does not work: an + # IN_MOVED_TO from an atomic rename on the host does not + # propagate across the nspawn mount-namespace boundary. The + # host-side trigger is the correct approach. + + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + inherit (nginxTree) appendHttpConfig virtualHosts; + }; + + services.dnsmasq = lib.mkIf networkCfg.enable ( + import ./dnsmasq.nix { + inherit + lib + networkCfg + forgeCfg + matrixCfg + hyperhiveDomain + ; + } + ); + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + cfg.port + # The gateway always terminates TLS (self-signed floor), so + # `httpsPort` is always opened alongside the plain-http `port`. + cfg.httpsPort + ]; + }; + + # `/etc/hosts` entries for local dev — bare hive domain + any + # 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 { + "127.0.0.1" = lib.unique ( + [ hyperhiveDomain ] + ++ lib.optional (config.services.hyperhive.forge.behindGateway or false + ) config.services.hyperhive.forge.domain + ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost + ); + }; + }; +} diff --git a/nix/modules/hive-gateway/dnsmasq.nix b/nix/modules/hive-gateway/dnsmasq.nix new file mode 100644 index 00000000..41361df4 --- /dev/null +++ b/nix/modules/hive-gateway/dnsmasq.nix @@ -0,0 +1,59 @@ +# Hive-internal DNS resolver + DHCP, co-located in the gateway +# container — single front-door for both DNS and HTTP, saves a +# sibling container. Listens on the bridge interface from +# `services.hyperhive.network`; authoritative for the hive domain + +# sub-domains, forwards everything else upstream. Returns the +# `services.dnsmasq` value for the container config (see +# ./default.nix); the DHCP pool bounds are computed by hive-network. +{ + lib, + networkCfg, + forgeCfg, + matrixCfg, + hyperhiveDomain, +}: +{ + enable = true; + # Don't substitute the container's /etc/resolv.conf — the gateway + # uses the host's resolver for its own outbound traffic; dnsmasq is + # purely for incoming queries from agent containers. + resolveLocalQueries = false; + settings = { + # Bind only on the bridge interface (and lo for health-checks). + # Outside hosts can't even see the listener. + interface = [ + networkCfg.bridgeName + "lo" + ]; + bind-interfaces = true; + port = 53; + # Don't read /etc/resolv.conf — we control upstream explicitly to + # dodge dependency on the gateway container's own resolver state. + no-resolv = true; + server = networkCfg.upstreamDns; + # Hive authoritative records — answer queries for the hive domain + # + its sub-domains with the bridge IP, where nginx is reachable + # from every container netns. + # + # The forge / matrix entries are redundant in the common case + # where `forge.domain` / `matrix.gatewayHost` are sub-domains of + # `hyperhive.domain` — dnsmasq's `//` rule already matches + # sub-domains. Kept explicit because operators can override either + # to a cross-domain hostname (e.g. `forge.domain = + # "git.example.com"`); listing them explicitly keeps that case + # routed without needing an extra config block. + address = [ + "/${hyperhiveDomain}/${networkCfg.bridgeIp}" + ] + ++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" + ++ lib.optional ( + matrixCfg.enable && matrixCfg.gatewayHost != null + ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; + # DHCP pool covering all usable host addresses on the bridge + # subnet — bounds computed by hive-network.nix from + # bridgeIp/bridgePrefixLength. All containers (agents and service + # containers such as hive-ci) receive their IPs dynamically. + dhcp-range = "${networkCfg.dhcpRangeStart},${networkCfg.dhcpRangeEnd},1h"; + dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; + }; +} diff --git a/nix/modules/hive-gateway/error-pages.nix b/nix/modules/hive-gateway/error-pages.nix new file mode 100644 index 00000000..3285632c --- /dev/null +++ b/nix/modules/hive-gateway/error-pages.nix @@ -0,0 +1,71 @@ +# Static error/help pages the gateway serves for routes it has +# special-cased, all rendered from one Catppuccin-styled template. +# Useful pages instead of nginx's default 404/502 — see +# `docs/gateway.md::Per-agent error pages` for the design rationale + +# page-vs-status semantics. Consumed by ./vhosts.nix. +{ pkgs }: +let + mkPage = + { + name, + title, + accent, + body, + }: + pkgs.writeText "hive-gateway-${name}.html" '' + + + + + ${title} ◆ hyperhive + + + +

◆ ${title}

+ ${body} + + + ''; +in +{ + notFound = mkPage { + name = "agent-not-found"; + title = "agent not found"; + accent = "#cba6f7"; + body = '' +

No agent matches the requested /agent/<name>/ path on this hive.

+

Operator: check the agent name in the dashboard.

+ ''; + }; + + unreachable = mkPage { + name = "agent-unreachable"; + title = "agent unreachable"; + accent = "#f9e2af"; + body = '' +

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

+

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

+ ''; + }; + + unauthorized = mkPage { + name = "unauthorized"; + title = "unauthorized"; + accent = "#f38ba8"; + body = '' +

This hive is protected by HTTP Basic auth. Valid credentials are required.

+

Operator: add a user with hivectl gateway create-user:

+
hivectl gateway create-user \
+      <username> --password-stdin
+

Then reload your browser and enter the credentials when prompted.

+ ''; + }; +} diff --git a/nix/modules/hive-gateway/options.nix b/nix/modules/hive-gateway/options.nix new file mode 100644 index 00000000..5d5a3133 --- /dev/null +++ b/nix/modules/hive-gateway/options.nix @@ -0,0 +1,310 @@ +# Option declarations for `services.hyperhive.gateway.*`. The gateway +# is always run alongside hyperhive (it's the single nginx in front of +# every surface and the only thing exposed to the outside); there is +# no enable flag. An operator who wants their own reverse proxy in +# front points it at the gateway's `port`. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.gateway; +in +{ + options.services.hyperhive.gateway = { + port = lib.mkOption { + type = lib.types.port; + default = 80; + example = 8080; + description = '' + TCP port the gateway listens on. Default 80 (canonical web + port). nginx inside the container binds <1024 because the + container's init runs as root; if 80 is already taken on the + host (existing nginx, traefik, etc.) override to an unused + port like 8080 or move the conflicting service. + ''; + }; + + upstreamHost = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Host the gateway proxies non-static requests to. Defaults to + `127.0.0.1` because the gateway container shares the host + netns, so loopback resolves directly to hive-c0re. + ''; + }; + + upstreamPort = lib.mkOption { + type = lib.types.port; + default = 7000; + description = '' + TCP port the gateway proxies non-static requests to. Defaults + to `7000` (hive-c0re's out-of-the-box dashboard port). Operators + who change `services.hyperhive.c0re.dashboardPort` should set + `upstreamPort` to match — kept as a hardcoded default rather + than a cross-reference to keep this module's options eval + independent of c0re's option tree shape. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Open `port` in the host firewall. Off by default (secure-by-default). + Flip to `true` to expose the gateway to + the operator's browser / external clients — required for any + out-of-host reach, since the agents themselves talk to + hive-c0re via the per-agent unix sockets and don't need the + nginx vhost. Leave off when running behind another reverse + proxy (e.g. caddy / traefik on the host) that handles TLS + termination + forwards to `port`. + + **Note**: this used to default to `true`. Add + `services.hyperhive.gateway.openFirewall = true;` to your host + config if external reach stopped working after a recent upgrade. + ''; + }; + + localHostsEntry = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Add an `/etc/hosts` entry mapping `services.hyperhive.domain` + to `127.0.0.1` on the host. Useful for local deployments + + tests where there's no real DNS for `services.hyperhive.domain` + but the operator (or browser-based tests) want to hit + `http://''${services.hyperhive.domain}` to exercise the + gateway shape. Off by default — operators running with real + DNS shouldn't have a stale `/etc/hosts` entry sticking + around. Requires `services.hyperhive.domain` to be set. + ''; + }; + + selfSignedTls = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + **DEPRECATED — ignored.** Self-signed TLS is now the implicit + default: when neither `tls.certDir` nor `tls.acme.enable` is + configured, the gateway generates and serves a hive-CA-signed + leaf (see the `hive-tls` module). There is no explicit toggle and + no http-only mode — matrix discovery hardcodes + `https:///.well-known/matrix/client`, so the gateway always + terminates TLS. This option is retained as a no-op so existing + configs eval; setting it (to either value) warns and has no + effect, and it will be removed in a future release. Use + `tls.certDir` or `tls.acme` to override the self-signed default. + + See `docs/gateway.md` ("Self-signed TLS"). + ''; + }; + + useSelfSigned = lib.mkOption { + type = lib.types.bool; + internal = true; + readOnly = true; + default = cfg.tls.certDir == null && !cfg.tls.acme.enable; + defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; + description = '' + Read-only derived flag: `true` when the gateway serves the + self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor + `tls.acme.enable` is configured. Single source of truth for the + self-signed condition; consumed by the `hive-tls` and `hive-ci` + modules so the derivation isn't duplicated. Internal — not meant to + be set by operators (use `tls.certDir` / `tls.acme` to override the + self-signed default). + ''; + }; + + httpsPort = lib.mkOption { + type = lib.types.port; + default = 443; + example = 8443; + description = '' + TCP port for the TLS-terminated vhosts. Default 443. The gateway + always terminates TLS (self-signed is the implicit floor when no + `tls.certDir` / ACME is configured), so this port is always active + alongside the plain-http `port`. + ''; + }; + + tls = { + certDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression ''"/var/lib/acme/example.com"''; + description = '' + Path to a host directory containing a TLS certificate and + private key for nginx. When set, nginx listens on `httpsPort` + and uses this cert, overriding the self-signed default — the + auto-generated hive-CA-signed leaf is skipped entirely. + + The directory is bind-mounted read-only into the gateway + container at `/run/hive-tls/`. nginx reads + `/` and `/`. + Default filenames (`cert.pem` / `key.pem`) match the output + layout of nixpkgs's `security.acme` module. + + Typical ACME setup: + ```nix + security.acme.certs."example.com" = { ... }; + services.hyperhive.gateway.tls.certDir = + config.security.acme.certs."example.com".directory; + ``` + + When using an external CA cert, peer hives can declare this + hive in `services.hyperhive.swarm.peers` without + `certFingerprint` — the standard CA bundle validates. + + Mutual exclusion with `tls.acme.enable` — set one or the other, + not both. + ''; + }; + + certName = lib.mkOption { + type = lib.types.str; + default = "cert.pem"; + description = '' + Filename of the TLS certificate within `tls.certDir`. Defaults + to `cert.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + keyName = lib.mkOption { + type = lib.types.str; + default = "key.pem"; + description = '' + Filename of the TLS private key within `tls.certDir`. Defaults + to `key.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + acme = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Let nginx inside the gateway container obtain and renew TLS + certificates automatically via ACME (Let's Encrypt). When + enabled, each vhost calls out to Let's Encrypt using the + HTTP-01 challenge on `port` (default 80) and stores certs + inside the gateway container's persistent state dir. + + Requirements: + - `services.hyperhive.domain` must be set and publicly + DNS-resolvable to this host. + - `services.hyperhive.gateway.openFirewall = true` so + Let's Encrypt can reach `/.well-known/acme-challenge/`. + - `tls.acme.email` must be set (ACME account contact). + + Mutual exclusion: `tls.certDir` set together with + `tls.acme.enable = true` fails at eval — pick one TLS source. + + Typical setup: + ```nix + services.hyperhive.gateway = { + openFirewall = true; + tls.acme = { + enable = true; + email = "admin@example.com"; + }; + }; + ``` + + After enabling, peer hives can omit `certFingerprint` in + `swarm.peers` — Let's Encrypt certs are CA-trusted + by default. + ''; + }; + + email = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "admin@example.com"; + description = '' + Email address for the ACME account registration with + Let's Encrypt. Required when `tls.acme.enable = true`. + Let's Encrypt sends expiry warnings to this address. + ''; + }; + }; + }; + + auth = { + enable = lib.mkEnableOption '' + HTTP basic auth on the gateway using an htpasswd file. When + enabled, every request to the gateway's main vhost requires a + valid username and password. nginx's built-in `auth_basic` + module validates credentials against + `/var/lib/hyperhive/gateway/gateway.htpasswd` on the host + (exposed as `/run/hive-state/gateway.htpasswd` inside the + container via the existing gateway state bind-mount). Off by default. + + Manage users with `hivectl gateway create-user`, `delete-user`, + and `list-users` — see `hivectl gateway --help` for usage. + The htpasswd file is created automatically when auth is enabled; + add at least one user before enabling to avoid locking everyone out. + ''; + + realm = lib.mkOption { + type = lib.types.strMatching "[^\"$]*"; + default = "hyperhive"; + example = "my-hive"; + description = '' + HTTP Basic auth `realm` value sent in the `WWW-Authenticate` + header when credentials are absent or rejected. Must not + contain `"` or `$` (nginx string metacharacters). + ''; + }; + }; + + hsts = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Add `Strict-Transport-Security` to all gateway vhosts. + + Disabled by default: HSTS pins HTTPS in the browser's HSTS + preload list; enabling it on a deployment that later loses TLS + will lock browsers out until the max-age expires. Only enable + this when you are certain TLS is permanent. + + The gateway always terminates TLS (self-signed floor), so + HSTS is always served over https when enabled — but mind the + warning above: HSTS pins https in the browser, so only enable it + when TLS is permanent for this deployment. + ''; + }; + + maxAge = lib.mkOption { + type = lib.types.ints.positive; + default = 31536000; + example = 86400; + description = '' + Value for the `max-age` directive in seconds. + Default: 31536000 (1 year), which is the value required for + HSTS preload list submission. Use a shorter value (e.g. 86400) + while testing so browsers forget the pin quickly. + ''; + }; + + includeSubDomains = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to include `includeSubDomains` in the HSTS header. + Only disable this if the gateway host has sub-domains that + intentionally serve plain HTTP. + ''; + }; + }; + }; +} diff --git a/nix/modules/hive-gateway/vhosts.nix b/nix/modules/hive-gateway/vhosts.nix new file mode 100644 index 00000000..4647db04 --- /dev/null +++ b/nix/modules/hive-gateway/vhosts.nix @@ -0,0 +1,353 @@ +# nginx virtual-host tree for the gateway container: the `_` default +# server (dashboard, per-agent routing, matrix discovery), the forge +# and matrix sub-domain vhosts, and the Accept-header SPA map for the +# matrix GUI. Pure function — called from ./default.nix inside the +# container config with the outer-scope config values as arguments; +# returns `{ virtualHosts, appendHttpConfig }`. +{ + lib, + cfg, # services.hyperhive.gateway + forgeCfg, + matrixCfg, + hyperhiveDomain, + dashboardDist, + errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized } + tlsCert, + tlsKey, +}: +let + # The gateway always terminates TLS: self-signed is the implicit + # floor when neither `tls.certDir` nor ACME is set, so there is no + # http-only mode. Kept as a named binding for the vhost listen/ssl + # wiring below. + hasTls = true; + # Listen addresses every vhost shares. Plain http on `cfg.port` + # always; `cfg.httpsPort` with TLS sits beside it when TLS is + # active (any mode). See `docs/gateway.md` ("TLS modes"). + vhostListen = [ + { + addr = "0.0.0.0"; + port = cfg.port; + } + ] + ++ lib.optional hasTls { + addr = "0.0.0.0"; + port = cfg.httpsPort; + ssl = true; + }; + # nixos `services.nginx.virtualHosts.` ssl attrs merged + # into each vhost. For ACME mode: `enableACME` + `addSSL` — + # NixOS's ACME integration manages the cert lifecycle and sets + # ssl_certificate automatically. For self-signed / certDir: + # explicit cert paths. + vhostTls = + if cfg.tls.acme.enable then + { + addSSL = true; + enableACME = true; + } + else + lib.optionalAttrs hasTls { + addSSL = true; + sslCertificate = tlsCert; + sslCertificateKey = tlsKey; + }; + + # Public-facing scheme + port-suffix for URLs the gateway + # mints into responses (well-known JSON, the deprecated + # `/matrix/*` 301 redirect, future absolute-URL needs). + # When TLS is active (self-signed OR operator cert), prefer + # `https://` (matrix-spec compliance) — 443 elides the + # port. Otherwise fall back to the plain-http listen with the + # bare port. See `docs/gateway.md` ("Self-signed TLS"). + publicScheme = if hasTls then "https" else "http"; + publicPort = if hasTls then cfg.httpsPort else cfg.port; + publicPortDefault = if hasTls then 443 else 80; + publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; + + # Security headers added at the server scope on every vhost. + # nginx's add_header inheritance rule: a location that defines its + # own add_header does NOT inherit the server-level ones. Any + # location with its own add_header (e.g. CORS on /.well-known or + # /_matrix/) must repeat the security headers explicitly — see those + # locations below. HTML-serving and proxy locations that carry no + # add_header of their own pick these up from the server scope + # automatically. + hstsDirectives = lib.concatStringsSep "; " ( + [ "max-age=${toString cfg.hsts.maxAge}" ] + ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" + ); + securityHeaders = '' + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} + ''; + + # Forge sub-domain vhost. `server_name = forge.domain`, proxies + # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, + # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on + # `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the + # forge isn't behind the gateway. + forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { + "${forgeCfg.domain}" = vhostTls // { + listen = vhostListen; + extraConfig = securityHeaders; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 1G; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ''; + }; + }; + }; + + # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. + # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll + # timeout). `/` serves fluffychat or 404 if GUI off. nginx + # longer-prefix-wins puts `/_matrix/` ahead of `/`. See + # `docs/gateway.md`. Empty attrset when matrix has no gateway host. + matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { + "${matrixCfg.gatewayHost}" = vhostTls // { + listen = vhostListen; + extraConfig = securityHeaders; + locations = { + "/_matrix/" = { + proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 50M; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ${securityHeaders} + add_header Access-Control-Allow-Origin *; + ''; + }; + } + // lib.optionalAttrs (matrixCfg.gui.enable) ( + { + # fluffychat at sub-domain root, SPA-fallback via + # the Accept-header `$matrix_spa_target` map. + "/" = { + alias = "${matrixCfg.gui.package}/"; + extraConfig = '' + try_files $uri $uri/ $matrix_spa_target =404; + ''; + }; + } + // { + # 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. + "= /config.json" = { + extraConfig = '' + default_type application/json; + return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; + ''; + }; + } + ) + // lib.optionalAttrs (!matrixCfg.gui.enable) { + "/" = { + return = "404"; + }; + }; + }; + }; + + # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link + # shim during the fluffychat sub-domain move). See `docs/gateway.md`. + matrixRedirectLocations = + lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) + ( + let + target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; + in + { + "/matrix/" = { + extraConfig = '' + rewrite ^/matrix/(.*)$ ${target}/$1 permanent; + ''; + }; + } + ); + + # `.well-known/matrix/{client,server}` discovery JSON. Points + # 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 ( + let + clientBaseUrl = + if matrixCfg.gatewayHost != null then + "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" + else + "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; + # `m.server` is NOT a URL: per the matrix server-server spec + # (Resolving Server Names) a delegated host with NO port resolves + # to the federation default 8448 (after the SRV check) — the + # https-implies-443 rule does NOT apply here. So the port must be + # explicit even when it's the HTTPS default; `publicPortSuffix` + # (which drops :443) is right for the client base_url above but + # wrong for federation delegation. Without this, peers federate to + # :8448 (closed) while the endpoint actually lives on + # the gateway's 443 vhost. See docs/gateway.md discovery flow. + serverHostPort = + if matrixCfg.gatewayHost != null then + "${matrixCfg.gatewayHost}:${toString publicPort}" + else + "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; + in + { + "= /.well-known/matrix/client" = { + extraConfig = '' + default_type application/json; + ${securityHeaders} + add_header Access-Control-Allow-Origin *; + return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}'; + ''; + }; + "= /.well-known/matrix/server" = { + extraConfig = '' + default_type application/json; + return 200 '{"m.server":"${serverHostPort}"}'; + ''; + }; + } + ); + + # `/agent/` catch-all 404 + the two internal error-page targets it + # points at. Per-agent `location /agent//` blocks live in the + # runtime-generated `/run/hive-state/agents.conf` (included via + # `extraConfig` on the vhost); nginx longest-prefix-match makes a + # real `/agent//` beat this catch-all. `internal` keeps the + # error pages reachable only through nginx's error handling. + agentLocations = { + "/agent/" = { + extraConfig = '' + error_page 404 = /__hive_agent_not_found; + return 404; + ''; + }; + "= /__hive_agent_not_found" = { + extraConfig = '' + internal; + alias ${errorPages.notFound}; + default_type text/html; + ''; + }; + "= /__hive_agent_unreachable" = { + extraConfig = '' + internal; + alias ${errorPages.unreachable}; + default_type text/html; + ''; + }; + }; + + # Shared auth block — separate locations don't inherit auth_basic, so + # each dashboard location (`/`, `/api/`) needs it or that surface is + # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot + # send HTTP Basic credentials with webhook deliveries, and the HMAC + # secret (`X-Hub-Signature-256`) protects those endpoints instead. + dashboardAuth = lib.optionalString cfg.auth.enable '' + auth_basic "${cfg.auth.realm}"; + auth_basic_user_file /run/hive-state/gateway.htpasswd; + # `=401` keeps the status 401 so the login dialog shows; the + # internal page explains `hivectl gateway create-user`. + error_page 401 =401 /__hive_auth_unauthorized; + ''; + + # Dashboard: nginx static-serves the dist, c0re is API-only. Routing + # is by PATH, never content-type. c0re serves exactly two prefixes — + # `/api/` (all dashboard data + actions + the SSE streams) and + # `/webhook/` (knowledge push + config-PR approval triggers, HMAC- + # guarded) — so those proxy to c0re and everything else serves the + # dist with an SPA fallback to index.html. Path routing is + # deterministic where an Accept-header split would make the SAME url + # behave differently by content-type (e.g. `/api/state` fetched with + # `Accept: text/html` wrongly getting index.html). A new top-level + # c0re route prefix (beyond /api + /webhook) needs a matching + # location added here. + dashboardProxyLocation = { + "/" = { + root = dashboardDist; + extraConfig = '' + try_files $uri /index.html; + ${dashboardAuth} + ''; + }; + "/api/" = { + proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; + proxyWebsockets = true; + extraConfig = '' + # off + 1d keep the SSE streams (/api/dashboard/stream, + # /api/build-logs/id/{id}/stream) live. + proxy_buffering off; + proxy_read_timeout 1d; + ${dashboardAuth} + ''; + }; + "/webhook/" = { + # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials + # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth + # for these endpoints; hive-c0re verifies it in the handler. + proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; + }; + }; +in +{ + # Accept-header SPA map for the matrix GUI only (see docs/gateway.md + # "SPA fallback"): text/html → index.html, else a sentinel so + # try_files falls through to 404. The dashboard doesn't use an + # Accept-header map — it routes by path (see dashboardProxyLocation). + appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' + map $http_accept $matrix_spa_target { + default "/__matrix_spa_no_html_fallback"; + "~*text/html" "/index.html"; + } + ''; + + virtualHosts = { + "_" = vhostTls // { + listen = vhostListen; + locations = + matrixRedirectLocations + // wellKnownLocations + // agentLocations + // dashboardProxyLocation + // lib.optionalAttrs cfg.auth.enable { + # Internal-only target for the 401 error_page above. + # `internal` prevents direct client access; `alias` serves + # the pre-built HTML from the Nix store. + "= /__hive_auth_unauthorized" = { + extraConfig = '' + internal; + alias ${errorPages.unauthorized}; + default_type text/html; + ''; + }; + }; + # Per-agent location blocks, generated at runtime by + # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf + # on the host. The bind-mount at /run/hive-state/ exposes + # that file here. nginx parses `include` at config-load + # time so a reload (triggered by c0re via systemd-run + # after each agents.conf write) picks up new or removed + # agents without a nixos-rebuild. nginx's longest-prefix- + # match rule ensures `/agent//` from this file beats + # the `/agent/` catch-all above. + extraConfig = securityHeaders + '' + include /run/hive-state/agents.conf; + ''; + }; + } + // forgeVhost + // matrixVhost; +} diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index 50bd9a6f..7a510657 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -5,6 +5,28 @@ }: let cfg = config.services.hyperhive.network; + + # IPv4 helpers for the DHCP-pool computation below — nix integers + # are 64-bit so all /0-/32 values are safe. + ipToInt = + ip: + builtins.foldl' (acc: x: acc * 256 + x) 0 ( + map lib.strings.toIntBase10 (lib.strings.splitString "." ip) + ); + intToIp = + n: + let + a = n / 16777216; + b = (n - a * 16777216) / 65536; + c = (n - a * 16777216 - b * 65536) / 256; + d = n - a * 16777216 - b * 65536 - c * 256; + in + "${toString a}.${toString b}.${toString c}.${toString d}"; + # 2^n via recursion (nix has no pow builtin). + pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); + hostCount = pow2 (32 - cfg.bridgePrefixLength); + # Mask off host bits to get the network base address. + networkBase = builtins.bitAnd (ipToInt cfg.bridgeIp) (4294967295 - hostCount + 1); in { # Hive-internal network — host-side bridge + per-agent DNS resolver. @@ -117,6 +139,35 @@ in ''; }; + # DHCP pool covering all usable host addresses on the bridge + # subnet, computed from bridgeIp/bridgePrefixLength: .2 (first + # usable after the .1 gateway) to .(hostCount-2) (last usable + # before broadcast). All containers — agents and service + # containers alike — receive their IPs dynamically from this pool; + # there are no hash-derived static assignments. Consumed by the + # dnsmasq that runs in the gateway container (hive-gateway module). + dhcpRangeStart = lib.mkOption { + type = lib.types.str; + internal = true; + readOnly = true; + default = intToIp (networkBase + 2); + defaultText = lib.literalMD "first usable bridge address after the gateway"; + description = '' + Read-only computed first address of the bridge DHCP pool. + ''; + }; + + dhcpRangeEnd = lib.mkOption { + type = lib.types.str; + internal = true; + readOnly = true; + default = intToIp (networkBase + hostCount - 2); + defaultText = lib.literalMD "last usable bridge address before broadcast"; + description = '' + Read-only computed last address of the bridge DHCP pool. + ''; + }; + isolateContainers = lib.mkOption { type = lib.types.bool; default = true;