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.
|
||||||
+236
-188
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
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 @@
|
|||||||
|
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": {
|
"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
@@ -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