deploy: split the forge's host decisions out of swarm.forge

`swarm.*` is what a hive needs to be a *client* of the swarm. For the
forge that is what it IS from any hive's point of view: its package, the
names and ports it answers on, the URLs it advertises, and the client id
it is registered under. How it is served, what it mirrors and where its
host-local secrets sit are decisions of the machine running it, so
behindGateway, openFirewall, mirrors, sso.clientSecretFile and
hostSwarmControllerTokenFile move to `deploy.forgejo.*`.

Unlike the wireguard mesh this SPLITS a module rather than relocating a
whole namespace. `sso` splits with it: `clientId` stays because it must
match the id in authelia's register, while the secret beside it is a
path on one host. Moving the whole `sso` block for symmetry with `ci`
was considered and rejected on exactly that asymmetry.

Declared in hive-forge/default.nix under the `deploy.*` path, following
swarm-victorialogs.nix; deploy.nix carries only the renames. `mirrors`
renames in one entry rather than one per field — it is a single option
of a list-of-submodule type, so the rename carries its whole value,
where `ci` needed five because it is a plain attrset of options.

Readers outside the module: hive-ci.nix binds `deploy.forgejo` for its
behindGateway assertion; swarm-authelia.nix and swarm-controller.nix
read theirs off the `deployCfg` they already bind. hivectl's `open`
printed `services.hyperhive.forge.behindGateway` in an operator-facing
hint — a path that never existed, missing `swarm.` — and hive-c0re's
state_snapshot doc comment carried the same defect; both now name the
new path. The rendered docs put the two halves on separate pages, so
the five descriptions of staying options that explain themselves in
terms of `behindGateway` now qualify it in full.

module-eval gains a forge case configured entirely through the old
paths, asserting the rendered firewall ports and the mirror env var
c0re seeds from: the new paths evaluate fine without the shims, so
dropping them reads as a clean tree. All five old paths are defined in
the fixture, so removing any single shim entry fails the eval rather
than only the two the assertion reads.
This commit is contained in:
atlas 2026-09-07 01:38:53 +02:00 committed by mara
commit 1db2ac26a7
13 changed files with 211 additions and 122 deletions

View file

@ -2,7 +2,11 @@
Private Forgejo instance running in a nixos-container, used as the
swarm's persistent code-collaboration surface (issues, PRs, reviews,
attachments). Configured via `services.hyperhive.swarm.forge.*`. Container
attachments). Configured via `services.hyperhive.swarm.forge.*` (what the
forge IS to every hive — its package, names, ports, URLs) plus
`services.hyperhive.deploy.forgejo.*` (what the host running it decides —
how it is served, what it mirrors, where its host-local secrets sit).
Container
shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL
handling live in [`docs/networking/gateway.md`](../networking/gateway.md); this file owns the
per-agent integration story and the notification pump that wakes

View file

