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.
584 lines
21 KiB
Nix
584 lines
21 KiB
Nix
{
|
|
clanLib,
|
|
...
|
|
}:
|
|
{
|
|
_class = "clan.service";
|
|
manifest.name = "phonebox";
|
|
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.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: opening the yggdrasil interface to non-clan nodes is only implemented for the iptables firewall backend";
|
|
}
|
|
];
|
|
|
|
clan.core.vars.generators.phonebox = {
|
|
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
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|