hyperhive/nix/host-modules/hive-forge/default.nix
atlas daa8a2eb4b feat(3149): the forge registers authelia as an OIDC login source
Additive, never exclusive: forgejo keeps its local password database
and gains a second way in. An identity provider that can take the forge
offline when it hiccups is a worse forge than one with two doors.

A login source in forgejo is a database ROW, not an `app.ini` key, so
this is a unit rather than config. It is ordered AFTER forgejo — unlike
its neighbour `forgejo-gpg-init`, which runs before — because on a fresh
hive that database does not exist until forgejo has started and
migrated; running first would either fail or initialise a schema behind
the server's back.

Idempotency is by query (`admin auth list`), not by a stamp file: the
same reasoning already written down for the GPG key next to it, that a
stamp outlives a state wipe and then suppresses the repair.

Two assertions rather than defaults, both firing at eval: SSO needs a
secret path, and it needs somewhere to discover the provider. Either
one missing produces a login button that always fails — a runtime
symptom several layers from its cause, which is exactly the trade an
eval error is worth making.

The secret is read from a path and passed on argv for one exec, because
`--secret` is the only input forgejo offers — no `--secret-file`, no env
var, though its sibling `forgejo-cli actions register` has both. Inside
this container the value is already at rest in the login-source row and
the only principals are root and forgejo, so argv widens its readership
to nobody new. Accepted deliberately, not overlooked.
2026-08-11 21:56:58 +02:00

832 lines
38 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
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/...`.
autheliaUrl = config.services.hyperhive.swarm.authelia.url;
autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration";
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.
args="--provider openidConnect \
--key ${lib.escapeShellArg cfg.sso.clientId} \
--auto-discover-url ${lib.escapeShellArg autheliaDiscoveryUrl} \
--scopes ${lib.escapeShellArg "openid profile email groups"}"
# shellcheck disable=SC2086
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
'';
};
};
};
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;
};
}