Moves `forgeVhost` out of the gateway's vhosts.nix and the forge's `address=` rule out of dnsmasq.nix, into nix/host-modules/hive-forge — the module that already owns everything else about the forge. The gateway keeps what is gateway knowledge (the listen set, which issuer covers a name, the header block) and loses the last reason it had to read `swarm.forge` at all: `forgeCfg` is gone from both files and from the module's `let`. Both halves stay gated on `behindGateway` — with it off the operator fronts forgejo themselves, so this hive must neither claim the vhost nor answer DNS for the name.
997 lines
46 KiB
Nix
997 lines
46 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 {
|
||
# This service's own gateway surface: the vhost that fronts it and
|
||
# the name the hive resolver answers for. Declared here rather than
|
||
# in the gateway so the forge's public face lives with the forge —
|
||
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`,
|
||
# `lib.securityHeaders`) and never needs to know this service by
|
||
# name.
|
||
#
|
||
# Both halves are gated on `behindGateway`: with it off the operator
|
||
# fronts forgejo themselves, so this hive must neither claim the
|
||
# vhost nor answer DNS for it.
|
||
services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain;
|
||
|
||
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||
# clones). SSH stays direct on `forge.sshPort`. See
|
||
# `docs/gateway.md`.
|
||
services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway {
|
||
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||
listen = gatewayCfg.lib.listen;
|
||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||
locations."/" = {
|
||
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/";
|
||
proxyWebsockets = true;
|
||
extraConfig = ''
|
||
proxy_buffering off;
|
||
client_max_body_size 1G;
|
||
proxy_read_timeout 1h;
|
||
proxy_send_timeout 1h;
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
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;
|
||
|
||
# Teach this container the SSO name, because nothing else will.
|
||
#
|
||
# The hive's dnsmasq is authoritative for the swarm service
|
||
# names, but only containers whose resolv.conf points at the
|
||
# bridge ask it — agent containers do, by an explicit unit
|
||
# (`nix/agent-modules/network.nix`) written for exactly this
|
||
# reason. This container resolves through the host's resolvers
|
||
# instead, and the swarm domain has no public records, so
|
||
# `admin auth add-oauth` fails at discovery with "no such
|
||
# host" while the same name resolves fine one container over.
|
||
#
|
||
# `127.0.0.1` rather than the bridge IP: sharing the host netns
|
||
# means loopback IS the host, where nginx serves this vhost.
|
||
# TLS still validates — the CA trust bundle is bind-mounted
|
||
# above, and the leaf covers this name.
|
||
#
|
||
# Only when THIS host runs authelia. With a remote provider the
|
||
# name belongs to another machine and must resolve normally.
|
||
networking.hosts = lib.mkIf ssoLocal {
|
||
"127.0.0.1" = [ autheliaCfg.domain ];
|
||
};
|
||
|
||
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;
|
||
};
|
||
}
|