1= WireGuard Mesh Network2:order: 034Full-mesh WireGuard VPN with automatic key generation, cross-host peer wiring, and hash-derived IPv6 ULA addresses.56== Directory Structure78----9.10├── flake.nix11└── wireguard/12 └── default.nix13----1415== flake.nix1617[source,nix]18----19{...}: {20 outputs =21 inputs:22 inputs.flake-parts.lib.mkFlake { inherit inputs; } {23 imports = [24 inputs.fleet.flakeModule25 ];26 fleetConfigurations.default = {27 imports = [28 (import ./wireguard { iface = "testwg"; })29 ];30 cluster.wireguard.testwg = {31 hosts = [32 "bernard"33 "edgeworth"34 ];35 port = 51825;36 };37 hosts.bernard = {38 system = "x86_64-linux";39 network.externalIps = [40 "78.11.11.11"41 ];42 };43 hosts.edgeworth = {44 system = "aarch64-linux";45 };46 };47 };48}49----5051Module is imported with a parameterized interface name (`testwg`), then configured declaratively via `cluster.wireguard.testwg`.52Hosts without `network.externalIps` (like `edgeworth`) are treated as NAT/roaming — no endpoint is set for them, peers connect outbound only.5354== wireguard/default.nix5556[source,nix]57----58{ iface }:59{ config, lib, fleetLib, ... }:60let61 inherit (lib.attrsets) genAttrs optionalAttrs;62 inherit (lib.strings) substring;63 inherit (lib.lists)64 filter65 sort66 unique67 length68 elemAt69 ;70 inherit (lib.options) mkOption;71 inherit (lib.types) listOf str int;72 inherit (lib) hashString lessThan;7374 cfg = config.cluster.wireguard.${iface};75 hostNames = sort lessThan cfg.hosts;76 hostHash = name: hashString "sha256" (name + iface);77 hostSubnet =78 name:79 let80 h = hostHash name;81 in82 "fd00:${substring 0 4 h}:${substring 4 4 h}:${substring 8 4 h}";83 hostIPv6 = name: "${hostSubnet name}::1";84in85{86 options.cluster.wireguard.${iface} = {87 hosts = mkOption {88 description = "Hostnames participating in this wireguard mesh";89 type = listOf str;90 };91 port = mkOption {92 description = "WireGuard listen port";93 type = int;94 default = 51824;95 };96 };9798 config = {99 assertions = [100 {101 assertion = length (unique (map hostSubnet hostNames)) == length hostNames;102 message = "${iface}: hostname-derived IPv6 subnet collision detected";103 }104 ];105 secrets."${iface}-psk" = {106 expectedOwners = hostNames;107 generator = fleetLib.mkBase64Bytes { count = 32; };108 };109110 hosts = genAttrs hostNames (name: {111 nixos =112 {113 config,114 nixosHosts,115 hosts,116 ...117 }:118 let119 peerNames = filter (n: n != name) hostNames;120 in121 {122 secrets."${iface}-key" = {123 generator = fleetLib.mkX25519 { encoding = "base64"; };124 };125 secrets."${iface}-psk" = {126 generator = "shared";127 };128129 networking.wireguard.interfaces.${iface} = {130 ips = [ "${hostIPv6 name}/64" ];131 listenPort = cfg.port;132 privateKeyFile = config.secrets."${iface}-key".secret.path;133134 peers = map (135 peerName:136 {137 publicKey = nixosHosts.${peerName}.secrets."${iface}-key".public.data;138 presharedKeyFile = config.secrets."${iface}-psk".secret.path;139 allowedIPs = [ "${hostSubnet peerName}::/64" ];140 }141 // optionalAttrs (hosts.${peerName}.network.externalIps or [ ] != [ ]) {142 endpoint = "${elemAt hosts.${peerName}.network.externalIps 0}:${toString cfg.port}";143 }144 ) peerNames;145 };146147 networking.firewall.allowedUDPPorts = [ cfg.port ];148 };149 });150 };151 _file = ./default.nix;152}153----154155== How It Works156157=== Multi-Instance Module158159The outer function `{ iface }:` makes the module parameterizable — import it multiple times with different interface names to create independent WireGuard meshes with separate keys and subnets:160161[source,nix]162----163imports = [164 (import ./wireguard { iface = "internal"; })165 (import ./wireguard { iface = "management"; })166];167----168169=== IPv6 ULA Addressing170171Each host gets a unique `fd00::/64` subnet derived from `sha256(hostname + iface)`.172No manual IP assignment needed — deterministic, collision-checked via assertion.173174For example, `bernard` on interface `testwg` gets `fd00:1c86:78d4:dcef::1/64`.175176=== Secrets177178`${iface}-key`::179Per-host X25519 keypair via `fleetLib.mkX25519 { encoding = "base64"; }`.180Private key encrypted in fleet state, public key plaintext — readable by other hosts at build time through `nixosHosts.${peerName}.secrets."${iface}-key".public.data`.181182`${iface}-psk`::183Shared preshared key via `fleetLib.mkBase64Bytes { count = 32; }`.184Defined at fleet level with `expectedOwners`, each host claims with `generator = "shared"`.185186=== NAT Handling187188Endpoint is set only for peers that have `network.externalIps`:189190[source,nix]191----192// optionalAttrs (hosts.${peerName}.network.externalIps or [ ] != [ ]) {193 endpoint = "...";194}195----196197Hosts behind NAT initiate connections outward. Add `persistentKeepalive = 25;` if needed to maintain NAT mappings.198199== Deployment200201Single command generates all secrets and deploys:202203[source,shell]204----205fleet --only bernard --only edgeworth deploy switch206----207208No separate key generation step — fleet generates and provisions secrets automatically.209210=== Fleet State After Deploy211212The fleet state file records encryption keys, per-host keypairs (separate distributions), and shared PSK:213214[source,nix]215----216{217 hosts = {218 bernard.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ25i6...";219 edgeworth.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIELc1m...";220 };221 secrets = {222 testwg-key = [223 {224 owners = [ "bernard" ];225 createdAt = "2026-04-23T16:39:29.532714437Z";226 public.raw = "<PLAINTEXT>T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M=";227 secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";228 }229 {230 owners = [ "edgeworth" ];231 createdAt = "2026-04-23T16:39:43.337788671Z";232 public.raw = "<PLAINTEXT>QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA=";233 secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";234 }235 ];236 testwg-psk = {237 owners = [ "bernard" "edgeworth" ];238 createdAt = "2026-04-23T16:39:30.126077659Z";239 secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";240 };241 };242}243----244245Note how `testwg-key` is a list — each host has its own distribution with a unique keypair.246`testwg-psk` is a single distribution shared by both hosts.247248=== Verification249250`wg show` on `edgeworth`:251252----253interface: testwg254 public key: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA=255 private key: (hidden)256 listening port: 51825257258peer: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M=259 preshared key: (hidden)260 endpoint: 78.11.11.11:51825261 allowed ips: fd00:1c86:78d4:dcef::/64262 latest handshake: 1 second ago263 transfer: 616 B received, 760 B sent264----265266`wg show` on `bernard`:267268----269interface: testwg270 public key: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M=271 private key: (hidden)272 listening port: 51825273274peer: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA=275 preshared key: (hidden)276 endpoint: 109.11.11.11:51825277 allowed ips: fd00:cac2:10cb:6ee9::/64278 latest handshake: 7 minutes, 18 seconds ago279 transfer: 468 B received, 380 B sent280----281282Public keys match between state file and `wg show`. `edgeworth` has no external IP in fleet config, yet `bernard` sees its endpoint — `edgeworth` initiated the connection outward through NAT.