refactor(#2015): drop redundant domain != null checks (domain is required) + update docs

This commit is contained in:
damocles 2026-06-26 20:43:54 +02:00 committed by mara
commit c8ae3371e1
6 changed files with 88 additions and 130 deletions

View file

@ -280,21 +280,18 @@ dashboard reach by design — the surface is privileged (approve /
deny / destroy) and must not be exposed without a real reverse deny / destroy) and must not be exposed without a real reverse
proxy in front. proxy in front.
## `HIVE_FORGE_URL`: domain via gateway for isolated agents, loopback for shared-netns ## `HIVE_FORGE_URL`: agents reach the forge via the gateway by domain
Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all
`hive-forge` calls against it. `hive-c0re.nix` sets this based on the `hive-forge` calls against it. Network isolation is always on (the
network isolation mode: shared-netns mode was removed), so agents run in a private netns and
can never reach the host's loopback. `hive-c0re.nix` sets
- **`network.isolateContainers = true`**: agents run in private netns and `HIVE_FORGE_URL` to `http://<forge.domain>` (default
get the bridge dnsmasq as their resolver. `HIVE_FORGE_URL` is set to `forge.<hive-domain>`; `services.hyperhive.domain` is required). Agents
`http://<forge.domain>` (default `forge.<hive-domain>`). Agents resolve get the bridge dnsmasq as their resolver, resolve the hostname →
the hostname via dnsmasq → bridge IP, then reach nginx on port 80 (bridge bridge IP, then reach nginx on port 80 (the bridge firewall opens
firewall opens 80+443 when isolation is on). nginx proxies to forgejo — the 80+443). nginx proxies to forgejo — the same path an operator browser
same path an operator browser takes, no raw port exposure needed. takes, no raw port exposure needed.
- **`network.isolateContainers = false`** (default): agents share the host's
network namespace, so loopback reaches forgejo directly. `HIVE_FORGE_URL`
is `http://127.0.0.1:<forge.httpPort>`.
## hive-forge container shape ## hive-forge container shape
@ -306,10 +303,13 @@ the operator already runs on the host — separate systemd namespace,
separate state dir, separate port unless the operator deliberately separate state dir, separate port unless the operator deliberately
collides. collides.
Container shares the host network namespace The forge container shares the host network namespace
(`privateNetwork = false`) so agents reach the forge at (`privateNetwork = false`), so forgejo's listeners look like a
`http://localhost:<httpPort>` without extra plumbing — nixos-container host-side service — nixos-container is here for state + systemd-unit
is here for state + systemd-unit isolation, not network isolation. isolation, not network isolation. Note this is the FORGE container;
agent containers are network-isolated and reach the forge through the
gateway by `forge.<domain>` (see `HIVE_FORGE_URL` above), not via the
host's loopback.
State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
and survives container restart / host reboot. To wipe, destroy the and survives container restart / host reboot. To wipe, destroy the
@ -336,9 +336,9 @@ via `-p 2222`). Port 22 is left alone on the host for openssh.
`openFirewall` (default **false**) controls whether `httpPort` and `openFirewall` (default **false**) controls whether `httpPort` and
`sshPort` are opened in the host firewall. Off by default (secure by `sshPort` are opened in the host firewall. Off by default (secure by
default): every agent container reaches Forgejo at `localhost:<httpPort>` default): agents reach Forgejo through the gateway (`forge.<domain>` on
via the shared host netns without a firewall hole. Flip to `true` when the bridge), not the raw port, so no firewall hole is needed. Flip to
you need: `true` when you need:
- The operator's browser to reach `http://<host>:<httpPort>/` directly - The operator's browser to reach `http://<host>:<httpPort>/` directly
(not behind the gateway). (not behind the gateway).
- External git clients that push/pull via SSH directly to the host. - External git clients that push/pull via SSH directly to the host.

View file

