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