git.delta.rocks / fleet / refs/commits / 992c7b63e98f

difftreelog

source

docs/examples/wireguard.adoc7.5 KiBrenderedsourcehistory
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.