The login source was never registered on any boot since it landed: forgejo-sso-source[422]: Command error: unexpected arguments: profile, email, groups' The flags were built as a shell STRING and word-split at the call site. Splitting happens after quote removal, so the quotes inside the value are just characters: --scopes 'openid profile email groups' reached forgejo as four words, three of them unexpected and one carrying an apostrophe. escapeShellArg interpolated into a string that is later word-split is a no-op that looks exactly like protection. An array carries the argument boundaries instead of re-deriving them from whitespace, and the shellcheck disable goes with it. Also assert the effect: the unit now fails if the source is absent from 'admin auth list' afterwards. The old failure exited non-zero and was still invisible to every check that read the rendered script rather than its result.
942 lines
44 KiB
Nix
942 lines
44 KiB
Nix
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.forge;
|
||
gatewayCfg = config.services.hyperhive.gateway;
|
||
hyperhiveDomain = config.services.hyperhive.domain;
|
||
swarmDomain = config.services.hyperhive.swarm.domain;
|
||
tlsCfg = config.services.hyperhive.tls;
|
||
|
||
# Self-signed gateway TLS: forgejo (Go) validates outbound webhook
|
||
# deliveries (e.g. the config-PR webhook to https://<domain>/webhook/...)
|
||
# against its system cert store, which lacks the runtime-generated hive
|
||
# CA — so delivery fails with an x509 "unknown authority". Go has no
|
||
# additive trust env var (SSL_CERT_FILE *replaces* the default bundle),
|
||
# so bind the public CA in and hand forgejo a combined bundle (system
|
||
# CAs + hive CA) via SSL_CERT_FILE. Only active in self-signed mode;
|
||
# with an operator cert / ACME the public chain already validates and
|
||
# this whole block drops out. The bind-mount + `container@` ordering
|
||
# that make the CA reachable are shared with hive-ci via the
|
||
# `hive-ca-trust` helper; only the Go SSL_CERT_FILE concat below is
|
||
# hive-forge-specific.
|
||
# Forgejo's name for the login source. A constant, not an option: it
|
||
# is the key this module's own idempotency check looks up, so making
|
||
# it configurable would buy nothing and add a way for the lookup and
|
||
# the row to disagree.
|
||
ssoSourceName = "authelia";
|
||
|
||
# `url` is the half of the authelia module that exists on EVERY hive —
|
||
# null when no SSO provider is configured anywhere, which the
|
||
# assertion below turns into an eval failure rather than a discovery
|
||
# request to `null/.well-known/...`.
|
||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||
autheliaUrl = autheliaCfg.url;
|
||
autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration";
|
||
|
||
# The all-local case: this host runs BOTH the forge and the swarm's
|
||
# authelia, so the secret can be moved without an operator. The other
|
||
# two cases (swarm side via swarmctl, remote hive) leave
|
||
# `clientSecretFile` to be set explicitly — see docs/swarm/.
|
||
ssoLocal = cfg.sso.enable && autheliaCfg.enable;
|
||
|
||
# Where the plaintext lands inside the forge container. Under
|
||
# /var/lib rather than /run: the forge may start before the delivery
|
||
# unit on a later boot, and a secret that evaporates on reboot turns a
|
||
# working login into an intermittent one.
|
||
forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret";
|
||
|
||
# Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName`
|
||
# the registration uses, so the redirect URI authelia is told to allow
|
||
# and the one forgejo will actually send cannot drift apart — a
|
||
# mismatch there is a rejected login with no error text worth reading.
|
||
ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback";
|
||
|
||
caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||
useSelfSigned = caTrust.useSelfSigned;
|
||
caContainerPath = caTrust.caContainerPath;
|
||
forgeCaBundle = "/run/hive-forge-ca/ca-bundle.crt";
|
||
|
||
# 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. The gateway always terminates TLS
|
||
# (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` — the canonical 443 elides
|
||
# the port suffix. When direct (`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.
|
||
defaultRootUrl =
|
||
if cfg.behindGateway then
|
||
let
|
||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||
in
|
||
"https://${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.swarm.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.<domain>` via the gateway. State
|
||
# at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||
# restart. See `docs/gateway.md::hive-forge container shape`.
|
||
|
||
# External Forgejo/Gitea/Codeberg-compatible forges (beyond the mandatory
|
||
# internal one) are entirely dashboard-provisioned — no nix config here.
|
||
# An operator manually creates a token on the external forge (however
|
||
# that forge lets them) and pastes name + base URL + token into the
|
||
# dashboard's FORGES tab; hive-c0re just persists it to
|
||
# `<state>/forge-<label>-token` + a `<state>/forge-<label>.json` sidecar
|
||
# (base URL), the same shape as the GitHub PAT / matrix extra-account
|
||
# flows. See `hive-c0re/src/dashboard/extra_forges.rs`.
|
||
|
||
# Forge moved under `swarm` when the swarm-global services were
|
||
# consolidated. One rename for the namespace: the subtree comes with it,
|
||
# so existing hives keep evaluating and get one warning naming both paths.
|
||
imports = [
|
||
(lib.mkRenamedOptionModule
|
||
[ "services" "hyperhive" "forge" ]
|
||
[ "services" "hyperhive" "swarm" "forge" ]
|
||
)
|
||
];
|
||
|
||
# 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.swarm.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 <sshPort> git@<domain>:<owner>/<repo>.git`.
|
||
'';
|
||
};
|
||
|
||
domain = lib.mkOption {
|
||
type = lib.types.str;
|
||
# Under the SWARM domain, not this hive's: a swarm runs one forge
|
||
# and every hive in it reaches the same host, so the name belongs
|
||
# to the swarm rather than to whichever hive happens to run it.
|
||
#
|
||
# Total on a null swarm domain so the required-domain assertion in
|
||
# hive-network.nix is the thing that fires; see the comment there.
|
||
default = if swarmDomain == null then "forge.invalid" else "forge.${swarmDomain}";
|
||
defaultText = lib.literalExpression ''"forge.''${services.hyperhive.swarm.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.swarm.domain}` — the
|
||
swarm's domain, not this hive's, because a swarm runs **one**
|
||
forge that every hive in it talks to.
|
||
|
||
⚠️ A deployment that was running before this moved keeps its
|
||
current name by pinning it here:
|
||
`forge.''${services.hyperhive.domain}`, which is exactly what
|
||
the old default rendered. Certificates follow either way: the
|
||
swarm-services sub-CA is name-constrained to the configured
|
||
names (see `./swarm-ca.nix`), not to a fixed tree.
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
publicUrl = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default =
|
||
if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null;
|
||
defaultText = lib.literalExpression ''
|
||
if behindGateway then "https://''${domain}" else null
|
||
'';
|
||
example = "https://forge.example.com";
|
||
description = ''
|
||
Browser-facing forge URL the dashboard uses to build clickable
|
||
forge links (the H0M3 Forge tile, per-agent-row forge links,
|
||
the approval-queue's "review PR on forge" link) — sourced into
|
||
every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`.
|
||
|
||
Defaults to `https://''${cfg.domain}` when `behindGateway =
|
||
true` (the gateway vhost is genuinely reachable at that URL)
|
||
and `null` otherwise. When `null`, the dashboard **hides**
|
||
forge links rather than guessing one — see
|
||
`docs/web-ui/dashboard.md::H0M3 page` for the rationale (a
|
||
link built from the operator's own browser hostname + a
|
||
container port is only an accident away from wrong on any
|
||
deployment that isn't plain localhost).
|
||
|
||
**Set this explicitly if `behindGateway = false`** and the
|
||
forge is still reachable at a stable URL you want linked from
|
||
the dashboard (e.g. `http://<lan-host>:''${toString cfg.httpPort}`
|
||
for an all-LAN deployment) — leaving it unset there means the
|
||
dashboard's forge links are simply absent, not broken.
|
||
'';
|
||
};
|
||
|
||
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, 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 `:<port>`.
|
||
- `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.<domain>` 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.swarm.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 `<owner>/<repo>` the pull-mirror is created at. The
|
||
`<owner>` 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.swarm.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).
|
||
'';
|
||
};
|
||
|
||
sso = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
example = true;
|
||
description = ''
|
||
Register the swarm's authelia as an OpenID Connect login
|
||
source on this forge.
|
||
|
||
**Additive, never exclusive.** Forgejo keeps its local
|
||
password database and gains an extra "sign in with" button;
|
||
this does not disable local login. Deliberate: an identity
|
||
provider that can take the forge offline when it hiccups is a
|
||
worse forge than one with two ways in.
|
||
'';
|
||
};
|
||
|
||
clientId = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "forgejo";
|
||
description = ''
|
||
OAuth2 client id this forge identifies itself with. Must match
|
||
the `id` of the corresponding entry in
|
||
`services.hyperhive.swarm.authelia.oidc.clients`.
|
||
'';
|
||
};
|
||
|
||
clientSecretFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "/var/lib/hyperhive/forge-oidc-secret";
|
||
description = ''
|
||
Path **inside the forge container** holding the client
|
||
secret's plaintext.
|
||
|
||
A path, never a value: an OIDC client secret has two holders
|
||
in two containers (authelia keeps a hash, this forge needs the
|
||
plaintext), and a literal written here would be rendered into
|
||
the world-readable nix store.
|
||
|
||
Required when `enable` is set — deliberately no fallback. A
|
||
forge that boots with SSO half-configured presents as a login
|
||
button that always fails, which is harder to diagnose than an
|
||
eval error.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf config.services.hyperhive.enable {
|
||
assertions = [
|
||
{
|
||
# Fail at EVAL, not at boot. The alternative failure is a login
|
||
# button that always 401s, three layers from the missing file.
|
||
assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null;
|
||
message = ''
|
||
services.hyperhive.swarm.forge.sso.enable requires
|
||
sso.clientSecretFile — the path (inside the forge container)
|
||
holding the OIDC client secret's plaintext.
|
||
|
||
On a hive that also runs the swarm's authelia this is wired up
|
||
for you. Set it explicitly when authelia lives on another
|
||
host: see docs/swarm/ for which secret goes where.
|
||
'';
|
||
}
|
||
{
|
||
# Without a provider URL there is nothing to discover against,
|
||
# and the rendered unit would ask `null/.well-known/…`.
|
||
assertion = !cfg.sso.enable || autheliaUrl != null;
|
||
message = ''
|
||
services.hyperhive.swarm.forge.sso.enable requires
|
||
services.hyperhive.swarm.authelia.url — the base URL of the
|
||
swarm's SSO provider.
|
||
|
||
It defaults to this host's own instance only when this host
|
||
runs authelia. A hive that federates with a swarm sets it
|
||
explicitly to wherever that provider lives.
|
||
'';
|
||
}
|
||
{
|
||
assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl;
|
||
message = ''
|
||
services.hyperhive.swarm.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). The default derives a non-empty
|
||
# `forge.<domain>`, but an operator-set empty string should fail
|
||
# loud.
|
||
assertion = cfg.domain != "";
|
||
message = ''
|
||
services.hyperhive.swarm.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>"), or set a non-empty
|
||
hostname like "forge.example.com" or "git.internal".
|
||
'';
|
||
}
|
||
{
|
||
# Each mirror dest must be exactly `<owner>/<repo>` — 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.swarm.forge.mirrors[].dest must be exactly
|
||
"<owner>/<repo>" (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.swarm.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").
|
||
'';
|
||
}
|
||
];
|
||
|
||
# `caTrust.containerOrdering` orders this unit after `hive-tls-ca.service`
|
||
# in self-signed mode (see the hive-ca-trust helper), so the CA bind
|
||
# source exists before nspawn sets the mount up.
|
||
systemd.services."container@hive-forge" = caTrust.containerOrdering;
|
||
|
||
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;
|
||
# Self-signed mode: bind the public hive CA cert read-only so forgejo
|
||
# can trust the gateway's self-signed leaf for outbound webhook
|
||
# delivery (combined bundle assembled at container start by
|
||
# hive-forge-ca-bundle below). Shared bind-mount + ordering come from
|
||
# the hive-ca-trust helper.
|
||
bindMounts = caTrust.bindMount;
|
||
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 ${./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";
|
||
|
||
# Shared host netns: this container's own firewall.service
|
||
# would rewrite the HOST ruleset (flush nixos-fw, drop the
|
||
# host's nixos-nat-* chains) at every boot — killing the
|
||
# bridge DHCP/DNS holes and agent NAT. The host firewall owns
|
||
# all filtering; never run one in here.
|
||
networking.firewall.enable = false;
|
||
|
||
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;
|
||
# Forgejo's docs say an empty `ALLOWED_DOMAINS` allows
|
||
# every domain, but that's not true in practice on the
|
||
# versions we've hit this on — the migration guard still
|
||
# rejects genuinely public hosts ("cannot import from
|
||
# disallowed hosts") unless the wildcard is set
|
||
# explicitly (a known upstream doc/behavior mismatch,
|
||
# tracked upstream in the go-gitea project). Public-domain
|
||
# pull/push mirrors (agents' personal repos synced to
|
||
# forge.darkest.space, etc.) were failing every sync
|
||
# attempt without this.
|
||
migrations.ALLOWED_DOMAINS = "*";
|
||
# `ALLOWED_HOST_LIST` is forgejo's webhook SSRF allow-list, and
|
||
# it's a STRICT whitelist (only listed hosts deliver). Its
|
||
# default is the `external` builtin: all public unicast IPs are
|
||
# allowed, private/loopback denied. We must KEEP `external` so
|
||
# user-repo webhooks to public hosts (github, slack, …) keep
|
||
# working, and ADD the hive gateway host on top: the config-PR +
|
||
# knowledge webhooks target `https://<hyperhive domain>/webhook/*`,
|
||
# which resolves to a private (RFC-1918) gateway IP that
|
||
# `external` alone would deny (so they'd only ever be caught by
|
||
# the 5-min poll fallback). Naming the single gateway host is
|
||
# tighter than the broad `private` builtin.
|
||
webhook.ALLOWED_HOST_LIST = "external,${hyperhiveDomain}";
|
||
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: <org>/<action>@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 - -"
|
||
];
|
||
|
||
# Self-signed mode: assemble the combined TLS trust bundle
|
||
# (system CAs + the bind-mounted hive CA) forgejo's Go HTTP
|
||
# client validates outbound webhook deliveries against. Go's
|
||
# SSL_CERT_FILE *replaces* the default bundle, so we concatenate
|
||
# rather than point at the CA alone — otherwise mirror fetches
|
||
# from public hosts would lose their trust anchors. Runs before
|
||
# forgejo each boot; /run is tmpfs so the bundle is rebuilt from
|
||
# the current CA every start.
|
||
systemd.services.hive-forge-ca-bundle = lib.mkIf useSelfSigned {
|
||
description = "assemble forgejo TLS trust bundle (system CAs + hive CA)";
|
||
wantedBy = [ "forgejo.service" ];
|
||
before = [ "forgejo.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
SyslogIdentifier = "hive-forge-ca-bundle";
|
||
};
|
||
path = [ pkgs.coreutils ];
|
||
script = ''
|
||
set -euo pipefail
|
||
install -d -m 0755 /run/hive-forge-ca
|
||
cat /etc/ssl/certs/ca-certificates.crt ${caContainerPath} \
|
||
> ${forgeCaBundle}
|
||
chmod 0644 ${forgeCaBundle}
|
||
'';
|
||
};
|
||
# Point forgejo's Go TLS stack at the combined bundle so webhook
|
||
# delivery to the self-signed gateway validates.
|
||
systemd.services.forgejo.environment.SSL_CERT_FILE = lib.mkIf useSelfSigned forgeCaBundle;
|
||
|
||
# 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
|
||
'';
|
||
};
|
||
|
||
# Register authelia as an OIDC login source.
|
||
#
|
||
# ⚠️ Ordered AFTER forgejo, unlike forgejo-gpg-init above, and
|
||
# the difference is not stylistic: a login source is a row in
|
||
# forgejo's database, and on a fresh hive that database does
|
||
# not exist until forgejo has started and run its migrations.
|
||
# Running first would either fail or let the CLI initialise a
|
||
# schema behind the server's back.
|
||
#
|
||
# Idempotency is by QUERY, not by a stamp file — same reason
|
||
# spelled out for the GPG key above: a stamp survives a state
|
||
# wipe that took the thing it claims exists, and then suppresses
|
||
# the repair.
|
||
systemd.services.forgejo-sso-source = lib.mkIf cfg.sso.enable {
|
||
description = "register authelia as Forgejo's OIDC login source";
|
||
after = [ "forgejo.service" ];
|
||
requires = [ "forgejo.service" ];
|
||
wantedBy = [ "multi-user.target" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
User = "forgejo";
|
||
Group = "forgejo";
|
||
SyslogIdentifier = "forgejo-sso-source";
|
||
};
|
||
# `FORGEJO_CUSTOM` (not `GITEA_CUSTOM` — forgejo renamed it)
|
||
# is how the CLI finds the app.ini upstream's module wrote.
|
||
environment.FORGEJO_CUSTOM = "/var/lib/forgejo/custom";
|
||
path = [
|
||
cfg.package
|
||
pkgs.coreutils
|
||
pkgs.gnugrep
|
||
];
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile})
|
||
if [ -z "$secret" ]; then
|
||
echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# `--secret` is the only input forgejo offers: there is no
|
||
# --secret-file and no env var, though its sibling
|
||
# `forgejo-cli actions register` has both. So the value is
|
||
# on this argv for the length of one exec, inside this
|
||
# container, where forgejo already stores it at rest in the
|
||
# login-source row — root and forgejo are the only
|
||
# principals here, and root can read the resting copy
|
||
# anyway. Accepted deliberately (see docs/swarm/), not
|
||
# overlooked; upstream gap filed.
|
||
# An ARRAY, not a string. A string of arguments has to be
|
||
# word-split at the call site to become argv, and splitting
|
||
# does not honour the quotes inside the value — the shell
|
||
# already finished quote removal by then, so
|
||
# `--scopes 'openid profile email groups'` arrives as four
|
||
# words with two stray apostrophes, and forgejo rejects the
|
||
# last three as unexpected arguments. An array carries the
|
||
# boundaries instead of re-deriving them from whitespace.
|
||
args=(
|
||
--provider openidConnect
|
||
--key ${lib.escapeShellArg cfg.sso.clientId}
|
||
--auto-discover-url ${lib.escapeShellArg autheliaDiscoveryUrl}
|
||
--scopes ${lib.escapeShellArg "openid profile email groups"}
|
||
)
|
||
|
||
if forgejo admin auth list | grep -q "[[:space:]]${ssoSourceName}[[:space:]]"; then
|
||
id=$(forgejo admin auth list \
|
||
| grep "[[:space:]]${ssoSourceName}[[:space:]]" \
|
||
| cut -f1)
|
||
forgejo admin auth update-oauth --id "$id" \
|
||
--name ${lib.escapeShellArg ssoSourceName} \
|
||
--secret "$secret" "''${args[@]}"
|
||
echo "updated OIDC login source ${ssoSourceName} (id $id)"
|
||
else
|
||
forgejo admin auth add-oauth \
|
||
--name ${lib.escapeShellArg ssoSourceName} \
|
||
--secret "$secret" "''${args[@]}"
|
||
echo "added OIDC login source ${ssoSourceName}"
|
||
fi
|
||
|
||
# Assert the EFFECT, not the command. The bug this replaced
|
||
# was a malformed argv: the unit ran the right verb, forgejo
|
||
# rejected it, and every check that read the rendered script
|
||
# still passed. A registration that does not appear in the
|
||
# source list did not happen, whatever the exit code said.
|
||
if ! forgejo admin auth list | grep -q "[[:space:]]${ssoSourceName}[[:space:]]"; then
|
||
echo "login source ${ssoSourceName} is absent after registration" >&2
|
||
exit 1
|
||
fi
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
# One declaration, two readers. The forge knows its own callback URL;
|
||
# making the operator restate it in authelia's client list would be a
|
||
# second source of truth for a string whose mismatch is a silent
|
||
# rejected login.
|
||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [
|
||
{
|
||
id = cfg.sso.clientId;
|
||
description = "HyperHive forge";
|
||
redirectUris = [ ssoRedirectUri ];
|
||
}
|
||
];
|
||
|
||
# Same case, same reasoning: this host minted the secret, so it can
|
||
# say where the forge will find it.
|
||
services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal (
|
||
lib.mkDefault forgeSecretPath
|
||
);
|
||
|
||
# The delivery. It runs on the HOST because that is the only place
|
||
# both container trees are addressable: they share this host's
|
||
# network namespace, which makes them feel co-located, but their
|
||
# filesystem roots are separate — the forge cannot open a path inside
|
||
# authelia's tree however local the port looks.
|
||
#
|
||
# ⚠️ Deliberately a copy and not a `bindMounts` entry.
|
||
# nixos-container refuses to start when a bind source is missing, and
|
||
# this secret does not exist until authelia's first boot has minted
|
||
# it — so binding it would make the forge wait on a file that waits
|
||
# on a container that starts after it. On a fresh hive that is a
|
||
# permanent stall presenting as "the forge is broken", several layers
|
||
# from its cause.
|
||
systemd.services.hive-forge-oidc-secret = lib.mkIf ssoLocal {
|
||
description = "deliver the forge's OIDC client secret from authelia";
|
||
after = [ "container@${autheliaCfg.machine}.service" ];
|
||
requires = [ "container@${autheliaCfg.machine}.service" ];
|
||
before = [ "container@hive-forge.service" ];
|
||
wantedBy = [ "container@hive-forge.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
SyslogIdentifier = "hive-forge-oidc-secret";
|
||
};
|
||
path = [ pkgs.coreutils ];
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
|
||
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${forgeSecretPath}"}
|
||
|
||
# authelia's container is up, but its first-boot generator may
|
||
# still be minting. Bounded wait, then fail: a silent skip here
|
||
# produces a forge whose SSO button 401s, which is the failure
|
||
# this whole design is trying not to ship.
|
||
for _ in $(seq 1 60); do
|
||
[ -s "$src" ] && break
|
||
sleep 2
|
||
done
|
||
if [ ! -s "$src" ]; then
|
||
echo "authelia has not minted $src after 120s" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# The owning uid is DISCOVERED, not assumed: read it off the
|
||
# forge container's own state dir. Whatever uid maps to forgejo
|
||
# inside that container is by definition the one that owns the
|
||
# directory it was created with, and hardcoding a number here
|
||
# would be a second place for it to be wrong.
|
||
uid=$(stat -c %u /var/lib/nixos-containers/hive-forge/var/lib/forgejo)
|
||
install -D -m 0400 -o "$uid" -g "$uid" "$src" "$dst"
|
||
'';
|
||
};
|
||
|
||
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;
|
||
};
|
||
}
|