diff --git a/checks/flake-module.nix b/checks/flake-module.nix
index 69c43aadd..64d6c9a92 100644
--- a/checks/flake-module.nix
+++ b/checks/flake-module.nix
@@ -104,6 +104,7 @@ in
nixos-test-user-firewall-nftables = self.clanLib.test.containerTest ./user-firewall/nftables.nix nixosTestArgs;
service-dummy-test = import ./service-dummy-test nixosTestArgs;
+ wireguard = import ./wireguard nixosTestArgs;
service-dummy-test-from-flake = import ./service-dummy-test-from-flake nixosTestArgs;
};
diff --git a/checks/wireguard/default.nix b/checks/wireguard/default.nix
new file mode 100644
index 000000000..bc01cab62
--- /dev/null
+++ b/checks/wireguard/default.nix
@@ -0,0 +1,115 @@
+{
+ pkgs,
+ nixosLib,
+ clan-core,
+ lib,
+ ...
+}:
+nixosLib.runTest (
+ { ... }:
+
+ let
+ machines = [
+ "controller1"
+ "controller2"
+ "peer1"
+ "peer2"
+ "peer3"
+ ];
+ in
+ {
+ imports = [
+ clan-core.modules.nixosTest.clanTest
+ ];
+
+ hostPkgs = pkgs;
+
+ name = "wireguard";
+
+ clan = {
+ directory = ./.;
+ modules."@clan/wireguard" = import ../../clanServices/wireguard/default.nix;
+ inventory = {
+
+ machines = lib.genAttrs machines (_: { });
+
+ instances = {
+
+ /*
+ wg-test-one
+ ┌───────────────────────────────┐
+ │ ◄───────────── │
+ │ controller2 controller1
+ │ ▲ ─────────────► ▲ ▲
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ └───────────────┐ │ │ │ │
+ │ │ │ └──────────────┐ │ │ │ │ │
+ │ ▼ │ ▼ ▼ ▼
+ └─► peer2 │ peer1 peer3
+ │ ▲
+ └──────────┘
+ */
+
+ wg-test-one = {
+
+ module.name = "@clan/wireguard";
+ module.input = "self";
+
+ roles.controller.machines."controller1".settings = {
+ endpoint = "192.168.1.1";
+ };
+
+ roles.controller.machines."controller2".settings = {
+ endpoint = "192.168.1.2";
+ };
+
+ roles.peer.machines = {
+ peer1.settings.controller = "controller1";
+ peer2.settings.controller = "controller2";
+ peer3.settings.controller = "controller1";
+ };
+ };
+
+ # TODO: Will this actually work with conflicting ports? Can we re-use interfaces?
+ #wg-test-two = {
+ # module.name = "@clan/wireguard";
+
+ # roles.controller.machines."controller1".settings = {
+ # endpoint = "192.168.1.1";
+ # port = 51922;
+ # };
+
+ # roles.peer.machines = {
+ # peer1 = { };
+ # };
+ #};
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+
+ # Show all addresses
+ machines = [peer1, peer2, peer3, controller1, controller2]
+ for m in machines:
+ m.systemctl("start network-online.target")
+
+ for m in machines:
+ m.wait_for_unit("network-online.target")
+ m.wait_for_unit("systemd-networkd.service")
+
+ print("\n\n" + "="*60)
+ print("STARTING PING TESTS")
+ print("="*60)
+
+ for m1 in 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")
+ '';
+ }
+)
diff --git a/checks/wireguard/sops/machines/controller1/key.json b/checks/wireguard/sops/machines/controller1/key.json
new file mode 100755
index 000000000..6e5be0e5a
--- /dev/null
+++ b/checks/wireguard/sops/machines/controller1/key.json
@@ -0,0 +1,6 @@
+[
+ {
+ "publickey": "age1rnkc2vmrupy9234clyu7fpur5kephuqs3v7qauaw5zeg00jqjdasefn3cc",
+ "type": "age"
+ }
+]
diff --git a/checks/wireguard/sops/machines/controller2/key.json b/checks/wireguard/sops/machines/controller2/key.json
new file mode 100755
index 000000000..d298a0dde
--- /dev/null
+++ b/checks/wireguard/sops/machines/controller2/key.json
@@ -0,0 +1,6 @@
+[
+ {
+ "publickey": "age1t2hhg99d4p2yymuhngcy5ccutp8mvu7qwvg5cdhck303h9e7ha9qnlt635",
+ "type": "age"
+ }
+]
diff --git a/checks/wireguard/sops/machines/peer1/key.json b/checks/wireguard/sops/machines/peer1/key.json
new file mode 100755
index 000000000..836e0b426
--- /dev/null
+++ b/checks/wireguard/sops/machines/peer1/key.json
@@ -0,0 +1,6 @@
+[
+ {
+ "publickey": "age1jts52rzlqcwjc36jkp56a7fmjn3czr7kl9ta2spkfzhvfama33sqacrzzd",
+ "type": "age"
+ }
+]
diff --git a/checks/wireguard/sops/machines/peer2/key.json b/checks/wireguard/sops/machines/peer2/key.json
new file mode 100755
index 000000000..0f6f90866
--- /dev/null
+++ b/checks/wireguard/sops/machines/peer2/key.json
@@ -0,0 +1,6 @@
+[
+ {
+ "publickey": "age12nqnp0zd435ckp5p0v2fv4p2x4cvur2mnxe8use2sx3fgy883vaq4ae75e",
+ "type": "age"
+ }
+]
diff --git a/checks/wireguard/sops/machines/peer3/key.json b/checks/wireguard/sops/machines/peer3/key.json
new file mode 100755
index 000000000..1c6e06cc1
--- /dev/null
+++ b/checks/wireguard/sops/machines/peer3/key.json
@@ -0,0 +1,6 @@
+[
+ {
+ "publickey": "age1sglr4zp34drjfydzeweq43fz3uwpul3hkh53lsfa9drhuzwmkqyqn5jegp",
+ "type": "age"
+ }
+]
diff --git a/checks/wireguard/sops/secrets/controller1-age.key/secret b/checks/wireguard/sops/secrets/controller1-age.key/secret
new file mode 100644
index 000000000..0534759ea
--- /dev/null
+++ b/checks/wireguard/sops/secrets/controller1-age.key/secret
@@ -0,0 +1,15 @@
+{
+ "data": "ENC[AES256_GCM,data:zDF0RiBqaawpg+GaFkuLPomJ01Xu+lgY5JfUzaIk2j03XkCzIf8EMrmn6pRtBP3iUjPBm+gQSTQk6GHTONrixA5hRNyETV+UgQw=,iv:zUUCAGZ0cz4Tc2t/HOjVYNsdnrAOtid/Ns5ak7rnyCk=,tag:z43WtNSue4Ddf7AVu21IKA==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlY1NEdjAzQm5RMFZWY3BJ\nclp6c01FdlZFK3dOSDB4cHc1NTdwMXErMFJFCnIrRVFNZEFYOG1rVUhFd2xsbTJ2\nVkJHNmdOWXlOcHJoQ0QzM1VyZmxmcGcKLS0tIFk1cEx4dFdvNGRwK1FWdDZsb1lR\nV2d1RFZtNzZqVFdtQ1FzNStEcEgyUUkKx8tkxqJz/Ko3xgvhvd6IYiV/lRGmrY13\nUZpYWR9tsQwZAR9dLjCyVU3JRuXeGB1unXC1CO0Ff3R0A/PuuRHh+g==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:37Z",
+ "mac": "ENC[AES256_GCM,data:8RGOUhZ2LGmC9ugULwHDgdMrtdo9vzBm3BJmL4XTuNJKm0NlKfgNLi1E4n9DMQ+kD4hKvcwbiUcwSGE8jZD6sm7Sh3bJi/HZCoiWm/O/OIzstli2NNDBGvQBgyWZA5H+kDjZ6aEi6icNWIlm5gsty7KduABnf5B3p0Bn5Uf5Bio=,iv:sGZp0XF+mgocVzAfHF8ATdlSE/5zyz5WUSRMJqNeDQs=,tag:ymYVBRwF5BOSAu5ONU2qKw==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/sops/secrets/controller1-age.key/users/admin b/checks/wireguard/sops/secrets/controller1-age.key/users/admin
new file mode 120000
index 000000000..9e21a9938
--- /dev/null
+++ b/checks/wireguard/sops/secrets/controller1-age.key/users/admin
@@ -0,0 +1 @@
+../../../users/admin
\ No newline at end of file
diff --git a/checks/wireguard/sops/secrets/controller2-age.key/secret b/checks/wireguard/sops/secrets/controller2-age.key/secret
new file mode 100644
index 000000000..c86b14363
--- /dev/null
+++ b/checks/wireguard/sops/secrets/controller2-age.key/secret
@@ -0,0 +1,15 @@
+{
+ "data": "ENC[AES256_GCM,data:dHM7zWzqnC1QLRKYpbI2t63kOFnSaQy6ur9zlkLQf17Q03CNrqUsZtdEbwMnLR3llu7eVMhtvVRkXjEkvn3leb9HsNFmtk/DP70=,iv:roEZsBFqRypM106O5sehTzo7SySOJUJgAR738rTtOo8=,tag:VDd9/6uU0SAM7pWRLIUhUQ==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKTEVYUmVGbUtOcHZ4cnc3\nKzNETnlxaVRKYTI3eWVHdEoyc3l2SnhsZ1J3CnB2RnZrOXM5Uml6TThDUlZjY25J\nbkJ6eUZ2ckN1NWpNUU9IaE93UDJQdlEKLS0tIC95ZDhkU0R1VHhCdldxdW4zSmps\nN3NqL1cvd05hRTRPdDA3R2pzNUFFajgKS+DJH14fH9AvEAa3PoUC1jEqKAzTmExN\nl32FeHTHbGMo1PKeaFm+Eg0WSpAmFE7beBunc5B73SW30ok6x4FcQw==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:47Z",
+ "mac": "ENC[AES256_GCM,data:77EnuBQyguvkCtobUg8/6zoLHjmeGDrSBZuIXOZBMxdbJjzhRg++qxQjuu6t0FoWATtz7u4Y3/jzUMGffr/N5HegqSq0D2bhv7AqJwBiVaOwd80fRTtM+YiP/zXsCk52Pj/Gadapg208bDPQ1BBDOyz/DrqZ7w//j+ARJjAnugI=,iv:IuTDmJKZEuHXJXjxrBw0gP2t6vpxAYEqbtpnVbavVCY=,tag:4EnpX6rOamtg1O+AaEQahQ==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/sops/secrets/controller2-age.key/users/admin b/checks/wireguard/sops/secrets/controller2-age.key/users/admin
new file mode 120000
index 000000000..9e21a9938
--- /dev/null
+++ b/checks/wireguard/sops/secrets/controller2-age.key/users/admin
@@ -0,0 +1 @@
+../../../users/admin
\ No newline at end of file
diff --git a/checks/wireguard/sops/secrets/peer1-age.key/secret b/checks/wireguard/sops/secrets/peer1-age.key/secret
new file mode 100644
index 000000000..e42782a7f
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer1-age.key/secret
@@ -0,0 +1,15 @@
+{
+ "data": "ENC[AES256_GCM,data:wcSsqxTKiMAnzPwxs5DNjcSdLyjVQ9UOrZxfSbOkVfniwx6F7xz6dLNhaDq7MHQ0vRWpg28yNs7NHrp52bYFnb/+eZsis46WiCw=,iv:B4t1lvS2gC601MtsmZfEiEulLWvSGei3/LSajwFS9Vs=,tag:hnRXlZyYEFfLJUrw1SqbSQ==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAybUgya2VEdzMvRG1hdkpu\nM2pGNmcyVmcvYVZ1ZjJlY3A1bXFUUUtkMTI0CmJoRFZmejZjN2UxUXNuc1k5WnE2\nNmxIcnpNQ1lJZ3ZKSmhtSlVURXJTSUUKLS0tIGU4Wi9yZ3VYekJkVW9pNWFHblFk\na0gzbTVKUWdSam1sVjRUaUlTdVd5YWMKntRc9yb9VPOTMibp8QM5m57DilP01N/X\nPTQaw8oI40znnHdctTZz7S+W/3Te6sRnkOhFyalWmsKY0CWg/FELlA==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:58Z",
+ "mac": "ENC[AES256_GCM,data:8nq+ugkUJxE24lUIySySs/cAF8vnfqr936L/5F0O1QFwNrbpPmKRXkuwa6u0V+187L2952Id20Fym4ke59f3fJJsF840NCKDwDDZhBZ20q9GfOqIKImEom/Nzw6D0WXQLUT3w8EMyJ/F+UaJxnBNPR6f6+Kx4YgStYzCcA6Ahzg=,iv:VBPktEz7qwWBBnXE+xOP/EUVy7/AmNCHPoK56Yt/ZNc=,tag:qXONwOLFAlopymBEf5p4Sw==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/sops/secrets/peer1-age.key/users/admin b/checks/wireguard/sops/secrets/peer1-age.key/users/admin
new file mode 120000
index 000000000..9e21a9938
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer1-age.key/users/admin
@@ -0,0 +1 @@
+../../../users/admin
\ No newline at end of file
diff --git a/checks/wireguard/sops/secrets/peer2-age.key/secret b/checks/wireguard/sops/secrets/peer2-age.key/secret
new file mode 100644
index 000000000..c8b3a966f
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer2-age.key/secret
@@ -0,0 +1,15 @@
+{
+ "data": "ENC[AES256_GCM,data:4d3ri0EsDmWRtA8vzvpPRLMsSp4MIMKwvtn0n0pRY05uBPXs3KcjnweMPIeTE1nIhqnMR2o2MfLah5TCPpaFax9+wxIt74uacbg=,iv:0LBAldTC/hN4QLCxgXTl6d9UB8WmUTnj4sD2zHQuG2w=,tag:zr/RhG/AU4g9xj9l2BprKw==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvV0JnZDhlU1piU1g2cng0\ncytKOEZ6WlZlNGRGUjV3MmVMd2Nzc0ZwelgwCjBGdThCUGlXbVFYdnNoZWpJZ3Vm\nc2xkRXhxS09vdzltSVoxLzhFSVduak0KLS0tIE5DRjJ6cGxiVlB1eElHWXhxN1pJ\nYWtIMDMvb0Z6akJjUzlqeEFsNHkxL2cKpghv/QegnXimeqd9OPFouGM//jYvoVmw\n2d4mLT2JSMkEhpfGcqb6vswhdJfCiKuqr2B4bqwAnPMaykhsm8DFRQ==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:20:08Z",
+ "mac": "ENC[AES256_GCM,data:BzlQVAJ7HzcxNPKB3JhabqRX/uU0EElj172YecjmOflHnzz/s9xgfdAfJK/c53hXlX4LtGPnubH7a8jOolRq98zmZeBYE27+WLs2aN7Ufld6mYk90/i7u4CqR+Fh2Kfht04SlUJCjnS5A9bTPwU9XGRHJ0BiOhzTuSMUJTRaPRM=,iv:L50K5zc1o99Ix9nP0pb9PRH+VIN2yvq7JqKeVHxVXmc=,tag:XFLkSCsdbTPxbasDYYxcFQ==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/sops/secrets/peer2-age.key/users/admin b/checks/wireguard/sops/secrets/peer2-age.key/users/admin
new file mode 120000
index 000000000..9e21a9938
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer2-age.key/users/admin
@@ -0,0 +1 @@
+../../../users/admin
\ No newline at end of file
diff --git a/checks/wireguard/sops/secrets/peer3-age.key/secret b/checks/wireguard/sops/secrets/peer3-age.key/secret
new file mode 100644
index 000000000..7f6fc613e
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer3-age.key/secret
@@ -0,0 +1,15 @@
+{
+ "data": "ENC[AES256_GCM,data:qfLm6+g1vYnESCik9uyBeKsY6Ju2Gq3arnn2I8HHNO67Ri5BWbOQTvtz7WT8/q94RwVjv8SGeJ/fsJSpwLSrJSbqTZCPAnYwzzQ=,iv:PnA9Ao8RRELNhNQYbaorstc0KaIXRU7h3+lgDCXZFHk=,tag:VeLgYQYwqthYihIoQTwYiA==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNWVVQaDJFd0N3WHptRC9Z\nZTgxTWh5bnU1SkpqRWRXZnhPaFhpSVJmVEhrCjFvdHFYenNWaFNrdXlha09iS2xj\nOTZDcUNkcHkvTDUwNjM4Z3gxUkxreUEKLS0tIE5oY3Q2bWhsb2FSQTVGTWVSclJw\nWllrelRwT3duYjJJbTV0d3FwU1VuNlkK2eN3fHFX/sVUWom8TeZC9fddqnSCsC1+\nJRCZsG46uHDxqLcKIfdFWh++2t16XupQYk3kn+NUR/aMc3fR32Uwjw==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:20:18Z",
+ "mac": "ENC[AES256_GCM,data:nUwsPcP1bsDjAHFjQ1NlVkTwyZY4B+BpzNkMx9gl0rE14j425HVLtlhlLndhRp+XMpnDldQppLAAtSdzMsrw8r5efNgTRl7cu4Fy/b9cHt84k7m0aou5lrGus9SV1bM7/fzC9Xm7CSXBcRzyDGVsKC6UBl1rx+ybh7HyAN05XSo=,iv:It57H+zUUNPkoN1D8sYwyZx5zIFIga7mydhGUHYBCGE=,tag:mBQdYqUpjPknbYa13qESyw==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/sops/secrets/peer3-age.key/users/admin b/checks/wireguard/sops/secrets/peer3-age.key/users/admin
new file mode 120000
index 000000000..9e21a9938
--- /dev/null
+++ b/checks/wireguard/sops/secrets/peer3-age.key/users/admin
@@ -0,0 +1 @@
+../../../users/admin
\ No newline at end of file
diff --git a/checks/wireguard/sops/users/admin/key.json b/checks/wireguard/sops/users/admin/key.json
new file mode 100644
index 000000000..e408aa96b
--- /dev/null
+++ b/checks/wireguard/sops/users/admin/key.json
@@ -0,0 +1,4 @@
+{
+ "publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "type": "age"
+}
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/machines/controller1 b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/machines/controller1
new file mode 120000
index 000000000..1ff577620
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/machines/controller1
@@ -0,0 +1 @@
+../../../../../../sops/machines/controller1
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/secret b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/secret
new file mode 100644
index 000000000..30dba950b
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/secret
@@ -0,0 +1,19 @@
+{
+ "data": "ENC[AES256_GCM,data:noe913+28JWkoDkGGMu++cc1+j5NPDoyIhWixdsowoiVO3cTWGkZ88SUGO5D,iv:ynYMljwqMcBdk8RpVcw/2Jflg2RCF28r4fKUgIAF8B4=,tag:+TsXDJgfUhKgg4iQVXKKlQ==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhYVRReTZBQ05GYmVBVjhS\nNXM5aFlhVzZRaVl6UHl6S3JnMC9Sb1dwZ1ZjCmVuS2dEVExYZWROVklUZWFCSnM2\nZnlxbVNseTM2c0Q0TjhsT3NzYmtqREUKLS0tIHBRTFpvVGt6d1cxZ2lFclRsUVhZ\nZDlWaG9PcXVrNUZKaEgxWndjUDVpYjgKt0eOhAgcYdkg9JSEakx4FjChLTn3pis+\njOkuGd4JfXMKcwC7vJV5ygQBxzVJSBw+RucP7sYCBPK0m8Voj94ntw==\n-----END AGE ENCRYPTED FILE-----\n"
+ },
+ {
+ "recipient": "age1rnkc2vmrupy9234clyu7fpur5kephuqs3v7qauaw5zeg00jqjdasefn3cc",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6MFJqNHNraG9DSnJZMFdz\ndU8zVXNTamxROFd1dWtuK2RiekhPdHhleVhFCi8zNWJDNXJMRUlDdjc4Q0UycTIz\nSGFGSmdnNU0wZWlDaTEwTzBqWjh6SFkKLS0tIEJOdjhOMDY2TUFLb3RPczNvMERx\nYkpSeW5VOXZvMlEvdm53MDE3aUFTNjgKyelSTjrTIR9I3rJd3krvzpsrKF1uGs4J\n4MtmQj0/3G+zPYZVBx7b3HF6B3f1Z7LYh05+z7nCnN/duXyPnDjNcg==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:37Z",
+ "mac": "ENC[AES256_GCM,data:+DmIkPG/H6tCtf8CvB98E1QFXv08QfTcCB3CRsi+XWnIRBkryRd/Au9JahViHMdK7MED8WNf84NWTjY2yH4y824/DjI8XXNMF1iVMo0CqY42xbVHtUuhXrYeT+c8CyEw+M6zfy1jC0+Bm3WQWgagz1G6A9SZk3D2ycu0N08+axA=,iv:kwBjTYebIy5i2hagAajSwwuKnSkrM9GyrnbeQXB2e/w=,tag:EgKJ5gVGYj1NGFUduxLGfg==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/users/admin b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/users/admin
new file mode 120000
index 000000000..ca714e122
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/privatekey/users/admin
@@ -0,0 +1 @@
+../../../../../../sops/users/admin
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/publickey/value b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/publickey/value
new file mode 100644
index 000000000..fbab5a790
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-keys-wg-test-one/publickey/value
@@ -0,0 +1 @@
+lQfR7GhivN87XoXruTGOPjVPhNu1Brt//wyc3pdwE20=
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/.validation-hash b/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/.validation-hash
new file mode 100644
index 000000000..ece4cec0f
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/.validation-hash
@@ -0,0 +1 @@
+7470bb5c79df224a9b7f5a2259acd2e46db763c27e24cb3416c8b591cb328077
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/prefix/value b/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/prefix/value
new file mode 100644
index 000000000..339f39241
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller1/wireguard-network-wg-test-one/prefix/value
@@ -0,0 +1 @@
+fd51:19c1:3b:f700
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/machines/controller2 b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/machines/controller2
new file mode 120000
index 000000000..0d2ebbf0b
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/machines/controller2
@@ -0,0 +1 @@
+../../../../../../sops/machines/controller2
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/secret b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/secret
new file mode 100644
index 000000000..3c476a10a
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/secret
@@ -0,0 +1,19 @@
+{
+ "data": "ENC[AES256_GCM,data:2kehACgvNgoYGPwnW7p86BR0yUu689Chth6qZf9zoJtuTY9ATS68dxDyBc5S,iv:qb2iDUtExegTeN3jt6SA8RnU61W5GDDhn56QXiQT4gw=,tag:pSGPICX5p6qlZ1WMVoIEYQ==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSTTR5TDY4RE9VYmlCK1dL\nWkVRcVZqVDlsbmQvUlJmdzF2b1Z1S0k3NngwCkFWNzRVaERtSmFsd0o2aFJOb0ZX\nSU9yUnVaNi9IUjJWeGRFcEpDUXo5WkEKLS0tIEczNkxiYnJsTWRoLzFhQVF1M21n\nWnZEdGV1N2N5d1FZQkJUQ1IrdGFLblkKPTpha2bxS8CCAMXWTDKX/WOcdvggaP3Y\nqewyahDNzb4ggP+LNKp55BtwFjdvoPoq4BpYOOgMRbQMMk+H1o9WFw==\n-----END AGE ENCRYPTED FILE-----\n"
+ },
+ {
+ "recipient": "age1t2hhg99d4p2yymuhngcy5ccutp8mvu7qwvg5cdhck303h9e7ha9qnlt635",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYcEZ6Tzk3M0pkV0tOdTBj\nenF2a0tHNnhBa0NrazMwV1VBbXBZR3pzSHpvCnBZOEU0VlFHS1FHcVpTTDdPczVV\nV0RFSlZ0VmIzWGoydEdKVXlIUE9OOEkKLS0tIFZ0cWVBR1loeVlWa2c4U3oweXE2\ncm1ja0JCS3U5Nk41dlAzV2NabDc2bDQKdgCDNnpRZlFPnEGlX6fo0SQX4yOB+E6r\ntnSwofR3xxZvkyme/6JJU5qBZXyCXEAhKMRkFyvJANXzMJAUo/Osow==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:48Z",
+ "mac": "ENC[AES256_GCM,data:e3EkL8vwRhLsec83Zi9DE3PKT+4RwgiffpN4QHcJKTgmDW6hzizWc5kAxbNWGJ9Qqe6sso2KY7tc+hg1lHEsmzjCbg153p8h+7lVI2XT6adi/CS8WZ2VpeL+0X9zDQCjqHmrESZAYFBdkLqO4jucdf0Pc3CKKD+N3BDDTwSUvHM=,iv:xvR7dJL8sdYen00ovrYT8PNxhB9XxSWDSRz1IK23I/o=,tag:OyhAvllBgfAp3eGeNpR/Nw==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/users/admin b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/users/admin
new file mode 120000
index 000000000..ca714e122
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/privatekey/users/admin
@@ -0,0 +1 @@
+../../../../../../sops/users/admin
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/publickey/value b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/publickey/value
new file mode 100644
index 000000000..29cc6e7d1
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-keys-wg-test-one/publickey/value
@@ -0,0 +1 @@
+5Z7gbLFbXpEFfomW2pKyZBpZN5xvUtiqrIL0GVfNtQ8=
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/.validation-hash b/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/.validation-hash
new file mode 100644
index 000000000..7e9366e0e
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/.validation-hash
@@ -0,0 +1 @@
+c3672fdb9fb31ddaf6572fc813cf7a8fe50488ef4e9d534c62d4f29da60a1a99
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/prefix/value b/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/prefix/value
new file mode 100644
index 000000000..8c63fc5ac
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/controller2/wireguard-network-wg-test-one/prefix/value
@@ -0,0 +1 @@
+fd51:19c1:c1:aa00
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/machines/peer1 b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/machines/peer1
new file mode 120000
index 000000000..3e5f3fae3
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/machines/peer1
@@ -0,0 +1 @@
+../../../../../../sops/machines/peer1
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/secret b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/secret
new file mode 100644
index 000000000..9a6185acd
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/secret
@@ -0,0 +1,19 @@
+{
+ "data": "ENC[AES256_GCM,data:b+akw85T3D9xc75CPLHucR//k7inpxKDvgpR8tCNKwNDRVjVHjcABhfZNLXW,iv:g11fZE8UI0MVh9GKdjR6leBlxa4wN7ZubozXG/VlBbw=,tag:0YkzWCW3zJ3Mt3br/jmTYw==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1jts52rzlqcwjc36jkp56a7fmjn3czr7kl9ta2spkfzhvfama33sqacrzzd",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXWkJUR0pIa2xOSEw2dThm\nYlNuOHZCVW93Wkc5LzE4YmpUTHRkZlk3ckc4CnN4M3ZRMWNFVitCT3FyWkxaR0di\nb0NmSXFhRHJmTWg0d05OcWx1LytscEEKLS0tIEtleTFqU3JrRjVsdHpJeTNuVUhF\nWEtnOVlXVXRFamFSak5ia2F2b0JiTzAKlhOBZvZ4AN+QqAYQXvd6YNmgVS4gtkWT\nbV3bLNTgwtrDtet9NDHM8vdF+cn5RZxwFfgmTbDEow6Zm8EXfpxj/g==\n-----END AGE ENCRYPTED FILE-----\n"
+ },
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6YVYyQkZqMTJYQTlyRG5Y\nbnJ2UkE1TS9FZkpSa2tQbk1hQjViMi9OcGk0CjFaZUdjU3JtNzh0bDFXdTdUVW4x\nanFqZHZjZjdzKzA2MC8vTWh3Uy82UGcKLS0tIDhyOFl3UGs3czdoMlpza3UvMlB1\nSE90MnpGc05sSCtmVWg0UVNVdmRvN2MKHlCr4U+7bsoYb+2fgT4mEseZCEjxrtLu\n55sR/4YH0vqMnIBnLTSA0e+WMrs3tQfseeJM5jY/ZNnpec1LbxkGTg==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:19:58Z",
+ "mac": "ENC[AES256_GCM,data:gEoEC9D2Z7k5F8egaY1qPXT5/96FFVsyofSBivQ28Ir/9xHX2j40PAQrYRJUWsk/GAUMOyi52Wm7kPuacw+bBcdtQ0+MCDEmjkEnh1V83eZ/baey7iMmg05uO92MYY5o4e7ZkwzXoAeMCMcfO0GqjNvsYJHF1pSNa+UNDj+eflw=,iv:dnIYpvhAdvUDe9md53ll42krb0sxcHy/toqGc7JFxNA=,tag:0WkZU7GeKMD1DQTYaI+1dg==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/users/admin b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/users/admin
new file mode 120000
index 000000000..ca714e122
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/privatekey/users/admin
@@ -0,0 +1 @@
+../../../../../../sops/users/admin
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/publickey/value b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/publickey/value
new file mode 100644
index 000000000..68081c5bf
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-keys-wg-test-one/publickey/value
@@ -0,0 +1 @@
+juK7P/92N2t2t680aLIRobHc3ts49CsZBvfZOyIKpUc=
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/.validation-hash b/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/.validation-hash
new file mode 100644
index 000000000..da9a88035
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/.validation-hash
@@ -0,0 +1 @@
+b36142569a74a0de0f9b229f2a040ae33a22d53bef5e62aa6939912d0cda05ba
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/suffix/value b/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/suffix/value
new file mode 100644
index 000000000..fd09cf0ed
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer1/wireguard-network-wg-test-one/suffix/value
@@ -0,0 +1 @@
+6987:50a0:9b93:4337
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/machines/peer2 b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/machines/peer2
new file mode 120000
index 000000000..6370c90d4
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/machines/peer2
@@ -0,0 +1 @@
+../../../../../../sops/machines/peer2
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/secret b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/secret
new file mode 100644
index 000000000..b6ccf727a
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/secret
@@ -0,0 +1,19 @@
+{
+ "data": "ENC[AES256_GCM,data:apX2sLwtq6iQgLJslFwiRMNBUe0XLzLQbhKfmb2pKiJG7jGNHUgHJz3Ls4Ca,iv:HTDatm3iD5wACTkkd3LdRNvJfnfg75RMtn9G6Q7Fqd4=,tag:Mfehlljnes5CFD1NJdk27A==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age12nqnp0zd435ckp5p0v2fv4p2x4cvur2mnxe8use2sx3fgy883vaq4ae75e",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVZzFyMUZsd2V2VWxOUmhP\nZE8yZTc4Q0RkZisxR25NemR1TzVDWmJZVjBVClA1MWhsU0xzSG16aUx3cWFWKzlG\nSkxrT09OTkVqLzlWejVESE1QWHVJaFkKLS0tIGxlaGVuWU43RXErNTB3c3FaUnM3\nT0N5M253anZkbnFkZWw2VHA0eWhxQW8Kd1PMtEX1h0Hd3fDLMi++gKJkzPi9FXUm\n+uYhx+pb+pJM+iLkPwP/q6AWC7T0T4bHfekkdzxrbsKMi73x/GrOiw==\n-----END AGE ENCRYPTED FILE-----\n"
+ },
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqVzRIMWdlNjVwTURyMFkv\nSUhiajZkZVNuWklRYit6cno4UzNDa2szOFN3CkQ2TWhHb25pbmR1MlBsRXNLL2lx\ncVZ3c3BsWXN2aS9UUVYvN3I4S0xUSmMKLS0tIE5FV0U5aXVUZk9XL0U0Z2ZSNGd5\nbU9zY3IvMlpSNVFLYkRNQUpUYVZOWFUK7j4Otzb8CJTcT7aAj9/irxHEDXh1HkTg\nzz7Ho8/ZncNtaCVHlHxjTgVW9d5aIx8fSsV9LRCFwHMtNzvwj1Nshg==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:20:08Z",
+ "mac": "ENC[AES256_GCM,data:e7WNVEz78noHBiz6S3A6qNfop+yBXB3rYN0k4GvaQKz3b99naEHuqIF8Smzzt4XrbbiPKu2iLa5ddLBlqqsi32UQUB8JS9TY7hvW8ol+jpn0VxusGCXW9ThdDEsM/hXiPyr331C73zTvbOYI1hmcGMlJL9cunVRO9rkMtEqhEfo=,iv:6zt7wjIs1y5xDHNK+yLOwoOuUpY7/dOGJGT6UWAFeOg=,tag:gzFTgoxhoLzUV0lvzOhhfg==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/users/admin b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/users/admin
new file mode 120000
index 000000000..ca714e122
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/privatekey/users/admin
@@ -0,0 +1 @@
+../../../../../../sops/users/admin
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/publickey/value b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/publickey/value
new file mode 100644
index 000000000..087ed4b7f
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-keys-wg-test-one/publickey/value
@@ -0,0 +1 @@
+XI9uSaQRDBCb82cMnGzGJcbqRfDG/IXZobyeL+kV03k=
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/.validation-hash b/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/.validation-hash
new file mode 100644
index 000000000..38043bffa
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/.validation-hash
@@ -0,0 +1 @@
+360f9fce4a984eb87ce2a673eb5341ecb89c0f62126548d45ef25ff5243dd646
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/suffix/value b/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/suffix/value
new file mode 100644
index 000000000..41acb1910
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer2/wireguard-network-wg-test-one/suffix/value
@@ -0,0 +1 @@
+3b21:3ced:003e:89b3
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/machines/peer3 b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/machines/peer3
new file mode 120000
index 000000000..356dfec23
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/machines/peer3
@@ -0,0 +1 @@
+../../../../../../sops/machines/peer3
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/secret b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/secret
new file mode 100644
index 000000000..208bcea8c
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/secret
@@ -0,0 +1,19 @@
+{
+ "data": "ENC[AES256_GCM,data:Gluvjes/3oH5YsDq00JDJyJgoEFcj56smioMArPSt309MDGExYX2QsCzeO1q,iv:oBBJRDdTj/1dWEvzhdFKQ2WfeCKyavKMLmnMbqnU5PM=,tag:2WNFxKz2dWyVcybpm5N4iw==,type:str]",
+ "sops": {
+ "age": [
+ {
+ "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtQWpjRmhZTFdPa2VSZkFN\nbUczMlY5bDBmMTdoMy8xcWxMaXpWVitMZGdjCnRWb2Y3eGpHU1hmNHRJVFBqbU5w\nVEZGdUIrQXk0U0dUUEZ6bE5EMFpTRHMKLS0tIGpYSmZmQThJUTlvTHpjc05ZVlM4\nQWhTOWxnUHZnYlJ3czE3ZUJ0L3ozWTQK3a7N0Zpzo4sUezYveqvKR49RUdJL23eD\n+cK5lk2xbtj+YHkeG+dg7UlHfDaicj0wnFH1KLuWmNd1ONa6eQp3BQ==\n-----END AGE ENCRYPTED FILE-----\n"
+ },
+ {
+ "recipient": "age1sglr4zp34drjfydzeweq43fz3uwpul3hkh53lsfa9drhuzwmkqyqn5jegp",
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3a2FOWlVsSkdnendrYmUz\ndEpuL1hZSWNFTUtDYm14S3V1aW9KS3hsazJRCkp2SkFFbi9hbGJpNks1MlNTL0s5\nTk5pcUMxaEJobkcvWmRGeU9jMkdNdzAKLS0tIDR6M0Y5eE1ETHJJejAzVW1EYy9v\nZCtPWHJPUkhuWnRzSGhMUUtTa280UmMKXvtnxyop7PmRvTOFkV80LziDjhGh93Pf\nYwhD/ByD/vMmr21Fd6PVHOX70FFT30BdnMc1/wt7c/0iAw4w4GoQsA==\n-----END AGE ENCRYPTED FILE-----\n"
+ }
+ ],
+ "lastmodified": "2025-08-13T09:20:18Z",
+ "mac": "ENC[AES256_GCM,data:3nXMTma0UYXCco+EM8UW45cth7DVMboFBKyesL86GmaG6OlTkA2/25AeDrtSVO13a5c2jC6yNFK5dE6pSe5R9f0BoDF7d41mgc85zyn+LGECNWKC6hy6gADNSDD6RRuV1S3FisFQl1F1LD8LiSWmg/XNMZzChNlHYsCS8M+I84g=,iv:pu5VVXAVPmVoXy0BJ+hq5Ar8R0pZttKSYa4YS+dhDNc=,tag:xp1S/4qExnxMTGwhfLJrkA==,type:str]",
+ "unencrypted_suffix": "_unencrypted",
+ "version": "3.10.2"
+ }
+}
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/users/admin b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/users/admin
new file mode 120000
index 000000000..ca714e122
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/privatekey/users/admin
@@ -0,0 +1 @@
+../../../../../../sops/users/admin
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/publickey/value b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/publickey/value
new file mode 100644
index 000000000..d16866845
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-keys-wg-test-one/publickey/value
@@ -0,0 +1 @@
+t6qN4VGLR+VMhrBDNKQEXZVyRsEXs1/nGFRs5DI82F8=
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/.validation-hash b/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/.validation-hash
new file mode 100644
index 000000000..6f65517da
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/.validation-hash
@@ -0,0 +1 @@
+e3facc99b73fe029d4c295f71829a83f421f38d82361cf412326398175da162a
\ No newline at end of file
diff --git a/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/suffix/value b/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/suffix/value
new file mode 100644
index 000000000..7ad4d1b18
--- /dev/null
+++ b/checks/wireguard/vars/per-machine/peer3/wireguard-network-wg-test-one/suffix/value
@@ -0,0 +1 @@
+e42b:bf85:33f4:f0b1
\ No newline at end of file
diff --git a/clanServices/wireguard/README.md b/clanServices/wireguard/README.md
new file mode 100644
index 000000000..bbc22cc71
--- /dev/null
+++ b/clanServices/wireguard/README.md
@@ -0,0 +1,217 @@
+# Wireguard VPN Service
+
+This service provides a Wireguard-based VPN mesh network with automatic IPv6 address allocation and routing between clan machines.
+
+## Overview
+
+The wireguard service creates a secure mesh network between clan machines using two roles:
+- **Controllers**: Machines with public endpoints that act as connection points and routers
+- **Peers**: Machines that connect through controllers to access the network
+
+## Features
+
+- Automatic IPv6 address allocation using ULA (Unique Local Address) prefixes
+- Full mesh connectivity between all machines
+- Automatic key generation and distribution
+- IPv6 forwarding on controllers for inter-peer communication
+- Support for multiple controllers for redundancy
+
+## Network Architecture
+
+### IPv6 Address Allocation
+- Base network: `/40` ULA prefix (deterministically generated from instance name)
+- Controllers: Each gets a `/56` subnet from the base `/40`
+- Peers: Each gets a unique 64-bit host suffix that is used in ALL controller subnets
+
+### Addressing Design
+- Each peer generates a unique host suffix (e.g., `:8750:a09b:0:1`)
+- This suffix is appended to each controller's `/56` prefix to create unique addresses
+- Example: peer1 with suffix `:8750:a09b:0:1` gets:
+ - `fd51:19c1:3b:f700:8750:a09b:0:1` in controller1's subnet
+ - `fd51:19c1:c1:aa00:8750:a09b:0:1` in controller2's subnet
+- Controllers allow each peer's `/96` subnet for routing flexibility
+
+### Connectivity
+- Peers use a single WireGuard interface with multiple IPs (one per controller subnet)
+- Controllers connect to ALL other controllers and ALL peers on a single interface
+- Controllers have IPv6 forwarding enabled to route traffic between peers
+- All traffic between peers flows through controllers
+- Symmetric routing is maintained as each peer has consistent IPs across all controllers
+
+### Example Network Topology
+
+```mermaid
+graph TB
+ subgraph Controllers
+ C1[controller1
endpoint: vpn1.example.com
fd51:19c1:3b:f700::/56]
+ C2[controller2
endpoint: vpn2.example.com
fd51:19c1:c1:aa00::/56]
+ end
+
+ subgraph Peers
+ P1[peer1
designated: controller1]
+ P2[peer2
designated: controller2]
+ P3[peer3
designated: controller1]
+ end
+
+ %% Controllers connect to each other
+ C1 <--> C2
+
+ %% All peers connect to all controllers
+ P1 <--> C1
+ P1 <--> C2
+ P2 <--> C1
+ P2 <--> C2
+ P3 <--> C1
+ P3 <--> C2
+
+ %% Peer-to-peer traffic flows through controllers
+ P1 -.->|via controllers| P3
+ P1 -.->|via controllers| P2
+ P2 -.->|via controllers| P3
+
+ classDef controller fill:#f9f,stroke:#333,stroke-width:4px
+ classDef peer fill:#bbf,stroke:#333,stroke-width:2px
+ class C1,C2 controller
+ class P1,P2,P3 peer
+```
+
+## Configuration
+
+### Basic Setup with Single Controller
+
+```nix
+# In your flake.nix or inventory
+{
+ services.wireguard.server1 = {
+ roles.controller = {
+ # Public endpoint where this controller can be reached
+ endpoint = "vpn.example.com";
+ # Optional: Change the UDP port (default: 51820)
+ port = 51820;
+ };
+ };
+
+ services.wireguard.laptop1 = {
+ roles.peer = {
+ # No configuration needed if only one controller exists
+ };
+ };
+}
+```
+
+### Multiple Controllers Setup
+
+```nix
+{
+ services.wireguard.server1 = {
+ roles.controller = {
+ endpoint = "vpn1.example.com";
+ };
+ };
+
+ services.wireguard.server2 = {
+ roles.controller = {
+ endpoint = "vpn2.example.com";
+ };
+ };
+
+ services.wireguard.laptop1 = {
+ roles.peer = {
+ # Must specify which controller's subnet to use for IP allocation
+ controller = "server1";
+ };
+ };
+}
+```
+
+### Advanced Options
+
+
+### Automatic Hostname Resolution
+
+The wireguard service automatically adds entries to `/etc/hosts` for all machines in the network. Each machine is accessible via its hostname in the format `.`.
+
+For example, with an instance named `vpn`:
+- `server1.vpn` - resolves to server1's IPv6 address
+- `laptop1.vpn` - resolves to laptop1's IPv6 address
+
+This allows machines to communicate using hostnames instead of IPv6 addresses:
+
+```bash
+# Ping another machine by hostname
+ping6 server1.vpn
+
+# SSH to another machine
+ssh user@laptop1.vpn
+```
+
+## Troubleshooting
+
+### Check Wireguard Status
+```bash
+sudo wg show
+```
+
+### Verify IP Addresses
+```bash
+ip addr show dev
+```
+
+### Check Routing
+```bash
+ip -6 route show dev
+```
+
+### Interface Fails to Start: "Address already in use"
+
+If you see this error in your logs:
+```
+wireguard: Could not bring up interface, ignoring: Address already in use
+```
+
+This means the configured port (default: 51820) is already in use by another service or wireguard instance. Solutions:
+
+1. **Check for conflicting wireguard instances:**
+ ```bash
+ sudo wg show
+ sudo ss -ulnp | grep 51820
+ ```
+
+2. **Use a different port:**
+ ```nix
+ services.wireguard.myinstance = {
+ roles.controller = {
+ endpoint = "vpn.example.com";
+ port = 51821; # Use a different port
+ };
+ };
+ ```
+
+3. **Ensure unique ports across multiple instances:**
+ If you have multiple wireguard instances on the same machine, each must use a different port.
+
+### Key Management
+
+Keys are automatically generated and stored in the clan vars system. To regenerate keys:
+
+```bash
+# Regenerate keys for a specific machine and instance
+clan vars generate --service wireguard-keys- --regenerate --machine
+
+# Apply the new keys
+clan machines update
+```
+
+## Security Considerations
+
+- All traffic is encrypted using Wireguard's modern cryptography
+- Private keys never leave the machines they're generated on
+- Public keys are distributed through the clan vars system
+- Controllers must have publicly accessible endpoints
+- Firewall rules are automatically configured for the Wireguard ports
+
+## Requirements
+
+- Controllers must have a publicly accessible endpoint (domain name or static IP)
+- IPv6 support in the kernel (standard in modern systems)
+- UDP port access (default: 51820, configurable)
diff --git a/clanServices/wireguard/default.nix b/clanServices/wireguard/default.nix
new file mode 100644
index 000000000..74317a8c0
--- /dev/null
+++ b/clanServices/wireguard/default.nix
@@ -0,0 +1,456 @@
+/*
+ There are two roles: peers and controllers:
+ - Every controller has an endpoint set
+ - There can be multiple peers
+ - There has to be one or more controllers
+ - Peers connect to ALL controllers (full mesh)
+ - If only one controller exists, peers automatically use it for IP allocation
+ - If multiple controllers exist, peers must specify which controller's subnet to use
+ - Controllers have IPv6 forwarding enabled, every peer and controller can reach
+ everyone else, via extra controller hops if necessary
+
+ Example:
+ ┌───────────────────────────────┐
+ │ ◄───────────── │
+ │ controller2 controller1
+ │ ▲ ─────────────► ▲ ▲
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ │ │ │ │ │
+ │ │ │ │ └───────────────┐ │ │ │ │
+ │ │ │ └──────────────┐ │ │ │ │ │
+ │ ▼ │ ▼ ▼ ▼
+ └─► peer2 │ peer1 peer3
+ │ ▲
+ └──────────┘
+
+ Network Architecture:
+
+ IPv6 Address Allocation:
+ - Base network: /40 ULA prefix (generated from instance name)
+ - Controllers: Each gets a /56 subnet from the base /40
+ - Peers: Each gets a unique host suffix that is used in ALL controller subnets
+
+ Address Assignment:
+ - Each peer generates a unique 64-bit host suffix (e.g., :8750:a09b:0:1)
+ - This suffix is appended to each controller's /56 prefix
+ - Example: peer1 with suffix :8750:a09b:0:1 gets:
+ - fd51:19c1:3b:f700:8750:a09b:0:1 in controller1's subnet
+ - fd51:19c1:c1:aa00:8750:a09b:0:1 in controller2's subnet
+
+ Peers: Use a SINGLE interface that:
+ - Connects to ALL controllers
+ - Has multiple IPs, one in each controller's subnet (with /56 prefix)
+ - Routes to each controller's /56 subnet via that controller
+ - allowedIPs: Each controller's /56 subnet
+ - No routing conflicts due to unique IPs per subnet
+
+ Controllers: Use a SINGLE interface that:
+ - Connects to ALL peers and ALL other controllers
+ - Gets a /56 subnet from the base /40 network
+ - Has IPv6 forwarding enabled for routing between peers
+ - allowedIPs:
+ - For peers: A /96 range containing the peer's address in this controller's subnet
+ - For other controllers: The controller's /56 subnet
+*/
+
+{ ... }:
+let
+ # Shared module for extraHosts configuration
+ extraHostsModule =
+ {
+ instanceName,
+ settings,
+ roles,
+ config,
+ lib,
+ ...
+ }:
+ {
+ networking.extraHosts =
+ let
+ domain = if settings.domain == null then instanceName else settings.domain;
+ # Controllers use their subnet's ::1 address
+ controllerHosts = lib.mapAttrsToList (
+ name: _value:
+ let
+ prefix = builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
+ );
+ # Controller IP is always ::1 in their subnet
+ ip = prefix + "::1";
+ in
+ "${ip} ${name}.${domain}"
+ ) roles.controller.machines;
+
+ # Peers use their suffix in their designated controller's subnet only
+ peerHosts = lib.mapAttrsToList (
+ peerName: peerValue:
+ let
+ peerSuffix = builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${peerName}/wireguard-network-${instanceName}/suffix/value"
+ );
+ # Determine designated controller
+ designatedController =
+ if (builtins.length (builtins.attrNames roles.controller.machines) == 1) then
+ (builtins.head (builtins.attrNames roles.controller.machines))
+ else
+ peerValue.settings.controller;
+ controllerPrefix = builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${designatedController}/wireguard-network-${instanceName}/prefix/value"
+ );
+ peerIP = controllerPrefix + ":" + peerSuffix;
+ in
+ "${peerIP} ${peerName}.${domain}"
+ ) roles.peer.machines;
+ in
+ builtins.concatStringsSep "\n" (controllerHosts ++ peerHosts);
+ };
+
+ # Shared interface options
+ sharedInterface =
+ { lib, ... }:
+ {
+ options.port = lib.mkOption {
+ type = lib.types.int;
+ example = 51820;
+ default = 51820;
+ description = ''
+ Port for the wireguard interface
+ '';
+ };
+
+ options.domain = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ defaultText = lib.literalExpression "instanceName";
+ default = null;
+ description = ''
+ Domain suffix to use for hostnames in /etc/hosts.
+ Defaults to the instance name.
+ '';
+ };
+ };
+in
+{
+ _class = "clan.service";
+ manifest.name = "clan-core/wireguard";
+ manifest.description = "Wireguard-based VPN mesh network with automatic IPv6 address allocation";
+ manifest.categories = [
+ "System"
+ "Network"
+ ];
+ manifest.readme = builtins.readFile ./README.md;
+
+ # Peer options and configuration
+ roles.peer = {
+ interface =
+ { lib, ... }:
+ {
+ imports = [ sharedInterface ];
+
+ options.controller = lib.mkOption {
+ type = lib.types.str;
+ example = "controller1";
+ description = ''
+ Machinename of the controller to attach to
+ '';
+ };
+ };
+
+ perInstance =
+ {
+ instanceName,
+ settings,
+ roles,
+ machine,
+ ...
+ }:
+ {
+ # Set default domain to instanceName
+
+ # Peers connect to all controllers
+ nixosModule =
+ {
+ config,
+ pkgs,
+ lib,
+ ...
+ }:
+ {
+ imports = [
+ (extraHostsModule {
+ inherit
+ instanceName
+ settings
+ roles
+ config
+ lib
+ ;
+ })
+ ];
+ # Network allocation generator for this peer - generates host suffix
+ clan.core.vars.generators."wireguard-network-${instanceName}" = {
+ files.suffix.secret = false;
+
+ runtimeInputs = with pkgs; [
+ python3
+ ];
+
+ # Invalidate on hostname changes
+ validation.hostname = machine.name;
+
+ script = ''
+ ${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" peer "${machine.name}"
+ '';
+ };
+
+ # Single wireguard interface with multiple IPs
+ networking.wireguard.interfaces."${instanceName}" = {
+ ips =
+ # Get this peer's suffix
+ let
+ peerSuffix =
+ config.clan.core.vars.generators."wireguard-network-${instanceName}".files.suffix.value;
+ in
+ # Create an IP in each controller's subnet
+ lib.mapAttrsToList (
+ ctrlName: _:
+ let
+ controllerPrefix = builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${ctrlName}/wireguard-network-${instanceName}/prefix/value"
+ );
+ peerIP = controllerPrefix + ":" + peerSuffix;
+ in
+ "${peerIP}/56"
+ ) roles.controller.machines;
+
+ privateKeyFile =
+ config.clan.core.vars.generators."wireguard-keys-${instanceName}".files."privatekey".path;
+
+ # Connect to all controllers
+ peers = lib.mapAttrsToList (name: value: {
+ publicKey = (
+ builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
+ )
+ );
+
+ # Allow each controller's /56 subnet
+ allowedIPs = [
+ "${
+ builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
+ )
+ }::/56"
+ ];
+
+ endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
+
+ persistentKeepalive = 25;
+ }) roles.controller.machines;
+ };
+ };
+ };
+ };
+
+ # Controller options and configuration
+ roles.controller = {
+ interface =
+ { lib, ... }:
+ {
+ imports = [ sharedInterface ];
+
+ options.endpoint = lib.mkOption {
+ type = lib.types.str;
+ example = "vpn.clan.lol";
+ description = ''
+ Endpoint where the controller can be reached
+ '';
+ };
+ };
+ perInstance =
+ {
+ settings,
+ instanceName,
+ roles,
+ machine,
+ ...
+ }:
+ {
+
+ # Controllers connect to all peers and other controllers
+ nixosModule =
+ {
+ config,
+ pkgs,
+ lib,
+ ...
+ }:
+ let
+ allOtherControllers = lib.filterAttrs (name: _v: name != machine.name) roles.controller.machines;
+ allPeers = roles.peer.machines;
+ in
+ {
+ imports = [
+ (extraHostsModule {
+ inherit
+ instanceName
+ settings
+ roles
+ config
+ lib
+ ;
+ })
+ ];
+ # Network allocation generator for this controller
+ clan.core.vars.generators."wireguard-network-${instanceName}" = {
+ files.prefix.secret = false;
+
+ runtimeInputs = with pkgs; [
+ python3
+ ];
+
+ # Invalidate on network or hostname changes
+ validation.hostname = machine.name;
+
+ script = ''
+ ${pkgs.python3}/bin/python3 ${./ipv6_allocator.py} "$out" "${instanceName}" controller "${machine.name}"
+ '';
+ };
+
+ # Enable ip forwarding, so wireguard peers can reach eachother
+ boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = 1;
+
+ networking.firewall.allowedUDPPorts = [ settings.port ];
+
+ # Single wireguard interface
+ networking.wireguard.interfaces."${instanceName}" = {
+ listenPort = settings.port;
+
+ ips = [
+ # Controller uses ::1 in its /56 subnet but with /40 prefix for proper routing
+ "${config.clan.core.vars.generators."wireguard-network-${instanceName}".files.prefix.value}::1/40"
+ ];
+
+ privateKeyFile =
+ 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
+ {
+ 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;
+ }
+ else
+ # For other controllers: use their /56 subnet
+ {
+ publicKey = (
+ builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
+ )
+ );
+
+ allowedIPs = [
+ "${
+ builtins.readFile (
+ config.clan.core.settings.directory
+ + "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
+ )
+ }::/56"
+ ];
+
+ endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
+ persistentKeepalive = 25;
+ }
+ ) (allPeers // allOtherControllers);
+ };
+ };
+ };
+ };
+
+ # Maps over all machines and produces one result per machine, regardless of role
+ perMachine =
+ { instances, machine, ... }:
+ {
+ nixosModule =
+ { pkgs, lib, ... }:
+ let
+ # Check if this machine has conflicting roles across all instances
+ machineRoleConflicts = lib.flatten (
+ lib.mapAttrsToList (
+ instanceName: instanceInfo:
+ let
+ isController =
+ instanceInfo.roles ? controller && instanceInfo.roles.controller.machines ? ${machine.name};
+ isPeer = instanceInfo.roles ? peer && instanceInfo.roles.peer.machines ? ${machine.name};
+ in
+ lib.optional (isController && isPeer) {
+ inherit instanceName;
+ machineName = machine.name;
+ }
+ ) instances
+ );
+ in
+ {
+ # Add assertions for role conflicts
+ assertions = lib.forEach machineRoleConflicts (conflict: {
+ assertion = false;
+ message = ''
+ Machine '${conflict.machineName}' cannot have both 'controller' and 'peer' roles in the wireguard instance '${conflict.instanceName}'.
+ A machine must be either a controller or a peer, not both.
+ '';
+ });
+
+ # Generate keys for each instance where this machine participates
+ clan.core.vars.generators = lib.mapAttrs' (
+ name: _instanceInfo:
+ lib.nameValuePair "wireguard-keys-${name}" {
+ files.publickey.secret = false;
+ files.privatekey = { };
+
+ runtimeInputs = with pkgs; [
+ wireguard-tools
+ ];
+
+ script = ''
+ wg genkey > $out/privatekey
+ wg pubkey < $out/privatekey > $out/publickey
+ '';
+ }
+ ) instances;
+
+ };
+ };
+}
diff --git a/clanServices/wireguard/flake-module.nix b/clanServices/wireguard/flake-module.nix
new file mode 100644
index 000000000..66529b160
--- /dev/null
+++ b/clanServices/wireguard/flake-module.nix
@@ -0,0 +1,7 @@
+{ lib, ... }:
+let
+ module = lib.modules.importApply ./default.nix { };
+in
+{
+ clan.modules.wireguard = module;
+}
diff --git a/clanServices/wireguard/ipv6_allocator.py b/clanServices/wireguard/ipv6_allocator.py
new file mode 100755
index 000000000..16050efb2
--- /dev/null
+++ b/clanServices/wireguard/ipv6_allocator.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""
+IPv6 address allocator for WireGuard networks.
+
+Network layout:
+- Base network: /40 ULA prefix (fd00::/8 + 32 bits from hash)
+- Controllers: Each gets a /56 subnet from the base /40 (256 controllers max)
+- Peers: Each gets a /96 subnet from their controller's /56
+"""
+
+import hashlib
+import ipaddress
+import sys
+from pathlib import Path
+
+
+def hash_string(s: str) -> str:
+ """Generate SHA256 hash of string."""
+ return hashlib.sha256(s.encode()).hexdigest()
+
+
+def generate_ula_prefix(instance_name: str) -> ipaddress.IPv6Network:
+ """
+ Generate a /40 ULA prefix from instance name.
+
+ Format: fd{32-bit hash}/40
+ This gives us fd00:0000:0000::/40 through fdff:ffff:ff00::/40
+ """
+ h = hash_string(instance_name)
+
+ # For /40, we need 32 bits after 'fd' (8 hex chars)
+ # But only the first 32 bits count for the network prefix
+ # The last 8 bits of the 40-bit prefix must be 0
+ prefix_bits = int(h[:8], 16)
+
+ # Mask to ensure we only use the first 32 bits for /40
+ # This gives us addresses like fd28:387a::/40
+ prefix_bits = prefix_bits & 0xFFFFFF00 # Clear last 8 bits
+
+ # Format as IPv6 address
+ prefix = f"fd{prefix_bits:08x}"
+ prefix_formatted = f"{prefix[:4]}:{prefix[4:8]}::/40"
+
+ network = ipaddress.IPv6Network(prefix_formatted)
+ return network
+
+
+def generate_controller_subnet(
+ base_network: ipaddress.IPv6Network, controller_name: str
+) -> ipaddress.IPv6Network:
+ """
+ Generate a /56 subnet for a controller from the base /40 network.
+
+ We have 16 bits (40 to 56) to allocate controller subnets.
+ This allows for 65,536 possible controller subnets.
+ """
+ h = hash_string(controller_name)
+ # Take 16 bits from hash for the controller subnet ID
+ controller_id = int(h[:4], 16)
+
+ # Create the controller subnet by adding the controller ID to the base network
+ # The controller subnet is at base_prefix:controller_id::/56
+ base_int = int(base_network.network_address)
+ controller_subnet_int = base_int | (controller_id << (128 - 56))
+ controller_subnet = ipaddress.IPv6Network((controller_subnet_int, 56))
+
+ return controller_subnet
+
+
+def generate_peer_suffix(peer_name: str) -> str:
+ """
+ Generate a unique 64-bit host suffix for a peer.
+
+ This suffix will be used in all controller subnets to create unique addresses.
+ Format: :xxxx:xxxx:xxxx:xxxx (64 bits)
+ """
+ h = hash_string(peer_name)
+ # Take 64 bits (16 hex chars) from hash for the host suffix
+ suffix_bits = h[:16]
+
+ # Format as IPv6 suffix without leading colon
+ suffix = f"{suffix_bits[0:4]}:{suffix_bits[4:8]}:{suffix_bits[8:12]}:{suffix_bits[12:16]}"
+ return suffix
+
+
+def main() -> None:
+ if len(sys.argv) < 4:
+ print(
+ "Usage: ipv6_allocator.py "
+ )
+ sys.exit(1)
+
+ output_dir = Path(sys.argv[1])
+ instance_name = sys.argv[2]
+ node_type = sys.argv[3]
+
+ # Generate base /40 network
+ base_network = generate_ula_prefix(instance_name)
+
+ if node_type == "controller":
+ if len(sys.argv) < 5:
+ print("Controller name required")
+ sys.exit(1)
+
+ controller_name = sys.argv[4]
+ subnet = generate_controller_subnet(base_network, controller_name)
+
+ # Extract clean prefix from subnet (e.g. "fd51:19c1:3b:f700::/56" -> "fd51:19c1:3b:f700")
+ prefix_str = str(subnet).split("/")[0].rstrip(":")
+ while prefix_str.endswith(":"):
+ prefix_str = prefix_str.rstrip(":")
+
+ # Write file
+ (output_dir / "prefix").write_text(prefix_str)
+
+ elif node_type == "peer":
+ if len(sys.argv) < 5:
+ print("Peer name required")
+ sys.exit(1)
+
+ peer_name = sys.argv[4]
+
+ # Generate the peer's host suffix
+ suffix = generate_peer_suffix(peer_name)
+
+ # Write file
+ (output_dir / "suffix").write_text(suffix)
+
+ else:
+ print(f"Unknown node type: {node_type}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 7baca7159..71bc37e43 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -92,7 +92,6 @@ nav:
- Services:
- Overview:
- reference/clanServices/index.md
-
- reference/clanServices/admin.md
- reference/clanServices/borgbackup.md
- reference/clanServices/data-mesher.md
@@ -109,6 +108,7 @@ nav:
- reference/clanServices/trusted-nix-caches.md
- reference/clanServices/users.md
- reference/clanServices/wifi.md
+ - reference/clanServices/wireguard.md
- reference/clanServices/zerotier.md
- API: reference/clanServices/clan-service-author-interface.md