Files
infra/modules/clan/phonebox-global/default.nix
T
kurogeek f398daacb5 clanService/phonebox-global: decentralized phone network over yggdrasil
New service alongside phonebox. Boxes get a random 6 digit number
(100000-999999) signed by the machine's yggdrasil key. A serf gossip
cluster on yggdrasil port 7946 exchanges the signed records;
phonebox-sync verifies key -> address derivation and the signature and
writes the number -> address map to /run/phonebox/numbers for the
asterisk dialplan. Any yggdrasil node running the service can reach any
other; clan-core's ygg-input filter is opened for SIP, RTP and gossip.

Includes a two-node VM test proving directory convergence and a call
over yggdrasil. The existing phonebox service is untouched.
2026-09-18 02:10:05 +00:00

584 lines
21 KiB
Nix

{
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
'';
};
};
};
};
}