Compare commits

...

4 Commits

Author SHA1 Message Date
DavHau
a0b16e30b6 wireguard: optional internet access for external peers
Add an option to allow external peers to route all their internet traffic through a controller.

Also add options to enable ipv4 and assign v4 addresses manually as this is needed for proper internet access for some regions
2025-10-21 11:49:37 +07:00
DavHau
9e36b00b48 wireguard: make external peers connect to all controllers 2025-10-21 11:49:37 +07:00
DavHau
c48be6b34f wireguard/test: update vars 2025-10-21 11:49:37 +07:00
DavHau
6a3f5e077b 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.
2025-10-21 11:49:37 +07:00
15 changed files with 516 additions and 75 deletions

View File

@@ -133,6 +133,75 @@ graph TB
### Advanced Options
#### External Peers
External peers are devices outside of your clan (like phones, laptops, etc.) that can connect to the mesh network through controllers. Each external peer gets its own keypair and can be configured with specific options.
##### IPv6-only external peers
```nix
{
instances = {
wireguard = {
module.name = "wireguard";
module.input = "clan-core";
roles.controller.machines.server1.settings = {
endpoint = "vpn.example.com";
# Define external peers with configuration options
externalPeers = {
dave = {
# No internet access - can only reach clan mesh
allowInternetAccess = false;
};
moms-phone = {
# Internet access enabled - IPv6 traffic routed through VPN
allowInternetAccess = true;
};
};
};
roles.peer.machines.laptop1 = {};
};
}
}
```
##### IPv4 support for external peers
If you need IPv4 internet access for external peers, you can enable IPv4 on the controller and assign IPv4 addresses to external peers:
```nix
{
instances = {
wireguard = {
module.name = "wireguard";
module.input = "clan-core";
roles.controller.machines.server1.settings = {
endpoint = "vpn.example.com";
# Enable IPv4 with controller's address
ipv4.enable = true;
ipv4.address = "10.42.1.1/24";
externalPeers = {
dave = {
# No internet access - can only reach clan mesh
allowInternetAccess = false;
ipv4.address = "10.42.1.50/32";
};
moms-phone = {
# Internet access enabled - IPv4 and IPv6 traffic routed through VPN
allowInternetAccess = true;
ipv4.address = "10.42.1.51/32";
};
};
};
roles.peer.machines.laptop1 = {};
};
}
}
```
**Note:** IPv4 addresses for external peers are only used for internet access through the controller, not for mesh communication (which uses IPv6).
External peers can connect to multiple controllers by adding the same peer name to multiple controllers' `externalPeers` configuration.
### Automatic Hostname Resolution

View File

