Compare commits

...
6 changed files with 70 additions and 89 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
@ -128,9 +129,11 @@ in
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive the agent swarm coordinator"; options.services.hyperhive.enable = lib.mkEnableOption "hyperhive the agent swarm coordinator";
# Canonical hive DNS domain shared by every subsystem that needs a # Canonical hive DNS domain shared by every subsystem that needs a
# stable hostname. Nullable + default null so existing configs # stable hostname. Typed nullOr (default null) so the option always
# evaluate unchanged; subsystems that need it (matrix) assert # exists, but it's REQUIRED whenever hyperhive is enabled — an
# non-null in their own config block. Full identity-surface # 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
# 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
# chain → identity.rs → claude prompt): docs/conventions.md:: # chain → identity.rs → claude prompt): docs/conventions.md::
# Hive identity (label + domain + display names). # Hive identity (label + domain + display names).
@ -143,9 +146,10 @@ in
stable name (currently: `services.hyperhive.matrix.serverName` stable name (currently: `services.hyperhive.matrix.serverName`
derives from this, defaulting to derives from this, defaulting to
`matrix.''${services.hyperhive.domain}` when `serverName` is `matrix.''${services.hyperhive.domain}` when `serverName` is
null). No default subsystems that opt to require it assert null). **Required** when `services.hyperhive.enable` eval fails
non-null in their own config and fail eval with a helpful with a helpful message if it's unset (it's baked into matrix on
message if it's missing. Exposed to agents as first boot and drives the gateway/forge/agent URLs, with no safe
default; changing it later is destructive). Exposed to agents as
`HYPERHIVE_HIVE_DOMAIN`; consumed by `HYPERHIVE_HIVE_DOMAIN`; consumed by
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>` `hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
qualified labels. qualified labels.
@ -828,6 +832,11 @@ in
config.services.hyperhive.swarm.wireguard.listenPort config.services.hyperhive.swarm.wireguard.listenPort
]; ];
# NB: `services.hyperhive.domain` is required when hyperhive is
# enabled — the canonical assertion lives in `hive-network.nix` (the
# hive resolver is authoritative for `<domain>` and agents reach the
# forge/matrix through the gateway by it). So everything below can
# treat `config.services.hyperhive.domain` as non-null.
assertions = assertions =
lib.optionals config.services.hyperhive.swarm.wireguard.enable [ lib.optionals config.services.hyperhive.swarm.wireguard.enable [
{ {
@ -897,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.