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:
@@ -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.
|
||||
+304
-256
@@ -6,7 +6,7 @@
|
||||
_class = "clan.service";
|
||||
manifest.name = "phonebox";
|
||||
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" ];
|
||||
|
||||
roles.default = {
|
||||
@@ -19,11 +19,6 @@
|
||||
description = "An Ethernet interface that connect to ATA box.";
|
||||
default = "enp2s0";
|
||||
};
|
||||
options.ownerName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "";
|
||||
default = "";
|
||||
};
|
||||
options.extraClientNumbers = lib.mkOption {
|
||||
type = with lib.types; listOf str;
|
||||
description = "List of client suffix number.";
|
||||
@@ -47,6 +42,7 @@
|
||||
}
|
||||
);
|
||||
description = "Extra client to be added to pjsip config as a fixed IP auth";
|
||||
default = { };
|
||||
example = {
|
||||
"01" = {
|
||||
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 =
|
||||
{
|
||||
@@ -75,6 +82,7 @@
|
||||
propagatedNativeBuildInputs = [ pkgs.spandsp3 ];
|
||||
});
|
||||
|
||||
machineName = config.clan.core.settings.machine.name;
|
||||
machines = lib.attrNames roles.default.machines;
|
||||
|
||||
user = "asterisk";
|
||||
@@ -83,62 +91,72 @@
|
||||
rtpPortTo = 20000;
|
||||
ata-interface = settings.ata-ethernet-iface;
|
||||
|
||||
contactList = builtins.map (machineName: {
|
||||
name = "${clanLib.getPublicValue {
|
||||
flake = config.clan.core.settings.directory;
|
||||
machine = machineName;
|
||||
generator = "phonebox";
|
||||
file = "owner-name";
|
||||
default = null;
|
||||
}}";
|
||||
number = "${
|
||||
# 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.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 = machineName;
|
||||
generator = "phonebox";
|
||||
file = "server-prefix-number";
|
||||
default = null;
|
||||
machine = name;
|
||||
generator = "yggdrasil";
|
||||
file = "address";
|
||||
default = "";
|
||||
}
|
||||
}${
|
||||
clanLib.getPublicValue {
|
||||
flake = config.clan.core.settings.directory;
|
||||
machine = machineName;
|
||||
generator = "phonebox";
|
||||
file = "ata-local-number";
|
||||
default = null;
|
||||
}
|
||||
}";
|
||||
}) machines;
|
||||
);
|
||||
|
||||
createContactListTiff =
|
||||
let
|
||||
contactTXT = lib.concatStringsSep "\n" (
|
||||
builtins.map (contact: "${contact.number}\t\t: \t\t${contact.name}") contactList
|
||||
);
|
||||
in
|
||||
pkgs.writeShellApplication {
|
||||
name = "create-contact-tiff";
|
||||
text = ''
|
||||
magick -background white -fill black -pointsize 20 -font DejaVu-Sans label:"${contactTXT}" "$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 ];
|
||||
};
|
||||
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";
|
||||
|
||||
genServerSIPEndpoint =
|
||||
{ hostname, address }:
|
||||
''
|
||||
[${hostname}](internal_endpoint)
|
||||
aors=${hostname}
|
||||
seedPeers = lib.unique (
|
||||
lib.filter (ip: ip != "") (map getYggdrasilIP (lib.remove machineName machines))
|
||||
++ settings.extraPeers
|
||||
);
|
||||
|
||||
[${hostname}](ip_auth)
|
||||
endpoint=${hostname}
|
||||
match=[${address}]
|
||||
# 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);
|
||||
|
||||
[${hostname}](dynamiic_aor)
|
||||
contact=sip:[${address}]
|
||||
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 }:
|
||||
@@ -151,7 +169,7 @@
|
||||
username=${localNumber}
|
||||
password=${localNumber}
|
||||
|
||||
[${localNumber}](dynamiic_aor)
|
||||
[${localNumber}](dynamic_aor)
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
'';
|
||||
@@ -168,7 +186,7 @@
|
||||
username=${localNumber}
|
||||
password=${localNumber}
|
||||
|
||||
[${localNumber}](dynamiic_aor)
|
||||
[${localNumber}](dynamic_aor)
|
||||
max_contacts=1
|
||||
'';
|
||||
|
||||
@@ -181,7 +199,7 @@
|
||||
contact_deny=::/0
|
||||
contact_permit=${settings.extraFixedIPClient.${number}.ip}/128
|
||||
|
||||
[${number}](dynamiic_aor)
|
||||
[${number}](dynamic_aor)
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
@@ -196,63 +214,58 @@
|
||||
''
|
||||
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 =
|
||||
machineName:
|
||||
if config.clan.core.vars.generators.yggdrasil.files.address ? value then
|
||||
clanLib.getPublicValue {
|
||||
flake = config.clan.core.settings.directory;
|
||||
machine = machineName;
|
||||
generator = "yggdrasil";
|
||||
file = "address";
|
||||
default = null;
|
||||
}
|
||||
else
|
||||
throw "clanService/yggdrasil is required";
|
||||
localNumbers = [
|
||||
ataLine
|
||||
]
|
||||
++ settings.extraClientNumbers
|
||||
++ lib.attrNames settings.extraFixedIPClient;
|
||||
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 = {
|
||||
server-prefix-number.secret = false;
|
||||
ata-local-number.secret = false;
|
||||
number.secret = false;
|
||||
public-key.secret = false;
|
||||
signature.secret = false;
|
||||
owner-name.secret = false;
|
||||
};
|
||||
|
||||
prompts = {
|
||||
server-prefix-number = {
|
||||
type = "line";
|
||||
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;
|
||||
type = "line";
|
||||
description = "The owner's name for this unit";
|
||||
};
|
||||
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/server-prefix-number > $out/server-prefix-number
|
||||
cat $prompts/ata-local-number > $out/ata-local-number
|
||||
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,188 +325,223 @@
|
||||
networking.firewall.allowedUDPPorts = [
|
||||
53
|
||||
67
|
||||
5060
|
||||
sipPort
|
||||
];
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
53
|
||||
];
|
||||
networking.firewall.interfaces =
|
||||
let
|
||||
matchAll = if !config.networking.nftables.enable then "zt+" else "zt*";
|
||||
in
|
||||
{
|
||||
"${matchAll}".allowedTCPPorts = [ 80 ];
|
||||
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 =
|
||||
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;
|
||||
};
|
||||
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
|
||||
'';
|
||||
|
||||
localNumber = clanLib.getPublicValue {
|
||||
flake = config.clan.core.settings.directory;
|
||||
machine = name;
|
||||
generator = "phonebox";
|
||||
file = "ata-local-number";
|
||||
default = null;
|
||||
};
|
||||
}
|
||||
]
|
||||
) [ ] machines;
|
||||
in
|
||||
{
|
||||
"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
|
||||
|
||||
"modules.conf" = ''
|
||||
[modules]
|
||||
autoload=yes
|
||||
load => res_fax_spandsp.so
|
||||
'';
|
||||
|
||||
load => res_fax_spandsp.so
|
||||
'';
|
||||
# Dial plan config
|
||||
"extensions.conf" = ''
|
||||
[from-internal]
|
||||
exten => 999,1,Answer()
|
||||
same => n,Playback(hello-world)
|
||||
same => n,Hangup()
|
||||
|
||||
# Dial plan config
|
||||
"extensions.conf" =
|
||||
let
|
||||
serverConf = builtins.foldl' (
|
||||
config: node:
|
||||
config
|
||||
+ (genExtentConf {
|
||||
inherit (node) prefixNumber hostname localNumber;
|
||||
})
|
||||
) "" nodes;
|
||||
in
|
||||
''
|
||||
[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 => 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 => 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)
|
||||
|
||||
exten => h,1,GotoIf($[''${FAXECHO}]?sendfax)
|
||||
same => n,Hangup()
|
||||
same => n(sendfax),Originate(PJSIP/00,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)
|
||||
|
||||
''
|
||||
+ (genLocalExtenConf {
|
||||
localNumber = config.clan.core.vars.generators.phonebox.files.ata-local-number.value;
|
||||
})
|
||||
+ lib.concatStringsSep "\n" (
|
||||
builtins.map (number: genLocalExtenConf { localNumber = number; }) settings.extraClientNumbers
|
||||
)
|
||||
+ lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (
|
||||
number: _: genLocalExtenConf { localNumber = number; }
|
||||
) settings.extraFixedIPClient
|
||||
)
|
||||
+ serverConf;
|
||||
''
|
||||
+ lib.concatMapStrings (number: genLocalExtenConf { localNumber = number; }) localNumbers
|
||||
+ ''
|
||||
|
||||
"rtp.conf" = ''
|
||||
[general]
|
||||
rtpstart=${builtins.toString rtpPortFrom}
|
||||
rtpend=${builtins.toString rtpPortTo}
|
||||
'';
|
||||
; 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()
|
||||
|
||||
"pjsip.conf" =
|
||||
let
|
||||
serverConf = builtins.foldl' (
|
||||
conf: node:
|
||||
conf
|
||||
+ (genServerSIPEndpoint {
|
||||
hostname = node.hostname;
|
||||
address = node.address;
|
||||
})
|
||||
) "" nodes;
|
||||
in
|
||||
''
|
||||
[transport-udp]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=0.0.0.0
|
||||
[transport-udp6]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=::
|
||||
; 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)
|
||||
'';
|
||||
|
||||
[base_endpoint](!)
|
||||
type=endpoint
|
||||
disallow=all
|
||||
allow=ulaw,alaw,g722,gsm
|
||||
direct_media=no
|
||||
"rtp.conf" = ''
|
||||
[general]
|
||||
rtpstart=${toString rtpPortFrom}
|
||||
rtpend=${toString rtpPortTo}
|
||||
'';
|
||||
|
||||
[internal_endpoint](!,base_endpoint)
|
||||
context=from-internal
|
||||
"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
|
||||
|
||||
[userpass_auth](!)
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
[transport-udp]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=0.0.0.0
|
||||
[transport-udp6]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=::
|
||||
|
||||
[ip_auth](!)
|
||||
type=identify
|
||||
endpoint=external
|
||||
[base_endpoint](!)
|
||||
type=endpoint
|
||||
disallow=all
|
||||
allow=ulaw,alaw,g722,gsm
|
||||
direct_media=no
|
||||
|
||||
[dynamiic_aor](!)
|
||||
type=aor
|
||||
[internal_endpoint](!,base_endpoint)
|
||||
context=from-internal
|
||||
|
||||
''
|
||||
+ (genLocalSIPEndpoint {
|
||||
localNumber = config.clan.core.vars.generators.phonebox.files.ata-local-number.value;
|
||||
})
|
||||
+ lib.concatStringsSep "\n" (
|
||||
builtins.map (number: genLocalSIPEndpointV6 { localNumber = number; }) settings.extraClientNumbers
|
||||
)
|
||||
+ lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (number: _: genLocalSIPIPEndpoint number) settings.extraFixedIPClient
|
||||
)
|
||||
+ serverConf;
|
||||
};
|
||||
[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 = [
|
||||
|
||||
@@ -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()
|
||||
@@ -2,7 +2,6 @@
|
||||
self,
|
||||
hostPkgs,
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
@@ -25,36 +24,54 @@
|
||||
directory = ./.;
|
||||
inventory = {
|
||||
machines.server = { };
|
||||
machines.nodeA = { };
|
||||
|
||||
instances = {
|
||||
yggdrasil = {
|
||||
module.name = "yggdrasil";
|
||||
roles.default.machines.server = { };
|
||||
roles.default.machines.nodeA = { };
|
||||
};
|
||||
phonebox-test = {
|
||||
module.name = "@clan/phonebox";
|
||||
module.input = "self";
|
||||
roles.default.machines."server".settings = {
|
||||
ata-ethernet-iface = "enp2s0";
|
||||
};
|
||||
roles.default.machines.server.settings.ata-ethernet-iface = "enp2s0";
|
||||
roles.default.machines.nodeA.settings.ata-ethernet-iface = "enp2s0";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nodes = {
|
||||
server = {
|
||||
services.asterisk = {
|
||||
};
|
||||
};
|
||||
server = { };
|
||||
nodeA = { };
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
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()
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
@@ -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": {
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzVlo0YjZFVFF2VkFlSzBL\ndHp4YTQ5RlNxV2FWNnNScTh5d0hXR05RL0NVCnBVUENNNjg0dTFVcmx3N3djZ25l\ncmxYTVVrNGhGSkdwVThoaC9UVTQ3L2sKLS0tIG1uREdFcXNubGxzUUQ1Rkh5Wnlx\nRG5OZmJwaGh2dkt6RSttb0gxZ3FBaEkKZzwUuQmOeBk5kfRfVVdqgNvsTU1Ssb/I\nx9Iv9w/YKHDmmcFLcAbGAHbS0/Js0YqBKZonxEMDdWP/+/F+Pv8LqQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGZVpldFNheTVGY1AwSitC\neXo3S1NVNnZCcnVhaVk5WTBLRVdrUjlTV0hRCi9ZOFBrQ0t3bGFwOHVEZWU2YWdi\nOUpnNGdwU0VSUkxZa3I0TUVEUENsU3MKLS0tIGVVRlpsZHN6a28vRG9JVTREUkZR\nQm40dnZxVUZMcFo2S3VOTUN2dzhkYlEKw3vn0del6gEimd7p4ASzhmEOqY4c7j+m\nELoY+j0568iROX9g9Ebt8GWbx8Yi81DX9RdCiZ4GdTB88sYURpsxIQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2025-10-31T07:08:30Z",
|
||||
"mac": "ENC[AES256_GCM,data:CBuTnVIta0eFqlB7ZpDkzPOEGbIQbb5oCpksI4umscB3uE0HM3j4N5r6bCPEcYpehB3qWXuCxlj4NfH7zmp6CDq6Be6bfpB1f8MCwTlPvQUho2f31so3U/g99q6ZyWI2rJO50dzn77bdma3JXo9VQb3uRqJ0mk72IYXFwdHvO9s=,iv:9uwjtGYpqsLcXRoBFmjJjfHUq7R47ZqkrKEXMulw4OY=,tag:4Auq3mnhlusogyW44SJfqg==,type:str]",
|
||||
"version": "3.11.0"
|
||||
"lastmodified": "2026-09-17T04:59:39Z",
|
||||
"mac": "ENC[AES256_GCM,data:GMWIElHv1WaRy8oE5pZSC5W6hP5vm+TcM/BFIWevDpPyFH16yLDxKsSYE9JbEZirFxwyQ9v0tX/DZs3M7kTmdZ6m3qB1Sh2068H5DKV8hSQc4FhbhsyjTL2wSTOqV5HihnoLxjg5Lg+WfnQIygwsYz/3UZrmdElUh1Il01+fXys=,iv:9Jz3v5f5KB9ivy0LNMC9HnLCKnoEI1uTE/gFiCzjLNw=,tag:E+fhqyswcFU7XqHyeKaz5w==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
651944
|
||||
@@ -0,0 +1 @@
|
||||
52460c3f17375aef0b8baa1e20be141901d9b99affb6a5b0dac90684d0047acc
|
||||
@@ -0,0 +1 @@
|
||||
COjaD1KTdEbR3H/0z5VT3DCEBs7WFsejnMhT6OLYMzeWTvZlJ+0Da3a9NvrFfSaa3GiMYFIp7dDHV3Xqba78AA==
|
||||
@@ -0,0 +1 @@
|
||||
26.11
|
||||
@@ -0,0 +1 @@
|
||||
201:b6e7:cf03:a322:9443:d1d1:5787:7d07
|
||||
+1
@@ -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
@@ -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 @@
|
||||
917612
|
||||
@@ -0,0 +1 @@
|
||||
95f093cb4b0b727fb38ddbfc8a3364d72d96b25882e44c5ca555644fc18fc2b2
|
||||
-1
@@ -1 +0,0 @@
|
||||
fake_line_value
|
||||
@@ -0,0 +1 @@
|
||||
77s68Xqs8EgJWQx/vyjvivDPV8w0PF0HUr1WdveoE5ErpY+7Im5c8LFbcoQDVjt2OwaorF3G0NUQoBI8yavPCw==
|
||||
@@ -1 +1 @@
|
||||
25.11
|
||||
26.11
|
||||
@@ -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": {
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age1fdkan6n20swmut0sa86g5a6gxrj8qj2sgqe8hxtw32c0u9rr4drqlyr5mf",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NnVzT0lOWnFTUU85c0d5\nZ0Q1b0xkS1NWbWVsc0o1emxLOW5NRU52REFZCjRkUGJHaDhyazdFdkxWaW1XVW53\nb25pUXlDRmhrTHlIWGl6RDJBZ2hTOW8KLS0tIGsxQmlQejkvQk9wTmtMQzZJa2dB\na0piOGlyMzVrTmNOUEhtaDNTWHdwTDAKroQG8KlnWZ6gwu1y0mr0gGezDF1jsS0Y\nC1LUAarHl+lY51sw+HJT88Y9mDfLjvYIMHKS33zdJuBXbNpoIfWyzA==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNZTI0MWQ4cjQ4YmYvT2Fj\nTEp5VHQ2NkFKVFhIV0dzVXNmSUVnWTZabVcwCmFvT25WTTMvcUVFY2xmWjVHRUx2\nRGRySVpYaGY4MFhiSDI3bzFxWUUwV2cKLS0tIGplZHUvWE50aFNvQmFzME9YUTIz\nYUZVQnJQZTNWV2FNWEx6WFp4WkFiRkUKmvikszfBTgfqTDEIQZwBBU+nhCYYEMu0\n6J8BPnH6ZoX6nFm1K34BkTGCLzbvM1lQL27hiP5oto5E2rBzpLgi6g==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||
},
|
||||
{
|
||||
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqQVJhYkI1eEFYL0d5OE1F\nSStqV0xPb0pybyt2QWd4SEpiaDhlNDJicTA0CkgrVU9FcThuQktaMThTaXBmWHNV\nd2tpMVVYT21TWUFvMS9wakl4RyszTGsKLS0tIHVVeDdoMUJQaURMOGFkM3dNa3ZG\nMmVPdEg3bmdZZHgwaGpnbFFKUTdvcUUKTaxEfp19+9AJihqx51m0cLz6IuR5pvnT\nt90kZxq+BH3/6gjiDlhwzqztnMbdqQYcVuCDVp/1aVWfThABUZ92aQ==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSbDV1UHJYdS9DcmR4QmJU\nTVNUeFpuTzYxWmR5WDFEUkswekZjQXVqTXdVCkZBUHc3Nk9veFBCWnhkTCtxQVBs\nTjdCbVFQVnc4NnJCWEZKc3VvZG5TUDgKLS0tIDk3dE85VFFId2pNb21HVktuN2V5\nTDVvRlVFaUdNRHdXZ3RaRTRoWDhEcFkKU16K1nNPuOJUpPTXx3dirg2r8SPYtUT+\nsv3htIRb/kfweRKx331vsC19V0hbsqXLatM+yPzYx8ITx4VUDXB4kA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1sstuuf7rzdwd88h2vcz89n9ctnarwx7e4uncyxaq3s4kcpag8a8qc7qaxj"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2025-12-05T04:42:06Z",
|
||||
"mac": "ENC[AES256_GCM,data:1/u3c/wf2Gh6PPeVTKKGBxG1FWvN7hyuzx3Qa7yU3yKCD7LDHrrzTbQkFHDo1ZrXixK5NICCw48BWA0Jao0kItu6aU1Dbk5PexZI9ls1eyaDS7nwtZuHKDSEtDYu/kx5kZgQKH9tsokLqKlcoeocS0Mp1tjvUSQsiZFaYsuKaM4=,iv:gCPqFgpwjSONn/JN3dpJOk1BXtdd9cvIAz/NKPuAdKg=,tag:j8upk9sgDiYj5lmfU4V+OQ==,type:str]",
|
||||
"version": "3.11.0"
|
||||
"lastmodified": "2026-09-17T04:59:40Z",
|
||||
"mac": "ENC[AES256_GCM,data:1FucJy4iHy3ZTUNwsZMCUAT8AZeIlfZS57wzgnozrFroHW7wO5uDiHbNwtnaSvvvsax4VrClLMHExkbwg9IgRRrvY6LN19UsLq1Ia504N8bxW/WAvknVIBKqijYT24joysqjtQKHefs4f8mboGFnF7cPcwtHggHrqs+F+ET1rdc=,iv:Vg0lSMsHslgVlm5n7r5jDEVJw+6rJKC6EE740sGkDz0=,tag:uy5xgN/iSjo//euKTbPoFQ==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAIC8cazhgQ+1Hqdm8Z43J5ooymP2ytrBEvdfXYz0ryp8=
|
||||
-----END PUBLIC KEY-----
|
||||
95f093cb4b0b727fb38ddbfc8a3364d72d96b25882e44c5ca555644fc18fc2b2
|
||||
@@ -1 +0,0 @@
|
||||
201:b9d:4329:71c2:79ca:3648:e86d:4236
|
||||
-1
@@ -1 +0,0 @@
|
||||
../../../../../../sops/machines/server
|
||||
-18
@@ -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
@@ -1 +0,0 @@
|
||||
../../../../../../sops/users/admin
|
||||
-1
@@ -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
|
||||
Reference in New Issue
Block a user