@@ -105,9 +105,31 @@ let
peerIP = controllerPrefix + ":" + peerSuffix;
in
"${peerIP} ${peerName}.${domain}"
) roles.peer.machines;
) roles.peer.machines or { };
# External peers
externalPeerHosts = lib.flatten (
lib.mapAttrsToList (
ctrlName: _ctrlValue:
lib.mapAttrsToList (
peer: _peerSettings:
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,89 @@ 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
'';
};
ipv4 = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable IPv4 support for external peers on this controller.
When enabled, the controller will have an IPv4 address and can route IPv4 traffic.
IPv4 is only used for internet access, not for mesh communication (which uses IPv6).
'';
};
address = lib.mkOption {
type = lib.types.str;
example = "10.42.1.1/24";
description = ''
IPv4 address for this controller in CIDR notation.
External peers with IPv4 addresses must be within the same subnet.
IPv4 is only used for internet access, not for mesh communication (which uses IPv6).
'';
};
};
externalPeers = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
allowInternetAccess = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to allow this external peer to access the internet through the controller.
When enabled, the controller will route internet traffic for this peer.
IPv4 is only used for internet access, not for mesh communication (which uses IPv6).
'';
};
ipv4.address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.42.1.50/32";
description = ''
IPv4 address for this external peer in CIDR notation.
The peer must be within the controller's IPv4 subnet.
Only used when the controller has IPv4 enabled.
IPv4 is only used for internet access, not for mesh communication (which uses IPv6).
'';
};
};
}
);
default = { };
example = {
dave = {
allowInternetAccess = false;
};
"moms-phone" = {
allowInternetAccess = true;
ipv4.address = "10.42.1.51/32";
};
};
description = ''
External peers that are not part of the clan.
For every entry here, a key pair for an external device will be generated.
This key pair can then be displayed via `clan vars get` and inserted into an external device, like a phone or laptop.
Each external peer can connect to the mesh through one or more controllers.
To connect to multiple controllers, add the same peer name to multiple controllers' `externalPeers`, or simply set set `roles.controller.settings.externalPeers`.
The external peer names 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 =
@@ -296,7 +395,37 @@ in
}:
let
allOtherControllers = lib.filterAttrs (name: _v: name != machine.name) roles.controller.machines;
allPeers = roles.peer.machines;
allPeers = roles.peer.machines or { };
# Collect all external peers from all controllers
allExternalPeers = lib.unique (
lib.flatten (
lib.mapAttrsToList (_: ctrl: lib.attrNames ctrl.settings.externalPeers) roles.controller.machines
)
);
controllerPrefix =
controllerName:
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${controllerName}/wireguard-network-${instanceName}/prefix/value"
);
peerSuffix =
peerName:
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${peerName}/wireguard-network-${instanceName}/suffix/value"
);
externalPeerSuffix =
externalName:
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/shared/wireguard-network-${instanceName}-external-peer-${externalName}/suffix/value"
);
thisControllerPrefix =
config.clan.core.vars.generators."wireguard-network-${instanceName}".files.prefix.value;
in
{
imports = [
@@ -310,93 +439,172 @@ 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' (lib.attrNames 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;
# 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;
}
// lib.optionalAttrs settings.ipv4.enable {
"net.ipv4.conf.all.forwarding" = 1;
};
# Enable ip forwarding, so wireguard peers can reach eachother
boot.kernel.sysctl."net.ipv6.conf.all.forwarding" = 1;
networking.firewall.allowedUDPPorts = [ settings.port ];
networking.firewall.extraCommands =
let
peersWithInternetAccess = lib.filterAttrs (
_: peerConfig: peerConfig.allowInternetAccess
) settings.externalPeers;
peerInfo = lib.mapAttrs (
peer: peerConfig:
let
ipv6Address = "${thisControllerPrefix}:${externalPeerSuffix peer}";
ipv4Address =
if settings.ipv4.enable && peerConfig.ipv4.address != null then
lib.head (lib.splitString "/" peerConfig.ipv4.address)
else
null;
in
{
inherit ipv6Address ipv4Address;
}
) peersWithInternetAccess;
in
lib.concatStringsSep "\n" (
(lib.mapAttrsToList (_peer: info: ''
ip6tables -t nat -A POSTROUTING -s ${info.ipv6Address}/128 ! -o '${instanceName}' -j MASQUERADE
'') peerInfo)
++ (lib.mapAttrsToList (
_peer: info:
lib.optionalString (info.ipv4Address != null) ''
iptables -t nat -A POSTROUTING -s ${info.ipv4Address} ! -o '${instanceName}' -j MASQUERADE
''
) peerInfo)
);
# 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"
];
"${thisControllerPrefix}::1/40"
]
++ lib.optional settings.ipv4.enable settings.ipv4.address;
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"
)
);
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;
# Allow the peer's /96 range in ALL controller subnets
allowedIPs = lib.mapAttrsToList (
ctrlName: _: "${controllerPrefix ctrlName}:${peerSuffix name}/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"
)
);
persistentKeepalive = 25;
}) allPeers)
++
# External peers configuration - includes all external peers from all controllers
(map (
peer:
let
# IPv6 allowed IPs for mesh communication
ipv6AllowedIPs = lib.mapAttrsToList (
ctrlName: _: "${controllerPrefix ctrlName}:${externalPeerSuffix peer}/96"
) roles.controller.machines;
allowedIPs = [
"${
# IPv4 allowed IP (only if this controller manages this peer and has IPv4 enabled)
ipv4AllowedIPs = lib.optional (
settings.ipv4.enable
&& settings.externalPeers ? ${peer}
&& settings.externalPeers.${peer}.ipv4.address != null
) settings.externalPeers.${peer}.ipv4.address;
in
{
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-network-${instanceName}/prefix/value"
+ "/vars/shared/wireguard-network-${instanceName}-external-peer-${peer}/publickey/value"
)
}::/56"
];
);
allowedIPs = ipv6AllowedIPs ++ ipv4AllowedIPs;
persistentKeepalive = 25;
}
) allExternalPeers)
++
# Other controllers configuration
(lib.mapAttrsToList (name: value: {
publicKey = (
builtins.readFile (
config.clan.core.settings.directory
+ "/vars/per-machine/${name}/wireguard-keys-${instanceName}/publickey/value"
)
);
allowedIPs = [ "${controllerPrefix name}::/56" ];
endpoint = "${value.settings.endpoint}:${toString value.settings.port}";
persistentKeepalive = 25;
}
) (allPeers // allOtherControllers);
}) allOtherControllers);
};
};
};
@@ -416,7 +624,7 @@ in
let
isController =
instanceInfo.roles ? controller && instanceInfo.roles.controller.machines ? ${machine.name};
isPeer = instanceInfo.roles ? peer && instanceInfo.roles.peer.machines ? ${machine.name};
isPeer = instanceInfo.roles ? peer && instanceInfo.roles.peer.machines or { } ? ${machine.name};
in
lib.optional (isController && isPeer) {
inherit instanceName;

View File

@@ -1,5 +1,6 @@
{
lib,
config,
...
}:
@@ -10,7 +11,28 @@ 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"
);
peerSuffix =
peerName:
builtins.readFile (
config.clan.directory + "/vars/per-machine/${peerName}/wireguard-network-wg-test-one/suffix/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,10 +69,19 @@ in
roles.controller.machines."controller1".settings = {
endpoint = "192.168.1.1";
# Enable IPv4 for external peers
ipv4.enable = true;
ipv4.address = "10.42.1.1/24";
# add an external peer to controller1 with IPv4
externalPeers.external1 = {
ipv4.address = "10.42.1.50/32";
};
};
roles.controller.machines."controller2".settings = {
endpoint = "192.168.1.2";
# add the same external peer to controller2 to test multi-controller connection
externalPeers.external1 = { };
};
roles.peer.machines = {
@@ -77,11 +108,77 @@ in
};
};
nodes.external1 =
let
controller1Prefix = controllerPrefix "controller1";
controller2Prefix = controllerPrefix "controller2";
external1Suffix = externalPeerSuffix "external1";
in
{
networking.extraHosts = ''
${controller1Prefix}::1 controller1.wg-test-one
${controller2Prefix}::1 controller2.wg-test-one
'';
networking.wireguard.interfaces."wg0" = {
# Multiple IPs, one in each controller's subnet (IPv6) plus IPv4
ips = [
"${controller1Prefix + ":" + external1Suffix}/56"
"${controller2Prefix + ":" + external1Suffix}/56"
"10.42.1.50/32" # IPv4 address for controller1
];
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=";
# Connect to both controllers
peers = [
# Controller 1
{
publicKey = (
builtins.readFile (
config.clan.directory + "/vars/per-machine/controller1/wireguard-keys-wg-test-one/publickey/value"
)
);
# Allow controller1's /56 subnet (IPv6) and IPv4 subnet
allowedIPs = [
"${controller1Prefix}::/56"
"10.42.1.0/24" # IPv4 subnet for internet access
];
endpoint = "controller1:51820";
persistentKeepalive = 25;
}
# Controller 2
{
publicKey = (
builtins.readFile (
config.clan.directory + "/vars/per-machine/controller2/wireguard-keys-wg-test-one/publickey/value"
)
);
# Allow controller2's /56 subnet
allowedIPs = [ "${controller2Prefix}::/56" ];
endpoint = "controller2:51820";
persistentKeepalive = 25;
}
];
};
};
testScript = ''
start_all()
# Show all addresses
machines = [peer1, peer2, peer3, controller1, controller2]
# Start network on all machines including external1
machines = [peer1, peer2, peer3, controller1, controller2, external1]
for m in machines:
m.systemctl("start network-online.target")
@@ -93,10 +190,39 @@ in
print("STARTING PING TESTS")
print("="*60)
for m1 in machines:
for m2 in machines:
# Test mesh connectivity between regular clan machines
clan_machines = [peer1, peer2, peer3, controller1, controller2]
for m1 in clan_machines:
for m2 in clan_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")
# Test that external peer can reach both controllers (multi-controller connection)
print("\n--- Testing external1 -> controller1 (direct connection) ---")
external1.wait_until_succeeds("ping -c1 controller1.wg-test-one >&2")
print("\n--- Testing external1 -> controller2 (direct connection) ---")
external1.wait_until_succeeds("ping -c1 controller2.wg-test-one >&2")
# Test IPv4 connectivity
print("\n--- Testing external1 -> controller1 (IPv4) ---")
external1.wait_until_succeeds("ping -c1 10.42.1.1 >&2")
# Test that all clan machines can reach the external peer
for m in clan_machines:
print(f"\n--- Pinging from {m.name} to external1.wg-test-one ---")
m.wait_until_succeeds("ping -c1 external1.wg-test-one >&2")
# Test that external peer can reach a regular peer via controller1
print("\n--- Testing external1 -> peer1 (via controller1) ---")
external1.wait_until_succeeds("ping -c1 ${controllerPrefix "controller1"}:${peerSuffix "peer1"} >&2")
# Test controller failover
print("\n--- Shutting down controller1 ---")
controller1.shutdown()
print("\n--- Testing external1 -> peer1 (via controller2 after controller1 shutdown) ---")
external1.wait_until_succeeds("ping -c1 ${controllerPrefix "controller2"}:${peerSuffix "peer1"} >&2")
'';
}