@ -10,7 +10,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next
| `<hive>/agent/<name>/` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) |
| `<hive>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
| `<hive>/matrix/` (deprecated) | `_` | 301 → `chat.<swarm>/` | `matrix.gui.enable` |
| `forge.<swarm>/` | `forge.<swarm>` | forgejo (`3000`) | `forge.behindGateway` |
| `forge.<swarm>/` | `forge.<swarm>` | forgejo (`3000`) | `deploy.forgejo.behindGateway` |
| `chat.<swarm>/_matrix/*` | `chat.<swarm>` | tuwunel (`8008`) | `matrix.gatewayHost != null` |
| `chat.<swarm>/` | `chat.<swarm>` | fluffychat-web static | `matrix.gui.enable` |
| `chat.<swarm>/config.json` | `chat.<swarm>` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` |
@ -62,7 +62,7 @@ Each location carries a duplicated `auth_basic` block (separate locations don't
`services.hyperhive.gateway.localHostsEntry = true` adds entries to the host's `/etc/hosts`:
- `<hive-domain>``127.0.0.1`
- `forge.<swarm>``127.0.0.1` (when forge.behindGateway)
- `forge.<swarm>``127.0.0.1` (when deploy.forgejo.behindGateway)
- `chat.<swarm>``127.0.0.1` (when matrix.gatewayHost set)
- `auth.<swarm>``127.0.0.1` (when deploy.authelia)
@ -159,7 +159,7 @@ flip together: the primary agent-name link, the favicon fetch
(`<url>/icon`), and the nav-strip `container`-kind links from
`DashboardState.links` (`GET /api/dashboard-state`). `forge`-kind nav-strip links still
resolve against `http://<host>:3000` (separate sub-domain transition
tracked by `forge.behindGateway`); `external`-kind links are
tracked by `deploy.forgejo.behindGateway`); `external`-kind links are
already absolute. See `docs/web-ui/dashboard.md::Container row` for the
frontend-side derivation.
@ -333,10 +333,12 @@ container.
```nix
services.hyperhive.swarm.forge = {
httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range
sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh
openFirewall = false; # default — expose httpPort + sshPort to the host firewall
httpPort = 3000; # default — HTTP listener; outside hyperhive's 7000/8100-8999 range
sshPort = 2222; # default — git-over-SSH; kept off 22 so it doesn't collide with the host openssh
};
# Which ports the forge answers on is swarm-wide; whether THIS host opens
# them in its firewall is a deployment decision, so it lives under deploy.*.
services.hyperhive.deploy.forgejo.openFirewall = false; # default
```
`httpPort` (default **3000**) is the port Forgejo's HTTP server binds to.
@ -357,7 +359,7 @@ the bridge), not the raw port, so no firewall hole is needed. Flip to
(not behind the gateway).
- External git clients that push/pull via SSH directly to the host.
Forgejo served through the gateway (`forge.behindGateway = true`) does
Forgejo served through the gateway (`deploy.forgejo.behindGateway = true`) does
not need `openFirewall` — the gateway's own `openFirewall` option covers
that path.
@ -373,8 +375,8 @@ covers most cases:
| Shape | Auto-derived `ROOT_URL` |
|---|---|
| `behindGateway = true` | `https://<forge.domain>/` (port suffix omitted when `gateway.httpsPort == 443`) |
| `behindGateway = false` | `http://<forge.domain>:<httpPort>/` |
| `deploy.forgejo.behindGateway = true` | `https://<forge.domain>/` (port suffix omitted when `gateway.httpsPort == 443`) |
| `deploy.forgejo.behindGateway = false` | `http://<forge.domain>:<httpPort>/` |
The gateway always terminates TLS, so the `behindGateway = true` case is
always advertised over `https://`; only the direct (`behindGateway =

View file

@ -100,7 +100,7 @@ build can't hold the runner's single slot indefinitely).
## Container design
- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `forge.behindGateway = true`.
- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`.
- **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter).
- **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`.
- **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only.
@ -150,10 +150,10 @@ forge provisioning sweep (orgs, agent accounts, etc.), so mirror
seeding lives in the same place rather than a separate host-side unit.
**General-purpose mirrors**: you can pre-seed any external repo as a
pull-mirror via `services.hyperhive.swarm.forge.mirrors`:
pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`:
```nix
services.hyperhive.swarm.forge.mirrors = [
services.hyperhive.deploy.forgejo.mirrors = [
{ upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; }
{ upstream = "https://github.com/example/tool"; dest = "mirrors/tool"; }
];

View file

@ -314,6 +314,6 @@ an error.
A surface has no URL when it isn't browser-reachable: `home` needs
`services.hyperhive.domain`; `forge` needs
`services.hyperhive.swarm.forge.behindGateway = true`; `matrix` needs
`services.hyperhive.deploy.forgejo.behindGateway = true`; `matrix` needs
`services.hyperhive.swarm.matrix.gui.enable = true`. In those cases the command
exits with a hint naming the option to set.

View file

@ -94,8 +94,8 @@ pub(super) struct StateSnapshot {
/// Public URL of the forge (e.g.
/// `"https://forge.pr1ma.darkest.space"`). Sourced from the
/// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module
/// sets from `services.hyperhive.forge.publicUrl` (defaults to
/// the gateway vhost URL when `forge.behindGateway = true`,
/// sets from `services.hyperhive.swarm.forge.publicUrl` (defaults to
/// the gateway vhost URL when `deploy.forgejo.behindGateway = true`,
/// `null` otherwise). `None` when absent — the frontend **hides**
/// forge links rather than guessing `http://<hostname>:3000`,
/// which is only right by accident on deployments that aren't

View file

@ -26,7 +26,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
),
OpenTarget::Forge => (
urls.forge,
"the public forge URL needs `services.hyperhive.forge.behindGateway = true`",
"the public forge URL needs `services.hyperhive.deploy.forgejo.behindGateway = true`",
),
OpenTarget::Matrix => (
urls.matrix,

View file

@ -98,6 +98,38 @@ in
[ "services" "hyperhive" "deploy" "forgejo" "ci" "package" ]
)
# The rest of the forge split. What stays under `swarm.forge` is what the
# forge IS from any hive's point of view — its package, the names and ports
# it answers on, the URLs it advertises, the client id it is registered
# under; these five are what the host running it decides. `sso` splits
# because its two halves are different facts: the client id must match the
# entry in authelia's register, the secret is a path on this machine.
#
# `mirrors` renames in one entry rather than one per field: it is a single
# option of a list-of-submodule type, so the rename carries its whole
# value. The `ci` block above needs five because it is a plain attrset of
# separate options, which is the case with no parent path to rename.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "behindGateway" ]
[ "services" "hyperhive" "deploy" "forgejo" "behindGateway" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "openFirewall" ]
[ "services" "hyperhive" "deploy" "forgejo" "openFirewall" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "mirrors" ]
[ "services" "hyperhive" "deploy" "forgejo" "mirrors" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "sso" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "forgejo" "sso" "clientSecretFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "hostSwarmControllerTokenFile" ]
[ "services" "hyperhive" "deploy" "forgejo" "hostSwarmControllerTokenFile" ]
)
# Retention is read only where the container is defined, so it is a
# decision of the host running the store rather than something the swarm
# agrees on. The two stores keep everything else — package, domain, port

View file

@ -220,8 +220,8 @@ in
# breaks the moment the operator's browser hostname isn't the forge
# host, e.g. through the gateway or a reverse proxy). Sourced from
# `services.hyperhive.swarm.forge.publicUrl`, which itself defaults to the
# gateway vhost URL when `behindGateway = true` and `null` otherwise
# — see that option's doc for the "hide, don't guess" rationale.
# gateway vhost URL when `deploy.forgejo.behindGateway = true` and `null`
# otherwise — see that option's doc for the "hide, don't guess" rationale.
# Absent here whenever `publicUrl` is `null`; the dashboard hides
# forge links rather than emitting one it can't justify.
HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl;

View file

@ -7,6 +7,7 @@
let
cfg = config.services.hyperhive.deploy.forgejo.ci;
forgeCfg = config.services.hyperhive.swarm.forge;
forgeDeployCfg = config.services.hyperhive.deploy.forgejo;
gatewayCfg = config.services.hyperhive.gateway;
networkCfg = config.services.hyperhive.network;
tlsCfg = config.services.hyperhive.deploy.hive-controller.tls;
@ -79,8 +80,8 @@ in
example = true;
description = ''
Run a Forgejo Actions runner in a `hive-ci` nixos-container.
Grouped under `services.hyperhive.swarm.forge` because the runner is
tightly coupled to the forge instance it registers against.
Grouped under `services.hyperhive.deploy.forgejo` because the runner
is tightly coupled to the forge instance it registers against.
Disabled by default; the internal forge it registers against is
always present (mandatory), so enabling this is all that's needed.
@ -153,17 +154,17 @@ in
};
config = lib.mkIf cfg.enable {
# `forge.behindGateway = true` (the default) is required because the
# CI container uses private networking and reaches the forge through
# `deploy.forgejo.behindGateway = true` (the default) is required because
# the CI container uses private networking and reaches the forge through
# the gateway vhost. Without the gateway vhost there is no HTTP
# listener for `forgeCfg.domain` on the bridge that the runner can
# connect to.
assertions = [
{
assertion = forgeCfg.behindGateway;
assertion = forgeDeployCfg.behindGateway;
message = ''
services.hyperhive.deploy.forgejo.ci.enable requires
services.hyperhive.swarm.forge.behindGateway = true.
services.hyperhive.deploy.forgejo.behindGateway = true.
The CI container runs with a private network namespace and
reaches the forge through the gateway vhost on the bridge IP.
Set behindGateway = true (it defaults to true alongside
@ -216,8 +217,8 @@ in
# Private network namespace, attached to the hive bridge so the
# runner reaches the forge via the gateway — and cannot reach
# host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.).
# Requires `forge.behindGateway = true` (asserted in the options
# block above). See docs/networking/network.md.
# Requires `deploy.forgejo.behindGateway = true` (asserted in the
# options block above). See docs/networking/network.md.
privateNetwork = true;
hostBridge = networkCfg.bridgeName;

View file

@ -75,7 +75,7 @@ let
# links. Operators can still override via `cfg.rootUrl` for bespoke
# shapes.
defaultRootUrl =
if cfg.behindGateway then
if deployCfg.forgejo.behindGateway then
let
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
in
@ -98,9 +98,9 @@ let
# operator hasn't already declared that dest themselves (else CI-on +
# an explicit `actions/checkout` entry would duplicate it).
effectiveMirrors =
cfg.mirrors
deployCfg.forgejo.mirrors
++ lib.optional (
ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors)
ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) deployCfg.forgejo.mirrors)
) actionCheckoutMirror;
in
{
@ -133,7 +133,7 @@ in
Removed rather than defaulted to true so a config that turned it
OFF fails here, where the line is, instead of silently gaining a
login provider on the next rebuild. Drop the line; if it was
false, set services.hyperhive.swarm.forge.sso.clientSecretFile and
false, set services.hyperhive.deploy.forgejo.sso.clientSecretFile and
services.hyperhive.swarm.authelia.url as the assertions describe.
'')
];
@ -178,7 +178,7 @@ in
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`
gateway vhost server-name when `deploy.forgejo.behindGateway = true`
(sub-domain routing see `docs/networking/gateway.md`).
Defaults to `forge.''${services.hyperhive.swarm.domain}` the
@ -201,7 +201,10 @@ in
publicUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default =
if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null;
if config.services.hyperhive.enable && deployCfg.forgejo.behindGateway then
"https://${cfg.domain}"
else
null;
defaultText = lib.literalExpression ''
if behindGateway then "https://''${domain}" else null
'';
@ -212,7 +215,7 @@ in
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 =
Defaults to `https://''${cfg.domain}` when `deploy.forgejo.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
@ -221,7 +224,7 @@ in
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
**Set this explicitly if `deploy.forgejo.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
@ -244,6 +247,60 @@ in
'';
};
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:
- `deploy.forgejo.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>`.
- `deploy.forgejo.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.
'';
};
# The swarm's authelia is always registered as an OpenID Connect
# login source here — there is no toggle, for the same reason the
# forge itself has none.
#
# **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 — which is also what makes always-on safe.
sso = {
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`.
'';
};
# The secret half is a path on the host that runs the forge, so it
# lives under `deploy.forgejo.sso` — see the block below.
};
};
# What stays above is what the forge IS from any hive's point of view: its
# package, the names and ports it answers on, the URLs it advertises, and
# the client id it is registered under. What lives here is what the host
# running it decides — how it is served, what it mirrors, and where its
# host-local secrets sit. Same rule as ./swarm-victorialogs.nix, and the
# renames are in ./deploy.nix with the rest.
options.services.hyperhive.deploy.forgejo = {
behindGateway = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
@ -274,29 +331,6 @@ in
'';
};
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;
@ -312,8 +346,8 @@ in
**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.
`services.hyperhive.deploy.forgejo.openFirewall = true;` to your
host config before rebuilding.
'';
};
@ -360,45 +394,27 @@ in
'';
};
# The swarm's authelia is always registered as an OpenID Connect
# login source here — there is no toggle, for the same reason the
# forge itself has none.
#
# **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 — which is also what makes always-on safe.
sso = {
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`.
'';
};
# The other half of the SSO pair: the client *id* is swarm-wide (it has
# to match authelia's register), the secret is a path on this host. The
# design note for the login source itself is with the id.
sso.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.
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.
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.
'';
};
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.
'';
};
hostSwarmControllerTokenFile = lib.mkOption {
@ -443,13 +459,13 @@ in
# 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;
services.hyperhive.gateway.localNames = lib.optional deployCfg.forgejo.behindGateway cfg.domain;
# This swarm-ui quick-links entry, same `behindGateway` guard as the
# vhost/DNS name above — with it off, this host doesn't actually
# serve `cfg.domain`, so linking to it would be dead. See
# `services.hyperhive.swarm.controller.links`'s description.
services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway {
services.hyperhive.swarm.controller.links = lib.optional deployCfg.forgejo.behindGateway {
label = "Forge";
icon = "";
url = "https://${cfg.domain}/";
@ -466,15 +482,17 @@ in
# what authelia compares against — this string agreeing with the
# `location` block above it is the whole mechanism. A near miss is a
# correctly minted token refused at the target.
services.hyperhive.swarm.otel.publishedScrapeTargets = lib.optionalAttrs cfg.behindGateway {
forgejo = "https://${cfg.domain}/metrics";
};
services.hyperhive.swarm.otel.publishedScrapeTargets =
lib.optionalAttrs deployCfg.forgejo.behindGateway
{
forgejo = "https://${cfg.domain}/metrics";
};
# `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/networking/gateway.md`.
services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway {
services.nginx.virtualHosts = lib.optionalAttrs deployCfg.forgejo.behindGateway {
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
@ -543,10 +561,10 @@ in
{
# 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.clientSecretFile != null;
assertion = deployCfg.forgejo.sso.clientSecretFile != null;
message = ''
The forge's SSO login source requires
services.hyperhive.swarm.forge.sso.clientSecretFile the path
services.hyperhive.deploy.forgejo.sso.clientSecretFile the path
(inside the forge container) holding the OIDC client secret's
plaintext.
@ -600,7 +618,7 @@ in
# 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
Every services.hyperhive.deploy.forgejo.mirrors[].dest must be exactly
"<owner>/<repo>" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors}
'';
}
@ -618,7 +636,7 @@ in
])
) effectiveMirrors;
message = ''
services.hyperhive.swarm.forge.mirrors[].dest must not place a mirror
services.hyperhive.deploy.forgejo.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").
@ -771,7 +789,7 @@ in
# scraper at the gateway, so a second credential system per
# service would buy nothing and would be the one that stops
# getting rotated.
metrics.ENABLED = cfg.behindGateway;
metrics.ENABLED = deployCfg.forgejo.behindGateway;
# The two per-dimension breakdowns, on the same condition as
# the endpoint itself: `gitea_issues_by_label{label=…}` and
# `gitea_issues_by_repository{repository=…}`. Off by default
@ -790,8 +808,8 @@ in
# forge that grew to thousands of repos would want this
# revisited — that is a real trigger, unlike a time-based one:
# `count(gitea_issues_by_repository)` answers it directly.
metrics.ENABLED_ISSUE_BY_LABEL = cfg.behindGateway;
metrics.ENABLED_ISSUE_BY_REPOSITORY = cfg.behindGateway;
metrics.ENABLED_ISSUE_BY_LABEL = deployCfg.forgejo.behindGateway;
metrics.ENABLED_ISSUE_BY_REPOSITORY = deployCfg.forgejo.behindGateway;
# Repo migrations / pull-mirrors fetch from the source
# URL *inside* Forgejo. hyperhive code is synced from
# `localhost` (and the host LAN), which Forgejo's
@ -1025,9 +1043,9 @@ in
script = ''
set -euo pipefail
secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile})
secret=$(cat ${lib.escapeShellArg deployCfg.forgejo.sso.clientSecretFile})
if [ -z "$secret" ]; then
echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2
echo "empty OIDC client secret at ${deployCfg.forgejo.sso.clientSecretFile}" >&2
exit 1
fi
@ -1204,7 +1222,7 @@ in
# 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 (
services.hyperhive.deploy.forgejo.sso.clientSecretFile = lib.mkIf ssoLocal (
lib.mkDefault forgeSecretPath
);
@ -1298,7 +1316,7 @@ in
set -euo pipefail
src=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${swarmControllerTokenPath}"}
dst=${lib.escapeShellArg cfg.hostSwarmControllerTokenFile}
dst=${lib.escapeShellArg deployCfg.forgejo.hostSwarmControllerTokenFile}
for _ in $(seq 1 60); do
[ -s "$src" ] && break
@ -1313,7 +1331,7 @@ in
'';
};
networking.firewall = lib.mkIf cfg.openFirewall {
networking.firewall = lib.mkIf deployCfg.forgejo.openFirewall {
allowedTCPPorts = [
cfg.httpPort
cfg.sshPort

View file

@ -1361,7 +1361,7 @@ in
# Denied or client-scoped depending on whether a
# collector is registered — see `metricsRule` above,
# which is where the reasoning for both halves lives.
lib.optional forgeCfg.behindGateway metricsRule
lib.optional deployCfg.forgejo.behindGateway metricsRule
# Also guarded on the domain being set: without it a null
# apex would render a rule matching the string "null".
++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) {

View file

@ -413,8 +413,8 @@ in
forgeTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
# `services.hyperhive.swarm.forge` has no `enable` of its own to
# check — the module activates on the general
# The forge has no `enable` of its own to check — neither half of
# its split namespace carries one — the module activates on the general
# `config.services.hyperhive.enable` instead (see
# `hive-forge/default.nix`'s own `config = lib.mkIf
# config.services.hyperhive.enable { ... }`), so that's the
@ -423,7 +423,8 @@ in
# that turns swarm-controller on — caught in review, not by
# `nix flake check` (nothing in its checked combinations forced
# this particular default to actually evaluate).
default = if config.services.hyperhive.enable then forgeCfg.hostSwarmControllerTokenFile else null;
default =
if config.services.hyperhive.enable then deployCfg.forgejo.hostSwarmControllerTokenFile else null;
defaultText = lib.literalExpression ''
forge's own `hostSwarmControllerTokenFile` when this host runs
hyperhive at all (forge has no separate enable), else null
@ -440,7 +441,7 @@ in
`hive-forge/default.nix`, it has no `enable` of its own).
Override explicitly if forge's actual token file ends up
somewhere else copy it out of forge's
{option}`services.hyperhive.swarm.forge.hostSwarmControllerTokenFile`
{option}`services.hyperhive.deploy.forgejo.hostSwarmControllerTokenFile`
with whatever secret management this deployment already uses,
the same shape `swarm.nix`'s `clientSecretFile` documents for
its own cross-host case. `null` means no forge access the

View file

@ -72,6 +72,23 @@ let
swarm.wireguard.privateKeyFile = "/etc/wireguard/hive.key";
};
# Same shape for the forge, which SPLIT rather than moving whole: the five
# host-side options are set here through their pre-rename paths while the
# rest of `swarm.forge` stays put. All five are defined so that dropping any
# single shim entry fails the eval, not just the two the assertion reads.
forgeOldPath = hive {
swarm.forge.behindGateway = true;
swarm.forge.openFirewall = true;
swarm.forge.hostSwarmControllerTokenFile = "/etc/forge/sc.token";
swarm.forge.sso.clientSecretFile = "/etc/forge/oidc.secret";
swarm.forge.mirrors = [
{
upstream = "https://example.invalid/tool";
dest = "mirrors/tool";
}
];
};
baoPkcs11 = hive {
deploy.bao.enable = true;
deploy.bao.seal = "pkcs11";
@ -205,6 +222,20 @@ let
in
wg.ips == [ "10.100.0.1/24" ] && wg.privateKeyFile == "/etc/wireguard/hive.key";
}
{
# Same reasoning one namespace over, plus the shape the mesh did not
# have: `mirrors` is a single option of a list-of-submodule type, so its
# one rename entry has to carry a whole compound value rather than a
# scalar. Both arms read a rendered effect — the host firewall and the
# env var c0re seeds mirrors from — not the option.
name = "a config written against the pre-rename forge paths still opens the firewall and seeds the mirror";
ok =
let
ports = forgeOldPath.networking.firewall.allowedTCPPorts;
seeded = builtins.fromJSON forgeOldPath.systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS;
in
builtins.elem 3000 ports && builtins.any (m: m.dest == "mirrors/tool") seeded;
}
{
# The gateway's per-name issuer choice. If this ever collapses to a
# constant, every swarm-service vhost serves a certificate its CA