From f398daacb54a34d9ede6b7751dc1f07780d94f8b Mon Sep 17 00:00:00 2001 From: kurogeek Date: Thu, 17 Sep 2026 06:34:27 +0000 Subject: [PATCH] clanService/phonebox-global: decentralized phone network over yggdrasil New service alongside phonebox. Boxes get a random 6 digit number (100000-999999) signed by the machine's yggdrasil key. A serf gossip cluster on yggdrasil port 7946 exchanges the signed records; phonebox-sync verifies key -> address derivation and the signature and writes the number -> address map to /run/phonebox/numbers for the asterisk dialplan. Any yggdrasil node running the service can reach any other; clan-core's ygg-input filter is opened for SIP, RTP and gossip. Includes a two-node VM test proving directory convergence and a call over yggdrasil. The existing phonebox service is untouched. --- modules/clan/phonebox-global/README.md | 45 ++ modules/clan/phonebox-global/default.nix | 583 ++++++++++++++++++ modules/clan/phonebox-global/flake-module.nix | 23 + modules/clan/phonebox-global/phonebox-sync.py | 111 ++++ .../clan/phonebox-global/tests/vm/default.nix | 77 +++ .../tests/vm/sops/machines/nodeA/key.json | 6 + .../tests/vm/sops/machines/server/key.json | 6 + .../vm/sops/secrets/nodeA-age.key/secret | 14 + .../vm/sops/secrets/nodeA-age.key/users/admin | 1 + .../vm/sops/secrets/server-age.key/secret | 14 + .../sops/secrets/server-age.key/users/admin | 1 + .../tests/vm/sops/users/admin/key.json | 4 + .../nodeA/phonebox-global/number/value | 1 + .../nodeA/phonebox-global/owner-name/value | 1 + .../nodeA/phonebox-global/public-key/value | 1 + .../nodeA/phonebox-global/signature/value | 1 + .../nodeA/state-version/version/value | 1 + .../per-machine/nodeA/yggdrasil/address/value | 1 + .../nodeA/yggdrasil/privateKey/machines/nodeA | 1 + .../nodeA/yggdrasil/privateKey/secret | 18 + .../nodeA/yggdrasil/privateKey/users/admin | 1 + .../nodeA/yggdrasil/publicKey/value | 1 + .../server/phonebox-global/number/value | 1 + .../server/phonebox-global/owner-name/value | 1 + .../server/phonebox-global/public-key/value | 1 + .../server/phonebox-global/signature/value | 1 + .../server/state-version/version/value | 1 + .../server/yggdrasil/address/value | 1 + .../yggdrasil/privateKey/machines/server | 1 + .../server/yggdrasil/privateKey/secret | 18 + .../server/yggdrasil/privateKey/users/admin | 1 + .../server/yggdrasil/publicKey/value | 1 + 32 files changed, 939 insertions(+) create mode 100644 modules/clan/phonebox-global/README.md create mode 100644 modules/clan/phonebox-global/default.nix create mode 100644 modules/clan/phonebox-global/flake-module.nix create mode 100644 modules/clan/phonebox-global/phonebox-sync.py create mode 100644 modules/clan/phonebox-global/tests/vm/default.nix create mode 100755 modules/clan/phonebox-global/tests/vm/sops/machines/nodeA/key.json create mode 100755 modules/clan/phonebox-global/tests/vm/sops/machines/server/key.json create mode 100644 modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/secret create mode 120000 modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/users/admin create mode 100644 modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/secret create mode 120000 modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/users/admin create mode 100644 modules/clan/phonebox-global/tests/vm/sops/users/admin/key.json create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/number/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/owner-name/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/public-key/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/signature/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/state-version/version/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/address/value create mode 120000 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/machines/nodeA create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/secret create mode 120000 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/users/admin create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/publicKey/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/number/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/owner-name/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/public-key/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/signature/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/state-version/version/value create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/address/value create mode 120000 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/machines/server create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/secret create mode 120000 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/users/admin create mode 100644 modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/publicKey/value diff --git a/modules/clan/phonebox-global/README.md b/modules/clan/phonebox-global/README.md new file mode 100644 index 0000000..22bd7f5 --- /dev/null +++ b/modules/clan/phonebox-global/README.md @@ -0,0 +1,45 @@ +A peer to peer phone relay network built on top of yggdrasil. + +Successor of the `phonebox` service with a global, decentralized number +directory. Every box is reachable from any yggdrasil node running this +service, not only from members of the same clan. A machine runs either +`phonebox` or `phonebox-global`, not both (they share the asterisk setup). +Regenerate vars with `clan vars generate --generator phonebox-global`. + +## Numbers + +Each box gets a random 6 digit number (100000-999999) when its vars are +generated. The number is signed with the box's yggdrasil private key, so a +number is cryptographically bound to the yggdrasil address it belongs to; +nobody can claim a number for an address they do not own. Regenerate the +`phonebox` vars to get a new number. + +Dialing from a phone plugged into the ATA: + +| dialed | reaches | +| ------------- | ------------------------------------------ | +| `NNNNNN` | the phone on box `NNNNNN` (line `00`) | +| `NNNNNNXX` | line `XX` on box `NNNNNN` | +| `00`, `01`... | local lines on this box | +| `888` | fax of the current number directory | +| `000` | fax echo test | +| `999` | hello world | + +Caller ID on the callee is the caller's box number followed by its line, so +calling it back works. + +## Directory + +There is no central registry. Each box runs a serf agent on yggdrasil port +7946 that gossips its signed number record (`number`, `key`, `sig`, `owner` +tags) to every other box it knows about. On every membership change the +`phonebox-sync` handler verifies all records and writes the resulting +`number -> yggdrasil address` map to `/run/phonebox/numbers/`, which the +asterisk dialplan reads at call time. + +Bootstrapping: boxes of the same clan join each other automatically. To +connect to boxes outside the clan add one of their yggdrasil addresses to +`extraPeers`; after the first successful join the agent remembers the whole +membership in `/var/lib/phonebox/serf.snapshot` and rejoins on its own. + +`serf members -rpc-addr 127.0.0.1:7373` shows the live directory. diff --git a/modules/clan/phonebox-global/default.nix b/modules/clan/phonebox-global/default.nix new file mode 100644 index 0000000..0e0cf21 --- /dev/null +++ b/modules/clan/phonebox-global/default.nix @@ -0,0 +1,583 @@ +{ + clanLib, + ... +}: +{ + _class = "clan.service"; + manifest.name = "phonebox-global"; + manifest.description = "A peer to peer phone relay network built on top of yggdrasil."; + manifest.readme = builtins.readFile ./README.md; + manifest.categories = [ "System" ]; + + roles.default = { + description = "a default server role"; + interface = + { lib, ... }: + { + options.ata-ethernet-iface = lib.mkOption { + type = lib.types.str; + description = "An Ethernet interface that connect to ATA box."; + default = "enp2s0"; + }; + options.extraClientNumbers = lib.mkOption { + type = with lib.types; listOf str; + description = "List of client suffix number."; + default = [ ]; + }; + + options.extraFixedIPClient = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + ip = lib.mkOption { + type = lib.types.str; + description = "IP address for this client"; + }; + + name = lib.mkOption { + type = lib.types.str; + description = "Name of the client"; + }; + }; + } + ); + description = "Extra client to be added to pjsip config as a fixed IP auth"; + default = { }; + example = { + "01" = { + ip = "192.168.1.3"; + name = "bob"; + }; + }; + }; + + options.extraPeers = lib.mkOption { + type = with lib.types; listOf str; + description = '' + Yggdrasil addresses of phonebox nodes outside this clan to bootstrap + the number directory from. Any single reachable node is enough; the + directory is gossiped from there and remembered across restarts. + ''; + default = [ ]; + example = [ "200:1234:5678:9abc:def0:1234:5678:9abc" ]; + }; + }; + perInstance = + { + roles, + settings, + ... + }: + { + + nixosModule = + { + lib, + config, + pkgs, + ... + }: + let + asterisk = pkgs.asterisk.overrideAttrs (old: { + propagatedNativeBuildInputs = [ pkgs.spandsp3 ]; + }); + + machineName = config.clan.core.settings.machine.name; + machines = lib.attrNames roles.default.machines; + + user = "asterisk"; + faxDir = "/run/asterisk/fax"; + rtpPortFrom = 10000; + rtpPortTo = 20000; + ata-interface = settings.ata-ethernet-iface; + + # The ATA's own line. Remote callers reach it by dialing the 6 + # digit box number alone or with this extension appended. + ataLine = "00"; + sipPort = 5060; + + # Well-known port of the phonebox directory gossip. Every phonebox + # node worldwide must agree on it, so it is not configurable. + directoryPort = 7946; + directoryRpc = "127.0.0.1:7373"; + directoryUser = "phonebox"; + directoryDir = "/run/phonebox"; + + phoneboxVars = config.clan.core.vars.generators.phonebox-global.files; + ownNumber = phoneboxVars.number.value; + ownerName = lib.trim phoneboxVars.owner-name.value; + + getYggdrasilIP = + name: + lib.trim ( + clanLib.getPublicValue { + flake = config.clan.core.settings.directory; + machine = name; + generator = "yggdrasil"; + file = "address"; + default = ""; + } + ); + + ownYggdrasilIP = + if config.clan.core.vars.generators.yggdrasil.files ? address then + config.clan.core.vars.generators.yggdrasil.files.address.value + else + throw "clanService/yggdrasil is required"; + + seedPeers = lib.unique ( + lib.filter (ip: ip != "") (map getYggdrasilIP (lib.remove machineName machines)) + ++ settings.extraPeers + ); + + # Rebuilds the number -> yggdrasil address directory from the + # gossip membership. Every record is verified: the yggdrasil + # address is derived from the record's public key, so a node can + # only publish numbers for the address it actually owns, and the + # number is signed with the matching private key. + phoneboxSync = pkgs.writers.writePython3Bin "phonebox-sync" { + libraries = [ pkgs.python3Packages.cryptography ]; + flakeIgnore = [ "E501" ]; + } (builtins.readFile ./phonebox-sync.py); + + phoneboxSyncWrapped = pkgs.writeShellApplication { + name = "phonebox-sync"; + runtimeInputs = [ pkgs.serfdom ]; + text = '' + exec ${lib.getExe phoneboxSync} ${directoryRpc} ${directoryDir} + ''; + }; + + createContactListTiff = pkgs.writeShellApplication { + name = "create-contact-tiff"; + text = '' + magick -background white -fill black -pointsize 20 -font DejaVu-Sans label:"$(cat ${directoryDir}/contacts.txt)" "$1" + magick "$1" -border 20x50 -bordercolor white "$1" + magick "$1" -resize 1728x -units PixelsPerInch -compress Group4 -density 204x196 -monochrome -depth 1 "$1" + ''; + runtimeInputs = [ pkgs.imagemagick ]; + }; + + genLocalSIPEndpoint = + { localNumber }: + '' + [${localNumber}](internal_endpoint) + aors=${localNumber} + auth=${localNumber} + + [${localNumber}](userpass_auth) + username=${localNumber} + password=${localNumber} + + [${localNumber}](dynamic_aor) + max_contacts=1 + remove_existing=yes + ''; + + genLocalSIPEndpointV6 = + { localNumber }: + '' + [${localNumber}](internal_endpoint) + transport=transport-udp6 + aors=${localNumber} + auth=${localNumber} + + [${localNumber}](userpass_auth) + username=${localNumber} + password=${localNumber} + + [${localNumber}](dynamic_aor) + max_contacts=1 + ''; + + genLocalSIPIPEndpoint = number: '' + + [${number}](internal_endpoint) + aors=${number} + auth=${number} + contact_deny=0.0.0.0/0 + contact_deny=::/0 + contact_permit=${settings.extraFixedIPClient.${number}.ip}/128 + + [${number}](dynamic_aor) + max_contacts=1 + remove_existing=yes + + [${number}](userpass_auth) + username=${number} + password=${number} + + ''; + + genLocalExtenConf = + { localNumber }: + '' + exten => ${localNumber},1,Dial(PJSIP/${localNumber},20) + ''; + + localNumbers = [ + ataLine + ] + ++ settings.extraClientNumbers + ++ lib.attrNames settings.extraFixedIPClient; + in + { + assertions = [ + { + assertion = !config.networking.nftables.enable; + message = "phonebox-global: opening the yggdrasil interface to non-clan nodes is only implemented for the iptables firewall backend"; + } + ]; + + clan.core.vars.generators.phonebox-global = { + files = { + number.secret = false; + public-key.secret = false; + signature.secret = false; + owner-name.secret = false; + }; + + dependencies = [ "yggdrasil" ]; + + prompts.owner-name = { + persist = true; + type = "line"; + description = "The owner's name for this unit"; + }; + + runtimeInputs = with pkgs; [ + coreutils + openssl + xxd + ]; + + # A random 6 digit number (100000-999999), bound to this + # machine's yggdrasil identity by signing it with the yggdrasil + # private key. Other nodes accept the number only if the + # signature checks out and the key derives to the yggdrasil + # address the record is gossiped from. Regenerate to re-roll. + script = '' + cat $prompts/owner-name > $out/owner-name + shuf -i 100000-999999 -n 1 | tr -d '\n' > $out/number + openssl pkey -in $in/yggdrasil/privateKey -pubout -outform DER \ + | tail -c 32 | xxd -p -c 64 | tr -d '\n' > $out/public-key + # openssl needs a regular file for one-shot ed25519 signing + printf 'phonebox:%s' "$(cat $out/number)" > $out/message + openssl pkeyutl -sign -rawin -inkey $in/yggdrasil/privateKey -in $out/message \ + | base64 -w0 > $out/signature + rm $out/message + ''; + }; + + networking.interfaces = { + ${ata-interface} = { + useDHCP = false; + ipv4.addresses = [ + { + address = "192.168.254.1"; + prefixLength = 24; + } + ]; + }; + }; + + services.dnsmasq = { + enable = true; + + settings = { + bind-dynamic = true; + listen-address = "192.168.254.1"; + # enable-ra = true; + domain-needed = true; + domain = "localhost"; + dhcp-range = [ + "192.168.254.100,192.168.254.100,255.255.255.0,3m" + ]; + dhcp-leasefile = "/dev/null"; + dhcp-option = [ + "3,192.168.254.1" + ]; + interface = [ ata-interface ]; + }; + }; + + services.nginx = { + enable = true; + virtualHosts = { + "_" = { + locations."/" = { + proxyPass = "http://192.168.254.100"; + extraConfig = '' + client_max_body_size 100M; + ''; + }; + }; + }; + }; + + networking.firewall.allowedUDPPortRanges = [ + { + from = rtpPortFrom; + to = rtpPortTo; + } + ]; + + networking.firewall.allowedUDPPorts = [ + 53 + 67 + sipPort + ]; + networking.firewall.allowedTCPPorts = [ + 53 + ]; + networking.firewall.interfaces = { + "zt+".allowedTCPPorts = [ 80 ]; + ygg = { + allowedTCPPorts = [ directoryPort ]; + allowedUDPPorts = [ directoryPort ]; + }; + }; + + # clanService/yggdrasil drops everything on the ygg interface that + # does not come from a clan member. Phonebox is a global network, + # so let SIP, RTP and the directory gossip through from anyone. + networking.firewall.extraCommands = lib.mkAfter '' + if ip6tables -n -L ygg-input >/dev/null 2>&1; then + ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString sipPort} -j RETURN + ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString rtpPortFrom}:${toString rtpPortTo} -j RETURN + ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString directoryPort} -j RETURN + ip6tables -I ygg-input 1 -i ygg -p tcp --dport ${toString directoryPort} -j RETURN + fi + ''; + + users.users.${directoryUser} = { + isSystemUser = true; + group = directoryUser; + }; + users.groups.${directoryUser} = { }; + + # Decentralized number directory: a serf gossip cluster over + # yggdrasil. Each node advertises its signed number record as + # tags; membership changes trigger phonebox-sync, which writes the + # verified number -> address map to ${directoryDir}/numbers/ for + # the dialplan. The snapshot lets a node rejoin from previously + # seen members, so seeds are only needed for the very first join. + systemd.services.phonebox-directory = { + description = "Phonebox number directory (serf gossip over yggdrasil)"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "yggdrasil.service" + ]; + serviceConfig = { + User = directoryUser; + Group = directoryUser; + RuntimeDirectory = "phonebox"; + RuntimeDirectoryMode = "0755"; + RuntimeDirectoryPreserve = "yes"; + StateDirectory = "phonebox"; + Restart = "always"; + RestartSec = 5; + ExecStart = lib.concatStringsSep " " ( + [ + (lib.getExe pkgs.serfdom) + "agent" + "-node=${machineName}-${ownNumber}" + "-bind=[::]:${toString directoryPort}" + "-advertise=[${ownYggdrasilIP}]:${toString directoryPort}" + "-rpc-addr=${directoryRpc}" + "-profile=wan" + "-snapshot=/var/lib/phonebox/serf.snapshot" + "-rejoin" + "-retry-max=0" + "-retry-interval=30s" + "-tag number=${ownNumber}" + "-tag key=${phoneboxVars.public-key.value}" + "-tag sig=${phoneboxVars.signature.value}" + "-tag ${lib.escapeShellArg "owner=${ownerName}"}" + "-event-handler=member-join,member-leave,member-failed,member-update,member-reap=${lib.getExe phoneboxSyncWrapped}" + ] + ++ map (ip: "-retry-join=[${ip}]:${toString directoryPort}") seedPeers + ); + }; + }; + + services.asterisk = { + enable = lib.mkDefault true; + package = lib.mkDefault asterisk; + confFiles = { + "logger.conf" = '' + [general] + dateformat = %F %T.%3q ; ISO 8601 date format with milliseconds + use_callids = yes + appendhostname = no + queue_log = yes + queue_log_to_file = no + queue_log_name = queue_log + queue_log_realtime_use_gmt = no + rotatestrategy = rotate + exec_after_rotate=gzip -9 $\{filename\}.2 + [logfiles] + console => notice,warning,error + security => security + messages => notice,warning,error + full => notice,warning,error,verbose,dtmf,fax + syslog.local0 => notice,warning,error + ''; + + "modules.conf" = '' + [modules] + autoload=yes + + load => res_fax_spandsp.so + ''; + + # Dial plan config + "extensions.conf" = '' + [from-internal] + exten => 999,1,Answer() + same => n,Playback(hello-world) + same => n,Hangup() + + exten => 000,1,Answer() + same => n,ReceiveFAX(${faxDir}/echo-''${UNIQUEID}.tiff) + same => n,Set(FAXFILE=${faxDir}/echo-''${UNIQUEID}.tiff) + same => n,Set(FAXECHO=true) + + exten => 888,1,Answer() + same => n,Set(FAXFILE=${faxDir}/contact.tiff) + same => n,System(${lib.getExe createContactListTiff} ''${FAXFILE}) + same => n,Set(FAXECHO=true) + same => n,Playback(vm-goodbye) + same => n,Wait(3) + + exten => h,1,GotoIf($[''${FAXECHO}]?sendfax) + same => n,Hangup() + same => n(sendfax),Originate(PJSIP/${ataLine},app,SendFAX,''${FAXFILE}) + same => n,Set(FAXECHO=false) + + ; 6 digit box number, optionally followed by a 2 digit line + exten => _[1-9]XXXXX,1,Goto(to-yggdrasil,''${EXTEN},1) + exten => _[1-9]XXXXXXX,1,Goto(to-yggdrasil,''${EXTEN},1) + + '' + + lib.concatMapStrings (number: genLocalExtenConf { localNumber = number; }) localNumbers + + '' + + ; Outbound: resolve the box number through the directory and + ; send the call to that node over yggdrasil. + [to-yggdrasil] + exten => _[1-9]X.,1,Set(PEER=''${FILE(${directoryDir}/numbers/''${EXTEN:0:6})}) + same => n,GotoIf($["''${PEER}" = ""]?unknown) + same => n,Set(CALLERID(num)=${ownNumber}''${CALLERID(num)}) + same => n,Set(CALLERID(name)=${ownerName}) + same => n,Dial(PJSIP/yggdrasil/sip:''${EXTEN}@[''${PEER}]:${toString sipPort},30) + same => n,Hangup() + same => n(unknown),Playback(ss-noservice) + same => n,Hangup() + + ; Inbound from any yggdrasil node: only our own number is served. + [from-yggdrasil] + exten => ${ownNumber},1,Dial(PJSIP/${ataLine},20) + exten => _${ownNumber}XX,1,Dial(PJSIP/''${EXTEN:6},20) + ''; + + "rtp.conf" = '' + [general] + rtpstart=${toString rtpPortFrom} + rtpend=${toString rtpPortTo} + ''; + + "pjsip.conf" = '' + [global] + type=global + ; Local lines authenticate by username; everything else from + ; the yggdrasil range is a remote phonebox node. + endpoint_identifier_order=username,ip,anonymous + + [transport-udp] + type=transport + protocol=udp + bind=0.0.0.0 + [transport-udp6] + type=transport + protocol=udp + bind=:: + + [base_endpoint](!) + type=endpoint + disallow=all + allow=ulaw,alaw,g722,gsm + direct_media=no + + [internal_endpoint](!,base_endpoint) + context=from-internal + + [userpass_auth](!) + type=auth + auth_type=userpass + + [dynamic_aor](!) + type=aor + + [yggdrasil](base_endpoint) + transport=transport-udp6 + context=from-yggdrasil + + [yggdrasil] + type=identify + endpoint=yggdrasil + match=200::/7 + + '' + + (genLocalSIPEndpoint { localNumber = ataLine; }) + + lib.concatMapStrings ( + number: genLocalSIPEndpointV6 { localNumber = number; } + ) settings.extraClientNumbers + + lib.concatMapStrings genLocalSIPIPEndpoint (lib.attrNames settings.extraFixedIPClient); + }; + }; + + environment.systemPackages = [ + createContactListTiff + phoneboxSyncWrapped + ]; + + systemd.tmpfiles.rules = [ + "d ${faxDir} 0755 ${user} ${user} - -" + ]; + + systemd.services.asterisk-watcher = { + enable = true; + description = "Asterisk Configuration files watcher"; + + requires = [ "asterisk.service" ]; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ + inotify-tools + asterisk + ]; + script = '' + inotifywait -m -e move /etc/asterisk | + while read path action file; do + case "$file" in + pjsip.conf) + echo "restarting pjsip" + asterisk -rx "pjsip reload" + ;; + esac + case "$file" in + extensions.conf) + echo "restarting core" + asterisk -rx "core restart now" + ;; + esac + done + ''; + }; + }; + }; + }; +} diff --git a/modules/clan/phonebox-global/flake-module.nix b/modules/clan/phonebox-global/flake-module.nix new file mode 100644 index 0000000..d273439 --- /dev/null +++ b/modules/clan/phonebox-global/flake-module.nix @@ -0,0 +1,23 @@ +{ + inputs, + self, + ... +}: +let + module = ./default.nix; +in +{ + clan.modules = { + phonebox-global = module; + }; + perSystem = + { ... }: + { + clan.nixosTests.service-phonebox-global = { + imports = [ ./tests/vm/default.nix ]; + _module.args = { inherit self inputs; }; + + clan.modules."@clan/phonebox-global" = module; + }; + }; +} diff --git a/modules/clan/phonebox-global/phonebox-sync.py b/modules/clan/phonebox-global/phonebox-sync.py new file mode 100644 index 0000000..a1add7b --- /dev/null +++ b/modules/clan/phonebox-global/phonebox-sync.py @@ -0,0 +1,111 @@ +"""Rebuild the phonebox number directory from serf membership. + +Usage: phonebox-sync + +Runs as a serf event handler. Every alive member carries its number record +as tags (number, key, sig, owner). A record is accepted only when the +yggdrasil address it is gossiped from derives from `key` and `sig` is a +valid ed25519 signature of "phonebox:" under that key, so a number +can only ever be bound to the address of the node that signed it. + +Output, consumed by the asterisk dialplan: + /numbers/ the yggdrasil address, no trailing newline + /contacts.txt " : " per line, for the fax +""" + +import base64 +import ipaddress +import json +import os +import subprocess +import sys +import syslog +import tempfile + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +def yggdrasil_address(public_key: bytes) -> ipaddress.IPv6Address: + """Port of yggdrasil-go address.AddrForKey. + + Invert the key; the address is 0x02, the count of leading one bits, + then the bits following the first zero, truncated to 128 bits. + """ + bits = "".join(f"{b ^ 0xFF:08b}" for b in public_key) + ones = len(bits) - len(bits.lstrip("1")) + body = int(bits[ones + 1:ones + 113], 2).to_bytes(14, "big") + return ipaddress.IPv6Address(bytes([0x02, ones]) + body) + + +def verified_record(member: dict) -> tuple[str, str, str, str]: + """Return (number, key hex, address, owner) or raise ValueError.""" + tags = member.get("tags", {}) + try: + number, key_hex, sig = tags["number"], tags["key"], tags["sig"] + key = bytes.fromhex(key_hex) + host, _ = member["addr"].rsplit(":", 1) + address = ipaddress.IPv6Address(host.strip("[]")) + signature = base64.b64decode(sig, validate=True) + except (KeyError, ValueError) as e: + raise ValueError(f"malformed record: {e}") + if not (len(number) == 6 and number.isdigit() and number[0] != "0"): + raise ValueError(f"invalid number {number!r}") + if yggdrasil_address(key) != address: + raise ValueError(f"key does not derive to address {address}") + try: + Ed25519PublicKey.from_public_bytes(key).verify(signature, b"phonebox:" + number.encode()) + except (ValueError, InvalidSignature): + raise ValueError(f"bad signature for number {number}") + return number, key_hex, str(address), tags.get("owner", "") + + +def directory(members: list[dict]) -> dict[str, tuple[str, str, str]]: + """number -> (key hex, address, owner); on a collision the lowest key wins.""" + book = {} + for member in members: + try: + number, key, address, owner = verified_record(member) + except ValueError as e: + syslog.syslog(syslog.LOG_WARNING, f"ignoring {member.get('name')}: {e}") + continue + if number in book: + syslog.syslog(syslog.LOG_WARNING, f"number {number} claimed by keys {book[number][0]} and {key}") + if book[number][0] <= key: + continue + book[number] = (key, address, owner) + return book + + +def write_atomic(path: str, content: str) -> None: + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path)) + with os.fdopen(fd, "w") as f: + f.write(content) + os.chmod(tmp, 0o644) + os.replace(tmp, path) + + +def main() -> None: + rpc_addr, state_dir = sys.argv[1:] + syslog.openlog("phonebox-sync") + out = subprocess.run( + ["serf", "members", "-format=json", "-status=alive", f"-rpc-addr={rpc_addr}"], + check=True, capture_output=True, text=True, + ).stdout + book = directory(json.loads(out)["members"]) + + numbers_dir = os.path.join(state_dir, "numbers") + os.makedirs(numbers_dir, exist_ok=True) + for number, (_, address, _) in book.items(): + write_atomic(os.path.join(numbers_dir, number), address) + for stale in set(os.listdir(numbers_dir)) - book.keys(): + os.unlink(os.path.join(numbers_dir, stale)) + write_atomic( + os.path.join(state_dir, "contacts.txt"), + "".join(f"{number}\t\t: \t\t{book[number][2]}\n" for number in sorted(book)), + ) + syslog.syslog(syslog.LOG_INFO, f"directory has {len(book)} numbers") + + +if __name__ == "__main__": + main() diff --git a/modules/clan/phonebox-global/tests/vm/default.nix b/modules/clan/phonebox-global/tests/vm/default.nix new file mode 100644 index 0000000..569acf5 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/default.nix @@ -0,0 +1,77 @@ +{ + self, + hostPkgs, + config, + lib, + ... +}: +{ + name = "service-phonebox-global"; + result.update-vars = + let + relativeDir = lib.removePrefix "${self}/" (toString config.clan.directory); + in + hostPkgs.writeShellScriptBin "update-vars" '' + set -x + export PRJ_ROOT=$(git rev-parse --show-toplevel) + ${ + self.inputs.clan-core.packages.${hostPkgs.system}.clan-cli + }/bin/clan-generate-test-vars $PRJ_ROOT/${relativeDir} ${config.name} + ''; + + clan = { + test.useContainers = false; + directory = ./.; + inventory = { + machines.server = { }; + machines.nodeA = { }; + + instances = { + yggdrasil = { + module.name = "yggdrasil"; + roles.default.machines.server = { }; + roles.default.machines.nodeA = { }; + }; + phonebox-global-test = { + module.name = "@clan/phonebox-global"; + module.input = "self"; + roles.default.machines.server.settings.ata-ethernet-iface = "enp2s0"; + roles.default.machines.nodeA.settings.ata-ethernet-iface = "enp2s0"; + }; + }; + }; + }; + + nodes = { + server = { }; + nodeA = { }; + }; + + testScript = + { nodes, ... }: + let + number = node: nodes.${node}.clan.core.vars.generators.phonebox-global.files.number.value; + address = node: nodes.${node}.clan.core.vars.generators.yggdrasil.files.address.value; + in + '' + start_all() + + for node in [server, nodeA]: + node.wait_for_unit("asterisk.service") + node.wait_for_unit("phonebox-directory.service") + + # Directory converges: each node learns the other's number and address. + server.wait_until_succeeds("test -f /run/phonebox/numbers/${number "nodeA"}", timeout=300) + nodeA.wait_until_succeeds("test -f /run/phonebox/numbers/${number "server"}", timeout=300) + assert server.succeed("cat /run/phonebox/numbers/${number "nodeA"}") == "${address "nodeA"}" + assert nodeA.succeed("cat /run/phonebox/numbers/${number "server"}") == "${address "server"}" + assert "${number "nodeA"}" in server.succeed("cat /run/phonebox/contacts.txt") + + # A call from server to nodeA's number is routed over yggdrasil and + # accepted by nodeA's asterisk (no phone is registered, so it fails + # after being identified, which is enough to prove the path). + nodeA.succeed("asterisk -rx 'pjsip set logger on'") + server.succeed("asterisk -rx 'channel originate Local/${number "nodeA"}@from-internal application Wait 3'") + nodeA.wait_until_succeeds("grep -q 'INVITE sip:${number "nodeA"}@' /var/log/asterisk/full", timeout=60) + ''; +} diff --git a/modules/clan/phonebox-global/tests/vm/sops/machines/nodeA/key.json b/modules/clan/phonebox-global/tests/vm/sops/machines/nodeA/key.json new file mode 100755 index 0000000..746aaa1 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/machines/nodeA/key.json @@ -0,0 +1,6 @@ +[ + { + "publickey": "age18yf7r0dalnue5vwzanxl046fxsskhu9lzvd488ajhz7rp62mu94scmw3w4", + "type": "age" + } +] \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/sops/machines/server/key.json b/modules/clan/phonebox-global/tests/vm/sops/machines/server/key.json new file mode 100755 index 0000000..781eca8 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/machines/server/key.json @@ -0,0 +1,6 @@ +[ + { + "publickey": "age1sy8xkmssttcchjj0nkapk4tu530lrzszdp4lka6nn4gzlxam6e2s3xzp2j", + "type": "age" + } +] \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/secret b/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/secret new file mode 100644 index 0000000..6444d4d --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:NkOeX6FboG7NOkdgiTgAsS+XjRoDU+AEYfMhpZXIJTYEM2dQti+PMGyizKRvr1wRnSGMp56XaX0bHdToBuf1gYruqAsEkKwqsYw=,iv:RFyO5/JybroNAKe+F3vv51l4OEu5XXNs+biTTH8poEg=,tag:zfPm0ZAjT2CC734EU+36KQ==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2RklrTFQxMll5VUxiaGVu\nNlRzeldTRnVrSG93TXYzc1YrRE9Nc1VqTHpNCjh4aHJMZmtpM2tDclNaT3d2SDhN\na1QxTXVFOHRuY3dpL1YxMTJKK3pUVmMKLS0tIHoyMGVyc01vak5EKzZONC9XUmFw\nMU5MRmx6UDROQzFiaTNuNXFiQTZoYmcK6wxypuP7vTnz//EcEt2pUNqUuKxjOaY3\nAmKToJQADJfzTsYGzpmWkkYaMHvG00v8Ja6TF6IxZr5SxUh4bdHr+w==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg" + } + ], + "lastmodified": "2026-09-18T02:02:10Z", + "mac": "ENC[AES256_GCM,data:kRpbcT5+I80iHPjPUnveQE9jpa65n0xPPQR/by4p1iM80ZKk9KH0cObB5hHhXO4BD1OqI79UJ3O9O7Jhmylgx5Uh8RNbK6wf26p4GO2Bvqh9r15NqbCZtXkoW30MTLLIs58Q7NeK9O2LgeckWrXLtyEytfhnTuoTSDFo8Sh8NQY=,iv:rcNow1Z4BN7bo1XERB6c+G6Gwl3tRU1VHvYmoDaLTl4=,tag:vy/5EB/1gw/KLLqXHSEV3g==,type:str]", + "version": "3.13.3" + } +} diff --git a/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/users/admin b/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/users/admin new file mode 120000 index 0000000..9e21a99 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/secrets/nodeA-age.key/users/admin @@ -0,0 +1 @@ +../../../users/admin \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/secret b/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/secret new file mode 100644 index 0000000..328b39d --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:Sy9T0zEgSGZ/qkz5kaRN2qlH2X+pFRibzb8FpWuknuSI/9lActSG2Loq4LhmhqPGC5MGVkQcz2pcVFI7afUjzDzejfJf4De1ka4=,iv:JNLXguWEwJXR2+WNl0v0nUvkESVAbccfeQSUijKbj2I=,tag:/AAFKncO+B/XmUZYTQnTTw==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqOXJoSXgvcEFXK2hWTlpi\nQWhCdnZPUCt5R2xEWFYxUnpWVlc1ei93TncwCjNKaVB0MFN1VUdheEwyUTBzcmFT\nb0RjTHp2MGE4cWIraCtZME9rQXAvVEkKLS0tIFpiZDhCN0NIY3FWTnlXaFhiMmtC\nTFJMUncvMnFRSEhNdHlhaWozcjlqYWsKLDH1VFG/QS3VIMn6Iwo2NmY5kdBiOPNR\nj4sOY6xBpWP5AiGUypGoX4Bm52gLgW5AdcBRIxxhFOekIv5G8wmRaw==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg" + } + ], + "lastmodified": "2026-09-18T02:02:23Z", + "mac": "ENC[AES256_GCM,data:DN97Dcpy29IZkBbwFVnMxnUnWYrgLKuvbANabkMCdpOJYgI1msYODltHJlR68Lk8Fmg25sXysvdbJOnc2cMH3pgcKxeyfhdAFwlIXdie1HRtTpZql15RF0fwPBGJjwL9YYubpZU7K4cNflGbjuOCEhp8bQOUYY8Ptd2deiDGGuw=,iv:bxTcKLUu6fcz7JKzStKiakgBeNx7aWCw6M7HolthfEo=,tag:/0nWBkdCPkpcWSpcfY/TfA==,type:str]", + "version": "3.13.3" + } +} diff --git a/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/users/admin b/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/users/admin new file mode 120000 index 0000000..9e21a99 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/secrets/server-age.key/users/admin @@ -0,0 +1 @@ +../../../users/admin \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/sops/users/admin/key.json b/modules/clan/phonebox-global/tests/vm/sops/users/admin/key.json new file mode 100644 index 0000000..e408aa9 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/sops/users/admin/key.json @@ -0,0 +1,4 @@ +{ + "publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg", + "type": "age" +} diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/number/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/number/value new file mode 100644 index 0000000..44c27eb --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/number/value @@ -0,0 +1 @@ +392268 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/owner-name/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/owner-name/value new file mode 100644 index 0000000..d8e8508 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/owner-name/value @@ -0,0 +1 @@ +fake_line_value \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/public-key/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/public-key/value new file mode 100644 index 0000000..1b317f5 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/public-key/value @@ -0,0 +1 @@ +cf28bbcd822efec197dfaa99fbe9004f78b4efa9724b4f453d05f12a61a0bb44 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/signature/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/signature/value new file mode 100644 index 0000000..c9aa5e7 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/phonebox-global/signature/value @@ -0,0 +1 @@ +CrnzGbqfVORt00zeI99crpBRUMfGF+2uEfaHe1L3Y8k7CIojc+8TID40HPhwEgXNtC9/xr61mN5L0Vp4vpviBA== \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/state-version/version/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/state-version/version/value new file mode 100644 index 0000000..fbadb25 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/state-version/version/value @@ -0,0 +1 @@ +26.11 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/address/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/address/value new file mode 100644 index 0000000..d026aed --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/address/value @@ -0,0 +1 @@ +200:61ae:8864:fba2:27c:d040:aacc:82d \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/machines/nodeA b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/machines/nodeA new file mode 120000 index 0000000..7982851 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/machines/nodeA @@ -0,0 +1 @@ +../../../../../../sops/machines/nodeA \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/secret b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/secret new file mode 100644 index 0000000..9d0019d --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/secret @@ -0,0 +1,18 @@ +{ + "data": "ENC[AES256_GCM,data:hR33SvxgsU+hZnvF2hoQxE7n0IB6kLC9MgK0wS0NXpCht5NuPjTo5p51oUE8t95Dht2qlEdXAkGO39k/h42p54vhqcfuF7u/eqVMdX6pEJyOokFmsdDmdVZziyhZghrTI/nDlctxCPcqFkeCHU4SIBZs85NS8uk=,iv:Q8k8OXSS3lFw33UO8JiZYNkrH3s+Cvh0xnAbMiBmnNA=,tag:DtNYKgDYB2nSHIjvMdvu/w==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTSzk1RUhIekVJRStQd3Er\nbXlHUmtVb201dkRteVR1VFpoMTVFcTFPSG5NCmlPcVM5cWVJQ0g0VHNXaS9rTzh2\nWTIyQnRXakVCRWRQL0xuVUdDOEV0WEUKLS0tIE43RENWL1M2STF1VzdMcHJTNThh\ndHpqNE9MMnkrMlIxN2FEcjZ6anRIbDAK3rZ1lzO42bH37lb+zoeF6rKoVgI0TBST\n9EoVwom5xOKtmVAh5jobY/z4TGSD1BqQ6P2rQ/IM1Dtw3/18Yk7TXQ==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age18yf7r0dalnue5vwzanxl046fxsskhu9lzvd488ajhz7rp62mu94scmw3w4" + }, + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkdDFsSTZHUG54ZWNtQVdj\nZno4enBaRVlaQU1wNTBEVDVscS9Jc1JaTXhjClA3YUNQa3B2L0JXY25SSHRGbS94\nNVVNdHZ5VzdXcWpXZG1ZS1U4T0NPUkEKLS0tIFJLVmZyazU4dWUyVnpvRDRWbHNn\nZFQ3dlhxN0lwc0loWEZIOE0yMElRV0EKeUVy2QpLHbJ8JUCl07f74V+ZnRkGfDdP\n5W28yiLuq+LYfKUaFYeRDvSVFCQo6FiYJcBPGmnPF5gg1BIUtUUBYw==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg" + } + ], + "lastmodified": "2026-09-18T02:02:10Z", + "mac": "ENC[AES256_GCM,data:Oq1UCi+VYuuAkMmVBife740m6fiXC+PfkaQAii+FORyv8Zpj938cPrZzE9z/LO5Ixye5cbUHGRhZr0vM1egnv9nhIFyfGinKyE/BLEOXHxOtHnAXSQOJU2bWiR6w/QzVCTy6vTTVnqD2AtEfbKndIK1PJ0ASLvxMGaRqRsA/juE=,iv:xCEQg6DHV6hbyfBk62bUKA1lnC3HgQtkn2o8i2CFjhs=,tag:Zvt9tomYGoDFDQGjLZC59A==,type:str]", + "version": "3.13.3" + } +} diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/users/admin b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/users/admin new file mode 120000 index 0000000..ca714e1 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/privateKey/users/admin @@ -0,0 +1 @@ +../../../../../../sops/users/admin \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/publicKey/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/publicKey/value new file mode 100644 index 0000000..1b317f5 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/nodeA/yggdrasil/publicKey/value @@ -0,0 +1 @@ +cf28bbcd822efec197dfaa99fbe9004f78b4efa9724b4f453d05f12a61a0bb44 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/number/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/number/value new file mode 100644 index 0000000..67e333f --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/number/value @@ -0,0 +1 @@ +981794 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/owner-name/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/owner-name/value new file mode 100644 index 0000000..d8e8508 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/owner-name/value @@ -0,0 +1 @@ +fake_line_value \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/public-key/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/public-key/value new file mode 100644 index 0000000..a2a2edc --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/public-key/value @@ -0,0 +1 @@ +7291692f9a94948724377fc22055edde8e2773a581153317bc20bd5779736647 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/signature/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/signature/value new file mode 100644 index 0000000..a3fa780 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/phonebox-global/signature/value @@ -0,0 +1 @@ +IwAOYcmClqi8K8EStthYMqWCr6WsxM3nD0CLDRvvb7I3CUyihXBjdghXLutDD/bFsHKzOkDVV/gLnTuRj9IEAg== \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/state-version/version/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/state-version/version/value new file mode 100644 index 0000000..fbadb25 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/state-version/version/value @@ -0,0 +1 @@ +26.11 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/address/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/address/value new file mode 100644 index 0000000..1bd759f --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/address/value @@ -0,0 +1 @@ +201:35ba:5b41:95ad:ade3:6f22:f7:7ea8 \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/machines/server b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/machines/server new file mode 120000 index 0000000..2bd819e --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/machines/server @@ -0,0 +1 @@ +../../../../../../sops/machines/server \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/secret b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/secret new file mode 100644 index 0000000..89f8ff5 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/secret @@ -0,0 +1,18 @@ +{ + "data": "ENC[AES256_GCM,data:H1ngxnRS+cFNSxe9K7C/xNS5hzXyeTWBLZI18TPvEyT5l/MSBeRuK6FZ/G0xUGQAJw4Xo1nyX2PZEYH5wjhnG0UziKAZeM2Oe0G/ilpNbHeMS+HLVriDETlMQz2Hf4Y1J6jOZqSTgFuPMVG5armpZUYTcW+p2zo=,iv:u9sO6eiFzxtT7uImnHLXT3aYJJxgby8d5H2ckfZdNIw=,tag:ZLl7AHHRF+jmr0tnujrpIg==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzdy9HVVFoa0N5ZnBDVDhM\ncm1vQzR3MUpSbW9KSjE0YmZueUlsY1gvUVc4CmlqaTBxMTNvZzdmdkJSMmMwN01N\nVGsvUzJ5Q055Tmk2L2ZUNjRpSHp0bE0KLS0tIGJhUUNDbGh1a09aeXMzWkl4K0F0\nZ0dYY1FnNCtkVXRVTVkyeXZPbFlVbUUK3a/V6XP6m8MDYPKwlu8z0cr4inuKGGNc\nYJjQw00ou3BdM/HOPZrMjYHtjg5WdtzYiFCOl8IRkeiDaEmXcOBjiw==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg" + }, + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSbW9lcXZweHZ6b0F1eVIy\nd2RWV2lkOHpoM2RkZWxSVEFwU1h5aXNMWldnCk5lS1E4b1BDMmRYUDNHK2NhTlVT\nWGpLWm4rUnpFVWNvMHNzclJuemNGZkEKLS0tIGZxcmowODUydm1kL24yVHd4YXl4\nWVd5eWZMZ0dyS2dpTDhuNXo4S25Ha0EKvc3hwrmlP5JcH7eQeRfoq40w/QdnQL8s\n+DAwsajzRp6H3/XVTm+nDQ+Qoc3aenewZkk6Pgn+x43hAh9zwuvGkA==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1sy8xkmssttcchjj0nkapk4tu530lrzszdp4lka6nn4gzlxam6e2s3xzp2j" + } + ], + "lastmodified": "2026-09-18T02:02:24Z", + "mac": "ENC[AES256_GCM,data:xrshyxQAnlSORTzxxcpt0ABaNWSCCVnwHl6oPUQ59reumF7BoAy8HmdSyWeEpIKBNo2Ane1xxXBHa62+w58r0WjcneNzy8yIBBwXr1BIRZPX83HrSz3+bENPtj6TPWdHpFZ5aDFM2jVlUvwIkn/4g+1B9FnQtoH8kuYBGe2uGCM=,iv:slMt0ag3Kp3iD89jQa/YDta4mJccKiSqcTCt8C+1Dzs=,tag:o56eg/BSHOtBUeNQqRjQDg==,type:str]", + "version": "3.13.3" + } +} diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/users/admin b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/users/admin new file mode 120000 index 0000000..ca714e1 --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/privateKey/users/admin @@ -0,0 +1 @@ +../../../../../../sops/users/admin \ No newline at end of file diff --git a/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/publicKey/value b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/publicKey/value new file mode 100644 index 0000000..a2a2edc --- /dev/null +++ b/modules/clan/phonebox-global/tests/vm/vars/per-machine/server/yggdrasil/publicKey/value @@ -0,0 +1 @@ +7291692f9a94948724377fc22055edde8e2773a581153317bc20bd5779736647 \ No newline at end of file