View File

@@ -0,0 +1 @@
966fa6c74dbea65154816755934180edaf20fc031b15622c3e4433516c08fbbc

View File

@@ -0,0 +1 @@
966fa6c74dbea65154816755934180edaf20fc031b15622c3e4433516c08fbbc

View File

@@ -0,0 +1,14 @@
{
"data": "ENC[AES256_GCM,data:dhEJ2dOGbK+oNj7YDzFD9455g5Cv7Ic8tZzPRI31ugzjHXOORbDq3+MyY/l/,iv:DP0S/taFONhNkVvvZQoTe2mnwRfJB7QgaILyZt9nT9E=,tag:TGFwbn6PTz4iGU7fnkSaWA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMQVRQNDNzUklFQnVIODZo\nbXNITlNhL1I2c1lyRjlUUGN2RTJLcFRJUjAwCmthT3JDRHdEa1RHM3pHT1pTQWd2\nMlkvMmVQRXk5VmZyTkU0RkpYVzlkNzgKLS0tIGVFY0ppc1dqQis5ay9HK2dydlht\neEI1eDlBekNtMTVoSk1hTFJUNUJiU2sK30cizM0xUDUDeTAQhtliL1KMNvnFcvxP\n8CFqOT36MBmIiC42lV0JTh/lsQbdirBNQLP9QUph8hGqbpehkleLlg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-15T04:08:18Z",
"mac": "ENC[AES256_GCM,data:1BPlDmBFyNubxq2DRqsYUuh+WsbihR5D6OIA6Zdx1XPpOXxzxXXWHddtWxcaT14HLL3VKfINAI5MSIx7aVMuTgEJzaCkRSd11T/qwrV1mAiGW1bo3vXBVtbii271hYHCRaLuWnm4kIDyIV6onQARprTZgZ0Rlh0x5qwGnkk4uPI=,iv:Vda4Vxmm5j2nAIw2g1ydy9+bJHkHy7v7fKvS+4K1zds=,tag:qUpSRCa66D/LXDawXl9eOQ==,type:str]",
"version": "3.11.0"
}
}

