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:
Mic92
2024-11-08 14:10:23 +00:00
10 changed files with 226 additions and 14 deletions

View File

@@ -36,5 +36,6 @@
xfce = ./xfce; xfce = ./xfce;
zerotier-static-peers = ./zerotier-static-peers; zerotier-static-peers = ./zerotier-static-peers;
zt-tcp-relay = ./zt-tcp-relay; zt-tcp-relay = ./zt-tcp-relay;
zerotier = ./zerotier;
}; };
} }

View 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.

View File

@@ -0,0 +1,2 @@
# TODO: only kept this file to not break documentation generation.
{ }

View 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;
};
}

View 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;
}

View File

@@ -0,0 +1,5 @@
{
imports = [
../shared.nix
];
}

View 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()
''}"
]
);
};
}

View File

@@ -66,22 +66,23 @@ nav:
- Reference: - Reference:
- Overview: reference/index.md - Overview: reference/index.md
- Clan Modules: - Clan Modules:
- reference/clanModules/index.md
- reference/clanModules/admin.md - reference/clanModules/admin.md
- reference/clanModules/borgbackup-static.md - reference/clanModules/borgbackup-static.md
- reference/clanModules/borgbackup.md - reference/clanModules/borgbackup.md
- reference/clanModules/deltachat.md - reference/clanModules/deltachat.md
- reference/clanModules/dyndns.md
- reference/clanModules/disk-id.md - reference/clanModules/disk-id.md
- reference/clanModules/dyndns.md
- reference/clanModules/ergochat.md - reference/clanModules/ergochat.md
- reference/clanModules/garage.md - reference/clanModules/garage.md
- reference/clanModules/golem-provider.md - reference/clanModules/golem-provider.md
- reference/clanModules/heisenbridge.md - reference/clanModules/heisenbridge.md
- reference/clanModules/index.md
- reference/clanModules/iwd.md - reference/clanModules/iwd.md
- reference/clanModules/localbackup.md - reference/clanModules/localbackup.md
- reference/clanModules/localsend.md - reference/clanModules/localsend.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/machine-id.md - reference/clanModules/machine-id.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/moonlight.md - reference/clanModules/moonlight.md
- reference/clanModules/mumble.md - reference/clanModules/mumble.md
- reference/clanModules/nginx.md - reference/clanModules/nginx.md
@@ -101,9 +102,11 @@ nav:
- reference/clanModules/vaultwarden.md - reference/clanModules/vaultwarden.md
- reference/clanModules/xfce.md - reference/clanModules/xfce.md
- reference/clanModules/zerotier-static-peers.md - reference/clanModules/zerotier-static-peers.md
- reference/clanModules/zerotier.md
- reference/clanModules/zt-tcp-relay.md - reference/clanModules/zt-tcp-relay.md
- CLI: - CLI:
- reference/cli/index.md - reference/cli/index.md
- reference/cli/backups.md - reference/cli/backups.md
- reference/cli/facts.md - reference/cli/facts.md
- reference/cli/flakes.md - reference/cli/flakes.md
@@ -117,6 +120,7 @@ nav:
- reference/cli/vms.md - reference/cli/vms.md
- Clan Core: - Clan Core:
- reference/clan-core/index.md - reference/clan-core/index.md
- reference/clan-core/backups.md - reference/clan-core/backups.md
- reference/clan-core/facts.md - reference/clan-core/facts.md
- reference/clan-core/sops.md - reference/clan-core/sops.md

View File

@@ -250,7 +250,7 @@ in
rules = [ rules = [
{ {
not = false; not = false;
or = false; "or" = false;
type = "ACTION_ACCEPT"; type = "ACTION_ACCEPT";
} }
]; ];

View File

@@ -65,10 +65,10 @@ def get_network_id() -> str:
def allow_member(args: argparse.Namespace) -> None: def allow_member(args: argparse.Namespace) -> None:
member_id = args.member_id
if args.member_ip: if args.member_ip:
member_ip = args.member_id member_id = compute_member_id(args.member_id)
member_id = compute_member_id(member_ip) else:
member_id = args.member_id
network_id = get_network_id() network_id = get_network_id()
token = ZEROTIER_STATE_DIR.joinpath("authtoken.secret").read_text() token = ZEROTIER_STATE_DIR.joinpath("authtoken.secret").read_text()
conn = http.client.HTTPConnection("localhost", 9993) 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" networks = ZEROTIER_STATE_DIR / "controller.d" / "network" / network_id / "member"
if not networks.exists(): if not networks.exists():
return return
if not args.no_headers:
print(f"{'Member ID':<10} {'Ipv6 Address':<39} {'Authorized'}")
for member in networks.iterdir(): for member in networks.iterdir():
with member.open() as f: with member.open() as f:
data = json.load(f) data = json.load(f)
@@ -98,16 +100,14 @@ def list_members(args: argparse.Namespace) -> None:
except KeyError as e: except KeyError as e:
msg = f"error: {member} does not contain an id" msg = f"error: {member} does not contain an id"
raise ClanError(msg) from e raise ClanError(msg) from e
print( ip = str(compute_zerotier_ip(network_id, member_id))
member_id, authorized = str(data.get("authorized", False))
compute_zerotier_ip(network_id, data["id"]), print(f"{member_id:<10} {ip:<39} {authorized}")
data["authorized"] or "false",
)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="Manage zerotier members")
subparser = parser.add_subparsers(dest="command") subparser = parser.add_subparsers(dest="command", required=True)
parser_allow = subparser.add_parser("allow", help="Allow a member to join") parser_allow = subparser.add_parser("allow", help="Allow a member to join")
parser_allow.add_argument( parser_allow.add_argument(
"--member-ip", "--member-ip",
@@ -118,6 +118,9 @@ def main() -> None:
parser_allow.set_defaults(func=allow_member) parser_allow.set_defaults(func=allow_member)
parser_list = subparser.add_parser("list", help="List members") 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) parser_list.set_defaults(func=list_members)
args = parser.parse_args() args = parser.parse_args()