hyperhive/nix/modules/hive-forge.nix
iris 188f3ea5ec chore: scrub #NNN issue references from code comments and nix descriptions
Part of the docs-migration chore (issue #708). Remove GitHub issue
numbers from inline comments, option descriptions, and rustdoc —
these are contextless noise for anyone reading the code without
access to the original discussions. Replace with prose that captures
the same rationale directly.

No functional change. Build still clean (cargo check passes).
2026-06-01 13:30:52 +02:00

381 lines
16 KiB
Nix

{
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 (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 cfg.behindGateway then
let
portSuffix = if gatewayCfg.port == 80 then "" else ":${toString gatewayCfg.port}";
in
"http://${cfg.domain}${portSuffix}/"
else
"http://${cfg.domain}:${toString cfg.httpPort}/";
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
in
{
# Private Forgejo in a `hive-forge` nixos-container, shared host
# netns so agents reach it on loopback. State at
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
# restart. See `docs/gateway.md::hive-forge container shape`.
options.services.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
`services.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, 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 <sshPort> git@<domain>:<owner>/<repo>.git`.
'';
};
domain = lib.mkOption {
type = lib.types.str;
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 = ''
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}` when the
hive-domain is set (idiomatic sub-domain shape `forge`
labelled under the hive's bare domain), falling back to
`localhost` otherwise (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.
'';
};
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 = gatewayCfg.enable or false;
defaultText = lib.literalExpression "config.services.hyperhive.gateway.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.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).
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/<name>/`)
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:
- `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 (`https://...`), a non-default gateway port, or
a bespoke shape. 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): the forge is reachable from the
host + every agent container via `localhost` either way
(shared netns), so the firewall opens only matter for access
from outside the host. Flip to `true` when you want the
operator's browser / external git clients to hit the forge
directly. (The container shares host netns, so this is the
only firewall layer that matters.)
**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.
'';
};
};
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}
'';
}
{
# `cfg.domain` can't be empty — would render `.<hive>` 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.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.<services.hyperhive.domain>" when set, else
"localhost"), or set a non-empty hostname like "forge.example.com"
or "git.internal".
'';
}
{
# 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.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.
'';
}
];
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 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
];
};
};
}