@ -112,8 +112,9 @@ in
# The forge is mandatory — hive-c0re mirrors every agent's applied # The forge is mandatory — hive-c0re mirrors every agent's applied
# config repo into it and it's the canonical store for the meta flake # config repo into it and it's the canonical store for the meta flake
# + `internal/*` repos, so there's no enable toggle; it deploys with # + `internal/*` repos, so there's no enable toggle; it deploys with
# hyperhive itself. hive-matrix is opt-in (off by default) and asserts # hyperhive itself. hive-matrix is opt-in (off by default). All
# that `services.hyperhive.domain` is set before it can be enabled. # subsystems rely on `services.hyperhive.domain`, which is required
# (asserted in hive-network.nix) whenever hyperhive is enabled.
imports = [ imports = [
./hive-ci.nix ./hive-ci.nix
./hive-forge.nix ./hive-forge.nix
@ -129,8 +130,8 @@ in
# Canonical hive DNS domain shared by every subsystem that needs a # Canonical hive DNS domain shared by every subsystem that needs a
# stable hostname. Typed nullOr (default null) so the option always # stable hostname. Typed nullOr (default null) so the option always
# exists, but it's REQUIRED whenever hyperhive is enabled — a config # exists, but it's REQUIRED whenever hyperhive is enabled — an
# assertion (see the `config` block) fails eval when it's unset, since # assertion in hive-network.nix fails eval when it's unset, since
# matrix bakes it in on first boot and the gateway/forge/agent URLs all # matrix bakes it in on first boot and the gateway/forge/agent URLs all
# derive from it (no safe default). Full identity-surface # derive from it (no safe default). Full identity-surface
# context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var # context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var
@ -831,51 +832,36 @@ in
config.services.hyperhive.swarm.wireguard.listenPort config.services.hyperhive.swarm.wireguard.listenPort
]; ];
assertions = [ # NB: `services.hyperhive.domain` is required when hyperhive is
{ # enabled — the canonical assertion lives in `hive-network.nix` (the
# `domain` has no safe default: it's baked into the matrix server # hive resolver is authoritative for `<domain>` and agents reach the
# (tuwunnel) on first boot and drives the gateway/forge/agent URLs. # forge/matrix through the gateway by it). So everything below can
# Under mandatory network isolation agents can only reach the forge # treat `config.services.hyperhive.domain` as non-null.
# via `forge.<domain>` on the bridge — with no domain there's no assertions =
# reachable name. Changing it later is destructive lib.optionals config.services.hyperhive.swarm.wireguard.enable [
# (deletes Matrix history), so it must be set before first boot. {
assertion = config.services.hyperhive.domain != null; assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null;
message = '' message = ''
services.hyperhive.domain must be set (e.g. "darkest.space"). services.hyperhive.swarm.wireguard.enable requires
It's baked into the matrix server on first boot and into the services.hyperhive.swarm.wireguard.privateKeyFile to be set.
gateway / forge / agent URLs, so hyperhive needs a stable domain Generate a key: wg genkey > /etc/wireguard/hive.key
and there is no safe default. Agents reach the forge via '';
forge.<domain> on the bridge network, so without it the forge is }
unreachable from containers. Changing the domain after first boot {
is destructive (deletes Matrix history) pick it before initial assertion = config.services.hyperhive.swarm.wireguard.address != "";
setup. See docs/gateway.md and docs/matrix.md. message = ''
''; services.hyperhive.swarm.wireguard.enable requires
} services.hyperhive.swarm.wireguard.address to be set
] (e.g. "10.100.0.1/24").
++ lib.optionals config.services.hyperhive.swarm.wireguard.enable [ '';
{ }
assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; ]
message = '' ++ lib.optionals config.services.hyperhive.otel.enable [
services.hyperhive.swarm.wireguard.enable requires {
services.hyperhive.swarm.wireguard.privateKeyFile to be set. assertion = config.services.hyperhive.otel.endpoint != "";
Generate a key: wg genkey > /etc/wireguard/hive.key message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
''; }
} ];
{
assertion = config.services.hyperhive.swarm.wireguard.address != "";
message = ''
services.hyperhive.swarm.wireguard.enable requires
services.hyperhive.swarm.wireguard.address to be set
(e.g. "10.100.0.1/24").
'';
}
]
++ lib.optionals config.services.hyperhive.otel.enable [
{
assertion = config.services.hyperhive.otel.endpoint != "";
message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
}
];
systemd.services.hive-c0re = { systemd.services.hive-c0re = {
description = "hyperhive coordinator daemon"; description = "hyperhive coordinator daemon";
@ -920,10 +906,11 @@ in
# behaviour (root auto-managed); true makes the sweep a no-op. # behaviour (root auto-managed); true makes the sweep a no-op.
HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless; HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless;
} }
// lib.optionalAttrs (config.services.hyperhive.domain != null) { // {
# Identity env vars threaded into c0re's own service env and # Identity env vars threaded into c0re's own service env and
# forwarded by meta.rs into every sub-agent's harness env — # forwarded by meta.rs into every sub-agent's harness env —
# full chain in docs/conventions.md::Hive identity. # full chain in docs/conventions.md::Hive identity. `domain` is
# required (asserted in hive-network.nix), so it's always set.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
} }
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) { // lib.optionalAttrs (config.services.hyperhive.hiveName != null) {

View file

@ -69,13 +69,8 @@ in
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = if hyperhiveDomain != null then "forge.${hyperhiveDomain}" else "localhost"; default = "forge.${hyperhiveDomain}";
defaultText = lib.literalExpression '' defaultText = lib.literalExpression ''"forge.''${services.hyperhive.domain}"'';
if services.hyperhive.domain != null then
"forge.''${services.hyperhive.domain}"
else
"localhost"
'';
example = "git.example.com"; example = "git.example.com";
description = '' description = ''
Public hostname for the forge. Doubles as both the forgejo Public hostname for the forge. Doubles as both the forgejo
@ -83,10 +78,10 @@ in
gateway vhost server-name when `behindGateway = true` gateway vhost server-name when `behindGateway = true`
(sub-domain routing see `docs/gateway.md`). (sub-domain routing see `docs/gateway.md`).
Defaults to `forge.''${services.hyperhive.domain}` when the Defaults to `forge.''${services.hyperhive.domain}` (idiomatic
hive-domain is set (idiomatic sub-domain shape `forge` sub-domain shape `forge` labelled under the hive's bare
labelled under the hive's bare domain), falling back to domain). `services.hyperhive.domain` is required, so there's
`localhost` otherwise (direct-on-port behaviour). always a domain to derive from.
Set to a full hostname (`git.example.com`, Set to a full hostname (`git.example.com`,
`forge.internal.lan`, etc.) for a bespoke vhost shape the `forge.internal.lan`, etc.) for a bespoke vhost shape the
@ -199,18 +194,17 @@ in
{ {
# `cfg.domain` can't be empty — would render `.<hive>` shaped # `cfg.domain` can't be empty — would render `.<hive>` shaped
# garbage as both server_name (nginx wildcard catch-all) and # garbage as both server_name (nginx wildcard catch-all) and
# /etc/hosts entry (invalid). Default rejects this case (lands # /etc/hosts entry (invalid). The default derives a non-empty
# `"localhost"` when hive-domain is unset), but operator-set # `forge.<domain>`, but an operator-set empty string should fail
# empty strings should fail loud. # loud.
assertion = cfg.domain != ""; assertion = cfg.domain != "";
message = '' message = ''
services.hyperhive.forge.domain = "" is rejected. The services.hyperhive.forge.domain = "" is rejected. The
rendered URLs would be invalid (nginx wildcard catch-all rendered URLs would be invalid (nginx wildcard catch-all
for an empty server_name, /etc/hosts rejects empty entries). for an empty server_name, /etc/hosts rejects empty entries).
Either leave at default (auto-derives to Either leave at default (auto-derives to
"forge.<services.hyperhive.domain>" when set, else "forge.<services.hyperhive.domain>"), or set a non-empty
"localhost"), or set a non-empty hostname like "forge.example.com" hostname like "forge.example.com" or "git.internal".
or "git.internal".
''; '';
} }
]; ];

View file

@ -385,14 +385,6 @@ in
config = lib.mkIf config.services.hyperhive.enable { config = lib.mkIf config.services.hyperhive.enable {
assertions = [ assertions = [
{
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
message = ''
services.hyperhive.gateway.localHostsEntry = true requires
services.hyperhive.domain to be set. Either pin a hostname
or leave `localHostsEntry` at its default of false.
'';
}
{ {
assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null);
message = '' message = ''
@ -632,10 +624,11 @@ in
''; '';
}; };
} }
// lib.optionalAttrs (hyperhiveDomain != null) { // {
# FluffyChat boot-config pre-fill so the client's # FluffyChat boot-config pre-fill so the client's
# `.well-known/matrix/client` lookup hits the # `.well-known/matrix/client` lookup hits the
# right delegation endpoint. # right delegation endpoint. `domain` is required, so
# this is always present.
"= /config.json" = { "= /config.json" = {
extraConfig = '' extraConfig = ''
default_type application/json; default_type application/json;
@ -678,7 +671,7 @@ in
# clients at `matrixCfg.gatewayHost` when set; falls back to direct # clients at `matrixCfg.gatewayHost` when set; falls back to direct
# `<hive>:<httpPort>`. CORS `*` per matrix spec. The `m.server` # `<hive>:<httpPort>`. CORS `*` per matrix spec. The `m.server`
# port-8448 carve-out is documented inline. See `docs/gateway.md`. # port-8448 carve-out is documented inline. See `docs/gateway.md`.
wellKnownLocations = lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) ( wellKnownLocations = lib.optionalAttrs matrixCfg.enable (
let let
clientBaseUrl = clientBaseUrl =
if matrixCfg.gatewayHost != null then if matrixCfg.gatewayHost != null then
@ -1020,7 +1013,7 @@ in
# sub-domain modules that are on. `lib.unique` dedupes if any # sub-domain modules that are on. `lib.unique` dedupes if any
# sub-domain happens to equal another. See `docs/gateway.md` # sub-domain happens to equal another. See `docs/gateway.md`
# ("Local dev"). # ("Local dev").
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) { networking.hosts = lib.mkIf cfg.localHostsEntry {
"127.0.0.1" = lib.unique ( "127.0.0.1" = lib.unique (
[ hyperhiveDomain ] [ hyperhiveDomain ]
++ lib.optional (config.services.hyperhive.forge.behindGateway or false ++ lib.optional (config.services.hyperhive.forge.behindGateway or false

View file

@ -149,13 +149,8 @@ in
gatewayHost = lib.mkOption { gatewayHost = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null; default = "matrix.${hyperhiveDomain}";
defaultText = lib.literalExpression '' defaultText = lib.literalExpression ''"matrix.''${services.hyperhive.domain}"'';
if services.hyperhive.domain != null then
"matrix.''${services.hyperhive.domain}"
else
null
'';
example = "matrix.example.com"; example = "matrix.example.com";
description = '' description = ''
Public hostname for the matrix homeserver behind the gateway. Public hostname for the matrix homeserver behind the gateway.
@ -294,25 +289,13 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
# serverName must exist (irrevocably embedded in user/room IDs); # `serverName` is irrevocably embedded in user/room IDs; it derives
# gatewayHost may not be "" (same footgun as forge.domain — # from `services.hyperhive.domain` (required, asserted in
# nginx rejects an empty server_name). docs/matrix.md::Assertion # hive-network.nix) when not set explicitly, so no separate
# rationale. # domain/serverName assertion is needed here. gatewayHost may not be
# "" (same footgun as forge.domain — nginx rejects an empty
# server_name). docs/matrix.md::Assertion rationale.
assertions = [ assertions = [
{
assertion = hyperhiveDomain != null || cfg.serverName != null;
message = ''
services.hyperhive.matrix.enable = true requires either:
- services.hyperhive.domain set to your host's canonical domain
(recommended; shared with forge / dashboard), or
- services.hyperhive.matrix.serverName set explicitly.
The matrix server_name is embedded into every user ID and
room ID on this homeserver it cannot be changed later
without losing every account and chat history. Pick a
stable hostname before enabling.
'';
}
{ {
assertion = cfg.gatewayHost == null || cfg.gatewayHost != ""; assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
message = '' message = ''

View file

@ -12,11 +12,12 @@ let
# The host-managed hive CA is the trust anchor for self-signed mode. # The host-managed hive CA is the trust anchor for self-signed mode.
# It is only stood up when the gateway actually serves a self-signed # It is only stood up when the gateway actually serves a self-signed
# cert: a domain must be set (the leaf SANs derive from it) and the # cert: the gateway must be in self-signed mode. `domain` is required
# gateway must be in self-signed mode. The self-signed condition is the # (asserted in hive-network.nix), so the leaf SANs always have a
# gateway module's single source of truth (`gateway.useSelfSigned`): # domain to derive from. The self-signed condition is the gateway
# true when neither an operator cert (`tls.certDir`) nor ACME is set. # module's single source of truth (`gateway.useSelfSigned`): true when
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned && domain != null; # neither an operator cert (`tls.certDir`) nor ACME is set.
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
in in
{ {
# Host-side TLS trust root for the self-signed gateway mode. # Host-side TLS trust root for the self-signed gateway mode.