hyperhive/nix/modules/hive-c0re.nix
atlas edad6f863c feat(#1886): trust a peer hive's root CA hive-wide for self-signed federation
Add swarm.peers.<domain>.caCert (path to a peer hive's root CA PEM),
trusted everywhere the hive's own internal CA is — so a self-signed
peer hive can federate (matrix) and any in-hive consumer validates its
certs.

Mechanism (reuses the existing hive-CA embedding): the meta-flake
renderer embeds a LIST of CA files next to each agent's flake —
hive-ca.pem (the hive's own self-signed CA, when active) plus each peer
caCert as peer-ca-<N>.pem — and emits them all in
security.pki.certificateFiles, so every agent trusts them at build
time. The matrix container trusts the same peer CAs for federation TLS.
Nothing is installed in the host trust store; the certs live in the nix
store (no mutable host file).

- meta.rs: embedded_ca_files() = hive CA + peer CAs (from new
  HIVE_PEER_CA_PATHS env); ca_embed_state() tracks the list (content +
  add/remove); sync_agents materialises + stages the list; render emits
  the multi-entry certificateFiles. Tests cover hive-only / hive+peers
  / peers-only / none.
- hive-c0re.nix: HIVE_PEER_CA_PATHS service env (colon-joined caCerts);
  caCert / certFingerprint option docs updated to the hive-wide scope.
- hive-matrix.nix + docs/swarm.md: scope + comment updates.

certFingerprint stays the c0re-only leaf-pin path.
2026-06-22 16:54:44 +02:00

1059 lines
46 KiB
Nix

{
hyperhivePackage,
hyperhiveFrontend,
hyperhiveAssets,
hyperhiveFlake,
hyperhiveNixpkgsUnstable,
agentBaseToplevel,
managerToplevel,
}:
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.c0re;
# Privsep splits ownership across users, so git/libgit2's dubious-
# ownership guard trips on legitimate cross-user reads: hive-priv (root)
# fetches the hive-core-owned meta/applied repos via nix, and hive-c0re
# (hive-core) fetches the agent-owned proposed-config repos. Both
# processes are trusted and can already read the files; this gitconfig
# only satisfies the ownership guard. libgit2 honours the literal `*`
# (mid-path globs aren't supported, so per-agent repos can't be listed);
# in practice these processes only ever touch hyperhive's own repos.
safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" ''
[safe]
directory = *
'';
# The `hive-c0re serve` config, written to the store as JSON and passed
# via a single `--config` flag so the systemd ExecStart line stays short
# instead of carrying every host-level setting as its own flag (the
# context-window + model-price maps alone were escaped JSON blobs on the
# command line). Keys are snake_case to match the `ServeConfig` serde
# shape the daemon deserialises (the container-injected HiveEnv fields,
# flattened, plus the hive-c0re-local model_prices table); per-flag
# overrides still work for ad-hoc invocations.
serveConfig = pkgs.writeText "hive-c0re-serve.json" (
builtins.toJSON {
hyperhive_flake = cfg.hyperhiveFlake;
nixpkgs_flake = cfg.nixpkgsFlake;
nixpkgs_unstable_flake = cfg.nixpkgsUnstableFlake;
dashboard_port = cfg.dashboardPort;
operator_pronouns = cfg.operatorPronouns;
context_window_tokens = cfg.contextWindowTokens;
agent_cpu_quota = cfg.agentCpuQuota;
agent_memory_max = cfg.agentMemoryMax;
model_prices = cfg.modelPrices;
}
);
# Stylix theme integration (zero-op auto-detect). When the operator's
# host config has stylix enabled, generate a base16 `colors.css` from
# its palette and overlay it onto the bundled frontend dist so the
# dashboard re-themes with no operator action and no npm/esbuild rebuild
# (a pure file-copy over the prebuilt dist). `colors.css` is the entire
# swap contract — `theme.css` derives every semantic var from the 16
# base16 slots (see docs/web-ui/css-vars.md). The guarded access makes
# this a clean no-op when stylix isn't imported into the host config.
stylixThemeColors =
if (config.stylix.enable or false) && ((config.lib.stylix or { }) ? colors) then
config.lib.stylix.colors.withHashtag
else
null;
themedColorsCss =
c:
pkgs.writeText "hyperhive-colors.css" ''
:root {
--base00: ${c.base00};
--base01: ${c.base01};
--base02: ${c.base02};
--base03: ${c.base03};
--base04: ${c.base04};
--base05: ${c.base05};
--base06: ${c.base06};
--base07: ${c.base07};
--base08: ${c.base08};
--base09: ${c.base09};
--base0A: ${c.base0A};
--base0B: ${c.base0B};
--base0C: ${c.base0C};
--base0D: ${c.base0D};
--base0E: ${c.base0E};
--base0F: ${c.base0F};
}
'';
# Overlay the generated colors.css onto both dist subtrees. Both the
# dashboard (served by hive-c0re via HIVE_STATIC_DIR) and the agent UIs
# (served by the gateway from HIVE_AGENT_FRONTEND_DIR — static files
# straight from the store) read their colors.css from this host-side
# tree, so swapping both re-themes both surfaces.
#
# Not covered here: an agent reached directly on its own harness web
# server (no gateway) serves from its per-agent `mergedDist`, built in
# the agent's own nixosSystem with no access to the host's stylix
# colours — theming that path needs the base16 palette forwarded
# host→agent, tracked separately.
themedFrontend =
c:
pkgs.runCommand "hyperhive-frontend-themed" { } ''
cp -r ${cfg.frontend} $out
chmod -R u+w $out
install -m644 ${themedColorsCss c} $out/dashboard/static/colors.css
install -m644 ${themedColorsCss c} $out/agent/static/colors.css
'';
servedFrontend =
if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend;
in
{
# The forge is part of the standard install — hive-c0re mirrors
# every agent's applied config repo into it. On by default; opt out
# with `services.hyperhive.forge.enable = false`. hive-matrix is
# opt-in (off by default) and asserts that `services.hyperhive.domain`
# is set before it can be enabled.
imports = [
./hive-ci.nix
./hive-forge.nix
./hive-gateway.nix
./hive-matrix.nix
./hive-network.nix
./hive-tls.nix
];
# Top-level hyperhive enable flag. When true, automatically enables
# hive-c0re and the on-by-default hyperhive subsystems.
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive the agent swarm coordinator";
# Canonical hive DNS domain shared by every subsystem that needs a
# stable hostname. Nullable + default null so existing configs
# evaluate unchanged; subsystems that need it (matrix) assert
# non-null in their own config block. Full identity-surface
# context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var
# chain → identity.rs → claude prompt): docs/conventions.md::
# Hive identity (label + domain + display names).
options.services.hyperhive.domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "darkest.space";
description = ''
Canonical host domain for hyperhive subsystems that need a
stable name (currently: `services.hyperhive.matrix.serverName`
derives from this, defaulting to
`matrix.''${services.hyperhive.domain}` when `serverName` is
null). No default subsystems that opt to require it assert
non-null in their own config and fail eval with a helpful
message if it's missing. Exposed to agents as
`HYPERHIVE_HIVE_DOMAIN`; consumed by
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
qualified labels.
'';
};
# Human display names for hive + swarm. Distinct from the DNS
# domain above (machine-readable) — see
# docs/conventions.md::Hive identity for the
# domain-vs-name-vs-swarm distinction + the env-var
# propagation chain.
options.services.hyperhive.hiveName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "pr1ma";
description = ''
Human-readable name of this single-host hive instance.
Distinct from `services.hyperhive.domain` (the machine-
addressable DNS name): the domain may carry the hive name as
its leftmost label by convention, but this option is the
canonical readable identity. Exposed to agents as
`HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and
per-agent system prompt when set. Null falls back to the
default behaviour (chrome shows the domain, prompt doesn't
mention a hive name).
'';
};
options.services.hyperhive.swarmName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "constellat1on";
description = ''
Human-readable name of the wider swarm this hive belongs to.
Hives at different DNS domains can share a swarm name when
they federate together. Exposed to agents as
`HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and
per-agent system prompt when set.
'';
};
# Peer hives in the same swarm. Each entry declares a remote hive
# reachable from this host. Serialised to JSON and injected as
# `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent
# containers via `meta.rs::FORWARDED_VARS`. Consumed by
# `identity.rs::peers()` + the dashboard's `peer_hives` state field
# (feeds the P33RS dashboard tab).
options.services.hyperhive.swarm.peers = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
certFingerprint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
description = ''
Expected TLS certificate fingerprint for this peer's HTTPS
endpoint. Null = trust the system CA bundle (for Let's
Encrypt peers). Set to pin a self-signed cert.
Format: the literal `sha256:` followed by exactly 64
hex digits (case-insensitive, no colon separators) the
SHA-256 digest of the peer's DER-encoded leaf certificate.
Generate with `openssl x509 -noout -fingerprint -sha256`,
then strip the colons and prepend `sha256:`. A malformed
value is ignored with a warning rather than weakening
trust. See docs/swarm.md for the full recipe.
Scopes only to hive-c0re's own peer HTTPS checks it does
NOT help Matrix federation (tuwunel validates against its
container trust bundle). For a self-signed peer whose root
CA you want trusted hive-wide (every agent + Matrix
federation), set `caCert` below.
'';
};
caCert = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "./peers/edge-ca.pem";
description = ''
Path to this peer hive's root CA certificate (PEM). When
set, the CA is embedded (at build time, into the nix store
no runtime file on the host) and trusted **everywhere the
hive's own internal CA is**: it rides alongside `hive-ca.pem`
in each agent's `security.pki.certificateFiles` (via the
meta-flake renderer), and is added to the Matrix homeserver
container's trust bundle so tuwunel validates *federation*
TLS from a self-signed peer hive whose cert chains to it.
This is the CA-trust path that `certFingerprint`
(leaf-pinning, c0re-only) can't cover, and is what unblocks
Matrix federation with a self-signed peer hive. Trust stays
inside the hive (agents + the Matrix container), never the
host system trust store. Mutually complementary with
`certFingerprint`; set `caCert` for the federation case. See
docs/swarm.md.
'';
};
wireguardPublicKey = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "base64pubkey=";
description = ''
WireGuard public key for this peer host. Required when
`services.hyperhive.swarm.wireguard.enable = true` and
you want this peer reachable over the mesh. Null = TLS-
only peering (public internet, no mesh tunnel).
'';
};
wireguardEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "203.0.113.1:51820";
description = ''
WireGuard endpoint for this peer in `host:port` form.
Required when the peer host is behind a firewall and
this host needs to initiate the tunnel. Null = this host
waits for the peer to connect (peer-initiates; peer must
have an endpoint pointing back at this host).
'';
};
wireguardAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.100.0.2/32";
description = ''
IP address (with prefix) of the peer host on the
WireGuard mesh. Used as the `allowedIPs` for the peer's
WireGuard config entry and injected into `HYPERHIVE_PEERS`
so hive-c0re can route intra-swarm traffic to the mesh
address rather than the public domain. Required to include
the peer in the WireGuard mesh (peers missing this field
are silently excluded from `wg-hive`).
'';
};
};
}
);
default = { };
example = {
"lab.example.com" = {
certFingerprint = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
};
"edge.corp" = { };
};
description = ''
Peer hives in the same swarm. The attrset key is the peer's DNS
domain used for dashboard links and Matrix federation discovery.
Null `certFingerprint` trusts the system CA bundle; set it to pin
a self-signed TLS cert. Add `wireguardPublicKey` + `wireguardAddress`
(and optionally `wireguardEndpoint`) to include the peer in the
WireGuard mesh when `swarm.wireguard.enable = true`.
'';
};
# WireGuard mesh config for the local host.
# When enabled, hive-c0re configures a `wg-hive` interface on the host
# connecting to all peers that have `wireguardPublicKey` declared.
# Peers reachable over the mesh are preferred for inter-hive traffic
# (no public TLS round-trip needed); peers without a public key still
# work via normal HTTPS.
options.services.hyperhive.swarm.wireguard = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable the WireGuard inter-hive mesh. When true, a `wg-hive`
interface is brought up connecting to all swarm peers that
declare a `wireguardPublicKey`. Requires
`privateKeyFile` to be set.
'';
};
privateKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/etc/wireguard/hive.key";
description = ''
Path to the host's WireGuard private key file. The file must
be readable by root and should have mode 0400. Generate with
`wg genkey > /etc/wireguard/hive.key`. Required when
`swarm.wireguard.enable = true`.
'';
};
address = lib.mkOption {
type = lib.types.str;
default = "";
example = "10.100.0.1/24";
description = ''
IP address (with prefix) of this host on the WireGuard mesh.
Use a /24 (or broader) prefix so the routing table covers all
peer /32 routes. Example: `"10.100.0.1/24"` for a 256-host mesh.
'';
};
listenPort = lib.mkOption {
type = lib.types.port;
default = 51820;
description = ''
UDP port the local WireGuard interface listens on. Must be
reachable from peer hosts when they initiate the tunnel.
Default: 51820 (standard WireGuard port).
'';
};
persistentKeepalive = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = 25;
example = 25;
description = ''
Seconds between keepalive packets sent to each peer. Useful
when this host (or a peer) is behind NAT keeps the UDP hole
open. Set to null to disable. Default: 25 seconds.
'';
};
};
options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
};
package = lib.mkOption {
type = lib.types.package;
default = hyperhivePackage pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = ''
hyperhive workspace package. Provides `/bin/hive-c0re`
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
(operator-facing host CLI for ad-hoc administration).
'';
};
frontend = lib.mkOption {
type = lib.types.package;
default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
description = ''
Bundled frontend dist (see `./nix/frontend.nix`). Output has
`dashboard/` and `agent/` subdirectories hive-c0re serves
`dashboard/` via `tower_http::ServeDir` from the path passed
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
the JSON contract (`/api/state`, the SSE streams, the action
endpoints) is the source of truth for any replacement.
'';
};
servedFrontend = lib.mkOption {
type = lib.types.package;
internal = true;
readOnly = true;
default = servedFrontend;
defaultText = lib.literalExpression "<stylix-themed overlay of `frontend`>";
description = ''
Internal, read-only: `frontend` re-themed with the active stylix
palette (or `frontend` verbatim when unthemed); has `dashboard/`
and `agent/`. Exposed so `hive-gateway.nix` can static-serve
`dashboard/` as an nginx root instead of proxying to hive-c0re.
'';
};
assets = lib.mkOption {
type = lib.types.package;
default = hyperhiveAssets pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
description = ''
Bundled static runtime assets (see `./nix/assets.nix`): the
project's branding family + the claude system-prompt template +
claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`;
passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR`
(`hive_sh4re::assets::*` resolve paths underneath). Override to
ship customised branding or prompts without rebuilding the
rust derivation.
'';
};
hyperhiveFlake = lib.mkOption {
type = lib.types.str;
default = hyperhiveFlake;
defaultText = lib.literalMD "the flake's own store path";
description = ''
URL of the hyperhive flake (no fragment). Inlined into each
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
build the container. Defaults to this flake's own store path
only override if you want agents tracking a different ref.
'';
};
nixpkgsFlake = lib.mkOption {
type = lib.types.str;
default = "path:${pkgs.path}";
defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`";
description = ''
Store-path URL for the `nixpkgs` input in the generated meta
flake. The meta flake declares this as a top-level input and
wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so
every agent container evaluates with this exact nixpkgs.
Defaults to `"path:''${pkgs.path}"` the store path of the
nixpkgs the host NixOS module was evaluated with. When the
operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
"nixpkgs"` in their host flake, `pkgs.path` resolves to the
host's own nixpkgs, so agents transparently track the same
channel as the host.
Override to pin agents to a specific nixpkgs version regardless
of the host's channel.
'';
};
nixpkgsUnstableFlake = lib.mkOption {
type = lib.types.str;
default = hyperhiveNixpkgsUnstable;
defaultText = lib.literalMD "hyperhive's own `nixpkgs-unstable` store path";
description = ''
Store-path URL for the `nixpkgs-unstable` input in the generated
meta flake. The meta flake declares this as a top-level input and
wires `inputs.hyperhive.inputs.nixpkgs-unstable.follows =
"nixpkgs-unstable"` so agents use this exact unstable nixpkgs.
Defaults to the store path of the `nixpkgs-unstable` input
hyperhive's own `flake.nix` was evaluated with (the channel that
carries `claude-code`). Override when you want to track a newer
unstable snapshot or a custom `claude-code` package.
'';
};
dashboardPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = "TCP port the hive-c0re dashboard listens on.";
};
operatorPronouns = lib.mkOption {
type = lib.types.str;
default = "she/her";
example = "they/them";
description = ''
Operator pronouns, free text. Threaded into every agent
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
harness substitutes it into the agent / manager system
prompt at boot so claude refers to the operator naturally
in third person ("ask her", "tell them", etc.). Changes
propagate to running agents on the next ` R3BU1LD`
forwards as a meta flake env-var bump, no per-agent
approval needed.
'';
};
preBuildAgentTemplates = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Pre-fetch the per-container system closures (agent-base +
manager toplevels) into the host's /nix/store as part of this
host's NixOS build, instead of letting the first agent spawn
do all the work.
Enabling this adds roughly the full nixpkgs runtime closure +
claude-code + the harness binary to your system closure size
(low single-digit GB), but the first `nixos-container start`
for any agent then completes in seconds instead of minutes
because nothing's left to fetch.
Off by default because the toplevels are pinned to
`x86_64-linux` (nixos-containers run native arch). Enabling
on an aarch64 host would force nix to build the x86 closure
via cross or a remote builder, which is rarely what you want.
Flip to `true` on an x86_64 host when you care more about
first-spawn latency than host store size or just
`nix build ${hyperhiveFlake}#agent-base-toplevel` once
manually to warm the store.
'';
};
contextWindowTokens = lib.mkOption {
type = lib.types.attrsOf lib.types.int;
default = {
haiku = 200000;
sonnet = 1000000;
opus = 1000000;
};
example = {
haiku = 150000;
sonnet = 900000;
};
description = ''
Per-model context-window sizes in tokens. Each key is a
model-family short name matched case-insensitively as a
substring of the active model name at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`). The defaults cover the known
Anthropic families; add entries for new models or override
existing ones here to change the window for all agents at once.
Passed to `hive-c0re serve` as JSON and injected into every
container's harness service environment as
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`. Changes propagate
on the next ` R3BU1LD` no per-agent approval needed.
'';
};
modelPrices = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
input = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million input tokens.";
};
output = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million output tokens.";
};
cache_read = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-read tokens.";
};
cache_write = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-creation (write) tokens.";
};
};
}
);
# Current Anthropic list prices for the Claude 4.x family (Opus
# 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL
# price (the default through the Claude subscription the agents run
# on). Keep in sync with `builtin_prices` in
# hive-c0re/src/hive_stats.rs.
default = {
opus = {
input = 5.0;
output = 25.0;
cache_read = 0.5;
cache_write = 10.0;
};
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
haiku = {
input = 1.0;
output = 5.0;
cache_read = 0.1;
cache_write = 2.0;
};
};
example = {
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
};
description = ''
Per-model USD prices (per **million** tokens) used for the
hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key
is a model-family short name matched case-insensitively as a
substring of the active model id at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`); the longest matching key wins, so
a specific entry beats a generic family name. Any model not
covered by this table falls back to hive-c0re's built-in
estimate.
The defaults track Anthropic list pricing at the time of
writing override them here to keep the estimate current
without a code change. Passed to `hive-c0re serve` as JSON via
`--model-prices`; read only by hive-c0re itself (not injected
into containers). Changes apply on the next host rebuild.
'';
};
agentCpuQuota = lib.mkOption {
type = lib.types.str;
default = "200%";
example = "400%";
description = ''
systemd `CPUQuota=` applied to every agent container via a
`container@h-<name>.service.d/` drop-in written on each
spawn/rebuild. Expressed as a percentage of one CPU core
`"200%"` allows each agent to use up to 2 cores. The old
hard-coded value was `"50%"`; bump this if agents are hitting
CPU limits during builds or heavy tool use.
For a hive-wide cap across all containers, set
`systemd.slices.machine.serviceConfig.CPUQuota` in your NixOS
config (all nspawn containers live in `machine.slice`).
'';
};
agentMemoryMax = lib.mkOption {
type = lib.types.str;
default = "4G";
example = "8G";
description = ''
systemd `MemoryMax=` applied to every agent container via the
same drop-in as `agentCpuQuota`. The old hard-coded value was
`"2G"`.
'';
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [
cfg.package
pkgs.git
];
# Pull the per-container toplevels into the host system closure.
# `system.extraDependencies` adds paths to the system build
# without referencing them at runtime — nixos-rebuild fetches /
# builds them, they end up in /nix/store, and the first
# nixos-container update + start for an agent has nothing left to
# do. Gated because the closure is sizeable and pinned to x86_64.
system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [
agentBaseToplevel
managerToplevel
];
# Unprivileged coordinator user. hive-c0re runs as this user
# (privsep phase 2); privileged operations are delegated to
# hive-priv which runs as root, socket-activated at
# /run/hive/priv.sock.
users.users.hive-core = {
isSystemUser = true;
group = "hive-core";
description = "hive-c0re coordinator daemon user";
};
users.groups.hive-core = { };
# The gateway nginx is always the sole external entry point (it runs
# alongside hyperhive), so the per-agent web-port range stays closed on
# the host firewall. See `docs/gateway.md::Firewall posture (host-level)`.
# WireGuard inter-hive mesh. Enabled when
# `services.hyperhive.swarm.wireguard.enable = true`. Brings up a
# `wg-hive` interface and connects to each peer that has
# `wireguardPublicKey` set. Firewall opens the UDP listen port on
# the host (not inside containers — this is host-level networking).
networking.wireguard.interfaces = lib.mkIf config.services.hyperhive.swarm.wireguard.enable (
let
wgCfg = config.services.hyperhive.swarm.wireguard;
meshPeers = lib.filterAttrs (
_: p: p.wireguardPublicKey != null && p.wireguardAddress != null
) config.services.hyperhive.swarm.peers;
in
{
wg-hive = {
ips = [ wgCfg.address ];
listenPort = wgCfg.listenPort;
privateKeyFile = wgCfg.privateKeyFile;
peers = lib.mapAttrsToList (
_domain: p:
{
publicKey = p.wireguardPublicKey;
allowedIPs = [ p.wireguardAddress ];
}
// lib.optionalAttrs (p.wireguardEndpoint != null) {
endpoint = p.wireguardEndpoint;
}
// lib.optionalAttrs (wgCfg.persistentKeepalive != null) {
persistentKeepalive = wgCfg.persistentKeepalive;
}
) meshPeers;
};
}
);
# Open the WireGuard UDP port on the host firewall when the mesh is on.
networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [
config.services.hyperhive.swarm.wireguard.listenPort
];
assertions = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [
{
assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null;
message = ''
services.hyperhive.swarm.wireguard.enable requires
services.hyperhive.swarm.wireguard.privateKeyFile to be set.
Generate a key: wg genkey > /etc/wireguard/hive.key
'';
}
{
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").
'';
}
];
systemd.services.hive-c0re = {
description = "hyperhive coordinator daemon";
wantedBy = [ "multi-user.target" ];
# Socket unit must start before the service so hive-c0re receives the
# pre-bound fd via LISTEN_FDS (socket activation). Without this
# dependency, nixos-rebuild switch activates hive-c0re.socket while
# hive-c0re.service is already running (started by multi-user.target),
# and systemd refuses with "Socket service already active". Adding
# requires+after causes systemd to stop the service, start the socket,
# then restart the service -- clean transition on every config apply.
requires = [ "hive-c0re.socket" ];
after = [ "hive-c0re.socket" ];
path = [
pkgs.git
"/run/current-system/sw"
];
environment = {
# nix (the prebuild `nix build`, flake-check, and meta eval) writes
# its fetcher/eval cache under $HOME/.cache. As a system user
# hive-core has no home, so HOME defaults to the unwritable
# /var/empty and Lix fails to initialise its cache. Point HOME at
# the writable StateDirectory.
HOME = "/var/lib/hyperhive";
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist
# now (see hive-gateway.nix); this router is API-only.
# Path to the base agent frontend dist. hive-c0re's
# gateway_nginx.rs uses this to generate split location
# blocks in agents.conf — static HTML/CSS/JS served from the
# nix store directly; dynamic API paths still proxied to the
# agent daemon. The nix store is shared across nspawn
# containers, so this path is reachable from inside the
# gateway container's nginx.
HIVE_AGENT_FRONTEND_DIR = "${servedFrontend}/agent";
# Path to the static runtime asset tree (branding + claude
# prompts). `hive_sh4re::assets::*` reads paths underneath.
# `forge.rs` reads the avatar PNGs from here on startup.
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
}
// lib.optionalAttrs (config.services.hyperhive.domain != null) {
# Identity env vars threaded into c0re's own service env and
# forwarded by meta.rs into every sub-agent's harness env —
# full chain in docs/conventions.md::Hive identity.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
}
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
}
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
}
// lib.optionalAttrs config.services.hyperhive.forge.enable {
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
# nginx proxies to forgejo. Set directly: this env only exists when
# hyperhive is enabled. See `docs/gateway.md::HIVE_FORGE_URL`.
HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.enable {
# In-cluster matrix homeserver URL for each agent's
# hive-matrix-daemon — the gateway vhost (`matrix.<domain>`). The
# gatewayHost null-guard falls back to loopback so a domain-less
# config still evals. Forwarded to agents by meta.rs alongside
# HIVE_FORGE_URL; shares the same env-forwarding ordering caveat
# (value baked at config-generation time).
HIVE_MATRIX_URL =
if config.services.hyperhive.matrix.gatewayHost != null then
"http://${config.services.hyperhive.matrix.gatewayHost}"
else
"http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
# Availability flags read by the dashboard's `/api/state`.
# Matrix GUI lives entirely on the gateway nginx (matrix tab
# only shows when both flags are on). Gateway routing detail:
# docs/gateway.md::Vhost map.
HIVE_MATRIX_GUI_ENABLED = "1";
}
// {
# The gateway always runs, so the dashboard always builds
# same-origin `/agent/<name>/` links (never the direct
# `<host>:<port>` TCP fallback). Kept as an env flag so the
# dashboard doesn't need to learn the gateway is unconditional.
HIVE_GATEWAY_ENABLED = "1";
}
//
lib.optionalAttrs
(config.services.hyperhive.forge.enable && config.services.hyperhive.forge.behindGateway)
{
# Public URL of the forge vhost served by hive-gateway. The
# dashboard uses this to build browser-facing forge links
# instead of hardcoding `<hostname>:3000`, which breaks when
# the operator accesses the dashboard through the gateway
# (forge sub-domain has no port; direct port URL would be
# wrong). Absent when `behindGateway = false` — dashboard
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()
# + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain
# is the attrset key; cert_fingerprint is null for CA-trusted peers;
# wireguard_address is omitted when not part of the mesh.
HYPERHIVE_PEERS = builtins.toJSON (
lib.mapAttrsToList (
domain: p:
{
inherit domain;
cert_fingerprint = p.certFingerprint;
}
// lib.optionalAttrs (p.wireguardAddress != null) {
wireguard_address = p.wireguardAddress;
}
) config.services.hyperhive.swarm.peers
);
}
//
lib.optionalAttrs
(lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers))
{
# Peer-hive root CA file paths (colon-joined), one per peer that
# declares `swarm.peers.<domain>.caCert`. hive-c0re's meta-flake
# renderer (meta.rs) embeds each next to every agent's flake and
# adds it to `security.pki.certificateFiles`, so a peer CA is
# trusted everywhere the hive's own internal CA (`hive-ca.pem`)
# is — i.e. by every agent. The matrix container trusts the same
# CAs separately for federation TLS. The `caCert` files are
# copied into the nix store at build, so these are store paths —
# nothing mutable lives on the host.
HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" (
lib.filter (c: c != null) (
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
)
);
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config ${serveConfig}";
# Migrate hive-c0re's *own* state to the service user after an
# upgrade from a root-run install (systemd's StateDirectory only
# chowns the top-level dir, not pre-existing files inside it). The
# `+` prefix runs as root despite User = hive-core; `-` tolerates
# failure. coreutils ships `chown` but no `sh`, so invoke the
# binaries directly rather than through a shell.
#
# CRITICAL: exclude the per-agent `agents/` subtree. Its contents
# (each agent's `claude/` OAuth creds, `state/`, `harness/`,
# `config/`) are owned by the per-agent / manager users, and each
# container's `hive-agent-user-migrate` activation script chowns
# them back to that user on boot. Blanket-chowning them to hive-core
# makes every agent's `~/.claude` unreadable — logging them all out
# with no way to log back in. So chown everything *except* agents/,
# plus the `agents/` dir node itself (not its contents) so c0re can
# still create new per-agent subdirs.
ExecStartPre = [
# Install the safe.directory gitconfig at $HOME/.gitconfig
# (HOME = /var/lib/hyperhive) so c0re's `git fetch`/`rev-parse`
# against the agent-owned proposed repos pass the ownership guard.
# Placed before the chown below so it's chowned to hive-core too.
"+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hyperhive/.gitconfig"
"+-${pkgs.findutils}/bin/find /var/lib/hyperhive -mindepth 1 -maxdepth 1 -not -name agents -exec ${pkgs.coreutils}/bin/chown -R hive-core:hive-core {} +"
"+-${pkgs.coreutils}/bin/chown hive-core:hive-core /var/lib/hyperhive/agents"
];
Restart = "on-failure";
RestartSec = 2;
User = "hive-core";
Group = "hive-core";
SupplementaryGroups = [ "systemd-journal" ];
RuntimeDirectory = "hyperhive";
RuntimeDirectoryMode = "0750";
RuntimeDirectoryPreserve = "yes";
StateDirectory = "hyperhive";
StateDirectoryMode = "0750";
};
};
# Socket unit for the hive-c0re admin socket. systemd creates and holds
# `/run/hyperhive/host.sock` before hive-c0re starts, then passes the fd
# via LISTEN_FDS (socket activation). Benefits: `hivectl` can connect
# the moment the socket unit is active — no racy retry window — and a
# hive-c0re restart never drops the socket inode, so queued commands
# drain cleanly.
#
# `hive-c0re serve` reads LISTEN_FDS via the `listenfd` crate and
# accepts the fd in preference to its own `bind()` path. When invoked
# directly (dev, CI, without the socket unit) LISTEN_FDS is absent and
# the traditional bind path runs unchanged — no regression.
systemd.sockets.hive-c0re = {
description = "hive-c0re admin socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
# Must match the `--socket` arg passed to `hive-c0re serve`.
ListenStream = "/run/hyperhive/host.sock";
# 0660 root:root — `hivectl` is a host-only tool run as root.
SocketMode = "0660";
# Parent dir inherits the RuntimeDirectory mode (0750) set on the
# service unit; DirectoryMode is only consulted when the dir is
# absent at socket-unit activation.
DirectoryMode = "0750";
};
};
# Socket unit for hive-priv — the narrow root helper that executes
# privileged operations on behalf of hive-c0re. Systemd creates and
# holds `/run/hive/priv.sock` before the first connection arrives.
#
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
# connect. hive-priv (server) runs as root and validates every request
# against a strict allowlist before executing any privileged op.
systemd.sockets.hive-priv = {
description = "hive-priv privileged helper socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/hive/priv.sock";
SocketMode = "0660";
SocketGroup = "hive-core";
# Create /run/hive/ if absent; 0755 so the hive-core user can
# traverse into it to reach the socket.
DirectoryMode = "0755";
};
};
# Service unit for hive-priv. Runs as root — it genuinely needs root to
# invoke `nixos-container`, write `/etc/nixos-containers/`, write
# systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`.
# Every request is validated against a strict container-name allowlist
# inside the binary; the attack surface is narrow by design.
#
# Socket-activated: systemd starts hive-priv on the first connection
# (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv
# reads them to accept the pre-bound socket fd instead of binding its
# own.
systemd.services.hive-priv = {
description = "hive-priv privileged helper";
# No wantedBy — socket-activated exclusively. The socket unit is the
# entry point; systemd starts this service on first connect.
after = [ "hive-priv.socket" ];
requires = [ "hive-priv.socket" ];
# `nixos-container` is a perl script that shells out by bare name to
# nix / nix-env / nix-instantiate (create + update), machinectl +
# systemctl (start/stop), and find / rm / umount / chattr (destroy);
# only nsenter + su are hardcoded. Give the helper exactly those —
# not the whole system profile — on top of the systemd/coreutils/
# findutils already in the default unit PATH. Without `nixos-container`
# on PATH every container op fails ENOENT, which `build_all` silently
# swallows into an empty list ("no managed containers").
path = [
pkgs.nixos-container
pkgs.nix # nix, nix-env, nix-instantiate — create + update
pkgs.util-linux # umount (nsenter is hardcoded in the script)
pkgs.e2fsprogs # chattr
pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume
];
environment = {
# `nixos-container update/create` runs `nix`, which writes its
# fetcher/eval cache under $HOME/.cache. With ProtectHome and no
# explicit HOME this lands on the unwritable /var/empty and Lix
# errors out. Point HOME at the StateDirectory below (persistent,
# so the cache survives across rebuilds).
HOME = "/var/lib/hive-priv";
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-priv";
Type = "simple";
User = "root";
PrivateTmp = true;
ProtectHome = true;
# Harden the file system view: strict makes the entire hierarchy
# read-only by default; ReadWritePaths carves out exactly the
# paths hive-priv must write to at runtime.
#
# Why each entry is needed:
# /etc/nixos-containers — writes <container>.conf (bind mounts,
# network isolation, nspawn flags)
# /run/hive-agent — chown/chmod per-agent socket directories
# /run/systemd — container@ unit drop-ins (resource limits)
# + machinectl / systemd-machined state
# /run/lock — `nixos-container` opens a lock file at
# /run/lock/nixos-container to serialise
# create/destroy. Under ProtectSystem=strict
# /run is read-only, so without this the very
# first `nixos-container create` (ruth, on a
# fresh host) dies with "Read-only file
# system" before any container exists.
# /var/lib/nixos-containers — container rootfs written by nixos-container
# /var/lib/hyperhive — agent state files written by WriteAgentForgeToken
# / WriteAgentMatrixToken (tokens under agents/<n>/state/)
# /nix — nix store + profile updates during
# container create/update
ProtectSystem = "strict";
ReadWritePaths = [
"/etc/nixos-containers"
"/run/hive-agent"
"/run/systemd"
"/run/lock"
"/var/lib/nixos-containers"
"/var/lib/hyperhive"
"/nix"
];
# Writable HOME for nix's caches (see environment.HOME above).
StateDirectory = "hive-priv";
# With ProtectSystem=strict the root filesystem is read-only inside
# hive-priv. When `nixos-container create/update` invokes nix, nix
# creates a temporary result symlink in its working directory. Without
# an explicit WorkingDirectory the cwd is / (inherited from systemd),
# which is read-only under strict, causing:
# error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system
# Point the working directory at the writable StateDirectory so nix
# drops its temp symlink there instead.
WorkingDirectory = "/var/lib/hive-priv";
# nix (run here as root for `nixos-container update --flake
# /var/lib/hyperhive/meta#<agent>`) fetches the hive-core-owned
# meta/applied repos; libgit2 refuses them without safe.directory.
# See safeDirGitconfig above.
ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig";
};
};
};
}