View File

@@ -0,0 +1 @@
966fa6c74dbea65154816755934180edaf20fc031b15622c3e4433516c08fbbc

View File

@@ -0,0 +1,14 @@
{
"data": "ENC[AES256_GCM,data:U5fkHGEM+/Xw9g1LGZckGWV225pnMZM28+oT4DscrMMt3XMFM+CRojd2JhC5,iv:ydAKRdJEKRI2wbcIsM/5YQQVkFVc3/JtCk3dwBJwJVw=,tag:ZiRsOzvFncUDZqok8fgh8A==,type:str]",
"sops": {
"age": [
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5OFFWRmp4R01WN0hZU2pu\nY0tCOWdrazZtbnVWczV2WnlPMUMvWUFmV1FBCjk1bnNtcmc5L0dRc1pTNUUwbzh2\nNUhhLzFycUFZd24yVEZVc3c4S2R0czAKLS0tIG8vY0lCRlhyYyszeEE0TzV4SWJj\nOFI0NFlvR25vNDB4KzJwdXQrTVJoS2MKHTgW/GlER4nP160Rcw9jZEraemouXrMz\nS9mLE/X3GBoAlgXmEwZC+7ZEZyNi/cd4FXX6edelD06S8uDjz0DKVg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-16T07:19:29Z",
"mac": "ENC[AES256_GCM,data:DsQrWj8TwtgnAjDTFFu5ALjr3z3fx0k3grnPxk0zxo4RxvtNI6cv5de91YJ8GYvbElT+ylJul91XUSQ93PtqskrUDYvlKzU60zUDjR/G27g+BIHuKBYIDYNLFKjkoI2/KffbVexRFAqqvkjwSYbA2aJOLfyvUoZZhCuMmOW6jW4=,iv:gLe/wXzqix63d7Fv8vLDydU30ElkyVz6N2TlCueFInw=,tag:7QPD4kfyE4hUeGRC0cgsYg==,type:str]",
"version": "3.11.0"
}
}

View File

@@ -0,0 +1 @@
bb+pM+fpJzqc1A+dvTdsE4JliVvdMMXoQUPaElkrs0w=