WireGuard Mesh Network
Full-mesh WireGuard VPN with automatic key generation, cross-host peer wiring, and hash-derived IPv6 ULA addresses.
Directory Structure
.
├── flake.nix
└── wireguard/
└── default.nix
flake.nix
{...}: {
outputs =
inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.fleet.flakeModule
];
fleetConfigurations.default = {
imports = [
(import ./wireguard { iface = "testwg"; })
];
cluster.wireguard.testwg = {
hosts = [
"bernard"
"edgeworth"
];
port = 51825;
};
hosts.bernard = {
system = "x86_64-linux";
network.externalIps = [
"78.11.11.11"
];
};
hosts.edgeworth = {
system = "aarch64-linux";
};
};
};
}
Module is imported with a parameterized interface name (testwg), then configured declaratively via cluster.wireguard.testwg.
Hosts without network.externalIps (like edgeworth) are treated as NAT/roaming — no endpoint is set for them, peers connect outbound only.
wireguard/default.nix
{ iface }:
{ config, lib, fleetLib, ... }:
let
inherit (lib.attrsets) genAttrs optionalAttrs;
inherit (lib.strings) substring;
inherit (lib.lists)
filter
sort
unique
length
elemAt
;
inherit (lib.options) mkOption;
inherit (lib.types) listOf str int;
inherit (lib) hashString lessThan;
cfg = config.cluster.wireguard.${iface};
hostNames = sort lessThan cfg.hosts;
hostHash = name: hashString "sha256" (name + iface);
hostSubnet =
name:
let
h = hostHash name;
in
"fd00:${substring04h}:${substring44h}:${substring84h}";
hostIPv6 = name: "${hostSubnetname}::1";
in
{
options.cluster.wireguard.${iface} = {
hosts = mkOption {
description = "Hostnames participating in this wireguard mesh";
type = listOf str;
};
port = mkOption {
description = "WireGuard listen port";
type = int;
default = 51824;
};
};
config = {
assertions = [
{
assertion = length (unique (map hostSubnet hostNames)) == length hostNames;
message = "${iface}: hostname-derived IPv6 subnet collision detected";
}
];
secrets."${iface}-psk" = {
expectedOwners = hostNames;
generator = fleetLib.mkBase64Bytes { count = 32; };
};
hosts = genAttrs hostNames (name: {
nixos =
{
config,
nixosHosts,
hosts,
...
}:
let
peerNames = filter (n: n != name) hostNames;
in
{
secrets."${iface}-key" = {
generator = fleetLib.mkX25519 { encoding = "base64"; };
};
secrets."${iface}-psk" = {
generator = "shared";
};
networking.wireguard.interfaces.${iface} = {
ips = [ "${hostIPv6name}/64" ];
listenPort = cfg.port;
privateKeyFile = config.secrets."${iface}-key".secret.path;
peers = map (
peerName:
{
publicKey = nixosHosts.${}.secrets."${iface}-key".public.data;
presharedKeyFile = config.secrets."${iface}-psk".secret.path;
allowedIPs = [ "${hostSubnetpeerName}::/64" ];
}
// optionalAttrs (hosts.${}.network.externalIps or [ ] != [ ]) {
endpoint = "${elemAthosts.${}.network.externalIps0}:${toStringcfg.port}";
}
) peerNames;
};
networking.firewall.allowedUDPPorts = [ cfg.port ];
};
});
};
_file = ./default.nix;
}
How It Works
Multi-Instance Module
The 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:
imports = [
(import ./wireguard { iface = "internal"; })
(import ./wireguard { iface = "management"; })
];
IPv6 ULA Addressing
Each host gets a unique fd00::/64 subnet derived from sha256(hostname + iface).
No manual IP assignment needed — deterministic, collision-checked via assertion.
For example, bernard on interface testwg gets fd00:1c86:78d4:dcef::1/64.
Secrets
${iface}-key-
Per-host X25519 keypair via
fleetLib.mkX25519 { encoding = "base64"; }. Private key encrypted in fleet state, public key plaintext — readable by other hosts at build time throughnixosHosts.${peerName}.secrets."${iface}-key".public.data. ${iface}-psk-
Shared preshared key via
fleetLib.mkBase64Bytes { count = 32; }. Defined at fleet level withexpectedOwners, each host claims withgenerator = "shared".
NAT Handling
Endpoint is set only for peers that have network.externalIps:
// optionalAttrs (hosts.${}.network.externalIps or [ ] != [ ]) {
endpoint = "...";
}
Hosts behind NAT initiate connections outward. Add persistentKeepalive = 25; if needed to maintain NAT mappings.
Deployment
Single command generates all secrets and deploys:
fleet --only bernard --only edgeworth deploy switch
No separate key generation step — fleet generates and provisions secrets automatically.
Fleet State After Deploy
The fleet state file records encryption keys, per-host keypairs (separate distributions), and shared PSK:
{
hosts = {
bernard.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ25i6...";
edgeworth.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIELc1m...";
};
secrets = {
testwg-key = [
{
owners = [ "bernard" ];
createdAt = "2026-04-23T16:39:29.532714437Z";
public.raw = "<PLAINTEXT>T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M=";
secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";
}
{
owners = [ "edgeworth" ];
createdAt = "2026-04-23T16:39:43.337788671Z";
public.raw = "<PLAINTEXT>QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA=";
secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";
}
];
testwg-psk = {
owners = [ "bernard" "edgeworth" ];
createdAt = "2026-04-23T16:39:30.126077659Z";
secret.raw = "<ENCRYPTED><BASE64-ENCODED>...";
};
};
}
Note how testwg-key is a list — each host has its own distribution with a unique keypair.
testwg-psk is a single distribution shared by both hosts.
Verification
wg show on edgeworth:
interface: testwg public key: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA= private key: (hidden) listening port: 51825 peer: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M= preshared key: (hidden) endpoint: 78.11.11.11:51825 allowed ips: fd00:1c86:78d4:dcef::/64 latest handshake: 1 second ago transfer: 616 B received, 760 B sent
wg show on bernard:
interface: testwg public key: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M= private key: (hidden) listening port: 51825 peer: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA= preshared key: (hidden) endpoint: 109.11.11.11:51825 allowed ips: fd00:cac2:10cb:6ee9::/64 latest handshake: 7 minutes, 18 seconds ago transfer: 468 B received, 380 B sent
Public 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.