{ pkgs, lib, config, ... }: let cfg = config.hyperhive.forge; in { # Private Forgejo for hyperhive agents, wrapped in a nixos-container # so it doesn't fight any `services.forgejo` the operator already # runs on the host. The container shares the host network namespace # (`privateNetwork = false`) so agents reach the forge at # `http://localhost:` without any extra plumbing — # nixos-container is just here for state + systemd-unit isolation, # not network isolation. # # Container name is `hive-forge` (not `h-*`), so hive-c0re's # lifecycle scanner ignores it; the operator manages it via the # standard `nixos-container` CLI. # # State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` # and survives container restart / host reboot. To wipe, destroy the # container. options.hyperhive.forge = { enable = lib.mkOption { type = lib.types.bool; default = true; description = '' Run hive-forge — a private Forgejo (in a nixos-container) for hyperhive agents. On by default: hive-c0re mirrors every agent's applied config repo into the forge's `agent-configs` org, so the forge is part of the standard install. Set `hyperhive.forge.enable = false` to opt out. ''; }; 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, manager 8000, sub-agents 8100..8999). 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 = "localhost"; example = "forge.internal"; 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. ''; }; 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. ''; }; openFirewall = lib.mkOption { type = lib.types.bool; default = true; description = '' Open `httpPort` + `sshPort` in the host firewall. Off when the forge should only be reachable from inside the host. (The container shares host netns, so this is the only firewall layer that matters.) ''; }; }; config = lib.mkIf cfg.enable { 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 = "http://${cfg.domain}:${toString cfg.httpPort}/"; 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 oneshot below. "default" resolves to # the first secret key found in GNUPGHOME. GNUPGHOME # must be absolute and writeable by the forgejo user. "repository.signing" = { SIGNING_KEY = "default"; GNUPGHOME = "/var/lib/forgejo/.gnupg"; }; # 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 ]; # Generate a GPG signing key for Forgejo on first boot so UI # merges produce signed commits instead of erroring "no key to # sign with". The key lives in forgejo's persistent state dir # (/var/lib/forgejo/.gnupg) and survives container restarts. # The stamp file prevents re-generation on subsequent boots. # Service runs as the forgejo user so file ownership is correct. systemd.services.forgejo-gpg-init = { description = "generate GPG signing key for Forgejo (once)"; # Start before forgejo so the key is ready when forgejo reads # repository.signing config on startup. wantedBy = [ "forgejo.service" ]; before = [ "forgejo.service" ]; unitConfig.ConditionPathExists = "!/var/lib/forgejo/.gnupg/hive-key-init.stamp"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "forgejo"; Group = "forgejo"; }; environment.GNUPGHOME = "/var/lib/forgejo/.gnupg"; path = [ pkgs.gnupg pkgs.coreutils ]; script = '' mkdir -p "$GNUPGHOME" chmod 700 "$GNUPGHOME" gpg --batch --gen-key <<'EOF' %no-protection Key-Type: RSA Key-Length: 4096 Name-Real: HyperHive Forge Name-Email: forgejo@hive Expire-Date: 0 EOF touch "$GNUPGHOME/hive-key-init.stamp" ''; }; }; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.httpPort cfg.sshPort ]; }; }; }