Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58da21d15 | ||
|
|
f398daacb5 |
@@ -0,0 +1,45 @@
|
|||||||
|
A peer to peer phone relay network built on top of yggdrasil.
|
||||||
|
|
||||||
|
Successor of the `phonebox` service with a global, decentralized number
|
||||||
|
directory. Every box is reachable from any yggdrasil node running this
|
||||||
|
service, not only from members of the same clan. A machine runs either
|
||||||
|
`phonebox` or `phonebox-global`, not both (they share the asterisk setup).
|
||||||
|
Regenerate vars with `clan vars generate --generator phonebox-global`.
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
Each box gets a random 6 digit number (100000-999999) when its vars are
|
||||||
|
generated. The number is signed with the box's yggdrasil private key, so a
|
||||||
|
number is cryptographically bound to the yggdrasil address it belongs to;
|
||||||
|
nobody can claim a number for an address they do not own. Regenerate the
|
||||||
|
`phonebox` vars to get a new number.
|
||||||
|
|
||||||
|
Dialing from a phone plugged into the ATA:
|
||||||
|
|
||||||
|
| dialed | reaches |
|
||||||
|
| ------------- | ------------------------------------------ |
|
||||||
|
| `NNNNNN` | the phone on box `NNNNNN` (line `00`) |
|
||||||
|
| `NNNNNNXX` | line `XX` on box `NNNNNN` |
|
||||||
|
| `00`, `01`... | local lines on this box |
|
||||||
|
| `888` | fax of the current number directory |
|
||||||
|
| `000` | fax echo test |
|
||||||
|
| `999` | hello world |
|
||||||
|
|
||||||
|
Caller ID on the callee is the caller's box number followed by its line, so
|
||||||
|
calling it back works.
|
||||||
|
|
||||||
|
## Directory
|
||||||
|
|
||||||
|
There is no central registry. Each box runs a serf agent on yggdrasil port
|
||||||
|
7946 that gossips its signed number record (`number`, `key`, `sig`, `owner`
|
||||||
|
tags) to every other box it knows about. On every membership change the
|
||||||
|
`phonebox-sync` handler verifies all records and writes the resulting
|
||||||
|
`number -> yggdrasil address` map to `/run/phonebox/numbers/`, which the
|
||||||
|
asterisk dialplan reads at call time.
|
||||||
|
|
||||||
|
Bootstrapping: boxes of the same clan join each other automatically. To
|
||||||
|
connect to boxes outside the clan add one of their yggdrasil addresses to
|
||||||
|
`extraPeers`; after the first successful join the agent remembers the whole
|
||||||
|
membership in `/var/lib/phonebox/serf.snapshot` and rejoins on its own.
|
||||||
|
|
||||||
|
`serf members -rpc-addr 127.0.0.1:7373` shows the live directory.
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
{
|
||||||
|
clanLib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
_class = "clan.service";
|
||||||
|
manifest.name = "phonebox-global";
|
||||||
|
manifest.description = "A peer to peer phone relay network built on top of yggdrasil.";
|
||||||
|
manifest.readme = builtins.readFile ./README.md;
|
||||||
|
manifest.categories = [ "System" ];
|
||||||
|
|
||||||
|
roles.default = {
|
||||||
|
description = "a default server role";
|
||||||
|
interface =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
options.ata-ethernet-iface = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "An Ethernet interface that connect to ATA box.";
|
||||||
|
default = "enp2s0";
|
||||||
|
};
|
||||||
|
options.extraClientNumbers = lib.mkOption {
|
||||||
|
type = with lib.types; listOf str;
|
||||||
|
description = "List of client suffix number.";
|
||||||
|
default = [ ];
|
||||||
|
};
|
||||||
|
|
||||||
|
options.extraFixedIPClient = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
ip = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "IP address for this client";
|
||||||
|
};
|
||||||
|
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Name of the client";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
description = "Extra client to be added to pjsip config as a fixed IP auth";
|
||||||
|
default = { };
|
||||||
|
example = {
|
||||||
|
"01" = {
|
||||||
|
ip = "192.168.1.3";
|
||||||
|
name = "bob";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
options.extraPeers = lib.mkOption {
|
||||||
|
type = with lib.types; listOf str;
|
||||||
|
description = ''
|
||||||
|
Yggdrasil addresses of phonebox nodes outside this clan to bootstrap
|
||||||
|
the number directory from. Any single reachable node is enough; the
|
||||||
|
directory is gossiped from there and remembered across restarts.
|
||||||
|
'';
|
||||||
|
default = [ ];
|
||||||
|
example = [ "200:1234:5678:9abc:def0:1234:5678:9abc" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
perInstance =
|
||||||
|
{
|
||||||
|
roles,
|
||||||
|
settings,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
|
||||||
|
nixosModule =
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
asterisk = pkgs.asterisk.overrideAttrs (old: {
|
||||||
|
propagatedNativeBuildInputs = [ pkgs.spandsp3 ];
|
||||||
|
});
|
||||||
|
|
||||||
|
machineName = config.clan.core.settings.machine.name;
|
||||||
|
machines = lib.attrNames roles.default.machines;
|
||||||
|
|
||||||
|
user = "asterisk";
|
||||||
|
faxDir = "/run/asterisk/fax";
|
||||||
|
rtpPortFrom = 10000;
|
||||||
|
rtpPortTo = 20000;
|
||||||
|
ata-interface = settings.ata-ethernet-iface;
|
||||||
|
|
||||||
|
# The ATA's own line. Remote callers reach it by dialing the 6
|
||||||
|
# digit box number alone or with this extension appended.
|
||||||
|
ataLine = "00";
|
||||||
|
sipPort = 5060;
|
||||||
|
|
||||||
|
# Well-known port of the phonebox directory gossip. Every phonebox
|
||||||
|
# node worldwide must agree on it, so it is not configurable.
|
||||||
|
directoryPort = 7946;
|
||||||
|
directoryRpc = "127.0.0.1:7373";
|
||||||
|
directoryUser = "phonebox";
|
||||||
|
directoryDir = "/run/phonebox";
|
||||||
|
|
||||||
|
phoneboxVars = config.clan.core.vars.generators.phonebox-global.files;
|
||||||
|
ownNumber = phoneboxVars.number.value;
|
||||||
|
ownerName = lib.trim phoneboxVars.owner-name.value;
|
||||||
|
|
||||||
|
getYggdrasilIP =
|
||||||
|
name:
|
||||||
|
lib.trim (
|
||||||
|
clanLib.getPublicValue {
|
||||||
|
flake = config.clan.core.settings.directory;
|
||||||
|
machine = name;
|
||||||
|
generator = "yggdrasil";
|
||||||
|
file = "address";
|
||||||
|
default = "";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ownYggdrasilIP =
|
||||||
|
if config.clan.core.vars.generators.yggdrasil.files ? address then
|
||||||
|
config.clan.core.vars.generators.yggdrasil.files.address.value
|
||||||
|
else
|
||||||
|
throw "clanService/yggdrasil is required";
|
||||||
|
|
||||||
|
seedPeers = lib.unique (
|
||||||
|
lib.filter (ip: ip != "") (map getYggdrasilIP (lib.remove machineName machines))
|
||||||
|
++ settings.extraPeers
|
||||||
|
);
|
||||||
|
|
||||||
|
# Rebuilds the number -> yggdrasil address directory from the
|
||||||
|
# gossip membership. Every record is verified: the yggdrasil
|
||||||
|
# address is derived from the record's public key, so a node can
|
||||||
|
# only publish numbers for the address it actually owns, and the
|
||||||
|
# number is signed with the matching private key.
|
||||||
|
phoneboxSync = pkgs.writers.writePython3Bin "phonebox-sync" {
|
||||||
|
libraries = [ pkgs.python3Packages.cryptography ];
|
||||||
|
flakeIgnore = [ "E501" ];
|
||||||
|
} (builtins.readFile ./phonebox-sync.py);
|
||||||
|
|
||||||
|
phoneboxSyncWrapped = pkgs.writeShellApplication {
|
||||||
|
name = "phonebox-sync";
|
||||||
|
runtimeInputs = [ pkgs.serfdom ];
|
||||||
|
text = ''
|
||||||
|
exec ${lib.getExe phoneboxSync} ${directoryRpc} ${directoryDir}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
createContactListTiff = pkgs.writeShellApplication {
|
||||||
|
name = "create-contact-tiff";
|
||||||
|
text = ''
|
||||||
|
magick -background white -fill black -pointsize 20 -font DejaVu-Sans label:"$(cat ${directoryDir}/contacts.txt)" "$1"
|
||||||
|
magick "$1" -border 20x50 -bordercolor white "$1"
|
||||||
|
magick "$1" -resize 1728x -units PixelsPerInch -compress Group4 -density 204x196 -monochrome -depth 1 "$1"
|
||||||
|
'';
|
||||||
|
runtimeInputs = [ pkgs.imagemagick ];
|
||||||
|
};
|
||||||
|
|
||||||
|
genLocalSIPEndpoint =
|
||||||
|
{ localNumber }:
|
||||||
|
''
|
||||||
|
[${localNumber}](internal_endpoint)
|
||||||
|
aors=${localNumber}
|
||||||
|
auth=${localNumber}
|
||||||
|
|
||||||
|
[${localNumber}](userpass_auth)
|
||||||
|
username=${localNumber}
|
||||||
|
password=${localNumber}
|
||||||
|
|
||||||
|
[${localNumber}](dynamic_aor)
|
||||||
|
max_contacts=1
|
||||||
|
remove_existing=yes
|
||||||
|
'';
|
||||||
|
|
||||||
|
genLocalSIPEndpointV6 =
|
||||||
|
{ localNumber }:
|
||||||
|
''
|
||||||
|
[${localNumber}](internal_endpoint)
|
||||||
|
transport=transport-udp6
|
||||||
|
aors=${localNumber}
|
||||||
|
auth=${localNumber}
|
||||||
|
|
||||||
|
[${localNumber}](userpass_auth)
|
||||||
|
username=${localNumber}
|
||||||
|
password=${localNumber}
|
||||||
|
|
||||||
|
[${localNumber}](dynamic_aor)
|
||||||
|
max_contacts=1
|
||||||
|
'';
|
||||||
|
|
||||||
|
genLocalSIPIPEndpoint = number: ''
|
||||||
|
|
||||||
|
[${number}](internal_endpoint)
|
||||||
|
aors=${number}
|
||||||
|
auth=${number}
|
||||||
|
contact_deny=0.0.0.0/0
|
||||||
|
contact_deny=::/0
|
||||||
|
contact_permit=${settings.extraFixedIPClient.${number}.ip}/128
|
||||||
|
|
||||||
|
[${number}](dynamic_aor)
|
||||||
|
max_contacts=1
|
||||||
|
remove_existing=yes
|
||||||
|
|
||||||
|
[${number}](userpass_auth)
|
||||||
|
username=${number}
|
||||||
|
password=${number}
|
||||||
|
|
||||||
|
'';
|
||||||
|
|
||||||
|
genLocalExtenConf =
|
||||||
|
{ localNumber }:
|
||||||
|
''
|
||||||
|
exten => ${localNumber},1,Dial(PJSIP/${localNumber},20)
|
||||||
|
'';
|
||||||
|
|
||||||
|
localNumbers = [
|
||||||
|
ataLine
|
||||||
|
]
|
||||||
|
++ settings.extraClientNumbers
|
||||||
|
++ lib.attrNames settings.extraFixedIPClient;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = !config.networking.nftables.enable;
|
||||||
|
message = "phonebox-global: opening the yggdrasil interface to non-clan nodes is only implemented for the iptables firewall backend";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
clan.core.vars.generators.phonebox-global = {
|
||||||
|
files = {
|
||||||
|
number.secret = false;
|
||||||
|
public-key.secret = false;
|
||||||
|
signature.secret = false;
|
||||||
|
owner-name.secret = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
dependencies = [ "yggdrasil" ];
|
||||||
|
|
||||||
|
prompts.owner-name = {
|
||||||
|
persist = true;
|
||||||
|
type = "line";
|
||||||
|
description = "The owner's name for this unit";
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
coreutils
|
||||||
|
openssl
|
||||||
|
xxd
|
||||||
|
];
|
||||||
|
|
||||||
|
# A random 6 digit number (100000-999999), bound to this
|
||||||
|
# machine's yggdrasil identity by signing it with the yggdrasil
|
||||||
|
# private key. Other nodes accept the number only if the
|
||||||
|
# signature checks out and the key derives to the yggdrasil
|
||||||
|
# address the record is gossiped from. Regenerate to re-roll.
|
||||||
|
script = ''
|
||||||
|
cat $prompts/owner-name > $out/owner-name
|
||||||
|
shuf -i 100000-999999 -n 1 | tr -d '\n' > $out/number
|
||||||
|
openssl pkey -in $in/yggdrasil/privateKey -pubout -outform DER \
|
||||||
|
| tail -c 32 | xxd -p -c 64 | tr -d '\n' > $out/public-key
|
||||||
|
# openssl needs a regular file for one-shot ed25519 signing
|
||||||
|
printf 'phonebox:%s' "$(cat $out/number)" > $out/message
|
||||||
|
openssl pkeyutl -sign -rawin -inkey $in/yggdrasil/privateKey -in $out/message \
|
||||||
|
| base64 -w0 > $out/signature
|
||||||
|
rm $out/message
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.interfaces = {
|
||||||
|
${ata-interface} = {
|
||||||
|
useDHCP = false;
|
||||||
|
ipv4.addresses = [
|
||||||
|
{
|
||||||
|
address = "192.168.254.1";
|
||||||
|
prefixLength = 24;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.dnsmasq = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
bind-dynamic = true;
|
||||||
|
listen-address = "192.168.254.1";
|
||||||
|
# enable-ra = true;
|
||||||
|
domain-needed = true;
|
||||||
|
domain = "localhost";
|
||||||
|
dhcp-range = [
|
||||||
|
"192.168.254.100,192.168.254.100,255.255.255.0,3m"
|
||||||
|
];
|
||||||
|
dhcp-leasefile = "/dev/null";
|
||||||
|
dhcp-option = [
|
||||||
|
"3,192.168.254.1"
|
||||||
|
];
|
||||||
|
interface = [ ata-interface ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts = {
|
||||||
|
"_" = {
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://192.168.254.100";
|
||||||
|
extraConfig = ''
|
||||||
|
client_max_body_size 100M;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedUDPPortRanges = [
|
||||||
|
{
|
||||||
|
from = rtpPortFrom;
|
||||||
|
to = rtpPortTo;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
networking.firewall.allowedUDPPorts = [
|
||||||
|
53
|
||||||
|
67
|
||||||
|
sipPort
|
||||||
|
];
|
||||||
|
networking.firewall.allowedTCPPorts = [
|
||||||
|
53
|
||||||
|
];
|
||||||
|
networking.firewall.interfaces = {
|
||||||
|
"zt+".allowedTCPPorts = [ 80 ];
|
||||||
|
ygg = {
|
||||||
|
allowedTCPPorts = [ directoryPort ];
|
||||||
|
allowedUDPPorts = [ directoryPort ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# clanService/yggdrasil drops everything on the ygg interface that
|
||||||
|
# does not come from a clan member. Phonebox is a global network,
|
||||||
|
# so let SIP, RTP and the directory gossip through from anyone.
|
||||||
|
networking.firewall.extraCommands = lib.mkAfter ''
|
||||||
|
if ip6tables -n -L ygg-input >/dev/null 2>&1; then
|
||||||
|
ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString sipPort} -j RETURN
|
||||||
|
ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString rtpPortFrom}:${toString rtpPortTo} -j RETURN
|
||||||
|
ip6tables -I ygg-input 1 -i ygg -p udp --dport ${toString directoryPort} -j RETURN
|
||||||
|
ip6tables -I ygg-input 1 -i ygg -p tcp --dport ${toString directoryPort} -j RETURN
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
users.users.${directoryUser} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = directoryUser;
|
||||||
|
};
|
||||||
|
users.groups.${directoryUser} = { };
|
||||||
|
|
||||||
|
# Decentralized number directory: a serf gossip cluster over
|
||||||
|
# yggdrasil. Each node advertises its signed number record as
|
||||||
|
# tags; membership changes trigger phonebox-sync, which writes the
|
||||||
|
# verified number -> address map to ${directoryDir}/numbers/ for
|
||||||
|
# the dialplan. The snapshot lets a node rejoin from previously
|
||||||
|
# seen members, so seeds are only needed for the very first join.
|
||||||
|
systemd.services.phonebox-directory = {
|
||||||
|
description = "Phonebox number directory (serf gossip over yggdrasil)";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"yggdrasil.service"
|
||||||
|
];
|
||||||
|
serviceConfig = {
|
||||||
|
User = directoryUser;
|
||||||
|
Group = directoryUser;
|
||||||
|
RuntimeDirectory = "phonebox";
|
||||||
|
RuntimeDirectoryMode = "0755";
|
||||||
|
RuntimeDirectoryPreserve = "yes";
|
||||||
|
StateDirectory = "phonebox";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
ExecStart = lib.concatStringsSep " " (
|
||||||
|
[
|
||||||
|
(lib.getExe pkgs.serfdom)
|
||||||
|
"agent"
|
||||||
|
"-node=${machineName}-${ownNumber}"
|
||||||
|
"-bind=[::]:${toString directoryPort}"
|
||||||
|
"-advertise=[${ownYggdrasilIP}]:${toString directoryPort}"
|
||||||
|
"-rpc-addr=${directoryRpc}"
|
||||||
|
"-profile=wan"
|
||||||
|
"-snapshot=/var/lib/phonebox/serf.snapshot"
|
||||||
|
"-rejoin"
|
||||||
|
"-retry-max=0"
|
||||||
|
"-retry-interval=30s"
|
||||||
|
"-tag number=${ownNumber}"
|
||||||
|
"-tag key=${phoneboxVars.public-key.value}"
|
||||||
|
"-tag sig=${phoneboxVars.signature.value}"
|
||||||
|
"-tag ${lib.escapeShellArg "owner=${ownerName}"}"
|
||||||
|
"-event-handler=member-join,member-leave,member-failed,member-update,member-reap=${lib.getExe phoneboxSyncWrapped}"
|
||||||
|
]
|
||||||
|
++ map (ip: "-retry-join=[${ip}]:${toString directoryPort}") seedPeers
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.asterisk = {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
package = lib.mkDefault asterisk;
|
||||||
|
confFiles = {
|
||||||
|
"logger.conf" = ''
|
||||||
|
[general]
|
||||||
|
dateformat = %F %T.%3q ; ISO 8601 date format with milliseconds
|
||||||
|
use_callids = yes
|
||||||
|
appendhostname = no
|
||||||
|
queue_log = yes
|
||||||
|
queue_log_to_file = no
|
||||||
|
queue_log_name = queue_log
|
||||||
|
queue_log_realtime_use_gmt = no
|
||||||
|
rotatestrategy = rotate
|
||||||
|
exec_after_rotate=gzip -9 $\{filename\}.2
|
||||||
|
[logfiles]
|
||||||
|
console => notice,warning,error
|
||||||
|
security => security
|
||||||
|
messages => notice,warning,error
|
||||||
|
full => notice,warning,error,verbose,dtmf,fax
|
||||||
|
syslog.local0 => notice,warning,error
|
||||||
|
'';
|
||||||
|
|
||||||
|
"modules.conf" = ''
|
||||||
|
[modules]
|
||||||
|
autoload=yes
|
||||||
|
|
||||||
|
load => res_fax_spandsp.so
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Dial plan config
|
||||||
|
"extensions.conf" = ''
|
||||||
|
[from-internal]
|
||||||
|
exten => 999,1,Answer()
|
||||||
|
same => n,Playback(hello-world)
|
||||||
|
same => n,Hangup()
|
||||||
|
|
||||||
|
exten => 000,1,Answer()
|
||||||
|
same => n,ReceiveFAX(${faxDir}/echo-''${UNIQUEID}.tiff)
|
||||||
|
same => n,Set(FAXFILE=${faxDir}/echo-''${UNIQUEID}.tiff)
|
||||||
|
same => n,Set(FAXECHO=true)
|
||||||
|
|
||||||
|
exten => 888,1,Answer()
|
||||||
|
same => n,Set(FAXFILE=${faxDir}/contact.tiff)
|
||||||
|
same => n,System(${lib.getExe createContactListTiff} ''${FAXFILE})
|
||||||
|
same => n,Set(FAXECHO=true)
|
||||||
|
same => n,Playback(vm-goodbye)
|
||||||
|
same => n,Wait(3)
|
||||||
|
|
||||||
|
exten => h,1,GotoIf($[''${FAXECHO}]?sendfax)
|
||||||
|
same => n,Hangup()
|
||||||
|
same => n(sendfax),Originate(PJSIP/${ataLine},app,SendFAX,''${FAXFILE})
|
||||||
|
same => n,Set(FAXECHO=false)
|
||||||
|
|
||||||
|
; 6 digit box number, optionally followed by a 2 digit line
|
||||||
|
exten => _[1-9]XXXXX,1,Goto(to-yggdrasil,''${EXTEN},1)
|
||||||
|
exten => _[1-9]XXXXXXX,1,Goto(to-yggdrasil,''${EXTEN},1)
|
||||||
|
|
||||||
|
''
|
||||||
|
+ lib.concatMapStrings (number: genLocalExtenConf { localNumber = number; }) localNumbers
|
||||||
|
+ ''
|
||||||
|
|
||||||
|
; Outbound: resolve the box number through the directory and
|
||||||
|
; send the call to that node over yggdrasil.
|
||||||
|
[to-yggdrasil]
|
||||||
|
exten => _[1-9]X.,1,Set(PEER=''${FILE(${directoryDir}/numbers/''${EXTEN:0:6})})
|
||||||
|
same => n,GotoIf($["''${PEER}" = ""]?unknown)
|
||||||
|
same => n,Set(CALLERID(num)=${ownNumber}''${CALLERID(num)})
|
||||||
|
same => n,Set(CALLERID(name)=${ownerName})
|
||||||
|
same => n,Dial(PJSIP/yggdrasil/sip:''${EXTEN}@[''${PEER}]:${toString sipPort},30)
|
||||||
|
same => n,Hangup()
|
||||||
|
same => n(unknown),Playback(ss-noservice)
|
||||||
|
same => n,Hangup()
|
||||||
|
|
||||||
|
; Inbound from any yggdrasil node: only our own number is served.
|
||||||
|
[from-yggdrasil]
|
||||||
|
exten => ${ownNumber},1,Dial(PJSIP/${ataLine},20)
|
||||||
|
exten => _${ownNumber}XX,1,Dial(PJSIP/''${EXTEN:6},20)
|
||||||
|
'';
|
||||||
|
|
||||||
|
"rtp.conf" = ''
|
||||||
|
[general]
|
||||||
|
rtpstart=${toString rtpPortFrom}
|
||||||
|
rtpend=${toString rtpPortTo}
|
||||||
|
'';
|
||||||
|
|
||||||
|
"pjsip.conf" = ''
|
||||||
|
[global]
|
||||||
|
type=global
|
||||||
|
; Local lines authenticate by username; everything else from
|
||||||
|
; the yggdrasil range is a remote phonebox node.
|
||||||
|
endpoint_identifier_order=username,ip,anonymous
|
||||||
|
|
||||||
|
[transport-udp]
|
||||||
|
type=transport
|
||||||
|
protocol=udp
|
||||||
|
bind=0.0.0.0
|
||||||
|
[transport-udp6]
|
||||||
|
type=transport
|
||||||
|
protocol=udp
|
||||||
|
bind=::
|
||||||
|
|
||||||
|
[base_endpoint](!)
|
||||||
|
type=endpoint
|
||||||
|
disallow=all
|
||||||
|
allow=ulaw,alaw,g722,gsm
|
||||||
|
direct_media=no
|
||||||
|
|
||||||
|
[internal_endpoint](!,base_endpoint)
|
||||||
|
context=from-internal
|
||||||
|
|
||||||
|
[userpass_auth](!)
|
||||||
|
type=auth
|
||||||
|
auth_type=userpass
|
||||||
|
|
||||||
|
[dynamic_aor](!)
|
||||||
|
type=aor
|
||||||
|
|
||||||
|
[yggdrasil](base_endpoint)
|
||||||
|
transport=transport-udp6
|
||||||
|
context=from-yggdrasil
|
||||||
|
|
||||||
|
[yggdrasil]
|
||||||
|
type=identify
|
||||||
|
endpoint=yggdrasil
|
||||||
|
match=200::/7
|
||||||
|
|
||||||
|
''
|
||||||
|
+ (genLocalSIPEndpoint { localNumber = ataLine; })
|
||||||
|
+ lib.concatMapStrings (
|
||||||
|
number: genLocalSIPEndpointV6 { localNumber = number; }
|
||||||
|
) settings.extraClientNumbers
|
||||||
|
+ lib.concatMapStrings genLocalSIPIPEndpoint (lib.attrNames settings.extraFixedIPClient);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [
|
||||||
|
createContactListTiff
|
||||||
|
phoneboxSyncWrapped
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${faxDir} 0755 ${user} ${user} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.asterisk-watcher = {
|
||||||
|
enable = true;
|
||||||
|
description = "Asterisk Configuration files watcher";
|
||||||
|
|
||||||
|
requires = [ "asterisk.service" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
path = with pkgs; [
|
||||||
|
inotify-tools
|
||||||
|
asterisk
|
||||||
|
];
|
||||||
|
script = ''
|
||||||
|
inotifywait -m -e move /etc/asterisk |
|
||||||
|
while read path action file; do
|
||||||
|
case "$file" in
|
||||||
|
pjsip.conf)
|
||||||
|
echo "restarting pjsip"
|
||||||
|
asterisk -rx "pjsip reload"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
extensions.conf)
|
||||||
|
echo "restarting core"
|
||||||
|
asterisk -rx "core restart now"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
self,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
module = ./default.nix;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
clan.modules = {
|
||||||
|
phonebox-global = module;
|
||||||
|
};
|
||||||
|
perSystem =
|
||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
clan.nixosTests.service-phonebox-global = {
|
||||||
|
imports = [ ./tests/vm/default.nix ];
|
||||||
|
_module.args = { inherit self inputs; };
|
||||||
|
|
||||||
|
clan.modules."@clan/phonebox-global" = module;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Unit tests for the directory sync logic (record verification,
|
||||||
|
# address derivation, collision handling, file output).
|
||||||
|
checks.phonebox-global-sync =
|
||||||
|
pkgs.runCommand "phonebox-global-sync-test"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [
|
||||||
|
(pkgs.python3.withPackages (ps: [ ps.cryptography ]))
|
||||||
|
pkgs.yggdrasil
|
||||||
|
];
|
||||||
|
PHONEBOX_SYNC = ./phonebox-sync.py;
|
||||||
|
}
|
||||||
|
''
|
||||||
|
python3 ${./tests/sync/test_sync.py} -v
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tests for phonebox-sync: record verification and directory output.
|
||||||
|
|
||||||
|
Run via the `phonebox-global-sync` flake check; needs the `cryptography`
|
||||||
|
python package and the `yggdrasil` binary on PATH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("phonebox_sync", os.environ["PHONEBOX_SYNC"])
|
||||||
|
sync = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(sync)
|
||||||
|
|
||||||
|
PORT = 7946
|
||||||
|
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
"""A phonebox node identity: yggdrasil key, address and signed number."""
|
||||||
|
|
||||||
|
def __init__(self, number: str, owner: str = ""):
|
||||||
|
self.key = Ed25519PrivateKey.generate()
|
||||||
|
self.public = self.key.public_key().public_bytes_raw()
|
||||||
|
self.address = str(sync.yggdrasil_address(self.public))
|
||||||
|
self.number = number
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
def member(self, name="node", **overrides) -> dict:
|
||||||
|
tags = {
|
||||||
|
"number": self.number,
|
||||||
|
"key": self.public.hex(),
|
||||||
|
"sig": base64.b64encode(self.key.sign(b"phonebox:" + self.number.encode())).decode(),
|
||||||
|
"owner": self.owner,
|
||||||
|
}
|
||||||
|
member = {"name": name, "addr": f"[{self.address}]:{PORT}", "port": PORT, "status": "alive", "tags": tags}
|
||||||
|
for k, v in overrides.items():
|
||||||
|
(tags if k in tags else member)[k] = v
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
def yggdrasil_binary_address(key: Ed25519PrivateKey) -> str:
|
||||||
|
private = key.private_bytes_raw() + key.public_key().public_bytes_raw()
|
||||||
|
out = subprocess.run(
|
||||||
|
["yggdrasil", "-useconf", "-address"],
|
||||||
|
input=json.dumps({"PrivateKey": private.hex()}),
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return out.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressDerivation(unittest.TestCase):
|
||||||
|
def test_known_vector(self):
|
||||||
|
# A real clan machine key and the address yggdrasil assigned to it.
|
||||||
|
key = bytes.fromhex("0ec53986a2bdca8a43f74063aeab6a958fc697c528c83e7281365072926886f0")
|
||||||
|
self.assertEqual(str(sync.yggdrasil_address(key)), "204:2758:cf2b:a846:aeb7:8117:f38a:2a92")
|
||||||
|
|
||||||
|
def test_matches_yggdrasil_binary(self):
|
||||||
|
for _ in range(16):
|
||||||
|
key = Ed25519PrivateKey.generate()
|
||||||
|
expected = ipaddress.IPv6Address(yggdrasil_binary_address(key))
|
||||||
|
self.assertEqual(sync.yggdrasil_address(key.public_key().public_bytes_raw()), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordVerification(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.node = Node("482913", owner="alice")
|
||||||
|
|
||||||
|
def test_valid_record(self):
|
||||||
|
number, key, address, owner = sync.verified_record(self.node.member())
|
||||||
|
self.assertEqual((number, key, address, owner), ("482913", self.node.public.hex(), self.node.address, "alice"))
|
||||||
|
|
||||||
|
def test_record_from_foreign_address_is_rejected(self):
|
||||||
|
other = Node("111111")
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not derive"):
|
||||||
|
sync.verified_record(self.node.member(addr=f"[{other.address}]:{PORT}"))
|
||||||
|
|
||||||
|
def test_signature_over_other_number_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "bad signature"):
|
||||||
|
sync.verified_record(self.node.member(number="999999"))
|
||||||
|
|
||||||
|
def test_garbage_signature_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "malformed"):
|
||||||
|
sync.verified_record(self.node.member(sig="not base64!"))
|
||||||
|
|
||||||
|
def test_missing_tags_are_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "malformed"):
|
||||||
|
sync.verified_record({"name": "foreign", "addr": f"[{self.node.address}]:{PORT}", "tags": {}})
|
||||||
|
|
||||||
|
def test_number_must_be_six_digits_without_leading_zero(self):
|
||||||
|
for bad in ["012345", "12345", "1234567", "12a456", ""]:
|
||||||
|
node = Node(bad)
|
||||||
|
with self.assertRaisesRegex(ValueError, "invalid number", msg=bad):
|
||||||
|
sync.verified_record(node.member())
|
||||||
|
|
||||||
|
def test_owner_defaults_to_empty(self):
|
||||||
|
member = self.node.member()
|
||||||
|
del member["tags"]["owner"]
|
||||||
|
self.assertEqual(sync.verified_record(member)[3], "")
|
||||||
|
|
||||||
|
|
||||||
|
class Directory(unittest.TestCase):
|
||||||
|
def test_invalid_records_are_skipped_not_fatal(self):
|
||||||
|
good = Node("482913", owner="alice")
|
||||||
|
bad = Node("555555")
|
||||||
|
members = [bad.member(sig="AAAA"), good.member(), {"name": "x", "addr": "[200::1]:7946", "tags": {}}]
|
||||||
|
self.assertEqual(sync.directory(members), {"482913": (good.public.hex(), good.address, "alice")})
|
||||||
|
|
||||||
|
def test_collision_lowest_key_wins_regardless_of_order(self):
|
||||||
|
a, b = Node("482913"), Node("482913")
|
||||||
|
winner = min((a, b), key=lambda n: n.public.hex())
|
||||||
|
for members in ([a.member("a"), b.member("b")], [b.member("b"), a.member("a")]):
|
||||||
|
self.assertEqual(sync.directory(members)["482913"][1], winner.address)
|
||||||
|
|
||||||
|
|
||||||
|
class Main(unittest.TestCase):
|
||||||
|
def run_sync(self, members: list[dict], state: str) -> None:
|
||||||
|
bindir = tempfile.mkdtemp()
|
||||||
|
with open(os.path.join(bindir, "serf"), "w") as f:
|
||||||
|
f.write("#!/bin/sh\ncat <<'EOF'\n" + json.dumps({"members": members}) + "\nEOF\n")
|
||||||
|
os.chmod(os.path.join(bindir, "serf"), 0o755)
|
||||||
|
env_path = os.environ["PATH"]
|
||||||
|
os.environ["PATH"] = bindir + os.pathsep + env_path
|
||||||
|
argv = sys.argv
|
||||||
|
sys.argv = ["phonebox-sync", "127.0.0.1:7373", state]
|
||||||
|
try:
|
||||||
|
sync.main()
|
||||||
|
finally:
|
||||||
|
sys.argv = argv
|
||||||
|
os.environ["PATH"] = env_path
|
||||||
|
|
||||||
|
def test_writes_numbers_and_contacts_and_removes_stale(self):
|
||||||
|
state = tempfile.mkdtemp()
|
||||||
|
os.makedirs(os.path.join(state, "numbers"))
|
||||||
|
with open(os.path.join(state, "numbers", "111111"), "w") as f:
|
||||||
|
f.write("200::dead")
|
||||||
|
a, b = Node("482913", owner="alice"), Node("555555", owner="bob")
|
||||||
|
self.run_sync([b.member("b"), a.member("a")], state)
|
||||||
|
|
||||||
|
numbers = os.path.join(state, "numbers")
|
||||||
|
self.assertEqual(sorted(os.listdir(numbers)), ["482913", "555555"])
|
||||||
|
with open(os.path.join(numbers, "482913")) as f:
|
||||||
|
self.assertEqual(f.read(), a.address) # no trailing newline: read by FILE() in the dialplan
|
||||||
|
with open(os.path.join(state, "contacts.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "482913\t\t: \t\talice\n555555\t\t: \t\tbob\n")
|
||||||
|
|
||||||
|
def test_empty_membership_clears_directory(self):
|
||||||
|
state = tempfile.mkdtemp()
|
||||||
|
self.run_sync([Node("482913").member()], state)
|
||||||
|
self.run_sync([], state)
|
||||||
|
self.assertEqual(os.listdir(os.path.join(state, "numbers")), [])
|
||||||
|
with open(os.path.join(state, "contacts.txt")) as f:
|
||||||
|
self.assertEqual(f.read(), "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
hostPkgs,
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
name = "service-phonebox-global";
|
||||||
|
result.update-vars =
|
||||||
|
let
|
||||||
|
relativeDir = lib.removePrefix "${self}/" (toString config.clan.directory);
|
||||||
|
in
|
||||||
|
hostPkgs.writeShellScriptBin "update-vars" ''
|
||||||
|
set -x
|
||||||
|
export PRJ_ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
${
|
||||||
|
self.inputs.clan-core.packages.${hostPkgs.system}.clan-cli
|
||||||
|
}/bin/clan-generate-test-vars $PRJ_ROOT/${relativeDir} ${config.name}
|
||||||
|
'';
|
||||||
|
|
||||||
|
clan = {
|
||||||
|
test.useContainers = false;
|
||||||
|
directory = ./.;
|
||||||
|
inventory = {
|
||||||
|
machines.server = { };
|
||||||
|
machines.nodeA = { };
|
||||||
|
|
||||||
|
instances = {
|
||||||
|
yggdrasil = {
|
||||||
|
module.name = "yggdrasil";
|
||||||
|
roles.default.machines.server = { };
|
||||||
|
roles.default.machines.nodeA = { };
|
||||||
|
};
|
||||||
|
phonebox-global-test = {
|
||||||
|
module.name = "@clan/phonebox-global";
|
||||||
|
module.input = "self";
|
||||||
|
roles.default.machines.server.settings.ata-ethernet-iface = "enp2s0";
|
||||||
|
roles.default.machines.nodeA.settings.ata-ethernet-iface = "enp2s0";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
server = { };
|
||||||
|
nodeA = { };
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript =
|
||||||
|
{ nodes, ... }:
|
||||||
|
let
|
||||||
|
number = node: nodes.${node}.clan.core.vars.generators.phonebox-global.files.number.value;
|
||||||
|
address = node: nodes.${node}.clan.core.vars.generators.yggdrasil.files.address.value;
|
||||||
|
in
|
||||||
|
''
|
||||||
|
start_all()
|
||||||
|
|
||||||
|
for node in [server, nodeA]:
|
||||||
|
node.wait_for_unit("asterisk.service")
|
||||||
|
node.wait_for_unit("phonebox-directory.service")
|
||||||
|
|
||||||
|
# Directory converges: each node learns the other's number and address.
|
||||||
|
server.wait_until_succeeds("test -f /run/phonebox/numbers/${number "nodeA"}", timeout=300)
|
||||||
|
nodeA.wait_until_succeeds("test -f /run/phonebox/numbers/${number "server"}", timeout=300)
|
||||||
|
assert server.succeed("cat /run/phonebox/numbers/${number "nodeA"}") == "${address "nodeA"}"
|
||||||
|
assert nodeA.succeed("cat /run/phonebox/numbers/${number "server"}") == "${address "server"}"
|
||||||
|
assert "${number "nodeA"}" in server.succeed("cat /run/phonebox/contacts.txt")
|
||||||
|
|
||||||
|
# A call from server to nodeA's number is routed over yggdrasil and
|
||||||
|
# accepted by nodeA's asterisk (no phone is registered, so it fails
|
||||||
|
# after being identified, which is enough to prove the path).
|
||||||
|
nodeA.succeed("asterisk -rx 'pjsip set logger on'")
|
||||||
|
server.succeed("asterisk -rx 'channel originate Local/${number "nodeA"}@from-internal application Wait 3'")
|
||||||
|
nodeA.wait_until_succeeds("grep -q 'INVITE sip:${number "nodeA"}@' /var/log/asterisk/full", timeout=60)
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age18yf7r0dalnue5vwzanxl046fxsskhu9lzvd488ajhz7rp62mu94scmw3w4",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"publickey": "age1sy8xkmssttcchjj0nkapk4tu530lrzszdp4lka6nn4gzlxam6e2s3xzp2j",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:NkOeX6FboG7NOkdgiTgAsS+XjRoDU+AEYfMhpZXIJTYEM2dQti+PMGyizKRvr1wRnSGMp56XaX0bHdToBuf1gYruqAsEkKwqsYw=,iv:RFyO5/JybroNAKe+F3vv51l4OEu5XXNs+biTTH8poEg=,tag:zfPm0ZAjT2CC734EU+36KQ==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2RklrTFQxMll5VUxiaGVu\nNlRzeldTRnVrSG93TXYzc1YrRE9Nc1VqTHpNCjh4aHJMZmtpM2tDclNaT3d2SDhN\na1QxTXVFOHRuY3dpL1YxMTJKK3pUVmMKLS0tIHoyMGVyc01vak5EKzZONC9XUmFw\nMU5MRmx6UDROQzFiaTNuNXFiQTZoYmcK6wxypuP7vTnz//EcEt2pUNqUuKxjOaY3\nAmKToJQADJfzTsYGzpmWkkYaMHvG00v8Ja6TF6IxZr5SxUh4bdHr+w==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-09-18T02:02:10Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:kRpbcT5+I80iHPjPUnveQE9jpa65n0xPPQR/by4p1iM80ZKk9KH0cObB5hHhXO4BD1OqI79UJ3O9O7Jhmylgx5Uh8RNbK6wf26p4GO2Bvqh9r15NqbCZtXkoW30MTLLIs58Q7NeK9O2LgeckWrXLtyEytfhnTuoTSDFo8Sh8NQY=,iv:rcNow1Z4BN7bo1XERB6c+G6Gwl3tRU1VHvYmoDaLTl4=,tag:vy/5EB/1gw/KLLqXHSEV3g==,type:str]",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../users/admin
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:Sy9T0zEgSGZ/qkz5kaRN2qlH2X+pFRibzb8FpWuknuSI/9lActSG2Loq4LhmhqPGC5MGVkQcz2pcVFI7afUjzDzejfJf4De1ka4=,iv:JNLXguWEwJXR2+WNl0v0nUvkESVAbccfeQSUijKbj2I=,tag:/AAFKncO+B/XmUZYTQnTTw==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqOXJoSXgvcEFXK2hWTlpi\nQWhCdnZPUCt5R2xEWFYxUnpWVlc1ei93TncwCjNKaVB0MFN1VUdheEwyUTBzcmFT\nb0RjTHp2MGE4cWIraCtZME9rQXAvVEkKLS0tIFpiZDhCN0NIY3FWTnlXaFhiMmtC\nTFJMUncvMnFRSEhNdHlhaWozcjlqYWsKLDH1VFG/QS3VIMn6Iwo2NmY5kdBiOPNR\nj4sOY6xBpWP5AiGUypGoX4Bm52gLgW5AdcBRIxxhFOekIv5G8wmRaw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-09-18T02:02:23Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:DN97Dcpy29IZkBbwFVnMxnUnWYrgLKuvbANabkMCdpOJYgI1msYODltHJlR68Lk8Fmg25sXysvdbJOnc2cMH3pgcKxeyfhdAFwlIXdie1HRtTpZql15RF0fwPBGJjwL9YYubpZU7K4cNflGbjuOCEhp8bQOUYY8Ptd2deiDGGuw=,iv:bxTcKLUu6fcz7JKzStKiakgBeNx7aWCw6M7HolthfEo=,tag:/0nWBkdCPkpcWSpcfY/TfA==,type:str]",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../users/admin
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
392268
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
fake_line_value
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cf28bbcd822efec197dfaa99fbe9004f78b4efa9724b4f453d05f12a61a0bb44
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
CrnzGbqfVORt00zeI99crpBRUMfGF+2uEfaHe1L3Y8k7CIojc+8TID40HPhwEgXNtC9/xr61mN5L0Vp4vpviBA==
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
26.11
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
200:61ae:8864:fba2:27c:d040:aacc:82d
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/machines/nodeA
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:hR33SvxgsU+hZnvF2hoQxE7n0IB6kLC9MgK0wS0NXpCht5NuPjTo5p51oUE8t95Dht2qlEdXAkGO39k/h42p54vhqcfuF7u/eqVMdX6pEJyOokFmsdDmdVZziyhZghrTI/nDlctxCPcqFkeCHU4SIBZs85NS8uk=,iv:Q8k8OXSS3lFw33UO8JiZYNkrH3s+Cvh0xnAbMiBmnNA=,tag:DtNYKgDYB2nSHIjvMdvu/w==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTSzk1RUhIekVJRStQd3Er\nbXlHUmtVb201dkRteVR1VFpoMTVFcTFPSG5NCmlPcVM5cWVJQ0g0VHNXaS9rTzh2\nWTIyQnRXakVCRWRQL0xuVUdDOEV0WEUKLS0tIE43RENWL1M2STF1VzdMcHJTNThh\ndHpqNE9MMnkrMlIxN2FEcjZ6anRIbDAK3rZ1lzO42bH37lb+zoeF6rKoVgI0TBST\n9EoVwom5xOKtmVAh5jobY/z4TGSD1BqQ6P2rQ/IM1Dtw3/18Yk7TXQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age18yf7r0dalnue5vwzanxl046fxsskhu9lzvd488ajhz7rp62mu94scmw3w4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkdDFsSTZHUG54ZWNtQVdj\nZno4enBaRVlaQU1wNTBEVDVscS9Jc1JaTXhjClA3YUNQa3B2L0JXY25SSHRGbS94\nNVVNdHZ5VzdXcWpXZG1ZS1U4T0NPUkEKLS0tIFJLVmZyazU4dWUyVnpvRDRWbHNn\nZFQ3dlhxN0lwc0loWEZIOE0yMElRV0EKeUVy2QpLHbJ8JUCl07f74V+ZnRkGfDdP\n5W28yiLuq+LYfKUaFYeRDvSVFCQo6FiYJcBPGmnPF5gg1BIUtUUBYw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-09-18T02:02:10Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:Oq1UCi+VYuuAkMmVBife740m6fiXC+PfkaQAii+FORyv8Zpj938cPrZzE9z/LO5Ixye5cbUHGRhZr0vM1egnv9nhIFyfGinKyE/BLEOXHxOtHnAXSQOJU2bWiR6w/QzVCTy6vTTVnqD2AtEfbKndIK1PJ0ASLvxMGaRqRsA/juE=,iv:xCEQg6DHV6hbyfBk62bUKA1lnC3HgQtkn2o8i2CFjhs=,tag:Zvt9tomYGoDFDQGjLZC59A==,type:str]",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/users/admin
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cf28bbcd822efec197dfaa99fbe9004f78b4efa9724b4f453d05f12a61a0bb44
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
981794
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
fake_line_value
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7291692f9a94948724377fc22055edde8e2773a581153317bc20bd5779736647
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
IwAOYcmClqi8K8EStthYMqWCr6WsxM3nD0CLDRvvb7I3CUyihXBjdghXLutDD/bFsHKzOkDVV/gLnTuRj9IEAg==
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
26.11
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
201:35ba:5b41:95ad:ade3:6f22:f7:7ea8
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/machines/server
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"data": "ENC[AES256_GCM,data:H1ngxnRS+cFNSxe9K7C/xNS5hzXyeTWBLZI18TPvEyT5l/MSBeRuK6FZ/G0xUGQAJw4Xo1nyX2PZEYH5wjhnG0UziKAZeM2Oe0G/ilpNbHeMS+HLVriDETlMQz2Hf4Y1J6jOZqSTgFuPMVG5armpZUYTcW+p2zo=,iv:u9sO6eiFzxtT7uImnHLXT3aYJJxgby8d5H2ckfZdNIw=,tag:ZLl7AHHRF+jmr0tnujrpIg==,type:str]",
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzdy9HVVFoa0N5ZnBDVDhM\ncm1vQzR3MUpSbW9KSjE0YmZueUlsY1gvUVc4CmlqaTBxMTNvZzdmdkJSMmMwN01N\nVGsvUzJ5Q055Tmk2L2ZUNjRpSHp0bE0KLS0tIGJhUUNDbGh1a09aeXMzWkl4K0F0\nZ0dYY1FnNCtkVXRVTVkyeXZPbFlVbUUK3a/V6XP6m8MDYPKwlu8z0cr4inuKGGNc\nYJjQw00ou3BdM/HOPZrMjYHtjg5WdtzYiFCOl8IRkeiDaEmXcOBjiw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSbW9lcXZweHZ6b0F1eVIy\nd2RWV2lkOHpoM2RkZWxSVEFwU1h5aXNMWldnCk5lS1E4b1BDMmRYUDNHK2NhTlVT\nWGpLWm4rUnpFVWNvMHNzclJuemNGZkEKLS0tIGZxcmowODUydm1kL24yVHd4YXl4\nWVd5eWZMZ0dyS2dpTDhuNXo4S25Ha0EKvc3hwrmlP5JcH7eQeRfoq40w/QdnQL8s\n+DAwsajzRp6H3/XVTm+nDQ+Qoc3aenewZkk6Pgn+x43hAh9zwuvGkA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age1sy8xkmssttcchjj0nkapk4tu530lrzszdp4lka6nn4gzlxam6e2s3xzp2j"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-09-18T02:02:24Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:xrshyxQAnlSORTzxxcpt0ABaNWSCCVnwHl6oPUQ59reumF7BoAy8HmdSyWeEpIKBNo2Ane1xxXBHa62+w58r0WjcneNzy8yIBBwXr1BIRZPX83HrSz3+bENPtj6TPWdHpFZ5aDFM2jVlUvwIkn/4g+1B9FnQtoH8kuYBGe2uGCM=,iv:slMt0ag3Kp3iD89jQa/YDta4mJccKiSqcTCt8C+1Dzs=,tag:o56eg/BSHOtBUeNQqRjQDg==,type:str]",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../sops/users/admin
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7291692f9a94948724377fc22055edde8e2773a581153317bc20bd5779736647
|
||||||
Reference in New Issue
Block a user