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
"${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
builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts);
"${peerIP} ${peer}.${domain}"
) (roles.controller.machines.${ctrlName}.settings.externalPeers)
) roles.controller.machines
);
in
builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts ++ externalPeerHosts);
};
# Shared interface options
@@ -268,13 +290,34 @@ in
{
imports = [ sharedInterface ];
options.endpoint = lib.mkOption {
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,8 +353,9 @@ in
;
})
];
# Network allocation generator for this controller
clan.core.vars.generators."wireguard-network-${instanceName}" = {
# Network prefix allocation generator for this controller
clan.core.vars.generators = {
"wireguard-network-${instanceName}" = {
files.prefix.secret = false;
runtimeInputs = with pkgs; [
@@ -325,8 +369,38 @@ in
${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,11 +418,9 @@ 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
@@ -373,10 +445,39 @@ in
) roles.controller.machines;
persistentKeepalive = 25;
}
else
# For other controllers: use their /56 subnet
{
}) allPeers)
++
# External peers configuration
(map (peer: {
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/shared/wireguard-network-${instanceName}-external-peer-${peer}/publickey/value"
)
);
# Allow the external 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/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;
}) 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);
};
};
};

View File

@@ -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")
'';
}