hyperhive/nix/modules/hive-forge.nix
atlas fe62db8917 fix(#981): enable Forgejo Actions so runner registration token API works
Without [actions] ENABLED = true in forgejo's config, the
GET /api/v1/admin/runners/registration-token endpoint returns
"runner registration token not found" regardless of token scopes,
causing hive-ci-register.service to fail on first boot.
2026-06-01 18:38:44 +02:00

387 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";
};
# 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;
# 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
];
};
};
}