diff --git a/docs/swarm.md b/docs/swarm.md index 54c074ec..e9c8587c 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -93,6 +93,79 @@ services.hyperhive.swarm.peers."pr1ma.example.com" = { certFingerprint = "sha256 Mixed trust is fine: A trusts B via CA bundle (no fingerprint), B pins A's self-signed cert. +## WireGuard inter-hive mesh (optional) + +The peer config above uses public HTTPS for all inter-hive traffic. +For private deployments — or to reduce latency and TLS overhead on +intra-swarm traffic — hive-c0re can configure a host-to-host +WireGuard mesh. + +### Generating keys + +On each hive host: + +```bash +wg genkey | install -m 0400 /dev/stdin /etc/wireguard/hive.key +wg pubkey < /etc/wireguard/hive.key # → share this with peer operators +``` + +### Config example (two hives) + +```nix +# hive A (pr1ma.example.com, mesh IP 10.100.0.1) +services.hyperhive = { + swarm.wireguard = { + enable = true; + privateKeyFile = "/etc/wireguard/hive.key"; + address = "10.100.0.1/24"; + listenPort = 51820; # optional, default 51820 + }; + + swarm.peers."edge.corp" = { + certFingerprint = "sha256:…"; # TLS trust (unchanged) + wireguardPublicKey = "base64key="; # peer's wg pubkey + wireguardEndpoint = "203.0.113.42:51820"; # peer's public IP:port + wireguardAddress = "10.100.0.2/32"; # peer's mesh IP + }; +}; + +# hive B (edge.corp, mesh IP 10.100.0.2) +services.hyperhive = { + swarm.wireguard = { + enable = true; + privateKeyFile = "/etc/wireguard/hive.key"; + address = "10.100.0.2/24"; + }; + + swarm.peers."pr1ma.example.com" = { + wireguardPublicKey = "base64key="; # hive A's wg pubkey + wireguardEndpoint = "198.51.100.1:51820"; + wireguardAddress = "10.100.0.1/32"; + }; +}; +``` + +### What the mesh does + +- `networking.wireguard.interfaces.wg-hive` is configured on the host + (not inside agent containers; containers reach peers via the host's + routing table). +- UDP port 51820 (or `listenPort`) is opened on the host firewall. +- `HYPERHIVE_PEERS` gains a `wireguard_address` field for each mesh + peer so hive-c0re can reach intra-swarm services without a public + DNS round-trip. +- `persistentKeepalive = 25` is set by default; override or null to + disable (not needed when both sides have public IPs and no NAT). + +### NAT / one-sided endpoints + +If one host is behind NAT and can't accept incoming connections, only +that host needs a null `wireguardEndpoint` on the peer config — the +other side initiates. With keepalive on, the NAT hole stays open. + +If both hosts are behind NAT, a STUN relay or a third host (exit node) +is required. Out of scope for v0. + ## Cross-references - `docs/conventions.md` § Hive identity — env vars, qualified labels diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 3fba5127..28400266 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -127,6 +127,45 @@ in Encrypt peers). Set to pin a self-signed cert. ''; }; + + 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 when + `wireguardPublicKey` is set. + ''; + }; }; } ); @@ -141,10 +180,74 @@ in 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. + 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; + 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; @@ -342,14 +445,70 @@ in # Open the per-agent web-port range when the gateway is *off* — # otherwise the gateway nginx is the sole external entry point. # See `docs/gateway.md::Firewall posture (host-level)`. - networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) { - allowedTCPPortRanges = [ - { - from = 8100; - to = 8999; - } - ]; - }; + networking.firewall.allowedTCPPortRanges = lib.mkIf (!config.services.hyperhive.gateway.enable) [ + { + from = 8100; + to = 8999; + } + ]; + + # WireGuard inter-hive mesh. Enabled when + # `services.hyperhive.swarm.wireguard.enable = true`. Brings up a + # `wg-hive` interface and connects to each peer that has + # `wireguardPublicKey` set. Firewall opens the UDP listen port on + # the host (not inside containers — this is host-level networking). + networking.wireguard.interfaces = lib.mkIf config.services.hyperhive.swarm.wireguard.enable ( + let + wgCfg = config.services.hyperhive.swarm.wireguard; + meshPeers = lib.filterAttrs ( + _: p: p.wireguardPublicKey != null && p.wireguardAddress != null + ) config.services.hyperhive.swarm.peers; + in + { + wg-hive = { + ips = [ wgCfg.address ]; + listenPort = wgCfg.listenPort; + privateKeyFile = wgCfg.privateKeyFile; + peers = lib.mapAttrsToList ( + _domain: p: + { + publicKey = p.wireguardPublicKey; + allowedIPs = [ p.wireguardAddress ]; + } + // lib.optionalAttrs (p.wireguardEndpoint != null) { + endpoint = p.wireguardEndpoint; + } + // lib.optionalAttrs (wgCfg.persistentKeepalive != null) { + persistentKeepalive = wgCfg.persistentKeepalive; + } + ) meshPeers; + }; + } + ); + + # Open the WireGuard UDP port on the host firewall when the mesh is on. + networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ + config.services.hyperhive.swarm.wireguard.listenPort + ]; + + assertions = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ + { + assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; + message = '' + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.privateKeyFile to be set. + Generate a key: wg genkey > /etc/wireguard/hive.key + ''; + } + { + assertion = config.services.hyperhive.swarm.wireguard.address != ""; + message = '' + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.address to be set + (e.g. "10.100.0.1/24"). + ''; + } + ]; systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; @@ -429,15 +588,22 @@ in HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}"; } // lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) { - # Peer hives serialised as a JSON array of {domain, cert_fingerprint} - # objects. Consumed by hive-ag3nt::identity::peers() + the dashboard's - # peer_hives StateSnapshot field (P33RS tab). Domain is the attrset key; - # cert_fingerprint is null for CA-trusted peers. + # Peer hives serialised as a JSON array of {domain, cert_fingerprint, + # wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers() + # + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain + # is the attrset key; cert_fingerprint is null for CA-trusted peers; + # wireguard_address is omitted when not part of the mesh. HYPERHIVE_PEERS = builtins.toJSON ( - lib.mapAttrsToList (domain: p: { - inherit domain; - cert_fingerprint = p.certFingerprint; - }) config.services.hyperhive.swarm.peers + lib.mapAttrsToList ( + domain: p: + { + inherit domain; + cert_fingerprint = p.certFingerprint; + } + // lib.optionalAttrs (p.wireguardAddress != null) { + wireguard_address = p.wireguardAddress; + } + ) config.services.hyperhive.swarm.peers ); }; serviceConfig = {