{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.forge; gatewayCfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; # ROOT_URL forgejo advertises in clone links + outbound URLs. When # served behind the gateway, `cfg.domain` doubles as both the # forgejo `DOMAIN` setting AND the gateway vhost server-name, so # ROOT_URL just uses it directly (dropping the port suffix on the # canonical port for the scheme — 80 for http, 443 for https). The # gateway always terminates TLS now — self-signed is the implicit floor # when neither `tls.certDir` nor ACME is configured — so behind the # gateway the forge is always advertised over `https` on `httpsPort`. # When direct (gateway off or `behindGateway = false`), keep the # host:httpPort shape so direct browser access still produces correct # links. Operators can still override via `cfg.rootUrl` for bespoke # shapes. gatewayTls = true; defaultRootUrl = if cfg.behindGateway then let scheme = if gatewayTls then "https" else "http"; port = if gatewayTls then gatewayCfg.httpsPort else gatewayCfg.port; canonicalPort = if gatewayTls then 443 else 80; portSuffix = if port == canonicalPort then "" else ":${toString port}"; in "${scheme}://${cfg.domain}${portSuffix}/" else "http://${cfg.domain}:${toString cfg.httpPort}/"; effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; # When CI is enabled, the runner needs `actions/checkout` resolvable # without external DNS (hive-ci shares the host netns, so a host-resolver # blip otherwise reds every `actions/checkout@vN` fetch from # data.forgejo.org). Auto-append a pull-mirror of it and point # forgejo's DEFAULT_ACTIONS_URL at this instance so `uses:` resolves local. ciEnabled = config.services.hyperhive.forge.ci.enable; actionCheckoutMirror = { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; }; # Auto-append the actions/checkout mirror only when CI is on AND the # operator hasn't already declared that dest themselves (else CI-on + # an explicit `actions/checkout` entry would duplicate it). effectiveMirrors = cfg.mirrors ++ lib.optional ( ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors) ) actionCheckoutMirror; in { # Private Forgejo in a `hive-forge` nixos-container, shared host # netns. Agents reach it at `forge.` via the gateway. State # at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives # restart. See `docs/gateway.md::hive-forge container shape`. # The internal forge is mandatory — it's the canonical store for the # meta flake + every agent's config repo (and the `internal/*` repos), # so there is no enable/disable toggle. It deploys whenever hyperhive # itself is enabled (`services.hyperhive.enable`). options.services.hyperhive.forge = { httpPort = lib.mkOption { type = lib.types.port; default = 3000; description = '' TCP port the forge serves HTTP on. Default 3000 sits outside hyperhive's claimed ranges (dashboard 7000, every agent in 8100..8999 via FNV-1a hash). Change this if you already have another forgejo bound to 3000. ''; }; sshPort = lib.mkOption { type = lib.types.port; default = 2222; description = '' TCP port the forge's built-in SSH server listens on. Kept off 22 so it doesn't clash with the host's openssh. Agents push with `ssh -p git@:/.git`. ''; }; domain = lib.mkOption { type = lib.types.str; default = "forge.${hyperhiveDomain}"; defaultText = lib.literalExpression ''"forge.''${services.hyperhive.domain}"''; example = "git.example.com"; description = '' 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` (sub-domain routing — see `docs/gateway.md`). Defaults to `forge.''${services.hyperhive.domain}` (idiomatic sub-domain shape — `forge` labelled under the hive's bare domain). `services.hyperhive.domain` is required, so there's always a domain to derive from. 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. ''; }; package = lib.mkOption { type = lib.types.package; default = pkgs.forgejo; defaultText = lib.literalExpression "pkgs.forgejo"; description = '' Forgejo package to run inside the container. Defaults to `pkgs.forgejo` (the latest release line) rather than the nixpkgs-module default of `pkgs.forgejo-lts`, because LTS lags far behind on schema and the DB easily ends up "newer than the binary" if the operator ever ran a non-LTS forgejo against the same state dir. Override to `pkgs.forgejo-lts` if you actively want the slower release train. ''; }; behindGateway = lib.mkOption { type = lib.types.bool; default = config.services.hyperhive.enable; defaultText = lib.literalExpression "config.services.hyperhive.enable"; description = '' Serve forgejo through the hive-gateway nginx as a sub-domain vhost (`server_name = cfg.domain`) instead of directly on `httpPort` (sub-domain routing — see `docs/gateway.md`). 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. Defaults to `services.hyperhive.enable` (the gateway always runs alongside hyperhive, so forge auto-routes through it). Set `false` explicitly to keep forge on the direct port even though the gateway is running (e.g. an external git client that doesn't traverse the gateway). Sub-domain routing is the preferred shape 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. ''; }; 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 `cfg.domain` + gateway state, including the scheme: - `behindGateway = true` → `https://''${cfg.domain}/`. The gateway always terminates TLS (self-signed is the implicit floor when no `gateway.tls.certDir` / ACME is set), so the forge is always advertised over https. A non-canonical `gateway.httpsPort` is appended as `:`. - `behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` The TLS scheme is derived automatically now, so you only need to set this for a genuinely bespoke shape (e.g. an external reverse proxy on a different host/path). Must end with `/` per forgejo's `ROOT_URL` contract. ''; }; openFirewall = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Open `httpPort` + `sshPort` in the host firewall. Off by default (secure-by-default): agent containers reach the forge at `forge.` via the gateway (not directly), and the host reaches it on loopback — so the firewall opens only matter for access from outside the host. Flip to `true` when you want the operator's browser or external git clients to hit the forge directly. **Breaking change**: this used to default to `true`. If you relied on the old default for external reach, add `services.hyperhive.forge.openFirewall = true;` to your host config before rebuilding. ''; }; mirrors = lib.mkOption { type = lib.types.listOf ( lib.types.submodule { options = { upstream = lib.mkOption { type = lib.types.str; example = "https://github.com/actions/checkout"; description = "Upstream clone URL to mirror from."; }; dest = lib.mkOption { type = lib.types.str; example = "actions/checkout"; description = '' Local `/` the pull-mirror is created at. The `` org is auto-created if missing. Keep mirror dests in their own orgs (e.g. `actions/*`) — separate from the hive-c0re-managed namespaces (config/shared/agents/core) so the seed never collides with core's own provisioning. ''; }; }; } ); default = [ ]; example = lib.literalExpression '' [ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } ] ''; description = '' General-purpose Forgejo **pull-mirrors** to auto-seed on the local forge. Each entry is created as a real Forgejo pull-mirror (it re-syncs from `upstream` out-of-band), not a one-off pushed clone — so a host-resolver blip leaves a *stale* mirror, never a hard failure on whatever reads it. When `services.hyperhive.forge.ci.enable` is set, an `actions/checkout` mirror is auto-appended to this list and forgejo's `DEFAULT_ACTIONS_URL` is pointed at this instance, so CI `uses: actions/checkout@vN` steps resolve entirely on loopback with no external DNS on the critical path (the seed/re-sync needs external DNS, but that's off the CI path). ''; }; }; config = lib.mkIf config.services.hyperhive.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} ''; } { # `cfg.domain` can't be empty — would render `.` shaped # garbage as both server_name (nginx wildcard catch-all) and # /etc/hosts entry (invalid). The default derives a non-empty # `forge.`, but an operator-set empty string should fail # loud. assertion = cfg.domain != ""; message = '' services.hyperhive.forge.domain = "" is rejected. The rendered URLs would be invalid (nginx wildcard catch-all for an empty server_name, /etc/hosts rejects empty entries). Either leave at default (auto-derives to "forge."), or set a non-empty hostname like "forge.example.com" or "git.internal". ''; } { # Each mirror dest must be exactly `/` — the seed # splits on the single slash to create the org + repo. assertion = lib.all (m: lib.length (lib.splitString "/" m.dest) == 2) effectiveMirrors; message = '' Every services.hyperhive.forge.mirrors[].dest must be exactly "/" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors} ''; } { # Keep mirror orgs out of the hive-c0re-managed namespaces # (config/shared/agents/core) so the seed never races / collides # with hive-c0re's own startup provisioning of those orgs. assertion = lib.all ( m: !(lib.elem (builtins.elemAt (lib.splitString "/" m.dest) 0) [ "config" "shared" "agents" "core" ]) ) effectiveMirrors; message = '' services.hyperhive.forge.mirrors[].dest must not place a mirror in a hive-c0re-managed org (config / shared / agents / core) — those are provisioned by hive-c0re and a mirror there would collide. Use a dedicated org (e.g. "actions/checkout"). ''; } ]; containers.hive-forge = { autoStart = true; ephemeral = false; # Share host netns — forgejo's HTTP / SSH listeners then look # exactly like a host-side service, no port forwarding dance, # and agent containers (which also share host netns) reach it # via plain `localhost`. privateNetwork = false; config = { pkgs, ... }: let # Build a custom static-root that is the standard forgejo data # output with our theme CSS added. Using STATIC_ROOT_PATH instead # of tmpfiles / bind-mounts means the theme is always present in # the nix store — no separate hive-forge container rebuild needed, # and no persistent-state directory involved. staticRootWithTheme = pkgs.runCommand "forgejo-static-with-theme" { } '' cp -r --no-preserve=mode,ownership ${cfg.package.data}/. $out/ mkdir -p $out/public/assets/css cp ${../forge-theme/theme-catppuccin-vibec0re.css} \ $out/public/assets/css/theme-catppuccin-vibec0re.css # Replace the default Forgejo logo + favicon with the hyperhive # mark. Files in public/assets/img/ are served before built-ins. mkdir -p $out/public/assets/img cp ${../../branding/hyperhive.svg} $out/public/assets/img/logo.svg cp ${../../branding/hyperhive.svg} $out/public/assets/img/favicon.svg cp ${../../branding/hyperhive.png} $out/public/assets/img/logo.png cp ${../../branding/hyperhive.png} $out/public/assets/img/favicon.png cp ${../../branding/hyperhive.png} $out/public/assets/img/avatar_default.png ''; in { system.stateVersion = "25.11"; services.forgejo = { enable = true; package = cfg.package; database.type = "sqlite3"; lfs.enable = true; settings = { DEFAULT.APP_NAME = "HyperHive"; server = { DOMAIN = cfg.domain; ROOT_URL = effectiveRootUrl; HTTP_PORT = cfg.httpPort; START_SSH_SERVER = true; SSH_PORT = cfg.sshPort; SSH_LISTEN_PORT = cfg.sshPort; BUILTIN_SSH_SERVER_USER = "git"; DISABLE_SSH = false; # Point forgejo at our extended static root that includes # the custom theme CSS baked straight into the nix store. STATIC_ROOT_PATH = staticRootWithTheme; }; # Registration off — operator seeds agent users via # `nixos-container run hive-forge -- forgejo admin # user create …`. service = { DISABLE_REGISTRATION = true; REQUIRE_SIGNIN_VIEW = false; }; repository = { DEFAULT_BRANCH = "main"; DEFAULT_PRIVATE = "private"; }; # Repo migrations / pull-mirrors fetch from the source # URL *inside* Forgejo. hyperhive code is synced from # `localhost` (and the host LAN), which Forgejo's # migration guard blocks by default ("cannot import from # disallowed hosts"). Allow loopback + RFC-1918 sources # so an in-hive mirror of the hyperhive repo works. migrations.ALLOW_LOCALNETWORKS = true; log.LEVEL = "Warn"; ui = { DEFAULT_THEME = "catppuccin-vibec0re"; THEMES = "catppuccin-vibec0re,forgejo-auto,forgejo-light,forgejo-dark,gitea-auto,gitea-light,gitea-dark"; }; # Point forgejo at the GPG key generated by the # forgejo-gpg-init service below. SIGNING_KEY = "default" # resolves via the forgejo process's git config # (`user.signingkey`) — which forgejo-gpg-init sets to the # generated key — not by scanning GNUPGHOME. GNUPGHOME is # the keyring forgejo signs from; must be absolute + # writeable by the forgejo user. "repository.signing" = { SIGNING_KEY = "default"; GNUPGHOME = "/var/lib/forgejo/.gnupg"; }; # Enable Forgejo Actions so the runner registration token # API endpoint is available. Without this the endpoint # returns "runner registration token not found" regardless # of token scopes. Required by `hive-ci-register.service` # in the hive-ci container. actions.ENABLED = true; # When CI is enabled, resolve `uses: /@vN` from # THIS instance (the seeded `actions/checkout` pull-mirror) # instead of the upstream default `data.forgejo.org` — keeps # the checkout step on loopback, immune to a host-resolver # blip. `self` = forgejo expands actions against its # own ROOT_URL. actions.DEFAULT_ACTIONS_URL = lib.mkIf ciEnabled "self"; # F3 (federation) computes its data dir relative to the # forgejo binary, which lands in the read-only nix # store and crashes anything that touches the F3 # subsystem — including `forgejo admin user create`, # which init-ses F3 even when ENABLED=false. Pin the # path absolute alongside the disable so the init # resolution succeeds before the flag is checked. "F3" = { ENABLED = false; PATH = "/var/lib/forgejo/data/f3"; }; }; }; environment.systemPackages = [ pkgs.forgejo pkgs.gnupg ]; # Forgejo's local Actions-artifact storage defaults to # `{APP_DATA_PATH}/actions_artifacts` (= # `/var/lib/forgejo/data/actions_artifacts`), but Forgejo does not # pre-create that directory. The artifact endpoint ingests the # chunked upload, then the merge-chunks step does an `lstat` on a # tmp dir under it and fails: # Error merge chunks: lstat # /var/lib/forgejo/data/actions_artifacts/tmpNNN: no such file or # directory # so every `upload-artifact` step dies after the build succeeds. # Pre-create the dir (forgejo-owned) so uploads actually persist. # `actions.ENABLED = true` registers the endpoints; this gives them # somewhere to write. systemd.tmpfiles.rules = [ "d /var/lib/forgejo/data 0750 forgejo forgejo - -" "d /var/lib/forgejo/data/actions_artifacts 0750 forgejo forgejo - -" ]; # Ensure Forgejo has a usable GPG signing key so UI merges / CRUD # commits are signed instead of erroring "does not have a signing # key". This service (a) generates a key in forgejo's persistent # keyring iff one isn't already present — keyed on the actual # secret key, NOT a stamp file, so a partial state wipe that loses # the key still regenerates it — and (b) points the forgejo user's # git config at it (`user.signingkey` + commit/tag gpgsign), which # is how `SIGNING_KEY = "default"` actually resolves. Runs as the # forgejo user before forgejo on each start; idempotent (the keygen # is guarded, the git-config is a cheap re-set). systemd.services.forgejo-gpg-init = { description = "ensure Forgejo's GPG signing key + git signing config"; # Start before forgejo so the key + signing config are ready when # forgejo reads repository.signing on startup. wantedBy = [ "forgejo.service" ]; before = [ "forgejo.service" ]; serviceConfig = { Type = "oneshot"; User = "forgejo"; Group = "forgejo"; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "forgejo-gpg-init"; }; # GNUPGHOME = the keyring forgejo signs from; HOME so # `git config --global` lands where the forgejo process reads it. environment = { GNUPGHOME = "/var/lib/forgejo/.gnupg"; HOME = "/var/lib/forgejo"; }; path = [ pkgs.gnupg pkgs.git pkgs.gnugrep pkgs.gawk pkgs.coreutils ]; script = '' set -euo pipefail mkdir -p "$GNUPGHOME" chmod 700 "$GNUPGHOME" # Generate only if no secret key is present (key-based guard, # not a stamp — a stamp can outlive the key after a state wipe # and wrongly suppress regeneration). if ! gpg --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec:'; then printf '%s\n' \ '%no-protection' \ 'Key-Type: RSA' \ 'Key-Length: 4096' \ 'Name-Real: HyperHive Forge' \ 'Name-Email: forgejo@hive' \ 'Expire-Date: 0' \ | gpg --batch --gen-key fi # Point git (hence Forgejo's SIGNING_KEY="default") at the key. KEYID=$(gpg --list-secret-keys --keyid-format long --with-colons \ | awk -F: '/^sec:/ { print $5; exit }') if [ -n "$KEYID" ]; then git config --global user.signingkey "$KEYID" git config --global commit.gpgsign true git config --global tag.gpgsign true fi ''; }; }; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.httpPort cfg.sshPort ]; }; # Forward the declared pull-mirrors to hive-c0re, which seeds them in # its forge provisioning sweep (`forge.rs::ensure_mirrors`, alongside # the SEEDED_ORGS ensure). c0re already holds the core admin token and # ensures the orgs there, so the seeding lives in one place rather than # a parallel host-side unit. JSON-encoded list of { upstream, dest }; # `[]` when nothing to seed (c0re no-ops). systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS = builtins.toJSON effectiveMirrors; }; }