diff --git a/clanServices/flake-module.nix b/clanServices/flake-module.nix index f430446d8..ace1d9aeb 100644 --- a/clanServices/flake-module.nix +++ b/clanServices/flake-module.nix @@ -5,5 +5,6 @@ ./hello-world/flake-module.nix ./wifi/flake-module.nix ./borgbackup/flake-module.nix + ./zerotier/flake-module.nix ]; } diff --git a/clanServices/zerotier/README.md b/clanServices/zerotier/README.md new file mode 100644 index 000000000..373de10ba --- /dev/null +++ b/clanServices/zerotier/README.md @@ -0,0 +1,44 @@ +--- +description = "Configures [Zerotier VPN](https://zerotier.com) secure and efficient networking within a Clan." +features = [ "inventory" ] +categories = [ "Network", "System" ] + +[constraints] +roles.controller.min = 1 +roles.controller.max = 1 +roles.moon.max = 7 +--- + +## 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. diff --git a/clanServices/zerotier/default.nix b/clanServices/zerotier/default.nix new file mode 100644 index 000000000..ced363943 --- /dev/null +++ b/clanServices/zerotier/default.nix @@ -0,0 +1,151 @@ +{ ... }: +{ + _class = "clan.service"; + manifest.name = "clan-core/zerotier"; + manifest.description = "Configuration of the secure and efficient Zerotier VPN"; + manifest.categories = [ "Utility" ]; + manifest.readme = builtins.readFile ./README.md; + + roles.peer = { + perInstance = + { instanceName, roles, ... }: + { + nixosModule = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ + (import ./shared.nix { + inherit + instanceName + roles + config + lib + pkgs + ; + }) + ]; + }; + }; + }; + + roles.moon = { + interface = + { lib, ... }: + { + options.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. + ''; + example = '' + [ 1.2.3.4" "10.0.0.3/9993" "2001:abcd:abcd::3/9993" ] + ''; + }; + + }; + perInstance = + { instanceName, roles, ... }: + { + nixosModule = + { + config, + lib, + pkgs, + settings, + ... + }: + { + config.clan.core.networking.zerotier.moon.stableEndpoints = settings.zerotier.moon.stableEndpoints; + + imports = [ + (import ./shared.nix { + inherit + instanceName + roles + config + lib + pkgs + ; + }) + ]; + }; + }; + }; + + roles.controller = { + perInstance = + { + instanceName, + roles, + ... + }: + { + nixosModule = + { + config, + lib, + pkgs, + ... + }: + let + uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list); + in + { + imports = [ + (import ./shared.nix { + inherit + instanceName + roles + config + lib + pkgs + ; + }) + ]; + config = { + systemd.services.zerotier-inventory-autoaccept = + let + machines = uniqueStrings ( + (lib.attrNames roles.moon.machines) + ++ (lib.attrNames roles.controller.machines) + ++ (lib.attrNames roles.peer.machines) + ); + networkIps = builtins.foldl' ( + ips: name: + if + builtins.pathExists "${config.clan.core.settings.directory}/vars/per-machine/${name}/zerotier/zerotier-ip/value" + then + ips + ++ [ + (builtins.readFile "${config.clan.core.settings.directory}/vars/per-machine/${name}/zerotier/zerotier-ip/value") + ] + else + ips + ) [ ] machines; + allHostIPs = 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/clanServices/zerotier/flake-module.nix b/clanServices/zerotier/flake-module.nix new file mode 100644 index 000000000..1bbbb3a5c --- /dev/null +++ b/clanServices/zerotier/flake-module.nix @@ -0,0 +1,9 @@ +{ + lib, + ... +}: +{ + clan.modules = { + zerotier = lib.modules.importApply ./default.nix { }; + }; +} diff --git a/clanServices/zerotier/shared.nix b/clanServices/zerotier/shared.nix new file mode 100644 index 000000000..edff0c290 --- /dev/null +++ b/clanServices/zerotier/shared.nix @@ -0,0 +1,72 @@ +{ + lib, + config, + pkgs, + roles, + instanceName, + ... +}: +let + controllerMachine = builtins.head (lib.attrNames roles.controller.machines or { }); + networkIdPath = "${config.clan.core.settings.directory}/vars/per-machine/${controllerMachine}/zerotier/zerotier-network-id/value"; + networkId = + if builtins.pathExists networkIdPath then + builtins.readFile networkIdPath + else + builtins.throw '' + No zerotier network id found for ${controllerMachine}. + Please run `clan vars generate ${controllerMachine}` first. + ''; + moons = lib.attrNames (roles.moon.machines or { }); + moonIps = builtins.foldl' ( + ips: name: + if + builtins.pathExists "${config.clan.core.settings.directory}/vars/per-machine/${name}/zerotier/zerotier-ip/value" + then + ips + ++ [ + (builtins.readFile "${config.clan.core.settings.directory}/vars/per-machine/${name}/zerotier/zerotier-ip/value") + ] + else + ips + ) [ ] moons; +in +{ + config = { + clan.core.networking.zerotier.networkId = networkId; + clan.core.networking.zerotier.name = instanceName; + + 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 72f5db72f..41404db20 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -86,6 +86,7 @@ nav: - reference/clanServices/borgbackup.md - reference/clanServices/hello-world.md - reference/clanServices/wifi.md + - reference/clanServices/zerotier.md - Clan Modules: - Overview: reference/clanModules/index.md - reference/clanModules/frontmatter/index.md