wireguard: add support for external peers

This adds support for external peers via the instance option `roles.controller.<name>.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.
This commit is contained in:
DavHau
2025-10-16 14:05:31 +07:00
parent 0d088cac7e
commit 6a3f5e077b
2 changed files with 199 additions and 34 deletions

View File

@@ -106,8 +106,30 @@ let
in in
"${peerIP} ${peerName}.${domain}" "${peerIP} ${peerName}.${domain}"
) roles.peer.machines; ) 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 in
builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts); builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts ++ externalPeerHosts);
}; };
# Shared interface options # Shared interface options
@@ -268,12 +290,33 @@ in
{ {
imports = [ sharedInterface ]; imports = [ sharedInterface ];
options.endpoint = lib.mkOption { options = {
type = lib.types.str; endpoint = lib.mkOption {
example = "vpn.clan.lol"; type = lib.types.str;
description = '' example = "vpn.clan.lol";
Endpoint where the controller can be reached 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 = perInstance =
@@ -310,23 +353,54 @@ in
; ;
}) })
]; ];
# Network allocation generator for this controller # Network prefix allocation generator for this controller
clan.core.vars.generators."wireguard-network-${instanceName}" = { clan.core.vars.generators = {
files.prefix.secret = false; "wireguard-network-${instanceName}" = {
files.prefix.secret = false;
runtimeInputs = with pkgs; [ runtimeInputs = with pkgs; [
python3 python3
]; ];
# Invalidate on network or hostname changes # Invalidate on network or hostname changes
validation.hostname = machine.name; validation.hostname = machine.name;
script = '' script = ''
${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" controller "${machine.name}" ${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; boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = 1;
networking.firewall.allowedUDPPorts = [ settings.port ]; networking.firewall.allowedUDPPorts = [ settings.port ];
@@ -344,19 +418,45 @@ in
config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path; config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path;
# Connect to all peers and other controllers # Connect to all peers and other controllers
peers = lib.mapAttrsToList ( peers =
name: value: # Peers configuration
if allPeers ? ${name} then (lib.mapAttrsToList (name: _value: {
# For peers: they now have our entire /56 subnet 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 = ( publicKey = (
builtins.readFile ( builtins.readFile (
config.clan.core.settings.directory 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 ( allowedIPs = lib.mapAttrsToList (
ctrlName: _: ctrlName: _:
let let
@@ -366,17 +466,18 @@ in
); );
peerSuffix = builtins.readFile ( peerSuffix = builtins.readFile (
config.clan.core.settings.directory 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 in
"${controllerPrefix}:${peerSuffix}/96" "${controllerPrefix}:${peerSuffix}/96"
) roles.controller.machines; ) roles.controller.machines;
# No endpoint for external peers, they initiate the connection
persistentKeepalive = 25; persistentKeepalive = 25;
} }) settings.externalPeers)
else ++
# For other controllers: use their /56 subnet # Other controllers configuration
{ (lib.mapAttrsToList (name: value: {
publicKey = ( publicKey = (
builtins.readFile ( builtins.readFile (
config.clan.core.settings.directory config.clan.core.settings.directory
@@ -395,8 +496,7 @@ in
endpoint = "${value.settings.endpoint}:${toString value.settings.port}"; endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
persistentKeepalive = 25; persistentKeepalive = 25;
} }) allOtherControllers);
) (allPeers // allOtherControllers);
}; };
}; };
}; };

View File

@@ -1,5 +1,6 @@
{ {
lib, lib,
config,
... ...
}: }:
@@ -10,7 +11,23 @@ let
"peer1" "peer1"
"peer2" "peer2"
"peer3" "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 in
{ {
name = "wireguard"; name = "wireguard";
@@ -47,6 +64,8 @@ in
roles.controller.machines."controller1".settings = { roles.controller.machines."controller1".settings = {
endpoint = "192.168.1.1"; endpoint = "192.168.1.1";
# add an external peer to controller1 only
externalPeers = [ "external1" ];
}; };
roles.controller.machines."controller2".settings = { 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 = '' testScript = ''
start_all() start_all()
@@ -94,9 +155,13 @@ in
print("="*60) print("="*60)
for m1 in machines: for m1 in machines:
# ping all other machines
for m2 in machines: for m2 in machines:
if m1 != m2: if m1 != m2:
print(f"\n--- Pinging from {m1.name} to {m2.name}.wg-test-one ---") 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") 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")
''; '';
} }