Merge pull request 'Add inventory module for zerotier' (#2108) from init/zerotier-inventory into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/2108
This commit is contained in:
@@ -36,5 +36,6 @@
|
||||
xfce = ./xfce;
|
||||
zerotier-static-peers = ./zerotier-static-peers;
|
||||
zt-tcp-relay = ./zt-tcp-relay;
|
||||
zerotier = ./zerotier;
|
||||
};
|
||||
}
|
||||
|
||||
38
clanModules/zerotier/README.md
Normal file
38
clanModules/zerotier/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description = "Configures [Zerotier VPN](https://zerotier.com) secure and efficient networking within a Clan.."
|
||||
features = [ "inventory" ]
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to set up and manage a [ZeroTier VPN](https://zerotier.com) for a clan network. Each VPN requires a single controller and can support multiple peers and optional moons for better connectivity.
|
||||
|
||||
## Roles
|
||||
|
||||
### 1. Controller
|
||||
|
||||
The [Controller](https://docs.zerotier.com/controller/) manages network membership and is responsible for admitting new peers.
|
||||
When a new node is added to the clan, the controller must be updated to ensure it has the latest member list.
|
||||
|
||||
- **Key Points:**
|
||||
- Must be online to admit new machines to the VPN.
|
||||
- Existing nodes can continue to communicate even when the controller is offline.
|
||||
|
||||
### 2. Moons
|
||||
|
||||
[Moons](https://docs.zerotier.com/roots) act as relay nodes,
|
||||
providing direct connectivity to peers via their public IP addresses.
|
||||
They enable devices that are not publicly reachable to join the VPN by routing through these nodes.
|
||||
|
||||
- **Configuration Notes:**
|
||||
- Each moon must define its public IP address.
|
||||
- Ensures connectivity for devices behind NAT or restrictive firewalls.
|
||||
|
||||
### 3. Peers
|
||||
|
||||
Peers are standard nodes in the VPN.
|
||||
They connect to other peers, moons, and the controller as needed.
|
||||
|
||||
- **Purpose:**
|
||||
- General role for all machines that are neither controllers nor moons.
|
||||
- Ideal for most clan members' devices.
|
||||
2
clanModules/zerotier/default.nix
Normal file
2
clanModules/zerotier/default.nix
Normal file
@@ -0,0 +1,2 @@
|
||||
# TODO: only kept this file to not break documentation generation.
|
||||
{ }
|
||||
47
clanModules/zerotier/roles/controller.nix
Normal file
47
clanModules/zerotier/roles/controller.nix
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
instanceNames = builtins.attrNames config.clan.inventory.services.zerotier;
|
||||
instanceName = builtins.head instanceNames;
|
||||
zeroTierInstance = config.clan.inventory.services.zerotier.${instanceName};
|
||||
roles = zeroTierInstance.roles;
|
||||
stringSet = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../shared.nix
|
||||
];
|
||||
config = {
|
||||
systemd.services.zerotier-inventory-autoaccept =
|
||||
let
|
||||
machines = stringSet (roles.moon.machines ++ roles.controller.machines ++ roles.peer.machines);
|
||||
networkIps = builtins.foldl' (
|
||||
ips: name:
|
||||
if builtins.pathExists "${config.clan.core.clanDir}/machines/${name}/facts/zerotier-ip" then
|
||||
ips
|
||||
++ [
|
||||
(builtins.readFile "${config.clan.core.clanDir}/machines/${name}/facts/zerotier-ip")
|
||||
]
|
||||
else
|
||||
ips
|
||||
) [ ] machines;
|
||||
allHostIPs = config.clan.zerotier.networkIps ++ networkIps;
|
||||
in
|
||||
{
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "zerotierone.service" ];
|
||||
path = [ config.clan.core.clanPkgs.zerotierone ];
|
||||
serviceConfig.ExecStart = pkgs.writeShellScript "zerotier-inventory-autoaccept" ''
|
||||
${lib.concatMapStringsSep "\n" (host: ''
|
||||
${config.clan.core.clanPkgs.zerotier-members}/bin/zerotier-members allow --member-ip ${host}
|
||||
'') allHostIPs}
|
||||
'';
|
||||
};
|
||||
|
||||
clan.core.networking.zerotier.controller.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
17
clanModules/zerotier/roles/moon.nix
Normal file
17
clanModules/zerotier/roles/moon.nix
Normal file
@@ -0,0 +1,17 @@
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
../shared.nix
|
||||
];
|
||||
options.clan.zerotier.moon.stableEndpoints = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Make this machine a moon.
|
||||
Other machines can join this moon by adding this moon in their config.
|
||||
It will be reachable under the given stable endpoints.
|
||||
'';
|
||||
};
|
||||
# TODO, we want to remove these options from clanCore
|
||||
config.clan.core.networking.zerotier.moon.stableEndpoints =
|
||||
config.clan.zerotier.moon.stableEndpoints;
|
||||
}
|
||||
5
clanModules/zerotier/roles/peer.nix
Normal file
5
clanModules/zerotier/roles/peer.nix
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
imports = [
|
||||
../shared.nix
|
||||
];
|
||||
}
|
||||
95
clanModules/zerotier/shared.nix
Normal file
95
clanModules/zerotier/shared.nix
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
instanceNames = builtins.attrNames config.clan.inventory.services.zerotier;
|
||||
instanceName = builtins.head instanceNames;
|
||||
zeroTierInstance = config.clan.inventory.services.zerotier.${instanceName};
|
||||
roles = zeroTierInstance.roles;
|
||||
controllerMachine = builtins.head roles.controller.machines;
|
||||
networkIdPath = "${config.clan.core.clanDir}/machines/${controllerMachine}/facts/zerotier-network-id";
|
||||
networkId = if builtins.pathExists networkIdPath then builtins.readFile networkIdPath else null;
|
||||
moons = roles.moon.machines;
|
||||
moonIps = builtins.foldl' (
|
||||
ips: name:
|
||||
if builtins.pathExists "${config.clan.core.clanDir}/machines/${name}/facts/zerotier-ip" then
|
||||
ips
|
||||
++ [
|
||||
(builtins.readFile "${config.clan.core.clanDir}/machines/${name}/facts/zerotier-ip")
|
||||
]
|
||||
else
|
||||
ips
|
||||
) [ ] moons;
|
||||
in
|
||||
{
|
||||
options.clan.zerotier = {
|
||||
excludeHosts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ config.clan.core.machineName ];
|
||||
description = "Hosts that should be excluded";
|
||||
};
|
||||
networkIps = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Extra zerotier network Ips that should be accepted";
|
||||
};
|
||||
networkIds = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Extra zerotier network Ids that should be accepted";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.length instanceNames == 1;
|
||||
message = "The zerotier module currently only supports one instance per machine, but found ${builtins.toString instanceNames}";
|
||||
}
|
||||
{
|
||||
assertion = builtins.length roles.controller.machines == 1;
|
||||
message = "The zerotier module requires exactly one controller, but found ${builtins.toString roles.controller.machines}";
|
||||
}
|
||||
];
|
||||
|
||||
clan.core.networking.zerotier.networkId = networkId;
|
||||
clan.core.networking.zerotier.name = instanceName;
|
||||
|
||||
# TODO: in future we want to have the node id of our moons in our facts
|
||||
systemd.services.zerotierone.serviceConfig.ExecStartPost = lib.mkIf (moonIps != [ ]) (
|
||||
lib.mkAfter [
|
||||
"+${pkgs.writeScript "orbit-moons-by-ip" ''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import json
|
||||
import ipaddress
|
||||
import subprocess
|
||||
|
||||
def compute_member_id(ipv6_addr: str) -> str:
|
||||
addr = ipaddress.IPv6Address(ipv6_addr)
|
||||
addr_bytes = bytearray(addr.packed)
|
||||
|
||||
# Extract the bytes corresponding to the member_id (node_id)
|
||||
node_id_bytes = addr_bytes[10:16]
|
||||
node_id = int.from_bytes(node_id_bytes, byteorder="big")
|
||||
|
||||
member_id = format(node_id, "x").zfill(10)[-10:]
|
||||
|
||||
return member_id
|
||||
def main() -> None:
|
||||
ips = json.loads(${builtins.toJSON (builtins.toJSON moonIps)})
|
||||
for ip in ips:
|
||||
member_id = compute_member_id(ip)
|
||||
res = subprocess.run(["zerotier-cli", "orbit", member_id, member_id])
|
||||
if res.returncode != 0:
|
||||
print(f"Failed to add {member_id} to orbit")
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
''}"
|
||||
]
|
||||
);
|
||||
|
||||
};
|
||||
}
|
||||
@@ -66,22 +66,23 @@ nav:
|
||||
- Reference:
|
||||
- Overview: reference/index.md
|
||||
- Clan Modules:
|
||||
- reference/clanModules/index.md
|
||||
- reference/clanModules/admin.md
|
||||
|
||||
- reference/clanModules/borgbackup-static.md
|
||||
- reference/clanModules/borgbackup.md
|
||||
- reference/clanModules/deltachat.md
|
||||
- reference/clanModules/dyndns.md
|
||||
- reference/clanModules/disk-id.md
|
||||
- reference/clanModules/dyndns.md
|
||||
- reference/clanModules/ergochat.md
|
||||
- reference/clanModules/garage.md
|
||||
- reference/clanModules/golem-provider.md
|
||||
- reference/clanModules/heisenbridge.md
|
||||
- reference/clanModules/index.md
|
||||
- reference/clanModules/iwd.md
|
||||
- reference/clanModules/localbackup.md
|
||||
- reference/clanModules/localsend.md
|
||||
- reference/clanModules/matrix-synapse.md
|
||||
- reference/clanModules/machine-id.md
|
||||
- reference/clanModules/matrix-synapse.md
|
||||
- reference/clanModules/moonlight.md
|
||||
- reference/clanModules/mumble.md
|
||||
- reference/clanModules/nginx.md
|
||||
@@ -101,9 +102,11 @@ nav:
|
||||
- reference/clanModules/vaultwarden.md
|
||||
- reference/clanModules/xfce.md
|
||||
- reference/clanModules/zerotier-static-peers.md
|
||||
- reference/clanModules/zerotier.md
|
||||
- reference/clanModules/zt-tcp-relay.md
|
||||
- CLI:
|
||||
- reference/cli/index.md
|
||||
|
||||
- reference/cli/backups.md
|
||||
- reference/cli/facts.md
|
||||
- reference/cli/flakes.md
|
||||
@@ -117,6 +120,7 @@ nav:
|
||||
- reference/cli/vms.md
|
||||
- Clan Core:
|
||||
- reference/clan-core/index.md
|
||||
|
||||
- reference/clan-core/backups.md
|
||||
- reference/clan-core/facts.md
|
||||
- reference/clan-core/sops.md
|
||||
|
||||
@@ -250,7 +250,7 @@ in
|
||||
rules = [
|
||||
{
|
||||
not = false;
|
||||
or = false;
|
||||
"or" = false;
|
||||
type = "ACTION_ACCEPT";
|
||||
}
|
||||
];
|
||||
|
||||
@@ -65,10 +65,10 @@ def get_network_id() -> str:
|
||||
|
||||
|
||||
def allow_member(args: argparse.Namespace) -> None:
|
||||
member_id = args.member_id
|
||||
if args.member_ip:
|
||||
member_ip = args.member_id
|
||||
member_id = compute_member_id(member_ip)
|
||||
member_id = compute_member_id(args.member_id)
|
||||
else:
|
||||
member_id = args.member_id
|
||||
network_id = get_network_id()
|
||||
token = ZEROTIER_STATE_DIR.joinpath("authtoken.secret").read_text()
|
||||
conn = http.client.HTTPConnection("localhost", 9993)
|
||||
@@ -90,6 +90,8 @@ def list_members(args: argparse.Namespace) -> None:
|
||||
networks = ZEROTIER_STATE_DIR / "controller.d" / "network" / network_id / "member"
|
||||
if not networks.exists():
|
||||
return
|
||||
if not args.no_headers:
|
||||
print(f"{'Member ID':<10} {'Ipv6 Address':<39} {'Authorized'}")
|
||||
for member in networks.iterdir():
|
||||
with member.open() as f:
|
||||
data = json.load(f)
|
||||
@@ -98,16 +100,14 @@ def list_members(args: argparse.Namespace) -> None:
|
||||
except KeyError as e:
|
||||
msg = f"error: {member} does not contain an id"
|
||||
raise ClanError(msg) from e
|
||||
print(
|
||||
member_id,
|
||||
compute_zerotier_ip(network_id, data["id"]),
|
||||
data["authorized"] or "false",
|
||||
)
|
||||
ip = str(compute_zerotier_ip(network_id, member_id))
|
||||
authorized = str(data.get("authorized", False))
|
||||
print(f"{member_id:<10} {ip:<39} {authorized}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparser = parser.add_subparsers(dest="command")
|
||||
parser = argparse.ArgumentParser(description="Manage zerotier members")
|
||||
subparser = parser.add_subparsers(dest="command", required=True)
|
||||
parser_allow = subparser.add_parser("allow", help="Allow a member to join")
|
||||
parser_allow.add_argument(
|
||||
"--member-ip",
|
||||
@@ -118,6 +118,9 @@ def main() -> None:
|
||||
parser_allow.set_defaults(func=allow_member)
|
||||
|
||||
parser_list = subparser.add_parser("list", help="List members")
|
||||
parser_list.add_argument(
|
||||
"--no-headers", action="store_true", help="Do not print headers"
|
||||
)
|
||||
parser_list.set_defaults(func=list_members)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
Reference in New Issue
Block a user