diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index 9be03593..ce29e344 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -9,26 +9,22 @@ let gatewayCfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; - # Sub-domain forgejo lives at when served behind the gateway (#749, - # mara verdict at issue:9609 — sub-domain over sub-path). Defaults - # to `forge.`; set to `null` to opt out of subdomain - # routing (forge stays direct on `cfg.httpPort`). Empty string is - # rejected at assertion time. - subdomain = - if cfg.subdomain == null then null else "${cfg.subdomain}.${hyperhiveDomain}"; - # ROOT_URL forgejo advertises in clone links + outbound URLs. When - # served behind the gateway (#749), use the sub-domain so generated - # URLs resolve cleanly through the per-subdomain server-block. When - # direct (gateway off, or operator nulled `cfg.subdomain`), keep the - # original port-3000 shape. Operators can override via `cfg.rootUrl` - # for TLS / non-default gateway ports / bespoke sub-domains. + # served behind the gateway (#749 — mara verdict at issue:9609, + # sub-domain over sub-path), `cfg.domain` doubles as both the + # forgejo `DOMAIN` setting AND the gateway vhost server-name, so + # ROOT_URL just uses it directly (drops the port suffix when the + # gateway is on the canonical port 80). When direct (gateway off + # or `behindGateway = false`), keep the host:port shape so direct + # browser access on `:httpPort` still produces correct links. + # Operators can override via `cfg.rootUrl` for TLS / non-default + # gateway ports / bespoke shapes. defaultRootUrl = - if subdomain != null && gatewayCfg.enable or false then + if cfg.behindGateway then let portSuffix = if gatewayCfg.port == 80 then "" else ":${toString gatewayCfg.port}"; in - "http://${subdomain}${portSuffix}/" + "http://${cfg.domain}${portSuffix}/" else "http://${cfg.domain}:${toString cfg.httpPort}/"; effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; @@ -86,13 +82,30 @@ in domain = lib.mkOption { type = lib.types.str; - default = "localhost"; - example = "forge.internal"; + 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 = '' - Hostname used in repo clone URLs the forge advertises. The - container shares host netns so `localhost` works for any - agent on the same host; set a real hostname when you want - clones from outside the host to look canonical. + Public hostname for the forge. Doubles as both the forgejo + `DOMAIN` setting (clone URLs forgejo advertises) AND the + gateway vhost server-name when `behindGateway = true` + (#749, mara verdict at issue:9609 — sub-domain over sub-path). + + 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 (pre-#749 direct-on-port behaviour). + + Set to a full hostname (`git.example.com`, + `forge.internal.lan`, etc.) for a bespoke vhost shape — the + full domain goes here, no separate sub-domain-label option + (mara on #754:9684 — "specify full forge domain in options + instead"). ''; }; @@ -111,29 +124,29 @@ in ''; }; - subdomain = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = "forge"; - example = "git"; + behindGateway = lib.mkOption { + type = lib.types.bool; + default = gatewayCfg.enable or false; + defaultText = lib.literalExpression "config.services.hyperhive.gateway.enable"; description = '' - Sub-domain label for the gateway vhost that serves forgejo - (#749). The gateway adds a `server { server_name ''${subdomain}.''${hyperhive.domain}; }` - block that proxies all `/` → `http://127.0.0.1:''${httpPort}/`. - Forgejo's `ROOT_URL` auto-flips to - `http://''${subdomain}.''${hyperhive.domain}/` so clone-links + - asset references resolve cleanly through the sub-domain. + Serve forgejo through the hive-gateway nginx as a sub-domain + vhost (`server_name = cfg.domain`) instead of directly on + `httpPort` (#749, mara verdict at issue:9609 — sub-domain + over sub-path). - Defaults to `"forge"` (→ `forge.''${hyperhive.domain}`). - Set to `null` to opt out — forge stays direct on `httpPort`, - no gateway vhost. Empty-string `""` is rejected (would render - `.''${hyperhive.domain}` — nginx treats that as a wildcard - catch-all, not a bare-domain server block, so the behaviour - is surprising; bare-domain landing is what the dashboard - already serves anyway). + When `true`: + - The gateway adds a `server { server_name = ''${cfg.domain}; }` + block that proxies all `/` → `http://127.0.0.1:''${httpPort}/`. + - Forgejo's `ROOT_URL` flips to `http(s)://''${cfg.domain}/` + (sub-domain root, no port suffix when gateway is on 80). + - `gateway.localHostsEntry = true` extends `/etc/hosts` to + include `cfg.domain → 127.0.0.1` for local dev. - Requires `services.hyperhive.domain` to be set. Requires - `services.hyperhive.gateway.enable = true` for the vhost to - actually exist. + Defaults to `services.hyperhive.gateway.enable` — flipping + the gateway on/off auto-routes forge through it. Set `false` + explicitly to keep forge on the direct port even when the + gateway is running (e.g. an external git client that doesn't + traverse the gateway). The mara-call on #749:9609 picks sub-domain over sub-path for forge + matrix (both are external standard apps with sub-domain- @@ -149,17 +162,17 @@ in example = "https://forge.example.com/"; description = '' Override the auto-derived forgejo `ROOT_URL`. When `null` - (default), `ROOT_URL` is derived from `subdomain` + gateway + (default), `ROOT_URL` is derived from `cfg.domain` + gateway state: - - gateway on + `subdomain != null` → `http://./` - (uses `services.hyperhive.gateway.port` when non-80) - - otherwise → `http://:/` (direct) + - `behindGateway = true` → `http://''${cfg.domain}/` (uses + `services.hyperhive.gateway.port` when non-80) + - `behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` Set this to a fully-qualified URL when running behind TLS - termination, a non-default gateway port, or a bespoke - sub-domain shape (e.g. `https://forge.example.com/`). Must - end with `/` per forgejo's `ROOT_URL` contract. + termination (`https://...`), a non-default gateway port, or + a bespoke shape. Must end with `/` per forgejo's `ROOT_URL` + contract. ''; }; @@ -198,32 +211,35 @@ in ''; } { - assertion = - cfg.subdomain == null - || hyperhiveDomain != null - || cfg.rootUrl != null; + # `cfg.domain` can't be empty — would render `.` shaped + # garbage as both server_name (nginx wildcard catch-all) and + # /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.subdomain = "${toString cfg.subdomain}" - requires services.hyperhive.domain to be set (sub-domain is - rendered as "."). Either set - services.hyperhive.domain, override services.hyperhive.forge.rootUrl - directly, or set services.hyperhive.forge.subdomain = null to opt - out of sub-domain routing. + 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." when set, else + "localhost"), or set a non-empty hostname like "forge.example.com" + or "git.internal". ''; } { - # `subdomain = ""` would render `.` as both the - # nginx server_name (treated as wildcard catch-all, surprising) - # and the /etc/hosts entry (invalid hostname). argus 🟡 on - # #754 v2 — fail loud here rather than ship the surprising - # behaviour. - assertion = cfg.subdomain != ""; + # behindGateway requires the gateway module to actually be on. + # Otherwise the configured `ROOT_URL` flips to a sub-domain + # shape that has no nginx vhost backing it → broken on the + # rebuild. + assertion = !cfg.behindGateway || (gatewayCfg.enable or false); message = '' - services.hyperhive.forge.subdomain = "" is rejected: the - rendered sub-domain "." is invalid (nginx - treats it as a wildcard catch-all, /etc/hosts rejects it). - Use `null` to opt out of sub-domain routing entirely, or - set a non-empty label like "forge" or "git". + services.hyperhive.forge.behindGateway = true requires + services.hyperhive.gateway.enable = true (the gateway vhost + serving forge needs the gateway container to actually be + running). Either turn the gateway on, or set + services.hyperhive.forge.behindGateway = false to keep forge + on its direct port. ''; } ]; diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 747d7822..d38e05de 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -398,14 +398,20 @@ in }; } // - # Forge sub-domain vhost (#749, mara verdict at issue:9609 — + # Forge vhost (#749, mara verdict at issue:9609 — # sub-domain over sub-path). When forgejo runs behind the - # gateway, it gets its own `server { server_name ...; }` - # block keyed on `.`. The + # gateway (`forge.behindGateway = true`), it gets its own + # `server { server_name = forge.domain; }` block. The # block proxies all `/` → `http://127.0.0.1:/` # so forgejo handles requests at root (default deploy shape # — no `ROOT_URL`-prefix translation needed). # + # `forge.domain` is the full hostname (e.g. + # `forge.darkest.space`, `git.example.com`) — single source + # of truth for both the forgejo `DOMAIN` setting and the + # gateway vhost name (mara on #754:9684 — "specify full + # forge domain in options instead"). + # # `client_max_body_size 1G` — git pushes + LFS uploads can # be large; nginx's default 1M would 413 most real commits. # @@ -416,13 +422,9 @@ in # `proxyWebsockets = true` keeps forgejo's live-update # endpoints (`/api/v1/events`) + any future websocket # endpoints working transparently. SSH stays direct on - # `cfg.sshPort` (separate listener protocol, not HTTP). - lib.optionalAttrs ( - forgeCfg.enable or false - && (forgeCfg.subdomain or null) != null - && hyperhiveDomain != null - ) { - "${forgeCfg.subdomain}.${hyperhiveDomain}" = { + # `forge.sshPort` (separate listener protocol, not HTTP). + lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) { + "${forgeCfg.domain}" = { listen = [ { addr = "0.0.0.0"; @@ -455,12 +457,19 @@ in # Operators with real DNS leave `localHostsEntry = false`; this # is the dev-loop shortcut for `http:///` + # `http://forge./` resolving locally. + # + # Forge's `cfg.domain` may equal `hyperhiveDomain` (e.g. operator + # set `forge.domain = "darkest.space"` matching the hive domain) + # — `lib.unique` collapses the duplicate so `/etc/hosts` doesn't + # carry the same entry twice. networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) { - "127.0.0.1" = [ hyperhiveDomain ] + "127.0.0.1" = lib.unique ( + [ hyperhiveDomain ] ++ lib.optional ( (config.services.hyperhive.forge.enable or false) - && (config.services.hyperhive.forge.subdomain or null) != null - ) "${config.services.hyperhive.forge.subdomain}.${hyperhiveDomain}"; + && (config.services.hyperhive.forge.behindGateway or false) + ) config.services.hyperhive.forge.domain + ); }; }; }