From e7689a68048ffa61f59f6f5f4f773ca9c0e67012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Mon, 13 Jul 2026 21:37:47 +0200 Subject: [PATCH] refactor: split hive-c0re module, aggregate host stack in nix/modules --- flake.nix | 61 +- nix/docs/default.nix | 17 +- nix/modules/default.nix | 25 + .../{hive-c0re.nix => hive-c0re/default.nix} | 567 ++---------------- nix/modules/hyperhive.nix | 109 ++++ nix/modules/otel.nix | 117 ++++ nix/modules/swarm.nix | 246 ++++++++ 7 files changed, 596 insertions(+), 546 deletions(-) create mode 100644 nix/modules/default.nix rename nix/modules/{hive-c0re.nix => hive-c0re/default.nix} (64%) create mode 100644 nix/modules/hyperhive.nix create mode 100644 nix/modules/otel.nix create mode 100644 nix/modules/swarm.nix diff --git a/flake.nix b/flake.nix index 95cd1f0d..3ed23ea6 100644 --- a/flake.nix +++ b/flake.nix @@ -105,40 +105,41 @@ nixosModules = { agent-base = ./nix/templates/agent-base.nix; ruth = ./nix/templates/manager.nix; - # The hive-c0re module wants `pkgs.hyperhive` for its default - # `services.hyperhive.c0re.package`. To avoid making operators apply an - # overlay (which would also pollute their host pkgs with our - # build), we thread the package straight from this flake's - # `packages..default` via a `hyperhivePackage` argument. - hive-c0re = import ./nix/modules/hive-c0re.nix { - hyperhivePackage = system: self.packages.${system}.default; - hyperhiveFrontend = system: self.packages.${system}.frontend; - hyperhiveAssets = system: self.packages.${system}.assets; - hyperhiveXdgIcons = system: self.packages.${system}.xdg-icons; - hyperhiveFlake = "${sources.hyperhiveFlakeSource}"; - # Narrow docs/ source, threaded as its own meta-flake input so - # doc edits don't re-hash the whole flake source. - hyperhiveDocs = "${sources.hyperhiveDocsSource}"; - # Per-container toplevels — wired into `system.extraDependencies` - # when `services.hyperhive.c0re.preBuildAgentTemplates` is on so the - # host system closure pre-fetches the heavy build inputs. - # Defined only for x86_64-linux because nixosConfigurations are - # hardcoded to that system; the option's default keeps the - # extra deps gated so aarch64 hosts don't accidentally pull - # them in via cross-build. - agentBaseToplevel = self.packages.x86_64-linux.agent-base-toplevel; - managerToplevel = self.packages.x86_64-linux.ruth-toplevel; - }; - hive-ci = ./nix/modules/hive-ci.nix; - hive-forge = ./nix/modules/hive-forge.nix; - # Convenience alias: one import covers the full hyperhive host - # stack (hive-c0re + hive-forge, since hive-c0re already pulls - # in hive-forge). Intended usage: + # The full host stack (nix/modules/default.nix aggregator) plus + # the package/source wiring from this flake. The wiring is a + # plain config module setting the `services.hyperhive.c0re.*` + # package options via `lib.mkDefault` — no overlay involved, and + # an operator override still wins. Intended usage: # # imports = [ hyperhive.nixosModules.default ]; # services.hyperhive.enable = true; # - default = self.nixosModules.hive-c0re; + default = + { lib, pkgs, ... }: + { + imports = [ ./nix/modules ]; + services.hyperhive.c0re = { + package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + frontend = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.frontend; + assets = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.assets; + xdgIcons = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.xdg-icons; + hyperhiveFlake = lib.mkDefault "${sources.hyperhiveFlakeSource}"; + # Narrow docs/ source, threaded as its own meta-flake input + # so doc edits don't re-hash the whole flake source. + hyperhiveDocs = lib.mkDefault "${sources.hyperhiveDocsSource}"; + # Per-container toplevels — wired into + # `system.extraDependencies` when + # `services.hyperhive.c0re.preBuildAgentTemplates` is on so + # the host system closure pre-fetches the heavy build + # inputs. x86_64-linux only (nixosConfigurations are + # hardcoded to that system); the gate keeps aarch64 hosts + # from pulling them in via cross-build. + agentBaseToplevel = lib.mkDefault self.packages.x86_64-linux.agent-base-toplevel; + managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel; + }; + }; + hive-ci = ./nix/modules/hive-ci.nix; + hive-forge = ./nix/modules/hive-forge.nix; }; nixosConfigurations = diff --git a/nix/docs/default.nix b/nix/docs/default.nix index fdcf36ea..dd003274 100644 --- a/nix/docs/default.nix +++ b/nix/docs/default.nix @@ -37,21 +37,14 @@ let # Stub host system: every hyperhive subsystem `mkForce false` so # heavy build inputs (matrix container, forge, etc.) stay out of # the eval — only option *declarations* matter for the doc walk. - # Import hive-c0re.nix from the content-addressed nixSrc with stub - # package args so the eval doesn't depend on self's Rust builds. + # Import the host-module aggregator from the content-addressed + # nixSrc; the package options (`services.hyperhive.c0re.package` + # etc.) carry no in-module defaults, but with hyperhive disabled + # nothing reads them, so no stubs are needed. hostEval = nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ - (import "${nixSrc}/modules/hive-c0re.nix" { - hyperhivePackage = _system: pkgs.emptyFile; - hyperhiveFrontend = _system: pkgs.emptyFile; - hyperhiveAssets = _system: pkgs.emptyDirectory; - hyperhiveFlake = ""; - hyperhiveDocs = ""; - hyperhiveXdgIcons = _system: pkgs.emptyFile; - agentBaseToplevel = pkgs.emptyFile; - managerToplevel = pkgs.emptyFile; - }) + "${nixSrc}/modules" ( { lib, ... }: { diff --git a/nix/modules/default.nix b/nix/modules/default.nix new file mode 100644 index 00000000..940c300d --- /dev/null +++ b/nix/modules/default.nix @@ -0,0 +1,25 @@ +# The full hyperhive host stack, pulled together in one place — this +# is what the flake exports as `nixosModules.default` (wrapped with +# the package/source wiring; see flake.nix). One import covers +# everything; `services.hyperhive.enable = true` turns the stack on. +# +# 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 +# + `internal/*` repos, so there's no enable toggle; it deploys with +# hyperhive itself. hive-matrix is opt-in (off by default). All +# subsystems rely on `services.hyperhive.domain`, which is required +# (asserted in hive-network.nix) whenever hyperhive is enabled. +{ + imports = [ + ./hyperhive.nix + ./hive-c0re + ./hive-ci.nix + ./hive-forge.nix + ./hive-gateway + ./hive-matrix.nix + ./hive-network.nix + ./hive-tls.nix + ./otel.nix + ./swarm.nix + ]; +} diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re/default.nix similarity index 64% rename from nix/modules/hive-c0re.nix rename to nix/modules/hive-c0re/default.nix index 3ad677f2..99088d89 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re/default.nix @@ -1,13 +1,11 @@ -{ - hyperhivePackage, - hyperhiveFrontend, - hyperhiveAssets, - hyperhiveFlake, - hyperhiveDocs, - hyperhiveXdgIcons, - agentBaseToplevel, - managerToplevel, -}: +# The hive-c0re coordinator daemon (unprivileged `hive-core` user) and +# its narrow root helper hive-priv, both socket-activated. Options +# under `services.hyperhive.c0re.*`. The package/source options +# (`package`, `frontend`, `assets`, `xdgIcons`, `hyperhiveFlake`, +# `hyperhiveDocs`, `agentBaseToplevel`, `managerToplevel`) have no +# in-module defaults — the flake's `nixosModules.default` wires them +# to this flake's own package outputs via `lib.mkDefault`, so +# operator overrides still win and no overlay is involved. { pkgs, lib, @@ -114,404 +112,6 @@ let if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend; in { - # 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 - # + `internal/*` repos, so there's no enable toggle; it deploys with - # hyperhive itself. hive-matrix is opt-in (off by default). All - # subsystems rely on `services.hyperhive.domain`, which is required - # (asserted in hive-network.nix) whenever hyperhive is enabled. - imports = [ - ./hive-ci.nix - ./hive-forge.nix - ./hive-gateway - ./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. Typed nullOr (default null) so the option always - # exists, but it's REQUIRED whenever hyperhive is enabled — an - # 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 - # 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). **Required** when `services.hyperhive.enable` — eval fails - with a helpful message if it's unset (it's baked into matrix on - 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 - `hive-ag3nt::identity::hive_domain()` for `@` - 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. - ''; - }; - - # Whether this hive runs "ruthless" — with no root/manager agent at all. - # When true, hive-c0re skips the root-agent auto-management sweep (create - # if missing, restart if present-but-stopped). Some hives don't want a - # root agent at all — see issue tracker "scope concept: special agents". - options.services.hyperhive.ruthless = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Run this hive "ruthless" — with no root (manager) agent at all (no - ruth). When `true`, hive-c0re skips the root-agent auto-management - sweep entirely (it otherwise creates the root agent's container when - missing and restarts it when present but stopped). Defaults to - `false` (the historical behaviour — the root agent is auto-managed - as required infrastructure). Exposed to hive-c0re as - `HYPERHIVE_RUTHLESS`. - ''; - }; - - options.services.hyperhive.github.enable = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - Hive-wide switch for the per-agent GitHub integration (the `gh` CLI - wrapper + git credential helper, per `hyperhive.github.enable`). On by - default: every agent gets the integration, inert until a PAT is - provisioned via the dashboard credentials tab or `hivectl github - set-token`. Set `false` to turn it off for the whole hive --- the - meta-flake renderer (`hive-c0re/src/meta.rs`) then injects - `hyperhive.github.enable = false` into every agent. Exposed to hive-c0re - as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off). - ''; - }; - - # Hive-wide OTEL stats export. Set ONCE here at host level; the - # meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the - # HYPERHIVE_OTEL_* env exported below off hive-c0re's unit and injects - # the matching `hyperhive.otel.*` build-time config into EVERY agent - # (mirroring the CA-cert injection), so each agent's harness exports - # its own Claude Code stats directly to the collector. There is no - # per-agent opt-in — this is the single switch for the whole hive. - options.services.hyperhive.otel = { - enable = lib.mkEnableOption '' - hive-wide export of every agent's Claude Code stats (token usage, - cost, tool calls) to an OTLP endpoint via Claude Code's built-in - OpenTelemetry. One switch for all agents; each harness exports - directly to the collector, so it keeps working even when hive-c0re - is down - ''; - - endpoint = lib.mkOption { - type = lib.types.str; - default = ""; - example = "https://collector.example.com/otel"; - description = '' - OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT` - for every agent. Required when `enable` is true. - ''; - }; - - protocol = lib.mkOption { - type = lib.types.enum [ - "http/protobuf" - "http/json" - "grpc" - ]; - default = "http/protobuf"; - description = '' - OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. - ''; - }; - - headersCredential = lib.mkOption { - # `str`, not `path`: a `path`-typed relative literal is hash-copied - # into the world-readable nix store at eval time, defeating the - # point. Keep it a string + require an absolute runtime path so the - # secret is only ever read from disk by systemd at start. - type = lib.types.nullOr lib.types.str; - default = null; - example = "/run/secrets/otel-headers"; - description = '' - Absolute path to an operator-provided secret file whose contents - become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. - `Authorization=Bearer `). hive-c0re forwards this host - file into each agent container's credential store via - systemd-nspawn `--load-credential=otel-headers:`; the inner - harness unit inherits it by name (`LoadCredential`), so the token - is never copied into the nix store, the generated config, a bind - mount, or argv. Must be absolute. Leave null if the endpoint - needs no auth header. A configured-but-missing file is skipped - with a log warning (OTEL still exports, without the auth header). - ''; - }; - - extraResourceAttributes = lib.mkOption { - type = lib.types.str; - default = ""; - example = "deployment.environment=prod"; - description = '' - Extra comma-separated entries appended to - `OTEL_RESOURCE_ATTRIBUTES` after the built-in - `service.name` / `agent` / `hive` / `swarm` labels. - ''; - }; - - debug = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Emit OTEL SDK diagnostic messages to every agent's stderr by - setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when - troubleshooting collector connectivity or endpoint config; - leave off in normal operation to avoid noise in agent logs. - Only meaningful when `enable` is true. - ''; - }; - - metricIntervalMs = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; - default = null; - example = 10000; - description = '' - Metric export interval in milliseconds, set as - `OTEL_METRIC_EXPORT_INTERVAL` for every agent. Claude Code's - default is 60000 (60s). Leave `null` to use that default. - - Each agent runs claude as a short-lived per-turn process; claude - force-flushes metrics on shutdown, so this is not required for - metrics to be exported, but a lower value gives more frequent - intermediate flushes within long turns. Cosmetic, not a - correctness knob. - ''; - }; - }; - - # 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; @@ -521,21 +121,22 @@ in }; 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). + (operator-facing host CLI for ad-hoc administration). Wired to + this flake's `packages..default` by + `nixosModules.default` (via `lib.mkDefault`, so setting it here + wins). ''; }; 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 + Bundled frontend dist (see `nix/packages/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 @@ -551,16 +152,15 @@ in 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 + and `agent/`. Exposed so the hive-gateway module 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 + Bundled static runtime assets (see `nix/packages/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` @@ -569,21 +169,30 @@ in rust derivation. ''; }; + xdgIcons = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons"; + description = '' + XDG icon set + .desktop entries for hyperhive processes (see + `nix/packages/hive-xdg-icons.nix`), installed into the host + system packages so desktop environments can match hyperhive + processes to their icon. + ''; + }; hyperhiveFlake = lib.mkOption { type = lib.types.str; - default = hyperhiveFlake; - defaultText = lib.literalMD "the flake's own store path"; + defaultText = lib.literalMD "the hyperhive flake's own filtered source 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. + build the container. Wired by `nixosModules.default` to this + flake's own filtered source — only override if you want agents + tracking a different ref. ''; }; hyperhiveDocs = lib.mkOption { type = lib.types.str; - default = hyperhiveDocs; defaultText = lib.literalMD "the docs/ tree's own store path"; description = '' URL of the narrow `docs/` source (no fragment). Inlined into the @@ -594,6 +203,24 @@ in instead of rebuilding every agent container. ''; }; + agentBaseToplevel = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel"; + description = '' + Pre-built agent-base container system closure, pulled into the + host system closure when `preBuildAgentTemplates` is on. Wired + by `nixosModules.default`; only evaluated when that option is + enabled. + ''; + }; + managerToplevel = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel"; + description = '' + Pre-built manager (ruth) container system closure — see + `agentBaseToplevel`. + ''; + }; nixpkgsFlake = lib.mkOption { type = lib.types.str; default = "path:${pkgs.path}"; @@ -657,8 +284,8 @@ in 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. + `nix build .#agent-base-toplevel` once manually to warm the + store. ''; }; contextWindowTokens = lib.mkOption { @@ -769,9 +396,8 @@ in systemd `CPUQuota=` applied to every agent container via a `container@h-.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. + `"200%"` allows each agent to use up to 2 cores. 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 @@ -785,8 +411,7 @@ in example = "8G"; description = '' systemd `MemoryMax=` applied to every agent container via the - same drop-in as `agentCpuQuota`. The old hard-coded value was - `"2G"`. + same drop-in as `agentCpuQuota`. ''; }; @@ -798,11 +423,10 @@ in Number of nix-heavy job-queue nodes (container prebuilds, profile swaps, first-spawn creates, meta lock bumps) hive-c0re runs concurrently. The default of 1 serializes all heavy nix - work like the pre-DAG rebuild queue did; raise it on hosts with - the cores/RAM to build several agent toplevels at once. - Per-agent correctness is independent of this count — each - agent's container-affecting operations are serialized by its - lifecycle lease regardless. + work; raise it on hosts with the cores/RAM to build several + agent toplevels at once. Per-agent correctness is independent + of this count — each agent's container-affecting operations are + serialized by its lifecycle lease regardless. ''; }; }; @@ -813,7 +437,7 @@ in pkgs.git # XDG icons + .desktop entries so desktop environments can match # hyperhive processes to their icon (task managers, CPU monitors, etc.). - (hyperhiveXdgIcons pkgs.stdenv.hostPlatform.system) + cfg.xdgIcons ]; # Serve config at a stable /etc path so hive-c0re's ExecStart @@ -828,8 +452,8 @@ in # 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 + cfg.agentBaseToplevel + cfg.managerToplevel ]; # Unprivileged coordinator user. hive-c0re runs as this user; @@ -846,76 +470,11 @@ in # 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 - ]; - # NB: `services.hyperhive.domain` is required when hyperhive is # enabled — the canonical assertion lives in `hive-network.nix` (the # hive resolver is authoritative for `` and agents reach the # forge/matrix through the gateway by it). So everything below can # treat `config.services.hyperhive.domain` as non-null. - assertions = - lib.optionals 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"). - ''; - } - ] - ++ 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 = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; @@ -941,7 +500,7 @@ in 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. + # (see the hive-gateway module); 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 @@ -955,8 +514,8 @@ in # `forge.rs` reads the avatar PNGs from here on startup. HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; # Whether this hive runs ruthless — no root/manager agent at all - # (`auto_update::ensure_root_agent`). Default false = historical - # behaviour (root auto-managed); true makes the sweep a no-op. + # (`auto_update::ensure_root_agent`). Default false = root + # auto-managed; true makes the sweep a no-op. HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless; } // { diff --git a/nix/modules/hyperhive.nix b/nix/modules/hyperhive.nix new file mode 100644 index 00000000..8b71b523 --- /dev/null +++ b/nix/modules/hyperhive.nix @@ -0,0 +1,109 @@ +# Top-level, cross-cutting hyperhive options: the master enable +# switch, the hive's identity (domain + display names), and hive-wide +# feature toggles read by several subsystem modules. Imported by the +# ./default.nix aggregator. +{ + lib, + ... +}: +{ + # 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. Typed nullOr (default null) so the option always + # exists, but it's REQUIRED whenever hyperhive is enabled — an + # 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 + # 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). **Required** when `services.hyperhive.enable` — eval fails + with a helpful message if it's unset (it's baked into matrix on + 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 + `hive-ag3nt::identity::hive_domain()` for `@` + 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. + ''; + }; + + # Whether this hive runs "ruthless" — with no root/manager agent at + # all. Some hives don't want a root agent — see issue tracker + # "scope concept: special agents". + options.services.hyperhive.ruthless = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Run this hive "ruthless" — with no root (manager) agent at all (no + ruth). When `true`, hive-c0re skips the root-agent auto-management + sweep entirely (it otherwise creates the root agent's container when + missing and restarts it when present but stopped). Defaults to + `false` (the root agent is auto-managed as required + infrastructure). Exposed to hive-c0re as `HYPERHIVE_RUTHLESS`. + ''; + }; + + options.services.hyperhive.github.enable = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Hive-wide switch for the per-agent GitHub integration (the `gh` CLI + wrapper + git credential helper, per `hyperhive.github.enable`). On by + default: every agent gets the integration, inert until a PAT is + provisioned via the dashboard credentials tab or `hivectl github + set-token`. Set `false` to turn it off for the whole hive --- the + meta-flake renderer (`hive-c0re/src/meta.rs`) then injects + `hyperhive.github.enable = false` into every agent. Exposed to hive-c0re + as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off). + ''; + }; +} diff --git a/nix/modules/otel.nix b/nix/modules/otel.nix new file mode 100644 index 00000000..d12da00b --- /dev/null +++ b/nix/modules/otel.nix @@ -0,0 +1,117 @@ +# Hive-wide OTEL stats export. Set ONCE here at host level; the +# meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the +# HYPERHIVE_OTEL_* env exported off hive-c0re's unit (see +# ./hive-c0re) and injects the matching `hyperhive.otel.*` build-time +# config into EVERY agent (mirroring the CA-cert injection), so each +# agent's harness exports its own Claude Code stats directly to the +# collector. There is no per-agent opt-in — this is the single switch +# for the whole hive. +{ + lib, + config, + ... +}: +{ + options.services.hyperhive.otel = { + enable = lib.mkEnableOption '' + hive-wide export of every agent's Claude Code stats (token usage, + cost, tool calls) to an OTLP endpoint via Claude Code's built-in + OpenTelemetry. One switch for all agents; each harness exports + directly to the collector, so it keeps working even when hive-c0re + is down + ''; + + endpoint = lib.mkOption { + type = lib.types.str; + default = ""; + example = "https://collector.example.com/otel"; + description = '' + OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT` + for every agent. Required when `enable` is true. + ''; + }; + + protocol = lib.mkOption { + type = lib.types.enum [ + "http/protobuf" + "http/json" + "grpc" + ]; + default = "http/protobuf"; + description = '' + OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. + ''; + }; + + headersCredential = lib.mkOption { + # `str`, not `path`: a `path`-typed relative literal is hash-copied + # into the world-readable nix store at eval time, defeating the + # point. Keep it a string + require an absolute runtime path so the + # secret is only ever read from disk by systemd at start. + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/otel-headers"; + description = '' + Absolute path to an operator-provided secret file whose contents + become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. + `Authorization=Bearer `). hive-c0re forwards this host + file into each agent container's credential store via + systemd-nspawn `--load-credential=otel-headers:`; the inner + harness unit inherits it by name (`LoadCredential`), so the token + is never copied into the nix store, the generated config, a bind + mount, or argv. Must be absolute. Leave null if the endpoint + needs no auth header. A configured-but-missing file is skipped + with a log warning (OTEL still exports, without the auth header). + ''; + }; + + extraResourceAttributes = lib.mkOption { + type = lib.types.str; + default = ""; + example = "deployment.environment=prod"; + description = '' + Extra comma-separated entries appended to + `OTEL_RESOURCE_ATTRIBUTES` after the built-in + `service.name` / `agent` / `hive` / `swarm` labels. + ''; + }; + + debug = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Emit OTEL SDK diagnostic messages to every agent's stderr by + setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when + troubleshooting collector connectivity or endpoint config; + leave off in normal operation to avoid noise in agent logs. + Only meaningful when `enable` is true. + ''; + }; + + metricIntervalMs = lib.mkOption { + type = lib.types.nullOr lib.types.ints.positive; + default = null; + example = 10000; + description = '' + Metric export interval in milliseconds, set as + `OTEL_METRIC_EXPORT_INTERVAL` for every agent. Claude Code's + default is 60000 (60s). Leave `null` to use that default. + + Each agent runs claude as a short-lived per-turn process; claude + force-flushes metrics on shutdown, so this is not required for + metrics to be exported, but a lower value gives more frequent + intermediate flushes within long turns. Cosmetic, not a + correctness knob. + ''; + }; + }; + + config = lib.mkIf config.services.hyperhive.c0re.enable { + assertions = 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."; + } + ]; + }; +} diff --git a/nix/modules/swarm.nix b/nix/modules/swarm.nix new file mode 100644 index 00000000..a97c1ead --- /dev/null +++ b/nix/modules/swarm.nix @@ -0,0 +1,246 @@ +# Swarm peering: the peer-hive declarations and the optional +# WireGuard inter-hive mesh. The peers are serialised into hive-c0re's +# environment (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS — see ./hive-c0re) +# and consumed by identity.rs + the dashboard's P33RS tab; the mesh +# config below is host-level networking. +{ + lib, + config, + ... +}: +{ + # Peer hives in the same swarm. Each entry declares a remote hive + # reachable from this host. + 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, a `wg-hive` interface connects 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. + ''; + }; + }; + + # Gated on the c0re daemon being enabled (the historical shape — the + # mesh is part of the coordinator host's networking). + config = lib.mkIf config.services.hyperhive.c0re.enable { + assertions = lib.optionals 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"). + ''; + } + ]; + + # WireGuard inter-hive mesh. Brings up a `wg-hive` interface and + # connects to each peer that has `wireguardPublicKey` set. + 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 (host-level networking — not inside containers). + networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ + config.services.hyperhive.swarm.wireguard.listenPort + ]; + }; +}