From 60d7c5d82c231089efe82aa0ddc756141dd95b6e Mon Sep 17 00:00:00 2001 From: a-kenji Date: Wed, 28 Aug 2024 10:54:40 +0200 Subject: [PATCH 1/5] add clanModule for zerotier inventory --- clanModules/flake-module.nix | 1 + clanModules/zerotier/README.md | 7 ++ clanModules/zerotier/default.nix | 2 + clanModules/zerotier/roles/controller.nix | 47 +++++++++++ clanModules/zerotier/roles/moon.nix | 17 ++++ clanModules/zerotier/roles/peer.nix | 5 ++ clanModules/zerotier/shared.nix | 95 +++++++++++++++++++++++ docs/mkdocs.yml | 10 ++- pkgs/zerotier-members/zerotier-members.py | 6 +- 9 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 clanModules/zerotier/README.md create mode 100644 clanModules/zerotier/default.nix create mode 100644 clanModules/zerotier/roles/controller.nix create mode 100644 clanModules/zerotier/roles/moon.nix create mode 100644 clanModules/zerotier/roles/peer.nix create mode 100644 clanModules/zerotier/shared.nix diff --git a/clanModules/flake-module.nix b/clanModules/flake-module.nix index ca4149039..dcee6a103 100644 --- a/clanModules/flake-module.nix +++ b/clanModules/flake-module.nix @@ -36,5 +36,6 @@ xfce = ./xfce; zerotier-static-peers = ./zerotier-static-peers; zt-tcp-relay = ./zt-tcp-relay; + zerotier = ./zerotier; }; } diff --git a/clanModules/zerotier/README.md b/clanModules/zerotier/README.md new file mode 100644 index 000000000..40a58b512 --- /dev/null +++ b/clanModules/zerotier/README.md @@ -0,0 +1,7 @@ +--- +description = "Statically configure the `zerotier` peers of a clan network." +features = [ "inventory" ] +--- +Statically configure the `zerotier` peers of a clan network. + +Requires a machine, that is the zerotier controller configured in the network. diff --git a/clanModules/zerotier/default.nix b/clanModules/zerotier/default.nix new file mode 100644 index 000000000..8007b6f11 --- /dev/null +++ b/clanModules/zerotier/default.nix @@ -0,0 +1,2 @@ +# TODO: only kept this file to not break documentation generation. +{ } diff --git a/clanModules/zerotier/roles/controller.nix b/clanModules/zerotier/roles/controller.nix new file mode 100644 index 000000000..27dd01d6d --- /dev/null +++ b/clanModules/zerotier/roles/controller.nix @@ -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; + }; +} diff --git a/clanModules/zerotier/roles/moon.nix b/clanModules/zerotier/roles/moon.nix new file mode 100644 index 000000000..81f11cd9d --- /dev/null +++ b/clanModules/zerotier/roles/moon.nix @@ -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; +} diff --git a/clanModules/zerotier/roles/peer.nix b/clanModules/zerotier/roles/peer.nix new file mode 100644 index 000000000..a56780406 --- /dev/null +++ b/clanModules/zerotier/roles/peer.nix @@ -0,0 +1,5 @@ +{ + imports = [ + ../shared.nix + ]; +} diff --git a/clanModules/zerotier/shared.nix b/clanModules/zerotier/shared.nix new file mode 100644 index 000000000..cb4dc3492 --- /dev/null +++ b/clanModules/zerotier/shared.nix @@ -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() + ''}" + ] + ); + + }; +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 94ec72b8a..804258d2d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/pkgs/zerotier-members/zerotier-members.py b/pkgs/zerotier-members/zerotier-members.py index a29e1aea9..87a6a12b9 100755 --- a/pkgs/zerotier-members/zerotier-members.py +++ b/pkgs/zerotier-members/zerotier-members.py @@ -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) From 5b4badab10ae700ddb44d288f8ef91cf77e288a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 8 Nov 2024 12:28:24 +0100 Subject: [PATCH 2/5] clanCore/zerotier: quote "or" keyword in attrset --- nixosModules/clanCore/zerotier/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixosModules/clanCore/zerotier/default.nix b/nixosModules/clanCore/zerotier/default.nix index 59804d911..4ae28a7e1 100644 --- a/nixosModules/clanCore/zerotier/default.nix +++ b/nixosModules/clanCore/zerotier/default.nix @@ -250,7 +250,7 @@ in rules = [ { not = false; - or = false; + "or" = false; type = "ACTION_ACCEPT"; } ]; From 671effe3c3a1f0b00e21f5205c20304cf39293f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 8 Nov 2024 12:38:36 +0100 Subject: [PATCH 3/5] clanModules/zerotier: add documentation --- clanModules/zerotier/README.md | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/clanModules/zerotier/README.md b/clanModules/zerotier/README.md index 40a58b512..0572addb4 100644 --- a/clanModules/zerotier/README.md +++ b/clanModules/zerotier/README.md @@ -1,7 +1,38 @@ --- -description = "Statically configure the `zerotier` peers of a clan network." +description = "Configures [Zerotier VPN](https://zerotier.com) secure and efficient networking within a Clan.." features = [ "inventory" ] --- -Statically configure the `zerotier` peers of a clan network. -Requires a machine, that is the zerotier controller configured in the network. +## 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. From 91ff83c3c03e2e6f52e9be7724f36c12bb155639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 8 Nov 2024 14:45:37 +0100 Subject: [PATCH 4/5] zerotier-members: make output better --- pkgs/zerotier-members/zerotier-members.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/zerotier-members/zerotier-members.py b/pkgs/zerotier-members/zerotier-members.py index 87a6a12b9..1fb9a36ef 100755 --- a/pkgs/zerotier-members/zerotier-members.py +++ b/pkgs/zerotier-members/zerotier-members.py @@ -101,7 +101,7 @@ def list_members(args: argparse.Namespace) -> None: print( member_id, compute_zerotier_ip(network_id, data["id"]), - data["authorized"] or "false", + data.get("authorized", False) ) From 94f0d86432b3efc85bb0b5a165f63e27e49cefa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 8 Nov 2024 14:48:05 +0100 Subject: [PATCH 5/5] zerotier-members: improve ux of console output --- pkgs/zerotier-members/zerotier-members.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pkgs/zerotier-members/zerotier-members.py b/pkgs/zerotier-members/zerotier-members.py index 1fb9a36ef..e3f51199d 100755 --- a/pkgs/zerotier-members/zerotier-members.py +++ b/pkgs/zerotier-members/zerotier-members.py @@ -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.get("authorized", 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()