From 6a3f5e077bd17628586089eab6f8d63204419520 Mon Sep 17 00:00:00 2001 From: DavHau Date: Thu, 16 Oct 2025 14:05:31 +0700 Subject: [PATCH] wireguard: add support for external peers This adds support for external peers via the instance option `roles.controller..settings.externalPeers = ["external1"]`. External peers are peers which are not associated wth a machine inside the clan, for example a mobile phone or a device that cannot be managed via clan for some reason. --- clanServices/wireguard/default.nix | 168 ++++++++++++++++---- clanServices/wireguard/tests/vm/default.nix | 65 ++++++++ 2 files changed, 199 insertions(+), 34 deletions(-) diff --git a/clanServices/wireguard/default.nix b/clanServices/wireguard/default.nix index 9188660d3..093cd8f92 100644 --- a/clanServices/wireguard/default.nix +++ b/clanServices/wireguard/default.nix @@ -106,8 +106,30 @@ let in "${peerIP} ${peerName}.${domain}" ) roles.peer.machines; + + # External peers + externalPeerHosts = lib.flatten ( + lib.mapAttrsToList ( + ctrlName: _ctrlValue: + lib.map ( + peer: + let + peerSuffix = builtins.readFile ( + config.clan.core.settings.directory + + "/vars/shared/wireguard-network-${instanceName}-external-peer-${peer}/suffix/value" + ); + controllerPrefix = builtins.readFile ( + config.clan.core.settings.directory + + "/vars/per-machine/${ctrlName}/wireguard-network-${instanceName}/prefix/value" + ); + peerIP = controllerPrefix + ":" + peerSuffix; + in + "${peerIP} ${peer}.${domain}" + ) (roles.controller.machines.${ctrlName}.settings.externalPeers) + ) roles.controller.machines + ); in - builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts); + builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts ++ externalPeerHosts); }; # Shared interface options @@ -268,12 +290,33 @@ in { imports = [ sharedInterface ]; - options.endpoint = lib.mkOption { - type = lib.types.str; - example = "vpn.clan.lol"; - description = '' - Endpoint where the controller can be reached - ''; + options = { + endpoint = lib.mkOption { + type = lib.types.str; + example = "vpn.clan.lol"; + description = '' + Endpoint where the controller can be reached + ''; + }; + externalPeers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "moms-phone" + "daddies-laptop" + ]; + description = '' + List of external peer names that are not part of the clan. + + For ever entry here, a key pair for an external device will be generated. + Each external peer must be configured on exactly ONE controller. + This key pair can then then be displayed via `clan vars get` and inserted into an external device, like a phone or laptop. + + The names in this list must not collide with machine names in the clan. + The machines which are part of the clan will be able to resolve the external peers via their host names, but not vice versa. + External peers can still reach machines from within the clan via their IPv6 addresses. + ''; + }; }; }; perInstance = @@ -310,23 +353,54 @@ in ; }) ]; - # Network allocation generator for this controller - clan.core.vars.generators."wireguard-network-${instanceName}" = { - files.prefix.secret = false; + # Network prefix allocation generator for this controller + clan.core.vars.generators = { + "wireguard-network-${instanceName}" = { + files.prefix.secret = false; - runtimeInputs = with pkgs; [ - python3 - ]; + runtimeInputs = with pkgs; [ + python3 + ]; - # Invalidate on network or hostname changes - validation.hostname = machine.name; + # Invalidate on network or hostname changes + validation.hostname = machine.name; - script = '' - ${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" controller "${machine.name}" - ''; - }; + script = '' + ${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" controller "${machine.name}" + ''; + }; + } + # For external peers, generate: suffix, public key, private key + // lib.genAttrs' settings.externalPeers (peer: { + name = "wireguard-network-${instanceName}-external-peer-${peer}"; + value = { + files.suffix.secret = false; + files.publickey.secret = false; + files.privatekey.secret = true; + files.privatekey.deploy = false; - # Enable ip forwarding, so wireguard peers can reach eachother + # The external peers keys are not deployed and are globally unique. + # Even if an external peer is connected to more than one controller, + # its private keys will remain the same. + share = true; + + runtimeInputs = with pkgs; [ + python3 + wireguard-tools + ]; + + # Invalidate on hostname changes + validation.hostname = peer; + + script = '' + ${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" peer "${peer}" + wg genkey > $out/privatekey + wg pubkey < $out/privatekey > $out/publickey + ''; + }; + }); + + # Enable ip forwarding, so wireguard peers can reach each other boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = 1; networking.firewall.allowedUDPPorts = [ settings.port ]; @@ -344,19 +418,45 @@ in config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path; # Connect to all peers and other controllers - peers = lib.mapAttrsToList ( - name: value: - if allPeers ? ${name} then - # For peers: they now have our entire /56 subnet - { + peers = + # Peers configuration + (lib.mapAttrsToList (name: _value: { + publicKey = ( + builtins.readFile ( + config.clan.core.settings.directory + + "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value" + ) + ); + + # Allow the peer's /96 range in ALL controller subnets + allowedIPs = lib.mapAttrsToList ( + ctrlName: _: + let + controllerPrefix = builtins.readFile ( + config.clan.core.settings.directory + + "/vars/per-machine/${ctrlName}/wireguard-network-${instanceName}/prefix/value" + ); + peerSuffix = builtins.readFile ( + config.clan.core.settings.directory + + "/vars/per-machine/${name}/wireguard-network-${instanceName}/suffix/value" + ); + in + "${controllerPrefix}:${peerSuffix}/96" + ) roles.controller.machines; + + persistentKeepalive = 25; + }) allPeers) + ++ + # External peers configuration + (map (peer: { publicKey = ( builtins.readFile ( config.clan.core.settings.directory - + "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value" + + "/vars/shared/wireguard-network-${instanceName}-external-peer-${peer}/publickey/value" ) ); - # Allow the peer's /96 range in ALL controller subnets + # Allow the external peer's /96 range in ALL controller subnets allowedIPs = lib.mapAttrsToList ( ctrlName: _: let @@ -366,17 +466,18 @@ in ); peerSuffix = builtins.readFile ( config.clan.core.settings.directory - + "/vars/per-machine/${name}/wireguard-network-${instanceName}/suffix/value" + + "/vars/shared/wireguard-network-${instanceName}-external-peer-${peer}/suffix/value" ); in "${controllerPrefix}:${peerSuffix}/96" ) roles.controller.machines; + # No endpoint for external peers, they initiate the connection persistentKeepalive = 25; - } - else - # For other controllers: use their /56 subnet - { + }) settings.externalPeers) + ++ + # Other controllers configuration + (lib.mapAttrsToList (name: value: { publicKey = ( builtins.readFile ( config.clan.core.settings.directory @@ -395,8 +496,7 @@ in endpoint = "${value.settings.endpoint}:${toString value.settings.port}"; persistentKeepalive = 25; - } - ) (allPeers // allOtherControllers); + }) allOtherControllers); }; }; }; diff --git a/clanServices/wireguard/tests/vm/default.nix b/clanServices/wireguard/tests/vm/default.nix index 0ea08786e..54bf69800 100644 --- a/clanServices/wireguard/tests/vm/default.nix +++ b/clanServices/wireguard/tests/vm/default.nix @@ -1,5 +1,6 @@ { lib, + config, ... }: @@ -10,7 +11,23 @@ let "peer1" "peer2" "peer3" + # external machine for external peer testing + "external1" ]; + + controllerPrefix = + controllerName: + builtins.readFile ( + config.clan.directory + + "/vars/per-machine/${controllerName}/wireguard-network-wg-test-one/prefix/value" + ); + # external peer suffixes are stored via shared vars + externalPeerSuffix = + externalName: + builtins.readFile ( + config.clan.directory + + "/vars/shared/wireguard-network-wg-test-one-external-peer-${externalName}/suffix/value" + ); in { name = "wireguard"; @@ -47,6 +64,8 @@ in roles.controller.machines."controller1".settings = { endpoint = "192.168.1.1"; + # add an external peer to controller1 only + externalPeers = [ "external1" ]; }; roles.controller.machines."controller2".settings = { @@ -77,6 +96,48 @@ in }; }; + nodes.external1 = + let + controller1Prefix = controllerPrefix "controller1"; + external1Suffix = externalPeerSuffix "external1"; + in + { + networking.extraHosts = '' + ${controller1Prefix}::1 controller1.wg-test-one + ''; + networking.wireguard.interfaces."wg0" = { + + ips = [ "${controller1Prefix + ":" + external1Suffix}/56" ]; + + privateKeyFile = + builtins.toFile "wg-priv-key" + # This needs to be updated whenever update-vars was executed + # Get the value from the generated vars via this command: + # echo "AGE-SECRET-KEY-1PL0M9CWRCG3PZ9DXRTTLMCVD57U6JDFE8K7DNVQ35F4JENZ6G3MQ0RQLRV" | SOPS_AGE_KEY_FILE=/dev/stdin nix run nixpkgs#sops decrypt clanServices/wireguard/tests/vm/vars/shared/wireguard-network-wg-test-one-external-peer-external1/privatekey/secret + "wO8dl3JWgV5J+0D/2UDcLsxTD25IWTvd5ed6vv2Nikk="; + + peers = [ + { + publicKey = ( + builtins.readFile ( + config.clan.directory + "/vars/per-machine/controller1/wireguard-keys-wg-test-one/publickey/value" + ) + ); + + # Allow each controller's /56 subnet + allowedIPs = [ + # "${controller1Prefix}::/56" + "::/0" + ]; + + endpoint = "controller1:51820"; + + persistentKeepalive = 25; + } + ]; + }; + }; + testScript = '' start_all() @@ -94,9 +155,13 @@ in print("="*60) for m1 in machines: + # ping all other machines for m2 in machines: if m1 != m2: print(f"\n--- Pinging from {m1.name} to {m2.name}.wg-test-one ---") m1.wait_until_succeeds(f"ping -c1 {m2.name}.wg-test-one >&2") + # ping external peer from all other peers and controllers + print(f"\n--- Pinging from {m1.name} to external1.wg-test-one ---") + m1.wait_until_succeeds("ping -c1 external1.wg-test-one >&2") ''; }