clanService/phonebox: global decentralized number directory over yggdrasil

Replace the static prefix+extension numbering with random 6 digit box
numbers (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.

Drop the server-prefix-number and ata-local-number vars, add a two-node
VM test that proves directory convergence and a call over yggdrasil.
This commit is contained in:
2026-09-17 06:34:27 +00:00
parent 0906700ad6
commit d623ef8470
48 changed files with 554 additions and 345 deletions
+42
View File
@@ -0,0 +1,42 @@
A peer to peer phone relay network built on top of yggdrasil.
Every box is reachable from any yggdrasil node running this service, not only
from members of the same clan.
## 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.
+236 -188
View File
@@ -6,7 +6,7 @@
_class = "clan.service"; _class = "clan.service";
manifest.name = "phonebox"; manifest.name = "phonebox";
manifest.description = "A peer to peer phone relay network built on top of yggdrasil."; manifest.description = "A peer to peer phone relay network built on top of yggdrasil.";
manifest.readme = "A peer to peer phone relay network built on top of yggdrasil."; manifest.readme = builtins.readFile ./README.md;
manifest.categories = [ "System" ]; manifest.categories = [ "System" ];
roles.default = { roles.default = {
@@ -19,11 +19,6 @@
description = "An Ethernet interface that connect to ATA box."; description = "An Ethernet interface that connect to ATA box.";
default = "enp2s0"; default = "enp2s0";
}; };
options.ownerName = lib.mkOption {
type = lib.types.str;
description = "";
default = "";
};
options.extraClientNumbers = lib.mkOption { options.extraClientNumbers = lib.mkOption {
type = with lib.types; listOf str; type = with lib.types; listOf str;
description = "List of client suffix number."; description = "List of client suffix number.";
@@ -47,6 +42,7 @@
} }
); );
description = "Extra client to be added to pjsip config as a fixed IP auth"; description = "Extra client to be added to pjsip config as a fixed IP auth";
default = { };
example = { example = {
"01" = { "01" = {
ip = "192.168.1.3"; ip = "192.168.1.3";
@@ -54,6 +50,17 @@
}; };
}; };
}; };
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 = perInstance =
{ {
@@ -75,6 +82,7 @@
propagatedNativeBuildInputs = [ pkgs.spandsp3 ]; propagatedNativeBuildInputs = [ pkgs.spandsp3 ];
}); });
machineName = config.clan.core.settings.machine.name;
machines = lib.attrNames roles.default.machines; machines = lib.attrNames roles.default.machines;
user = "asterisk"; user = "asterisk";
@@ -83,63 +91,73 @@
rtpPortTo = 20000; rtpPortTo = 20000;
ata-interface = settings.ata-ethernet-iface; ata-interface = settings.ata-ethernet-iface;
contactList = builtins.map (machineName: { # The ATA's own line. Remote callers reach it by dialing the 6
name = "${clanLib.getPublicValue { # digit box number alone or with this extension appended.
flake = config.clan.core.settings.directory; ataLine = "00";
machine = machineName; sipPort = 5060;
generator = "phonebox";
file = "owner-name";
default = null;
}}";
number = "${
clanLib.getPublicValue {
flake = config.clan.core.settings.directory;
machine = machineName;
generator = "phonebox";
file = "server-prefix-number";
default = null;
}
}${
clanLib.getPublicValue {
flake = config.clan.core.settings.directory;
machine = machineName;
generator = "phonebox";
file = "ata-local-number";
default = null;
}
}";
}) machines;
createContactListTiff = # Well-known port of the phonebox directory gossip. Every phonebox
let # node worldwide must agree on it, so it is not configurable.
contactTXT = lib.concatStringsSep "\n" ( directoryPort = 7946;
builtins.map (contact: "${contact.number}\t\t: \t\t${contact.name}") contactList directoryRpc = "127.0.0.1:7373";
directoryUser = "phonebox";
directoryDir = "/run/phonebox";
phoneboxVars = config.clan.core.vars.generators.phonebox.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 = "";
}
); );
in
pkgs.writeShellApplication { 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"; name = "create-contact-tiff";
text = '' text = ''
magick -background white -fill black -pointsize 20 -font DejaVu-Sans label:"${contactTXT}" "$1" 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" -border 20x50 -bordercolor white "$1"
magick "$1" -resize 1728x -units PixelsPerInch -compress Group4 -density 204x196 -monochrome -depth 1 "$1" magick "$1" -resize 1728x -units PixelsPerInch -compress Group4 -density 204x196 -monochrome -depth 1 "$1"
''; '';
runtimeInputs = [ pkgs.imagemagick ]; runtimeInputs = [ pkgs.imagemagick ];
}; };
genServerSIPEndpoint =
{ hostname, address }:
''
[${hostname}](internal_endpoint)
aors=${hostname}
[${hostname}](ip_auth)
endpoint=${hostname}
match=[${address}]
[${hostname}](dynamiic_aor)
contact=sip:[${address}]
'';
genLocalSIPEndpoint = genLocalSIPEndpoint =
{ localNumber }: { localNumber }:
'' ''
@@ -151,7 +169,7 @@
username=${localNumber} username=${localNumber}
password=${localNumber} password=${localNumber}
[${localNumber}](dynamiic_aor) [${localNumber}](dynamic_aor)
max_contacts=1 max_contacts=1
remove_existing=yes remove_existing=yes
''; '';
@@ -168,7 +186,7 @@
username=${localNumber} username=${localNumber}
password=${localNumber} password=${localNumber}
[${localNumber}](dynamiic_aor) [${localNumber}](dynamic_aor)
max_contacts=1 max_contacts=1
''; '';
@@ -181,7 +199,7 @@
contact_deny=::/0 contact_deny=::/0
contact_permit=${settings.extraFixedIPClient.${number}.ip}/128 contact_permit=${settings.extraFixedIPClient.${number}.ip}/128
[${number}](dynamiic_aor) [${number}](dynamic_aor)
max_contacts=1 max_contacts=1
remove_existing=yes remove_existing=yes
@@ -196,63 +214,58 @@
'' ''
exten => ${localNumber},1,Dial(PJSIP/${localNumber},20) exten => ${localNumber},1,Dial(PJSIP/${localNumber},20)
''; '';
genExtentConf =
{
prefixNumber,
hostname,
localNumber,
}:
let
replaceWithX =
ln: builtins.concatStringsSep "" (builtins.genList (_: "X") (builtins.stringLength ln));
in
''
exten => _${prefixNumber}${replaceWithX localNumber},1,Dial(PJSIP/''${EXTEN:${builtins.toString (builtins.stringLength prefixNumber)}}@${hostname},30)
'';
getYggdrasilIP = localNumbers = [
machineName: ataLine
if config.clan.core.vars.generators.yggdrasil.files.address ? value then ]
clanLib.getPublicValue { ++ settings.extraClientNumbers
flake = config.clan.core.settings.directory; ++ lib.attrNames settings.extraFixedIPClient;
machine = machineName;
generator = "yggdrasil";
file = "address";
default = null;
}
else
throw "clanService/yggdrasil is required";
in in
{ {
clan.core.vars.generators.phonebox = builtins.break { assertions = [
{
assertion = !config.networking.nftables.enable;
message = "phonebox: opening the yggdrasil interface to non-clan nodes is only implemented for the iptables firewall backend";
}
];
clan.core.vars.generators.phonebox = {
files = { files = {
server-prefix-number.secret = false; number.secret = false;
ata-local-number.secret = false; public-key.secret = false;
signature.secret = false;
owner-name.secret = false; owner-name.secret = false;
}; };
prompts = { dependencies = [ "yggdrasil" ];
server-prefix-number = {
type = "line"; prompts.owner-name = {
persist = true;
description = "Server prefix number: indicate server to connect to [10XX]";
};
ata-local-number = {
persist = true;
type = "line";
description = "Local suffix number: indicate local number on the server [XX00]";
};
owner-name = {
persist = true; persist = true;
type = "line"; type = "line";
description = "The owner's name for this unit"; 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 = '' script = ''
cat $prompts/server-prefix-number > $out/server-prefix-number
cat $prompts/ata-local-number > $out/ata-local-number
cat $prompts/owner-name > $out/owner-name 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
''; '';
}; };
@@ -312,51 +325,88 @@
networking.firewall.allowedUDPPorts = [ networking.firewall.allowedUDPPorts = [
53 53
67 67
5060 sipPort
]; ];
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [
53 53
]; ];
networking.firewall.interfaces = networking.firewall.interfaces = {
let "zt+".allowedTCPPorts = [ 80 ];
matchAll = if !config.networking.nftables.enable then "zt+" else "zt*"; ygg = {
in allowedTCPPorts = [ directoryPort ];
{ allowedUDPPorts = [ directoryPort ];
"${matchAll}".allowedTCPPorts = [ 80 ]; };
};
# 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 = { services.asterisk = {
enable = lib.mkDefault true; enable = lib.mkDefault true;
package = lib.mkDefault asterisk; package = lib.mkDefault asterisk;
confFiles = confFiles = {
let
nodes = builtins.foldl' (
nodes: name:
nodes
++ [
{
hostname = name;
address = getYggdrasilIP name;
prefixNumber = clanLib.getPublicValue {
flake = config.clan.core.settings.directory;
machine = name;
generator = "phonebox";
file = "server-prefix-number";
default = null;
};
localNumber = clanLib.getPublicValue {
flake = config.clan.core.settings.directory;
machine = name;
generator = "phonebox";
file = "ata-local-number";
default = null;
};
}
]
) [ ] machines;
in
{
"logger.conf" = '' "logger.conf" = ''
[general] [general]
dateformat = %F %T.%3q ; ISO 8601 date format with milliseconds dateformat = %F %T.%3q ; ISO 8601 date format with milliseconds
@@ -384,17 +434,7 @@
''; '';
# Dial plan config # Dial plan config
"extensions.conf" = "extensions.conf" = ''
let
serverConf = builtins.foldl' (
config: node:
config
+ (genExtentConf {
inherit (node) prefixNumber hostname localNumber;
})
) "" nodes;
in
''
[from-internal] [from-internal]
exten => 999,1,Answer() exten => 999,1,Answer()
same => n,Playback(hello-world) same => n,Playback(hello-world)
@@ -414,41 +454,48 @@
exten => h,1,GotoIf($[''${FAXECHO}]?sendfax) exten => h,1,GotoIf($[''${FAXECHO}]?sendfax)
same => n,Hangup() same => n,Hangup()
same => n(sendfax),Originate(PJSIP/00,app,SendFAX,''${FAXFILE}) same => n(sendfax),Originate(PJSIP/${ataLine},app,SendFAX,''${FAXFILE})
same => n,Set(FAXECHO=false) 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)
'' ''
+ (genLocalExtenConf { + lib.concatMapStrings (number: genLocalExtenConf { localNumber = number; }) localNumbers
localNumber = config.clan.core.vars.generators.phonebox.files.ata-local-number.value; + ''
})
+ lib.concatStringsSep "\n" ( ; Outbound: resolve the box number through the directory and
builtins.map (number: genLocalExtenConf { localNumber = number; }) settings.extraClientNumbers ; send the call to that node over yggdrasil.
) [to-yggdrasil]
+ lib.concatStringsSep "\n" ( exten => _[1-9]X.,1,Set(PEER=''${FILE(${directoryDir}/numbers/''${EXTEN:0:6})})
lib.mapAttrsToList ( same => n,GotoIf($["''${PEER}" = ""]?unknown)
number: _: genLocalExtenConf { localNumber = number; } same => n,Set(CALLERID(num)=${ownNumber}''${CALLERID(num)})
) settings.extraFixedIPClient same => n,Set(CALLERID(name)=${ownerName})
) same => n,Dial(PJSIP/yggdrasil/sip:''${EXTEN}@[''${PEER}]:${toString sipPort},30)
+ serverConf; 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" = '' "rtp.conf" = ''
[general] [general]
rtpstart=${builtins.toString rtpPortFrom} rtpstart=${toString rtpPortFrom}
rtpend=${builtins.toString rtpPortTo} rtpend=${toString rtpPortTo}
''; '';
"pjsip.conf" = "pjsip.conf" = ''
let [global]
serverConf = builtins.foldl' ( type=global
conf: node: ; Local lines authenticate by username; everything else from
conf ; the yggdrasil range is a remote phonebox node.
+ (genServerSIPEndpoint { endpoint_identifier_order=username,ip,anonymous
hostname = node.hostname;
address = node.address;
})
) "" nodes;
in
''
[transport-udp] [transport-udp]
type=transport type=transport
protocol=udp protocol=udp
@@ -471,29 +518,30 @@
type=auth type=auth
auth_type=userpass auth_type=userpass
[ip_auth](!) [dynamic_aor](!)
type=identify
endpoint=external
[dynamiic_aor](!)
type=aor type=aor
[yggdrasil](base_endpoint)
transport=transport-udp6
context=from-yggdrasil
[yggdrasil]
type=identify
endpoint=yggdrasil
match=200::/7
'' ''
+ (genLocalSIPEndpoint { + (genLocalSIPEndpoint { localNumber = ataLine; })
localNumber = config.clan.core.vars.generators.phonebox.files.ata-local-number.value; + lib.concatMapStrings (
}) number: genLocalSIPEndpointV6 { localNumber = number; }
+ lib.concatStringsSep "\n" ( ) settings.extraClientNumbers
builtins.map (number: genLocalSIPEndpointV6 { localNumber = number; }) settings.extraClientNumbers + lib.concatMapStrings genLocalSIPIPEndpoint (lib.attrNames settings.extraFixedIPClient);
)
+ lib.concatStringsSep "\n" (
lib.mapAttrsToList (number: _: genLocalSIPIPEndpoint number) settings.extraFixedIPClient
)
+ serverConf;
}; };
}; };
environment.systemPackages = [ environment.systemPackages = [
createContactListTiff createContactListTiff
phoneboxSyncWrapped
]; ];
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
+111
View File
@@ -0,0 +1,111 @@
"""Rebuild the phonebox number directory from serf membership.
Usage: phonebox-sync <serf rpc addr> <state dir>
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:<number>" 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:
<state dir>/numbers/<number> the yggdrasil address, no trailing newline
<state dir>/contacts.txt "<number> : <owner>" 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()
+28 -11
View File
@@ -2,7 +2,6 @@
self, self,
hostPkgs, hostPkgs,
config, config,
inputs,
lib, lib,
... ...
}: }:
@@ -25,36 +24,54 @@
directory = ./.; directory = ./.;
inventory = { inventory = {
machines.server = { }; machines.server = { };
machines.nodeA = { };
instances = { instances = {
yggdrasil = { yggdrasil = {
module.name = "yggdrasil"; module.name = "yggdrasil";
roles.default.machines.server = { }; roles.default.machines.server = { };
roles.default.machines.nodeA = { };
}; };
phonebox-test = { phonebox-test = {
module.name = "@clan/phonebox"; module.name = "@clan/phonebox";
module.input = "self"; module.input = "self";
roles.default.machines."server".settings = { roles.default.machines.server.settings.ata-ethernet-iface = "enp2s0";
ata-ethernet-iface = "enp2s0"; roles.default.machines.nodeA.settings.ata-ethernet-iface = "enp2s0";
};
}; };
}; };
}; };
}; };
nodes = { nodes = {
server = { server = { };
services.asterisk = { nodeA = { };
};
};
}; };
testScript = '' testScript =
{ nodes, ... }:
let
number = node: nodes.${node}.clan.core.vars.generators.phonebox.files.number.value;
address = node: nodes.${node}.clan.core.vars.generators.yggdrasil.files.address.value;
in
''
start_all() start_all()
server.wait_for_unit("asterisk") for node in [server, nodeA]:
node.wait_for_unit("asterisk.service")
node.wait_for_unit("phonebox-directory.service")
server.succeed("systemctl status asterisk") # 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)
''; '';
} }
@@ -0,0 +1,6 @@
[
{
"publickey": "age1r2lq949hmh0xr74p7r53ksxvgdvljpxcdqnx2xr0gk2dgsvcg56qapalmt",
"type": "age"
}
]
@@ -1,6 +1,6 @@
[ [
{ {
"publickey": "age1fdkan6n20swmut0sa86g5a6gxrj8qj2sgqe8hxtw32c0u9rr4drqlyr5mf", "publickey": "age1sstuuf7rzdwd88h2vcz89n9ctnarwx7e4uncyxaq3s4kcpag8a8qc7qaxj",
"type": "age" "type": "age"
} }
] ]
@@ -0,0 +1,14 @@
{
"data": "ENC[AES256_GCM,data:ZDGbL/Ohsv8Y/w1O0dLrBsUu/i1JRP7SS+NgPuCJL5M1fwUEJteXS7ztaTCnbohVszWPli/5KJl9PDgdpx1s42BDHCKOJNJ2umw=,iv:1D1iC4h4nshIkz8BuJlUNib9lCsdfGdq7BFUkTg9pII=,tag:qskMx9qsD4rTQvX5XlZYDA==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWWDM3TGV4eDNZZXJzTW1i\nSk1xVHNKNjBTcEd2NTRyK0tIdXpMcmJOVndBCnNNTmNLYjNNTWZYNnJQWDBRMmxC\nbkFsenFCNmxpWWRubXhxWGMzQ3ZtRTQKLS0tIDBwU2xrV1FJNmR6MDFzVGIyYm0x\nTjJhK0ttYThoK2lqN3NxeEZ0L1FYUEkKZOyJ+eEgQfTCfC6inGESCR991GbHWQAu\nWXJz164ezx4t2M/8ZqKHcKBEgHPDNjnUekrCwCECwN2lYI9zZ8qEbw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
}
],
"lastmodified": "2026-09-17T04:58:57Z",
"mac": "ENC[AES256_GCM,data:Bt/dflNdH9UxmVO9tN69GApoQtlugXqMk3pMiXhT5vSeGzlFWZF7M+vewkSp/mLAE9sPspZJ8iYy4l+WjzBQ/x0yq/QZndSYVVt1Nnx24BBBiUqrJvaViGvEo1GLIwu1aJvRXRtoQZtYUGO9mogL09dNRMPTViBRdFPtpRhv5Jo=,iv:0F9mr7xwU1v1XuVPwNCj+kjH0pcchPimqdplpLnHMo8=,tag:1N68pGuIEPIQDX5t2/YWcg==,type:str]",
"version": "3.13.3"
}
}
@@ -0,0 +1 @@
../../../users/admin
@@ -1,14 +1,14 @@
{ {
"data": "ENC[AES256_GCM,data:t+zYfcA2f1sdUNBAl+bRGyhPEl5HZFIu+au6heH3+SoHf0zy+Deh25gaVay/oswIg2q806ozjEnTw9q2eaq0VXwMSAWOoTWteI0=,iv:sXoOmfzoCv6GHe21n2Elxr/GTdViI5vfLzMwOLvf1F4=,tag:Fmo9MjEaKV3BehR/bYRdVw==,type:str]", "data": "ENC[AES256_GCM,data:YhOnYArQxZhufQNUwHKNDghgYar5X06pPv7zyJ37ejutbE/rpZ0SnQQcmT8MFxHNzh+j0iVqVzBqoPM26/EK9DJM82nW8WjhPS8=,iv:NKfGXblgZNui60bwsShx/DheYIKHZjwEJu2KG06NT6A=,tag:GVuh6nhFehlv8ibU1i1iMQ==,type:str]",
"sops": { "sops": {
"age": [ "age": [
{ {
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg", "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGZVpldFNheTVGY1AwSitC\neXo3S1NVNnZCcnVhaVk5WTBLRVdrUjlTV0hRCi9ZOFBrQ0t3bGFwOHVEZWU2YWdi\nOUpnNGdwU0VSUkxZa3I0TUVEUENsU3MKLS0tIGVVRlpsZHN6a28vRG9JVTREUkZR\nQm40dnZxVUZMcFo2S3VOTUN2dzhkYlEKw3vn0del6gEimd7p4ASzhmEOqY4c7j+m\nELoY+j0568iROX9g9Ebt8GWbx8Yi81DX9RdCiZ4GdTB88sYURpsxIQ==\n-----END AGE ENCRYPTED FILE-----\n",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzVlo0YjZFVFF2VkFlSzBL\ndHp4YTQ5RlNxV2FWNnNScTh5d0hXR05RL0NVCnBVUENNNjg0dTFVcmx3N3djZ25l\ncmxYTVVrNGhGSkdwVThoaC9UVTQ3L2sKLS0tIG1uREdFcXNubGxzUUQ1Rkh5Wnlx\nRG5OZmJwaGh2dkt6RSttb0gxZ3FBaEkKZzwUuQmOeBk5kfRfVVdqgNvsTU1Ssb/I\nx9Iv9w/YKHDmmcFLcAbGAHbS0/Js0YqBKZonxEMDdWP/+/F+Pv8LqQ==\n-----END AGE ENCRYPTED FILE-----\n" "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
} }
], ],
"lastmodified": "2025-10-31T07:08:30Z", "lastmodified": "2026-09-17T04:59:39Z",
"mac": "ENC[AES256_GCM,data:CBuTnVIta0eFqlB7ZpDkzPOEGbIQbb5oCpksI4umscB3uE0HM3j4N5r6bCPEcYpehB3qWXuCxlj4NfH7zmp6CDq6Be6bfpB1f8MCwTlPvQUho2f31so3U/g99q6ZyWI2rJO50dzn77bdma3JXo9VQb3uRqJ0mk72IYXFwdHvO9s=,iv:9uwjtGYpqsLcXRoBFmjJjfHUq7R47ZqkrKEXMulw4OY=,tag:4Auq3mnhlusogyW44SJfqg==,type:str]", "mac": "ENC[AES256_GCM,data:GMWIElHv1WaRy8oE5pZSC5W6hP5vm+TcM/BFIWevDpPyFH16yLDxKsSYE9JbEZirFxwyQ9v0tX/DZs3M7kTmdZ6m3qB1Sh2068H5DKV8hSQc4FhbhsyjTL2wSTOqV5HihnoLxjg5Lg+WfnQIygwsYz/3UZrmdElUh1Il01+fXys=,iv:9Jz3v5f5KB9ivy0LNMC9HnLCKnoEI1uTE/gFiCzjLNw=,tag:E+fhqyswcFU7XqHyeKaz5w==,type:str]",
"version": "3.11.0" "version": "3.13.3"
} }
} }
@@ -0,0 +1 @@
52460c3f17375aef0b8baa1e20be141901d9b99affb6a5b0dac90684d0047acc
@@ -0,0 +1 @@
COjaD1KTdEbR3H/0z5VT3DCEBs7WFsejnMhT6OLYMzeWTvZlJ+0Da3a9NvrFfSaa3GiMYFIp7dDHV3Xqba78AA==
@@ -0,0 +1 @@
201:b6e7:cf03:a322:9443:d1d1:5787:7d07
@@ -0,0 +1 @@
../../../../../../sops/machines/nodeA
@@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:VeeLshHQC8jAWAo9u1TnVL7C6rWvh9hgRViLwhOk5uvOdQn9FvmydPKphlFD0/V65PBLN8fwt/pyhDfaItmNluW7bQBHmvoZE20voVM9srWAcwuyr4gUawJlI1QeCYL7wsGobZ/FH0ID7JglrX4fOaPkQUnhdHI=,iv:hEn5z2s7bDuZtQKaHhwmQXoRPop3S9jv/KSzPsYvHN0=,tag:hM/ElS6ZzaVyjjzqy4gm0g==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrdkI1cFRXdXRQM3RtbmY1\nTTZGSFBwbkJETUw4ZUJHVEdPYnhyN2lVSDFJClkrSjJWbFJRYnd1Smt4dXVqd1hS\nSkI0UzhwQStkeXprWHZ3RnFQOUh0RDAKLS0tIENNTjdTNVdqbUh3TENFUnpFbWc5\nYisxZVptWXQ3b21ZLy8zbHBxaC82S1UKRDuRZ1IUHatj4IvMl8d7ixzwX1F17Nm3\nYS+cQ5W3gc/lCle5bguACeGYlGvqTUWMl2EDVX+TDp054pYsKtecJg==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
},
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKSGxvdllTWnpEU0FxYkx4\nSnNiR0VOMzhZR1FDclM5RkdpOHk3emxnTkdrCkt4KzBDczhtNDIyUElrQ1EyL3Q3\ndjhNSUxUaFVDd3Fqa1NJS1dwSHl5dVkKLS0tIDI4anhsSjB6bmlYUlVudktSMnI3\nMlRKMUNkaS95bjQ4cXdiQVIzSWlPY0UKcMwMWNX8t1KKOfjJl1i8GJvDbnDY68X6\nz4j4BFSDJiZ1wTaKO0tZMb3NaDDVr4TZUPJwaKwepSqjVY0BizTuNw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1r2lq949hmh0xr74p7r53ksxvgdvljpxcdqnx2xr0gk2dgsvcg56qapalmt"
}
],
"lastmodified": "2026-09-17T04:58:58Z",
"mac": "ENC[AES256_GCM,data:HOLDDIfvnmzYCuqPYGgcMeD0AZHl8BFcY6gbu/2WZiO9roce1EcTtiGAzZyBYK0eqvNbxOxCOx7qhvM+8KKHwqPctMmpyWuPu/hpEPPHQ6ufF14jsIEyJJeOJAJfmx7t1tfen1lsyM1EoGNszp7h8ANjzfitN7VWUw+yTrlDsgY=,iv:KhwlvAzinbT2YBqRHdLDzr4lnlqhNQoJQ/gqDDQ7TMk=,tag:/Bu6SEVMG9Sn2pxIOR7NxQ==,type:str]",
"version": "3.13.3"
}
}
@@ -0,0 +1 @@
52460c3f17375aef0b8baa1e20be141901d9b99affb6a5b0dac90684d0047acc
@@ -1 +0,0 @@
../../../../../../sops/machines/server
@@ -1,18 +0,0 @@
{
"data": "ENC[AES256_GCM,data:L4fWAVQdQP2tgYPzUnDY5X0=,iv:fWeTc1buW/JI/8qngZwzDp+wq2OTZPGdItqG0Up5eZ8=,tag:RJt6PQdCisDhtbr3ph3fWA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1fdkan6n20swmut0sa86g5a6gxrj8qj2sgqe8hxtw32c0u9rr4drqlyr5mf",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFK2x0SkcxK2RxTDRvOEpZ\nTS9QNGJxbmZIL2xtUWxyNjA2VDNVWEQzQVc4CkkxeEZDQm1mQVdUUzVIQThDVkV3\nU2lWbnlnb3lqWDZ0NWRqa2RJV0pVK1UKLS0tIHc5eUJBUUtiL0NwZDRnN004UEtr\nYnU2SWlFOGlucTdydGVmZCtGK3NjS28KkNAxwz9MesicLWtViL302AwZYdiTHmd5\nppbwelisNVlsYHSa5ybVDYER4IUz1d8AKO0jtS7qEDfT53R36swSAA==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArbEpzdk0xQTFkM2V0K2xz\nRFhDak0ybkhZUjJJdVI4YTl3cStXT2hTZDBFCmRvQ3ZXYkQ1NktCb004ZE1FS1FF\naSt4RlVXSlRhYWdlQ3htTkdkb2dyVDAKLS0tIG16VFNQM0dnaGVSRmlFRFB0WWtW\nN0dPL08rV01sY3ovRnEvUnNMaWVhd2sKvQIZIo5pPMXKh9Ea3ZgHj99Dn1X3JkmB\noscG8S7HOJh/cw+uITmkuv00TyIA9pid6L1kXvfcfv+tcuY9H1Vg4w==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-31T07:08:30Z",
"mac": "ENC[AES256_GCM,data:bmvshL97XNvNYYg0EjkUAQViHpKJ/+A+CGE90/uSM2WXooJF5yzvVJSHLAuYxWM295awN8ygTRUZ2VJ0SyfjzjAyUIH4SxqlrgywqMouyVsiFDAB6R4AmMmbvvC6rSs/TSMOwYC5pchrISbpsE2kmJnAXCqcev6Q0fl8Sa1PSxo=,iv:BuBQCFb3EWfG+bPzgaRDseDq6MJQ/Fs8okvksvEj1bA=,tag:n14VDbQk+zl+XbJPkTRAGg==,type:str]",
"version": "3.11.0"
}
}
@@ -0,0 +1 @@
95f093cb4b0b727fb38ddbfc8a3364d72d96b25882e44c5ca555644fc18fc2b2
@@ -0,0 +1 @@
77s68Xqs8EgJWQx/vyjvivDPV8w0PF0HUr1WdveoE5ErpY+7Im5c8LFbcoQDVjt2OwaorF3G0NUQoBI8yavPCw==
@@ -1 +1 @@
202:fe87:1ca6:3cfd:e095:c2b1:321c:c391 200:d41e:d869:69e9:1b00:98e4:4806:eb99
@@ -1,18 +1,18 @@
{ {
"data": "ENC[AES256_GCM,data:0LwQxArH6fpIYpGIEzPtjh8elyKSaed7L+KqgnIlPVneR0UbsbOM6p5kVGTRPh5LWH6jmkURCqc2c5oT5CNLBePLQcgvtfLDSFWyyPrD9WtBSPu+YGF7K51JUUIkX07//LqTQQaM5Uu/STQRIoL1BD0Tofk2woA=,iv:3rSDqBj8N9RsSLijZm7mUUjUhiLHc2yidGkil8NvCD8=,tag:F8zlw9pfMjZdJSObDePLsQ==,type:str]", "data": "ENC[AES256_GCM,data:bZpxvXJIZy3xCyjzrZV/YtiQMs2RU/oeRA4wnbYi9YKh/Vz8sSw6ECkrmmP/aG76t6m4+3c1DOo5PBcf+SPbIsMld7DXSlLTa5Y7fd03QqNy4bhXhHqebARZGAy3G5KoNNq1+e73UQuqe9531dDbyZ5y6pcuSPA=,iv:Pp/0BJoZCsuao7qY6bv+AnSI6MVy1ionei8MlSsBt20=,tag:Kfs0pDp3TCpk4gW9LOvcFg==,type:str]",
"sops": { "sops": {
"age": [ "age": [
{ {
"recipient": "age1fdkan6n20swmut0sa86g5a6gxrj8qj2sgqe8hxtw32c0u9rr4drqlyr5mf", "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNZTI0MWQ4cjQ4YmYvT2Fj\nTEp5VHQ2NkFKVFhIV0dzVXNmSUVnWTZabVcwCmFvT25WTTMvcUVFY2xmWjVHRUx2\nRGRySVpYaGY4MFhiSDI3bzFxWUUwV2cKLS0tIGplZHUvWE50aFNvQmFzME9YUTIz\nYUZVQnJQZTNWV2FNWEx6WFp4WkFiRkUKmvikszfBTgfqTDEIQZwBBU+nhCYYEMu0\n6J8BPnH6ZoX6nFm1K34BkTGCLzbvM1lQL27hiP5oto5E2rBzpLgi6g==\n-----END AGE ENCRYPTED FILE-----\n",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NnVzT0lOWnFTUU85c0d5\nZ0Q1b0xkS1NWbWVsc0o1emxLOW5NRU52REFZCjRkUGJHaDhyazdFdkxWaW1XVW53\nb25pUXlDRmhrTHlIWGl6RDJBZ2hTOW8KLS0tIGsxQmlQejkvQk9wTmtMQzZJa2dB\na0piOGlyMzVrTmNOUEhtaDNTWHdwTDAKroQG8KlnWZ6gwu1y0mr0gGezDF1jsS0Y\nC1LUAarHl+lY51sw+HJT88Y9mDfLjvYIMHKS33zdJuBXbNpoIfWyzA==\n-----END AGE ENCRYPTED FILE-----\n" "recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
}, },
{ {
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg", "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSbDV1UHJYdS9DcmR4QmJU\nTVNUeFpuTzYxWmR5WDFEUkswekZjQXVqTXdVCkZBUHc3Nk9veFBCWnhkTCtxQVBs\nTjdCbVFQVnc4NnJCWEZKc3VvZG5TUDgKLS0tIDk3dE85VFFId2pNb21HVktuN2V5\nTDVvRlVFaUdNRHdXZ3RaRTRoWDhEcFkKU16K1nNPuOJUpPTXx3dirg2r8SPYtUT+\nsv3htIRb/kfweRKx331vsC19V0hbsqXLatM+yPzYx8ITx4VUDXB4kA==\n-----END AGE ENCRYPTED FILE-----\n",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqQVJhYkI1eEFYL0d5OE1F\nSStqV0xPb0pybyt2QWd4SEpiaDhlNDJicTA0CkgrVU9FcThuQktaMThTaXBmWHNV\nd2tpMVVYT21TWUFvMS9wakl4RyszTGsKLS0tIHVVeDdoMUJQaURMOGFkM3dNa3ZG\nMmVPdEg3bmdZZHgwaGpnbFFKUTdvcUUKTaxEfp19+9AJihqx51m0cLz6IuR5pvnT\nt90kZxq+BH3/6gjiDlhwzqztnMbdqQYcVuCDVp/1aVWfThABUZ92aQ==\n-----END AGE ENCRYPTED FILE-----\n" "recipient": "age1sstuuf7rzdwd88h2vcz89n9ctnarwx7e4uncyxaq3s4kcpag8a8qc7qaxj"
} }
], ],
"lastmodified": "2025-12-05T04:42:06Z", "lastmodified": "2026-09-17T04:59:40Z",
"mac": "ENC[AES256_GCM,data:1/u3c/wf2Gh6PPeVTKKGBxG1FWvN7hyuzx3Qa7yU3yKCD7LDHrrzTbQkFHDo1ZrXixK5NICCw48BWA0Jao0kItu6aU1Dbk5PexZI9ls1eyaDS7nwtZuHKDSEtDYu/kx5kZgQKH9tsokLqKlcoeocS0Mp1tjvUSQsiZFaYsuKaM4=,iv:gCPqFgpwjSONn/JN3dpJOk1BXtdd9cvIAz/NKPuAdKg=,tag:j8upk9sgDiYj5lmfU4V+OQ==,type:str]", "mac": "ENC[AES256_GCM,data:1FucJy4iHy3ZTUNwsZMCUAT8AZeIlfZS57wzgnozrFroHW7wO5uDiHbNwtnaSvvvsax4VrClLMHExkbwg9IgRRrvY6LN19UsLq1Ia504N8bxW/WAvknVIBKqijYT24joysqjtQKHefs4f8mboGFnF7cPcwtHggHrqs+F+ET1rdc=,iv:Vg0lSMsHslgVlm5n7r5jDEVJw+6rJKC6EE740sGkDz0=,tag:uy5xgN/iSjo//euKTbPoFQ==,type:str]",
"version": "3.11.0" "version": "3.13.3"
} }
} }
@@ -1,3 +1 @@
-----BEGIN PUBLIC KEY----- 95f093cb4b0b727fb38ddbfc8a3364d72d96b25882e44c5ca555644fc18fc2b2
MCowBQYDK2VwAyEAIC8cazhgQ+1Hqdm8Z43J5ooymP2ytrBEvdfXYz0ryp8=
-----END PUBLIC KEY-----
@@ -1 +0,0 @@
201:b9d:4329:71c2:79ca:3648:e86d:4236
@@ -1 +0,0 @@
../../../../../../sops/machines/server
@@ -1,18 +0,0 @@
{
"data": "ENC[AES256_GCM,data:bAJv40t1hkgMBz4boOEIw9GIY/4UFYI2WZidRrU88ky0JDBwSmHDnxuBFRpdjfAx7sO9d6vSHxAmaf45NlTKwnkUJnaIW0NK+979lXbLV+AG0suc4Vci6fsdbAHofR//3DfTYs03HoALkRUgRemTl7kQtdMB6E6LN02OE19HSVuFjMiEipwosmFfMoR69ZZuPSzNg1uVnw==,iv:LbZZGbwkXc71PAKgjD2CvXJVtyiqgi+cNsBzbulPyIk=,tag:VPESc2Y8SdU5CovQinZleA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1fdkan6n20swmut0sa86g5a6gxrj8qj2sgqe8hxtw32c0u9rr4drqlyr5mf",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2L3FzSkh3UTkvdTR6eEVE\nNnYrcnAydUl4QnJSbWJRbFFQODFmSld1YkJZCnNFUHp6YjQ3WW0vVHh5dXZBMzFJ\nYWRnZWNHN2dkL1JBWHNrZTcvTHI5S3MKLS0tIGpnekxneURuMWFkQ1RLTEszTFhi\nUUkzR2ljTXFhb1c2RDhpeFJXaXpYakUK/fLOqjNR2LML7uN3fiB9GdhWTDcr0wn4\n37ESeS1kx0EobRMaDVu8GPZovcdypFOOPiuUpEu6hIEdwvl736oDSA==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQWE2L3F1RW9oNlQzR0I3\nZDJoZ1hIM2N0Mm1aQ1IrN1gweWZxeWVGdUdzCmlIZXdKYm8rUGkvVVpiY1BJZmlD\nN0MwRHN3dUNrRU9McjNFMXpranJGU1UKLS0tIG1yMmJGaEp1cU1iZGdqdzRUTWZW\nZzZrVkRuOTBTcnFuaTE1Um1ISUxBaTQKf842rL3N7Gl1QfrIURWiu26LwO0ERkP4\nvfXN2HH1jjp2pblQF9qb+5vmUsaX1pPSY1R+YMvUK7wIwOb9zyIfmQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2025-10-31T07:50:02Z",
"mac": "ENC[AES256_GCM,data:/c1jqMSLd9/fxm1PxtRITr3qkjtrlJ/5wJgEVkraAqU6XTuzSyseMhdeWLL2Fy2OunT/+alM6VpaliBmcZqRkSSGngbuvMsujDiJLgUXQ8wNhMcK7ln/dqFMcA+RYGxihGEMwuPKs3yOVKj9PDzFYnm6TUCFkU/heotI/hQ1lQI=,iv:EsWsi4r4DctMLvi4WmqKe0D1NTHwYVKCcxKQ+6grzYg=,tag:28dmpzF/6dbvGIriaBVKPw==,type:str]",
"version": "3.11.0"
}
}
@@ -1 +0,0 @@
../../../../../../sops/users/admin
@@ -1 +0,0 @@
301:b9d:4329:71c2::/64
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
60
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
30
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
40
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
70
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
50
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
10
@@ -1 +0,0 @@
00
@@ -1 +0,0 @@
20