From bdf8fdabd756bc55130ec8954b51bb98b26152e5 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:27:20 +0200 Subject: [PATCH 1/6] feat(#2862): swarm snapshot store, the btrfs receive endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 of the storage backend: hives push agent snapshots over the WireGuard mesh that swarm.nix already brings up. No controller dependency — a btrfs subvolume tree, a socket-activated receiver, and the existing mesh. The mesh is the authentication. Cryptokey routing already binds a peer's source address to its public key (allowedIPs = [ peer.wireguardAddress ]), so the store adds no key material and no certs; anything else would authenticate the same fact twice. Destination is keyed per AGENT, not per hive: after a migration the same agent's next incremental send arrives from a different hive, and a per-hive prefix would split its snapshot chain and break the incremental parent lookup — the exact case this store exists to serve. The sender unavoidably contributes the agent name (a btrfs stream carries no such notion, and the subvolume name inside it is the sender's). So the receiver owns the destination root and VALIDATES the sender-supplied leaf against a whitelist charset — no slash, no dot, so neither traversal nor an absolute path can survive it. ListenStream binds this host's mesh address, never a wildcard, and that is asserted rather than commented: bound to 0.0.0.0 the socket would be an unauthenticated remote write into agent state. swarm.nix: the mesh config moves off the c0re.enable gate onto swarm.wireguard.enable. The mesh is host networking, not a c0re feature — a swarm host that runs no hive (this store) previously got no wg-hive interface at all. Nothing in that block was c0re-specific; the peer data c0re consumes is rendered in hive-c0re and stays gated there. Confinement is deliberately not in the module: it is a property of the deployment (a dedicated VM, or a container in the all-local case). The systemd hardening is defence in depth only — btrfs receive needs CAP_SYS_ADMIN, which can mount() its way out of the namespace those directives set up. The `dedicated` option turns "this host runs nothing else" into an assertion the build checks instead of an assumption the deployer remembers. --- nix/host-modules/default.nix | 1 + nix/host-modules/hive-snapshot-store.nix | 238 +++++++++++++++++++++++ nix/host-modules/swarm.nix | 24 ++- 3 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 nix/host-modules/hive-snapshot-store.nix diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 72451000..79647d04 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -19,6 +19,7 @@ ./hive-matrix.nix ./hive-network.nix ./hive-priv.nix + ./hive-snapshot-store.nix ./hive-tls.nix ./otel.nix ./swarm.nix diff --git a/nix/host-modules/hive-snapshot-store.nix b/nix/host-modules/hive-snapshot-store.nix new file mode 100644 index 00000000..fa149656 --- /dev/null +++ b/nix/host-modules/hive-snapshot-store.nix @@ -0,0 +1,238 @@ +# hive-snapshot-store — the swarm's `btrfs receive` endpoint. Hives push +# agent snapshots here over the existing WireGuard mesh; a destination +# hive later pulls one back to complete a migration. Only the receive +# half exists today — the pull side needs an authorisation model for +# "which hive may fetch which agent's state", which lands with the +# swarm controller. +# +# This is NOT the swarm controller and does not depend on it: a btrfs +# subvolume tree, a socket-activated receiver, and the `wg-hive` +# interface `swarm.nix` already brings up. Deliberately no WireGuard +# config of its own --- the mesh's cryptokey routing +# (`allowedIPs = [ peer.wireguardAddress ]`) already binds a peer's +# source address to its public key, so the mesh IS the authentication +# and adding certs here would authenticate the same fact twice. +# +# Confinement is a property of the DEPLOYMENT, not of this unit: in a +# real swarm the store is its own small VM (the machine is the +# boundary); in the all-local case it's a container on the c0re host. +# The module therefore hardcodes neither --- see `dedicated` below. +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.snapshotStore; + wgCfg = config.services.hyperhive.swarm.wireguard; + + # `swarm.wireguard.address` carries a prefix ("10.100.0.1/24") because + # it feeds `networking.wireguard.interfaces.wg-hive.ips`. A listen + # address must be the bare IP, so strip it. + meshAddress = lib.head (lib.splitString "/" wgCfg.address); + + # The receiver. Socket-activated with Accept=yes, so stdin IS the + # accepted connection and systemd hands us the peer address in + # $REMOTE_ADDR --- which, on this interface, is a cryptographically + # authenticated statement about which hive is talking (see the + # cryptokey-routing note above). + # + # PROTOCOL: one `agent \n` header line, then the raw `btrfs + # send` stream. The header exists because a btrfs stream does not + # carry the sending hive's notion of *which agent* it is --- the + # subvolume name inside the stream is chosen by the sender. + # + # ⚠️ The security rule, stated precisely, because the absolute form + # ("the sender never names its destination") is not achievable with + # btrfs send/receive: the RECEIVER owns the destination ROOT, and any + # sender-supplied component is VALIDATED, never used as a path. The + # name must match [A-Za-z0-9_-]+ exactly --- no slash, no dot, so no + # traversal and no absolute path can survive it. The root is ours; + # the leaf is checked against a whitelist charset before it is joined. + receiveScript = pkgs.writeShellScript "hive-snapshot-receive" '' + set -euo pipefail + + # Read exactly the header line, leaving the byte stream untouched + # for btrfs receive. `read` stops at the newline and does not + # buffer ahead, which is why the header is a line and not a + # fixed-width record. + if ! read -r keyword agent; then + echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} closed before sending a header" >&2 + exit 1 + fi + + if [ "$keyword" != "agent" ]; then + echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} sent a bad header keyword" >&2 + exit 1 + fi + + # Validate rather than trust. Anything outside this charset is + # rejected outright --- this is the check that makes the joined + # path below safe, so it must stay a whitelist, never a blocklist + # of bad characters. + case "$agent" in + "" | *[!A-Za-z0-9_-]*) + echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} sent an invalid agent name" >&2 + exit 1 + ;; + esac + + dest="${cfg.path}/$agent" + + # One subvolume tree per AGENT, not per hive: after a migration the + # same agent's next incremental send arrives from a DIFFERENT hive, + # and a per-hive prefix would split its snapshot chain in two and + # break the incremental parent lookup --- exactly the case this + # store exists to serve. + mkdir -p "$dest" + + echo "hive-snapshot-store: receiving agent=$agent from ''${REMOTE_ADDR:-?}" >&2 + exec ${pkgs.btrfs-progs}/bin/btrfs receive "$dest" + ''; +in +{ + options.services.hyperhive.snapshotStore = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm snapshot store on this host: a `btrfs receive` + endpoint that hives push agent snapshots to over the WireGuard + mesh. Off by default --- it is a distinct deployment role, not + part of a hive. + + Requires `services.hyperhive.swarm.wireguard.enable`: the mesh + is both the transport and the authentication, so there is no + meaningful configuration without it. + ''; + }; + + path = lib.mkOption { + type = lib.types.path; + default = "/var/lib/hyperhive-snapshots"; + description = '' + Root of the snapshot tree. Must be on a btrfs filesystem --- + `btrfs receive` fails otherwise. One subvolume directory per + agent is created beneath it, so an agent's incremental chain + stays contiguous across a migration between hives. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 51821; + description = '' + TCP port the receiver listens on. Bound to this host's + WireGuard mesh address only --- never a wildcard --- so it is + reachable exactly by mesh peers and by nothing else. + ''; + }; + + dedicated = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Assert that this host runs no other hyperhive role. The store + aggregates every agent's state from every hive in the swarm, so + the intended deployment is a dedicated machine (or a container + in the all-local case) where the machine itself is the security + boundary. + + That assumption is true on day one and silently false the day + someone notices the box has spare disk. This option makes it a + thing the build checks rather than a thing the deployer + remembers. Set to `false` to co-locate deliberately --- the + point is that it becomes a decision, not an accident. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = wgCfg.enable; + message = '' + services.hyperhive.snapshotStore.enable requires + services.hyperhive.swarm.wireguard.enable --- the mesh is the + store's transport AND its authentication (cryptokey routing + binds a peer's source address to its public key). Without it + there is nothing to bind the listener to and no way to tell + which hive is pushing. + ''; + } + { + assertion = wgCfg.address != ""; + message = '' + services.hyperhive.snapshotStore.enable requires + services.hyperhive.swarm.wireguard.address to be set --- the + receiver binds to this host's mesh address, and refuses to + fall back to a wildcard. + ''; + } + { + assertion = !cfg.dedicated || !config.services.hyperhive.c0re.enable; + message = '' + services.hyperhive.snapshotStore is enabled alongside + services.hyperhive.c0re on the same host. The store holds + every agent's state from every hive, so it is meant to run on + a machine of its own where the machine is the boundary. + + If the co-location is deliberate (the all-local single-host + deployment, where the store runs as a container), set + services.hyperhive.snapshotStore.dedicated = false to record + that decision explicitly. + ''; + } + ]; + + # The store root must exist before the first connection arrives --- + # the receiver runs on demand and should not be the thing that + # creates its own tree lazily. + systemd.tmpfiles.rules = [ "d ${cfg.path} 0700 root root -" ]; + + # Socket-activated on purpose: no long-running root daemon, and the + # unit exists only while a transfer does. + # + # ⚠️ ListenStream is the mesh address, never 0.0.0.0. Bound to a + # wildcard this socket would be an unauthenticated remote write + # into agent state, so the binding IS the access control and is + # asserted above rather than left to a comment. + # + # Accept=yes gives one service instance per connection and sets + # $REMOTE_ADDR for the handler --- which is how the receiver knows + # which peer it is talking to. + systemd.sockets.hive-snapshot-store = { + description = "hyperhive swarm snapshot store receiver socket"; + wantedBy = [ "sockets.target" ]; + socketConfig = { + ListenStream = "${meshAddress}:${toString cfg.port}"; + Accept = "yes"; + }; + }; + + # `btrfs receive` needs CAP_SYS_ADMIN, so this runs as root by + # nature. The hardening below is defence in depth and NOT a + # boundary: a process holding CAP_SYS_ADMIN can call mount(2) and + # undo the namespace these directives set up. The real boundary is + # the deployment (dedicated host / container) --- see `dedicated`. + systemd.services."hive-snapshot-store@" = { + description = "hyperhive swarm snapshot store receiver"; + after = [ "hive-snapshot-store.socket" ]; + requires = [ "hive-snapshot-store.socket" ]; + serviceConfig = { + ExecStart = receiveScript; + SyslogIdentifier = "hive-snapshot-store"; + # StandardInput=socket wires the accepted connection to stdin, + # which is what the handler reads the header + stream from. + StandardInput = "socket"; + StandardError = "journal"; + User = "root"; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ cfg.path ]; + }; + }; + }; +} diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index a6750900..36152c4c 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -184,10 +184,15 @@ }; }; - # Gated on the c0re daemon being enabled — 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 [ + # Gated on the mesh itself, NOT on the c0re daemon. The mesh is host + # networking, not a c0re feature: a swarm host that runs no hive — + # the snapshot store, for one — still has to join the mesh, and under + # the old `c0re.enable` gate it silently got no `wg-hive` interface + # at all. Nothing below is c0re-specific; the peer data + # c0re consumes (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS) is rendered in + # ./hive-c0re and stays gated there. + config = lib.mkIf config.services.hyperhive.swarm.wireguard.enable { + assertions = [ { assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; message = '' @@ -208,7 +213,7 @@ # 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 ( + networking.wireguard.interfaces = let wgCfg = config.services.hyperhive.swarm.wireguard; meshPeers = lib.filterAttrs ( @@ -234,12 +239,11 @@ } ) 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 [ + # Open the WireGuard UDP port on the host firewall (host-level + # networking — not inside containers). + networking.firewall.allowedUDPPorts = [ config.services.hyperhive.swarm.wireguard.listenPort ]; }; From 4989579270c81acfa381f0d4e48ad80cf2452612 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:32:01 +0200 Subject: [PATCH 2/6] fix(#2862): open the receiver's port on the mesh interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argus caught it: binding the socket to the mesh address does not open the port. NixOS's firewall is default-deny and filters in netfilter, before a packet reaches a bound socket — the bind chooses which address accepts connections, not whether packets arrive. As shipped the receiver was unreachable. swarm.nix already shows the pattern for exactly this situation: it opens the mesh's UDP port explicitly right after bringing the interface up. Interface-scoped to wg-hive rather than host-wide, so the option's "reachable exactly by mesh peers" claim is actually true. A global allowedTCPPorts would open the port on every interface including the public NIC, leaving only the socket's bind address between the internet and a root btrfs receive. --- nix/host-modules/hive-snapshot-store.nix | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nix/host-modules/hive-snapshot-store.nix b/nix/host-modules/hive-snapshot-store.nix index fa149656..2a31c5f8 100644 --- a/nix/host-modules/hive-snapshot-store.nix +++ b/nix/host-modules/hive-snapshot-store.nix @@ -191,6 +191,22 @@ in # creates its own tree lazily. systemd.tmpfiles.rules = [ "d ${cfg.path} 0700 root root -" ]; + # Open the receiver's port, scoped to the mesh interface. + # + # ⚠️ Binding the socket to the mesh address is NOT sufficient on its + # own: NixOS's firewall is default-deny and filters in netfilter, + # before a packet ever reaches a bound socket. The bind chooses + # WHICH address accepts connections; it does not open the port. The + # mesh's own UDP port is opened the same explicit way in swarm.nix. + # + # Interface-scoped rather than host-wide so the reachability + # property stays exactly what the option docs claim --- mesh peers + # and nobody else. A global `allowedTCPPorts` would open the port on + # every interface, including whatever public NIC the box has, and + # only the socket's bind address would still be standing between + # the internet and a root `btrfs receive`. + networking.firewall.interfaces.wg-hive.allowedTCPPorts = [ cfg.port ]; + # Socket-activated on purpose: no long-running root daemon, and the # unit exists only while a transfer does. # From c051cd97174a4a9df33368871e0be6c9ea617803 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:34:34 +0200 Subject: [PATCH 3/6] docs(#2862): document the snapshot store, drop the dedicated option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara: the option was the wrong shape for the concern. "this host runs nothing else" is a deployment expectation, not something a module should assert about its own host — and asserting it made co-location look like a config toggle rather than what it is. Replaced with docs/snapshot-store.md, which the module had no docs page at all before: enabling it, why the mesh is the authentication (cryptokey routing already binds source address to pubkey, so certs would authenticate the same fact twice and add an expiry), why the destination is keyed per agent (a per-hive prefix splits an agent's chain the first time it migrates), what the sender may and may not choose, why the firewall rule is interface-scoped, what a snapshot does and does not contain, and what the pull side still needs. The dedicated-host expectation is stated there as an operational assumption with its own failure mode — true on day one, quietly false the day someone notices the box has spare disk — rather than as an assertion someone flips to false to make the build proceed. Linked from CLAUDE.md's reading paths. --- CLAUDE.md | 4 + docs/snapshot-store.md | 186 +++++++++++++++++++++++ nix/host-modules/hive-snapshot-store.nix | 36 +---- 3 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 docs/snapshot-store.md diff --git a/CLAUDE.md b/CLAUDE.md index 1c72fa66..9d811e00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,10 @@ read them à la carte. - **"How do I connect two hives into a swarm? How do I declare peer hives and configure TLS trust?"** → [`docs/swarm.md`](docs/swarm.md). +- **"Where do agent snapshots go? How does the swarm's `btrfs receive` + endpoint authenticate a pushing hive, and what does a snapshot + actually contain?"** → + [`docs/snapshot-store.md`](docs/snapshot-store.md). - **"How does the rebuild queue work? What are queue kinds and sources?"** → [`docs/coordinator.md`](docs/coordinator.md). - **"How does the CI runner work? What's the auto-registration flow?"** → diff --git a/docs/snapshot-store.md b/docs/snapshot-store.md new file mode 100644 index 00000000..8bacdd21 --- /dev/null +++ b/docs/snapshot-store.md @@ -0,0 +1,186 @@ +# Snapshot store + +The swarm's `btrfs receive` endpoint. Hives push agent snapshots to it +over the WireGuard mesh; a destination hive later pulls one back to +complete a migration. + +Two things it is not, both worth stating because both are easy to +assume: + +- **It is not the swarm controller**, and does not depend on one. It is + a NixOS host role: a btrfs subvolume tree, a socket-activated + receiver, and the `wg-hive` interface the swarm module already brings + up. That is why it can be deployed before any controller exists. +- **It is not a backup product.** It happens to hold the data a backup + would hold, and it should be operated accordingly (see + [Operating it](#operating-it)) --- but nothing in it does scheduling, + verification, or restore orchestration. + +## Enabling it + +```nix +services.hyperhive.snapshotStore = { + enable = true; + path = "/var/lib/hyperhive-snapshots"; # must be on btrfs + port = 51821; +}; + +# The mesh is a hard requirement, and is asserted: +services.hyperhive.swarm.wireguard = { + enable = true; + address = "10.100.0.9/24"; + privateKeyFile = "/etc/wireguard/hive.key"; +}; +``` + +The store host is a swarm member like any other: peers declare it, and +it declares them, through `services.hyperhive.swarm.peers`. See +[swarm.md](swarm.md) for the mesh itself. + +Note that the mesh is gated on `swarm.wireguard.enable`, **not** on +`c0re.enable` --- a store host runs no hive and would otherwise get no +`wg-hive` interface at all. + +## The mesh is the authentication + +There are no certificates here, and no key material of its own. That is +deliberate rather than an omission. + +WireGuard's cryptokey routing already binds a peer's source address to +its public key: the swarm module configures each peer with +`allowedIPs = [ peer.wireguardAddress ]`, so a packet arriving from +that address provably came from the holder of that private key. A +packet that reaches the receiver has therefore already been +authenticated by the kernel. + +Layering TLS client certs on top would authenticate *the same fact* a +second time, and add a credential with an expiry --- a migration that +fails because a renewal quietly didn't happen, discovered on the day +you need to move an agent. + +## One subvolume per agent, not per hive + +The destination is keyed by **agent**. + +This is not cosmetic. After a migration, an agent's next incremental +send arrives from a *different* hive than the previous one. Keying by +hive would split that agent's snapshot chain across two directories, +and `btrfs send -p` would fail to find its parent --- breaking exactly +the case the store exists to serve. + +## What the sender can and cannot choose + +A `btrfs send` stream carries no notion of *which agent* it belongs to, +and the subvolume name inside it is chosen by the sender. So the +protocol is one `agent ` header line, then the raw stream. + +The rule that matters: + +> **The receiver owns the destination root. The sender-supplied name is +> validated, never used as a path.** + +Validation is a whitelist --- `[A-Za-z0-9_-]+` and nothing else. No +slash and no dot means neither directory traversal nor an absolute path +can survive it. It is deliberately a whitelist and not a list of +forbidden characters: a blocklist only ever excludes the attacks +somebody already thought of. + +## Reachability + +The receiver is socket-activated, and the socket binds **this host's +mesh address**, never a wildcard. Both the mesh being enabled and the +address being set are assertions, not documentation --- bound to +`0.0.0.0` this socket is an unauthenticated remote write into agent +state. + +Binding is not sufficient on its own. NixOS's firewall is default-deny +and filters in netfilter, *before* a packet reaches a bound socket, so +the port is opened explicitly --- and scoped to the mesh interface: + +```nix +networking.firewall.interfaces.wg-hive.allowedTCPPorts = [ cfg.port ]; +``` + +A host-wide `allowedTCPPorts` would open the port on every interface +including a public NIC, leaving only the socket's bind address between +the internet and a root `btrfs receive`. + +## Operating it + +### Confinement is the deployment's job + +`btrfs receive` needs `CAP_SYS_ADMIN`, so the receiver runs as root. +The unit sets `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp` and a +narrow `ReadWritePaths` --- but those are **defence in depth, not a +boundary**: a process holding `CAP_SYS_ADMIN` can call `mount(2)` and +undo the namespace they set up. + +The boundary is the machine. The intended deployments are: + +- **a swarm**: the store is its own small VM. The machine is the + boundary, which is stronger than anything the unit could assert about + itself. +- **all-in-one / local**: the store runs as a container on the c0re + host. + +The second is worth keeping deliberately, and not only for +convenience: it means the confined path is exercised by every local +deployment. The usual failure mode for an isolated variant is that +nobody runs it day to day, so it rots and is discovered broken in +production. + +⚠️ **The assumption to keep true over time:** the store host runs +nothing else. That is true on day one and quietly false the day someone +notices the box has spare disk. Nothing in the config objects when it +stops being true. + +### It holds every agent's state from every hive + +Which makes it the highest-value target in the swarm by a wide margin, +and means it should get the treatment a backup host gets --- restricted +access, and a decision (rather than an omission) on encryption at rest. + +The trap is the label: this box holds backup-grade data while not being +called a backup, so it can end up with backup-grade *exposure* and +non-backup-grade *controls*. Nobody puts a migration staging area on +the access-review list. + +### What a snapshot contains + +The snapshot covers an agent's **state subvolume**, which is the parent +of `state/`, `claude/` and `harness/`. Consequences: + +- The Claude session (`claude/`) travels, so a restored agent keeps its + live `--continue` session rather than needing to log in again. +- `harness/` travels too, including `harness/bash-tasks/`. Task output + is part of an agent's working continuity, so this is wanted --- but it + means anything that has ever leaked into a task's captured output is + in the retained snapshots as well. + +It does **not** cover the agent's applied config (`/applied//`) or +its topology entry, both of which live outside the subvolume. A restore +therefore yields an agent's memory without its definition; closing that +gap is tracked separately. + +### Retention + +Retention lives on the *sending* side (last-N by count, swept +periodically), not here. Count rather than age is deliberate: a count +is bounded by construction, whereas an age policy silently scales disk +usage with how hot a hive runs. + +Per-agent or per-hive `btrfs qgroup` quotas are not configured yet. +Without them one runaway hive can fill the store and take out every +other hive's snapshots. + +## Not built yet + +**The pull side.** Push is safe with minimal authorisation because a +hive can only ever write to a chain it owns. Pull is the direction that +needs a policy: unrestricted, any compromised hive could read every +agent's state from every other hive. It needs a notion of which hive +currently owns which agent, and that ownership record lands with the +swarm controller work. + +With a single hive the question is trivial --- the only peer owns +everything it sends --- which is why the receive half ships first. diff --git a/nix/host-modules/hive-snapshot-store.nix b/nix/host-modules/hive-snapshot-store.nix index 2a31c5f8..5ffa3cbf 100644 --- a/nix/host-modules/hive-snapshot-store.nix +++ b/nix/host-modules/hive-snapshot-store.nix @@ -16,7 +16,8 @@ # Confinement is a property of the DEPLOYMENT, not of this unit: in a # real swarm the store is its own small VM (the machine is the # boundary); in the all-local case it's a container on the c0re host. -# The module therefore hardcodes neither --- see `dedicated` below. +# The module hardcodes neither. docs/snapshot-store.md covers what the +# deployment is expected to provide. { pkgs, lib, @@ -129,23 +130,6 @@ in ''; }; - dedicated = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Assert that this host runs no other hyperhive role. The store - aggregates every agent's state from every hive in the swarm, so - the intended deployment is a dedicated machine (or a container - in the all-local case) where the machine itself is the security - boundary. - - That assumption is true on day one and silently false the day - someone notices the box has spare disk. This option makes it a - thing the build checks rather than a thing the deployer - remembers. Set to `false` to co-locate deliberately --- the - point is that it becomes a decision, not an accident. - ''; - }; }; config = lib.mkIf cfg.enable { @@ -170,20 +154,6 @@ in fall back to a wildcard. ''; } - { - assertion = !cfg.dedicated || !config.services.hyperhive.c0re.enable; - message = '' - services.hyperhive.snapshotStore is enabled alongside - services.hyperhive.c0re on the same host. The store holds - every agent's state from every hive, so it is meant to run on - a machine of its own where the machine is the boundary. - - If the co-location is deliberate (the all-local single-host - deployment, where the store runs as a container), set - services.hyperhive.snapshotStore.dedicated = false to record - that decision explicitly. - ''; - } ]; # The store root must exist before the first connection arrives --- @@ -231,7 +201,7 @@ in # nature. The hardening below is defence in depth and NOT a # boundary: a process holding CAP_SYS_ADMIN can call mount(2) and # undo the namespace these directives set up. The real boundary is - # the deployment (dedicated host / container) --- see `dedicated`. + # the deployment --- see docs/snapshot-store.md. systemd.services."hive-snapshot-store@" = { description = "hyperhive swarm snapshot store receiver"; after = [ "hive-snapshot-store.socket" ]; From 57459cb6d8aa9af4626aa998e92de6266195978d Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:42:37 +0200 Subject: [PATCH 4/6] refactor(#2862): split the wireguard mesh out of swarm.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara asked, and the file had already stopped being one thing: after the gate moved off c0re.enable, swarm.nix held two concerns with different audiences and different gates. swarm.nix now declares WHO the peers are — data hive-c0re serialises into HYPERHIVE_PEERS and the dashboard renders. Declaration only, no config block. swarm-wireguard.nix owns the mesh: assertions, the wg-hive interface, the firewall port. That is plain host networking, and a machine which runs no hive at all — the snapshot store — still needs it. Under the old layout a reader could not tell which half of swarm.nix applied to a non-hive host. The two stay coupled by data, not by structure: the per-peer wireguard* fields stay on the peer submodule, because that is where a peer is described, and the mesh module reads them. No behaviour change — same options, same gate, same rendered config. --- nix/host-modules/default.nix | 1 + nix/host-modules/swarm-wireguard.nix | 142 +++++++++++++++++++++++++++ nix/host-modules/swarm.nix | 136 ++----------------------- 3 files changed, 150 insertions(+), 129 deletions(-) create mode 100644 nix/host-modules/swarm-wireguard.nix diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 79647d04..ebd10e31 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -22,6 +22,7 @@ ./hive-snapshot-store.nix ./hive-tls.nix ./otel.nix + ./swarm-wireguard.nix ./swarm.nix ]; } diff --git a/nix/host-modules/swarm-wireguard.nix b/nix/host-modules/swarm-wireguard.nix new file mode 100644 index 00000000..4433de13 --- /dev/null +++ b/nix/host-modules/swarm-wireguard.nix @@ -0,0 +1,142 @@ +# The WireGuard inter-hive mesh for the local host. Split out of +# ./swarm.nix because the two are different concerns with different +# audiences: that file declares WHO the peers are (data hive-c0re +# serialises into HYPERHIVE_PEERS and the dashboard renders), while +# this one is plain host networking that a machine which runs no hive +# at all --- the snapshot store, for one --- still needs. +# +# The two stay coupled by data, not by structure: the per-peer +# `wireguard*` fields live on the peer submodule in ./swarm.nix, since +# that is where a peer is described, and this module reads them. +{ + lib, + config, + ... +}: +{ + # 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 mesh itself, NOT on the c0re daemon. The mesh is host + # networking, not a c0re feature: a swarm host that runs no hive — + # the snapshot store, for one — still has to join the mesh, and under + # the old `c0re.enable` gate it silently got no `wg-hive` interface + # at all. Nothing below is c0re-specific; the peer data + # c0re consumes (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS) is rendered in + # ./hive-c0re and stays gated there. + config = lib.mkIf config.services.hyperhive.swarm.wireguard.enable { + assertions = [ + { + 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 = + 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 (host-level + # networking — not inside containers). + networking.firewall.allowedUDPPorts = [ + config.services.hyperhive.swarm.wireguard.listenPort + ]; + }; +} diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 36152c4c..00465c78 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -1,8 +1,11 @@ -# Swarm peering: the peer-hive declarations and the optional -# WireGuard inter-hive mesh. The peers are serialised into hive-c0re's +# Swarm peering: who the peer hives 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. +# and consumed by identity.rs + the dashboard's P33RS tab. +# +# Declaration only — this module has no `config` block. The mesh that +# uses the `wireguard*` fields below lives in ./swarm-wireguard.nix, +# because bringing up an interface is host networking rather than swarm +# bookkeeping, and a host that runs no hive still needs it. { lib, config, @@ -122,129 +125,4 @@ ''; }; - # 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 mesh itself, NOT on the c0re daemon. The mesh is host - # networking, not a c0re feature: a swarm host that runs no hive — - # the snapshot store, for one — still has to join the mesh, and under - # the old `c0re.enable` gate it silently got no `wg-hive` interface - # at all. Nothing below is c0re-specific; the peer data - # c0re consumes (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS) is rendered in - # ./hive-c0re and stays gated there. - config = lib.mkIf config.services.hyperhive.swarm.wireguard.enable { - assertions = [ - { - 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 = - 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 (host-level - # networking — not inside containers). - networking.firewall.allowedUDPPorts = [ - config.services.hyperhive.swarm.wireguard.listenPort - ]; - }; } From 70bcdb54639460747c70dde54cf76c2a1eca3fd6 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:49:25 +0200 Subject: [PATCH 5/6] refactor(#2862): swarm- prefix for the snapshot store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara, in preparation for the swarm tier: the store is a swarm-level role, not a hive one, so hive- was misleading about which tier it belongs to. Module, units, syslog identifier, log lines and docs all move to swarm-snapshot-store. Also moved the option under services.hyperhive.swarm.snapshotStore, to sit with swarm.peers and swarm.wireguard rather than dangling off the top level. That is a judgement call beyond the literal rename — flagged on the PR, and cheap precisely now: the option has never shipped, so there is no deployment to migrate, whereas doing it after a release would be a breaking change for no new benefit. --- docs/snapshot-store.md | 2 +- nix/host-modules/default.nix | 2 +- ...hot-store.nix => swarm-snapshot-store.nix} | 30 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) rename nix/host-modules/{hive-snapshot-store.nix => swarm-snapshot-store.nix} (89%) diff --git a/docs/snapshot-store.md b/docs/snapshot-store.md index 8bacdd21..daaa8542 100644 --- a/docs/snapshot-store.md +++ b/docs/snapshot-store.md @@ -19,7 +19,7 @@ assume: ## Enabling it ```nix -services.hyperhive.snapshotStore = { +services.hyperhive.swarm.snapshotStore = { enable = true; path = "/var/lib/hyperhive-snapshots"; # must be on btrfs port = 51821; diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index ebd10e31..2b87a421 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -19,9 +19,9 @@ ./hive-matrix.nix ./hive-network.nix ./hive-priv.nix - ./hive-snapshot-store.nix ./hive-tls.nix ./otel.nix + ./swarm-snapshot-store.nix ./swarm-wireguard.nix ./swarm.nix ]; diff --git a/nix/host-modules/hive-snapshot-store.nix b/nix/host-modules/swarm-snapshot-store.nix similarity index 89% rename from nix/host-modules/hive-snapshot-store.nix rename to nix/host-modules/swarm-snapshot-store.nix index 5ffa3cbf..d8049684 100644 --- a/nix/host-modules/hive-snapshot-store.nix +++ b/nix/host-modules/swarm-snapshot-store.nix @@ -1,4 +1,4 @@ -# hive-snapshot-store — the swarm's `btrfs receive` endpoint. Hives push +# swarm-snapshot-store — the swarm's `btrfs receive` endpoint. Hives push # agent snapshots here over the existing WireGuard mesh; a destination # hive later pulls one back to complete a migration. Only the receive # half exists today — the pull side needs an authorisation model for @@ -25,7 +25,7 @@ ... }: let - cfg = config.services.hyperhive.snapshotStore; + cfg = config.services.hyperhive.swarm.snapshotStore; wgCfg = config.services.hyperhive.swarm.wireguard; # `swarm.wireguard.address` carries a prefix ("10.100.0.1/24") because @@ -51,7 +51,7 @@ let # name must match [A-Za-z0-9_-]+ exactly --- no slash, no dot, so no # traversal and no absolute path can survive it. The root is ours; # the leaf is checked against a whitelist charset before it is joined. - receiveScript = pkgs.writeShellScript "hive-snapshot-receive" '' + receiveScript = pkgs.writeShellScript "swarm-snapshot-receive" '' set -euo pipefail # Read exactly the header line, leaving the byte stream untouched @@ -59,12 +59,12 @@ let # buffer ahead, which is why the header is a line and not a # fixed-width record. if ! read -r keyword agent; then - echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} closed before sending a header" >&2 + echo "swarm-snapshot-store: peer ''${REMOTE_ADDR:-?} closed before sending a header" >&2 exit 1 fi if [ "$keyword" != "agent" ]; then - echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} sent a bad header keyword" >&2 + echo "swarm-snapshot-store: peer ''${REMOTE_ADDR:-?} sent a bad header keyword" >&2 exit 1 fi @@ -74,7 +74,7 @@ let # of bad characters. case "$agent" in "" | *[!A-Za-z0-9_-]*) - echo "hive-snapshot-store: peer ''${REMOTE_ADDR:-?} sent an invalid agent name" >&2 + echo "swarm-snapshot-store: peer ''${REMOTE_ADDR:-?} sent an invalid agent name" >&2 exit 1 ;; esac @@ -88,12 +88,12 @@ let # store exists to serve. mkdir -p "$dest" - echo "hive-snapshot-store: receiving agent=$agent from ''${REMOTE_ADDR:-?}" >&2 + echo "swarm-snapshot-store: receiving agent=$agent from ''${REMOTE_ADDR:-?}" >&2 exec ${pkgs.btrfs-progs}/bin/btrfs receive "$dest" ''; in { - options.services.hyperhive.snapshotStore = { + options.services.hyperhive.swarm.snapshotStore = { enable = lib.mkOption { type = lib.types.bool; default = false; @@ -137,7 +137,7 @@ in { assertion = wgCfg.enable; message = '' - services.hyperhive.snapshotStore.enable requires + services.hyperhive.swarm.snapshotStore.enable requires services.hyperhive.swarm.wireguard.enable --- the mesh is the store's transport AND its authentication (cryptokey routing binds a peer's source address to its public key). Without it @@ -148,7 +148,7 @@ in { assertion = wgCfg.address != ""; message = '' - services.hyperhive.snapshotStore.enable requires + services.hyperhive.swarm.snapshotStore.enable requires services.hyperhive.swarm.wireguard.address to be set --- the receiver binds to this host's mesh address, and refuses to fall back to a wildcard. @@ -188,7 +188,7 @@ in # Accept=yes gives one service instance per connection and sets # $REMOTE_ADDR for the handler --- which is how the receiver knows # which peer it is talking to. - systemd.sockets.hive-snapshot-store = { + systemd.sockets.swarm-snapshot-store = { description = "hyperhive swarm snapshot store receiver socket"; wantedBy = [ "sockets.target" ]; socketConfig = { @@ -202,13 +202,13 @@ in # boundary: a process holding CAP_SYS_ADMIN can call mount(2) and # undo the namespace these directives set up. The real boundary is # the deployment --- see docs/snapshot-store.md. - systemd.services."hive-snapshot-store@" = { + systemd.services."swarm-snapshot-store@" = { description = "hyperhive swarm snapshot store receiver"; - after = [ "hive-snapshot-store.socket" ]; - requires = [ "hive-snapshot-store.socket" ]; + after = [ "swarm-snapshot-store.socket" ]; + requires = [ "swarm-snapshot-store.socket" ]; serviceConfig = { ExecStart = receiveScript; - SyslogIdentifier = "hive-snapshot-store"; + SyslogIdentifier = "swarm-snapshot-store"; # StandardInput=socket wires the accepted connection to stdin, # which is what the handler reads the header + stream from. StandardInput = "socket"; From 6a6266cd5e822856a596ba3ab9168bde708f0f77 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 31 Jul 2026 18:54:53 +0200 Subject: [PATCH 6/6] refactor(#2862): keep the option at services.hyperhive.snapshotStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the namespace move from the previous commit — mara's reason is better than mine was. I grouped it with swarm.peers and swarm.wireguard because the module serves the swarm tier. But those two describe THE SWARM: who is in it, how it is meshed. snapshotStore describes THIS HOST'S ROLE. On a standalone store box the operator enables one service, and nesting it under `swarm` implies they are configuring a swarm when they are not. The swarm- prefix on the file and units stands: the name says which tier the component serves, the option path says what you are turning on. Those are different questions and they are allowed different answers. --- docs/snapshot-store.md | 2 +- nix/host-modules/swarm-snapshot-store.nix | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/snapshot-store.md b/docs/snapshot-store.md index daaa8542..8bacdd21 100644 --- a/docs/snapshot-store.md +++ b/docs/snapshot-store.md @@ -19,7 +19,7 @@ assume: ## Enabling it ```nix -services.hyperhive.swarm.snapshotStore = { +services.hyperhive.snapshotStore = { enable = true; path = "/var/lib/hyperhive-snapshots"; # must be on btrfs port = 51821; diff --git a/nix/host-modules/swarm-snapshot-store.nix b/nix/host-modules/swarm-snapshot-store.nix index d8049684..bcbafc99 100644 --- a/nix/host-modules/swarm-snapshot-store.nix +++ b/nix/host-modules/swarm-snapshot-store.nix @@ -25,7 +25,7 @@ ... }: let - cfg = config.services.hyperhive.swarm.snapshotStore; + cfg = config.services.hyperhive.snapshotStore; wgCfg = config.services.hyperhive.swarm.wireguard; # `swarm.wireguard.address` carries a prefix ("10.100.0.1/24") because @@ -93,7 +93,7 @@ let ''; in { - options.services.hyperhive.swarm.snapshotStore = { + options.services.hyperhive.snapshotStore = { enable = lib.mkOption { type = lib.types.bool; default = false; @@ -137,7 +137,7 @@ in { assertion = wgCfg.enable; message = '' - services.hyperhive.swarm.snapshotStore.enable requires + services.hyperhive.snapshotStore.enable requires services.hyperhive.swarm.wireguard.enable --- the mesh is the store's transport AND its authentication (cryptokey routing binds a peer's source address to its public key). Without it @@ -148,7 +148,7 @@ in { assertion = wgCfg.address != ""; message = '' - services.hyperhive.swarm.snapshotStore.enable requires + services.hyperhive.snapshotStore.enable requires services.hyperhive.swarm.wireguard.address to be set --- the receiver binds to this host's mesh address, and refuses to fall back to a wildcard.