Forgejo reported "does not have a signing key" on PRs despite the forgejo-gpg-init service generating one. Two causes, both fixed: - SIGNING_KEY = "default" resolves through the forgejo process's git config (user.signingkey), not by scanning GNUPGHOME — and the keygen never set it, so "default" found no key. forgejo-gpg-init now sets the forgejo user's git user.signingkey to the generated key (+ commit and tag gpgsign), with HOME pinned to the state dir so the global config lands where forgejo reads it. - The keygen guard was stamp-file based, so a partial state wipe that lost the key while keeping the stamp would never regenerate. It's now keyed on the actual secret key (regenerate iff gpg shows none) and runs idempotently before forgejo on each start. Also drops the heredoc for the gpg params in favour of printf (avoids nix-string indentation fragility) and corrects the stale comment that claimed "default" scans GNUPGHOME.
400 lines
17 KiB
Nix
400 lines
17 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 = 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/<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".
|
|
'';
|
|
}
|
|
];
|
|
|
|
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;
|
|
# 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
|
|
];
|
|
|
|
# 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";
|
|
};
|
|
# 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
|
|
];
|
|
};
|
|
};
|
|
}
|