diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index b7e4e3c7..9a2b0c18 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -6,6 +6,31 @@ }: let cfg = config.services.hyperhive.forge; + 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`). + subdomain = + if cfg.subdomain == null then null else if cfg.subdomain == "" then hyperhiveDomain 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. + defaultRootUrl = + if subdomain != null && gatewayCfg.enable or false then + let + portSuffix = if gatewayCfg.port == 80 then "" else ":${toString gatewayCfg.port}"; + in + "http://${subdomain}${portSuffix}/" + else + "http://${cfg.domain}:${toString cfg.httpPort}/"; + effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; in { # Private Forgejo for hyperhive agents, wrapped in a nixos-container @@ -85,6 +110,55 @@ in ''; }; + subdomain = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "forge"; + example = "git"; + 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. + + Defaults to `"forge"` (→ `forge.''${hyperhive.domain}`). + Set to `null` to opt out — forge stays direct on `httpPort`, + no gateway vhost. Set to the empty string `""` for a bare-domain + landing (advanced: collides with the dashboard server block). + + Requires `services.hyperhive.domain` to be set. Requires + `services.hyperhive.gateway.enable = true` for the vhost to + actually exist. + + The mara-call on #749:9609 picks sub-domain over sub-path for + forge + matrix (both are external standard apps with sub-domain- + native config defaults). Per-agent UIs stay on sub-path + (`/agent//`) because they're hyperhive-internal + + already base-path-aware via #731. + ''; + }; + + rootUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://forge.example.com/"; + description = '' + Override the auto-derived forgejo `ROOT_URL`. When `null` + (default), `ROOT_URL` is derived from `subdomain` + gateway + state: + + - gateway on + `subdomain != null` → `http://./` + (uses `services.hyperhive.gateway.port` when non-80) + - otherwise → `http://:/` (direct) + + 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. + ''; + }; + openFirewall = lib.mkOption { type = lib.types.bool; default = false; @@ -108,6 +182,33 @@ in }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl; + message = '' + services.hyperhive.forge.rootUrl must end with "/". forgejo's + ROOT_URL contract requires a trailing slash for correct + relative-link generation; without it forgejo emits URLs like + `https://forge.example.com.user.id` instead of + `https://forge.example.com/user.id`. Got: ${toString cfg.rootUrl} + ''; + } + { + assertion = + cfg.subdomain == null + || hyperhiveDomain != null + || cfg.rootUrl != null; + 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. + ''; + } + ]; + containers.hive-forge = { autoStart = true; ephemeral = false; @@ -150,7 +251,7 @@ in DEFAULT.APP_NAME = "HyperHive"; server = { DOMAIN = cfg.domain; - ROOT_URL = "http://${cfg.domain}:${toString cfg.httpPort}/"; + ROOT_URL = effectiveRootUrl; HTTP_PORT = cfg.httpPort; START_SSH_SERVER = true; SSH_PORT = cfg.sshPort; diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index b497426b..747d7822 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -8,6 +8,7 @@ let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; + forgeCfg = config.services.hyperhive.forge; # Per-agent port table for `/agent//` routing (#15 v0). Single- # sourced from `cfg.agentPortsFile` (default @@ -237,7 +238,8 @@ in "~*text/html" "/matrix/index.html"; } ''; - virtualHosts."_" = { + virtualHosts = { + "_" = { listen = [ { addr = "0.0.0.0"; @@ -393,7 +395,52 @@ in ''; }; }; - }; + }; + } + // + # Forge sub-domain 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 + # block proxies all `/` → `http://127.0.0.1:/` + # so forgejo handles requests at root (default deploy shape + # — no `ROOT_URL`-prefix translation needed). + # + # `client_max_body_size 1G` — git pushes + LFS uploads can + # be large; nginx's default 1M would 413 most real commits. + # + # Long timeouts for big repo operations: a fresh clone of a + # multi-GB repo can take minutes; the default 60s + # `proxy_read_timeout` would abort mid-stream. + # + # `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}" = { + listen = [ + { + addr = "0.0.0.0"; + port = cfg.port; + } + ]; + 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; + ''; + }; + }; + }; }; }; }; @@ -402,8 +449,18 @@ in allowedTCPPorts = [ cfg.port ]; }; + # `/etc/hosts` entries for local dev: the bare hive domain plus + # any sub-domain modules (forge, matrix-via-#751) that are on. + # All map to `127.0.0.1` since the gateway shares host netns. + # Operators with real DNS leave `localHostsEntry = false`; this + # is the dev-loop shortcut for `http:///` + + # `http://forge./` resolving locally. networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) { - "127.0.0.1" = [ hyperhiveDomain ]; + "127.0.0.1" = [ hyperhiveDomain ] + ++ lib.optional ( + (config.services.hyperhive.forge.enable or false) + && (config.services.hyperhive.forge.subdomain or null) != null + ) "${config.services.hyperhive.forge.subdomain}.${hyperhiveDomain}"; }; }; }