Compare commits

..
6 changed files with 89 additions and 70 deletions

View file

@ -280,18 +280,21 @@ 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`: agents reach the forge via the gateway by domain ## `HIVE_FORGE_URL`: domain via gateway for isolated agents, loopback for shared-netns
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. Network isolation is always on (the `hive-forge` calls against it. `hive-c0re.nix` sets this based on the
shared-netns mode was removed), so agents run in a private netns and network isolation mode:
can never reach the host's loopback. `hive-c0re.nix` sets
`HIVE_FORGE_URL` to `http://<forge.domain>` (default - **`network.isolateContainers = true`**: agents run in private netns and
`forge.<hive-domain>`; `services.hyperhive.domain` is required). Agents get the bridge dnsmasq as their resolver. `HIVE_FORGE_URL` is set to
get the bridge dnsmasq as their resolver, resolve the hostname → `http://<forge.domain>` (default `forge.<hive-domain>`). Agents resolve
bridge IP, then reach nginx on port 80 (the bridge firewall opens the hostname via dnsmasq → bridge IP, then reach nginx on port 80 (bridge
80+443). nginx proxies to forgejo — the same path an operator browser firewall opens 80+443 when isolation is on). nginx proxies to forgejo — the
takes, no raw port exposure needed. same path an operator browser 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
@ -303,13 +306,10 @@ 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.
The forge container shares the host network namespace Container shares the host network namespace
(`privateNetwork = false`), so forgejo's listeners look like a (`privateNetwork = false`) so agents reach the forge at
host-side service — nixos-container is here for state + systemd-unit `http://localhost:<httpPort>` without extra plumbing — nixos-container
isolation, not network isolation. Note this is the FORGE container; is here for state + systemd-unit isolation, not network isolation.
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): agents reach Forgejo through the gateway (`forge.<domain>` on default): every agent container reaches Forgejo at `localhost:<httpPort>`
the bridge), not the raw port, so no firewall hole is needed. Flip to via the shared host netns without a firewall hole. Flip to `true` when
`true` when you need: 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,9 +112,8 @@ 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). All # hyperhive itself. hive-matrix is opt-in (off by default) and asserts
# subsystems rely on `services.hyperhive.domain`, which is required # that `services.hyperhive.domain` is set before it can be enabled.
# (asserted in hive-network.nix) whenever hyperhive is enabled.
imports = [ imports = [
./hive-ci.nix ./hive-ci.nix
./hive-forge.nix ./hive-forge.nix
@ -129,11 +128,9 @@ 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. Typed nullOr (default null) so the option always # stable hostname. Nullable + default null so existing configs
# exists, but it's REQUIRED whenever hyperhive is enabled — an # evaluate unchanged; subsystems that need it (matrix) assert
# assertion in hive-network.nix fails eval when it's unset, since # non-null in their own config block. Full identity-surface
# 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).
@ -146,10 +143,9 @@ 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). **Required** when `services.hyperhive.enable` eval fails null). No default subsystems that opt to require it assert
with a helpful message if it's unset (it's baked into matrix on non-null in their own config and fail eval with a helpful
first boot and drives the gateway/forge/agent URLs, with no safe message if it's missing. Exposed to agents as
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.
@ -832,11 +828,6 @@ 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 [
{ {
@ -906,11 +897,10 @@ 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. `domain` is # full chain in docs/conventions.md::Hive identity.
# 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,8 +69,13 @@ in
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "forge.${hyperhiveDomain}"; default = if hyperhiveDomain != null then "forge.${hyperhiveDomain}" else "localhost";
defaultText = lib.literalExpression ''"forge.''${services.hyperhive.domain}"''; defaultText = lib.literalExpression ''
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
@ -78,10 +83,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}` (idiomatic Defaults to `forge.''${services.hyperhive.domain}` when the
sub-domain shape `forge` labelled under the hive's bare hive-domain is set (idiomatic sub-domain shape `forge`
domain). `services.hyperhive.domain` is required, so there's labelled under the hive's bare domain), falling back to
always a domain to derive from. `localhost` otherwise (direct-on-port behaviour).
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
@ -194,17 +199,18 @@ 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). The default derives a non-empty # /etc/hosts entry (invalid). Default rejects this case (lands
# `forge.<domain>`, but an operator-set empty string should fail # `"localhost"` when hive-domain is unset), but operator-set
# loud. # empty strings should fail 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>"), or set a non-empty "forge.<services.hyperhive.domain>" when set, else
hostname like "forge.example.com" or "git.internal". "localhost"), or set a non-empty hostname like "forge.example.com"
or "git.internal".
''; '';
} }
]; ];

View file

@ -385,6 +385,14 @@ 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 = ''
@ -624,11 +632,10 @@ 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. `domain` is required, so # right delegation endpoint.
# this is always present.
"= /config.json" = { "= /config.json" = {
extraConfig = '' extraConfig = ''
default_type application/json; default_type application/json;
@ -671,7 +678,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 ( wellKnownLocations = lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) (
let let
clientBaseUrl = clientBaseUrl =
if matrixCfg.gatewayHost != null then if matrixCfg.gatewayHost != null then
@ -1013,7 +1020,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 { networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
"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,8 +149,13 @@ in
gatewayHost = lib.mkOption { gatewayHost = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = "matrix.${hyperhiveDomain}"; default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null;
defaultText = lib.literalExpression ''"matrix.''${services.hyperhive.domain}"''; defaultText = lib.literalExpression ''
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.
@ -289,13 +294,25 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
# `serverName` is irrevocably embedded in user/room IDs; it derives # serverName must exist (irrevocably embedded in user/room IDs);
# from `services.hyperhive.domain` (required, asserted in # gatewayHost may not be "" (same footgun as forge.domain —
# hive-network.nix) when not set explicitly, so no separate # nginx rejects an empty server_name). docs/matrix.md::Assertion
# domain/serverName assertion is needed here. gatewayHost may not be # rationale.
# "" (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,12 +12,11 @@ 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: the gateway must be in self-signed mode. `domain` is required # cert: a domain must be set (the leaf SANs derive from it) and the
# (asserted in hive-network.nix), so the leaf SANs always have a # gateway must be in self-signed mode. The self-signed condition is the
# domain to derive from. The self-signed condition is the gateway # gateway module's single source of truth (`gateway.useSelfSigned`):
# module's single source of truth (`gateway.useSelfSigned`): true when # true when neither an operator cert (`tls.certDir`) nor ACME is set.
# neither an operator cert (`tls.certDir`) nor ACME is set. active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned && domain != null;
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.