diff --git a/flake.lock b/flake.lock index c58a5fe..e8ea372 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,50 @@ { "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1744478979, + "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43975d782b418ebf4969e9ccba82466728c2851b", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, "disko": { "inputs": { "nixpkgs": [ @@ -20,6 +65,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1774709303, @@ -38,9 +104,25 @@ }, "root": { "inputs": { + "agenix": "agenix", "disko": "disko", "nixpkgs": "nixpkgs" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index ed22d64..8370871 100644 --- a/flake.nix +++ b/flake.nix @@ -5,15 +5,22 @@ url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; }; + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, disko, ... }: + outputs = { self, nixpkgs, disko, agenix, ... }: let + pkgs = import nixpkgs { system = "x86_64-linux"; }; + # Helper to build a NixOS host config from hosts// mkHost = name: system: nixpkgs.lib.nixosSystem { modules = [ { nixpkgs.hostPlatform = system; } disko.nixosModules.disko + agenix.nixosModules.default ./modules/common.nix ./hosts/${name} ]; @@ -28,10 +35,17 @@ (name: cfg: mkHost name cfg.system) hosts; + devShells.x86_64-linux.default = pkgs.mkShell { + packages = [ + pkgs.colmena + agenix.packages.x86_64-linux.default + ]; + }; + # colmena hive for ongoing deployments colmena = { meta = { - nixpkgs = import nixpkgs { system = "x86_64-linux"; }; # fallback for colmena internals + nixpkgs = pkgs; specialArgs = { inherit disko; }; }; } // nixpkgs.lib.mapAttrs (name: cfg: { @@ -43,6 +57,7 @@ imports = [ { nixpkgs.hostPlatform = cfg.system; } disko.nixosModules.disko + agenix.nixosModules.default ./modules/common.nix ./hosts/${name} ]; diff --git a/hosts/telefonmann/default.nix b/hosts/telefonmann/default.nix index 6e15a81..8006dcc 100644 --- a/hosts/telefonmann/default.nix +++ b/hosts/telefonmann/default.nix @@ -1,4 +1,4 @@ -{ ... }: { +{ config, ... }: { imports = [ ./hardware.nix ./disko.nix @@ -20,25 +20,46 @@ serverAddress = "10.0.10.2"; ntpServer = "10.0.10.1"; - phones = { + # directoryName = "tel.baubs.net"; # shown in phone directory title and HTML page header + # directoryPort = 8080; # HTTP port for directory, intercom XML, and status page + # sipPort = 5060; # SIP TCP/UDP port + # rtpStart = 10000; # RTP port range start + # rtpEnd = 20000; # RTP port range end + + sharedPhones = { "e0899d946ccc" = { - model = "cisco-8961"; - extension = "100"; - label = "Küchentelefon"; - password = "changeme100"; - voicemailTimeout = 10; + model = "cisco-8961"; + extension = "20"; + displayName = "Küche"; + label = "Küchentelefon"; # shown on the phone screen (max ~12 chars) + password = "changeme100"; }; "e0899d947650" = { - model = "cisco-8961"; - extension = "102"; - label = "Flur"; - password = "changeme100"; - voicemailTimeout = 10; + model = "cisco-8961"; + extension = "22"; + displayName = "Flur"; + label = "Flur"; + password = "changeme100"; }; - "101" = { - model = "sip-client"; - extension = "101"; - password = "changeme101"; + "fromschofon" = { + model = "sip-client"; + extension = "23"; + displayName = "Frosch"; + label = "Frosch"; + password = "changeme102"; + }; + }; + + persons = { + "jannel" = { + extension = "21"; + displayName = "Jannel"; + mailbox = true; + # ringTimeout = 30; # seconds to ring before going to voicemail + mailboxGreeting = ./greetings/anrufbeantworter.wav; + phones = { + "jannel-mobile" = { model = "sip-client"; password = "changeme101"; }; + }; }; }; @@ -46,16 +67,107 @@ "Wombel" = ./backgrounds/wombel.png; }; - intercomPrefix = "*80"; + intercomPrefix = "*80"; # generates e.g. *8020, *8021, *8022 per extension + + codecs = { + trunk = [ "g722" "alaw" "ulaw" ]; + # hardwarePhones = [ "g722" "alaw" "ulaw" "ilbc" ]; # default; Cisco 8961 supported set + # softClients = [ "opus" "g722" "alaw" "ulaw" ]; # default; opus first for best quality + }; + + mohClasses = { + "default" = { + files = [ ./music/vapor.mp3 ]; + sort = "random"; + # sort = "alphabetical"; + }; + # add more classes here and reference them per-DID via musicOnHold = "classname" + }; + + sipTrunks = { + "ewe1" = { + hostFile = config.age.secrets."voip-trunk-ewe-host".path; + usernameFile = config.age.secrets."voip-trunk-ewe1-username".path; + passwordFile = config.age.secrets."voip-trunk-ewe1-password".path; + callerIdFile = config.age.secrets."voip-trunk-ewe1-callerid".path; + }; + "ewe2" = { + hostFile = config.age.secrets."voip-trunk-ewe-host".path; + usernameFile = config.age.secrets."voip-trunk-ewe2-username".path; + passwordFile = config.age.secrets."voip-trunk-ewe2-password".path; + callerIdFile = config.age.secrets."voip-trunk-ewe2-callerid".path; + }; + "ewe3" = { + hostFile = config.age.secrets."voip-trunk-ewe-host".path; + usernameFile = config.age.secrets."voip-trunk-ewe3-username".path; + passwordFile = config.age.secrets."voip-trunk-ewe3-password".path; + callerIdFile = config.age.secrets."voip-trunk-ewe3-callerid".path; + }; + }; + + sharedMailbox = { + mailboxId = "200"; + checkExtension = "*98"; + displayName = "Baubse"; + greeting = ./greetings/anrufbeantworter.wav; + }; + + dids = { + "baubse" = { + numberFile = config.age.secrets."voip-trunk-ewe1-callerid".path; + trunk = "ewe1"; + displayName = "Baubse"; + routing = { + type = "all"; # ring all phones (sharedPhones + persons) on their L2 line + # timeout = 30; # seconds to ring before falling through to mailbox + }; + mailbox = "shared"; # → sharedMailbox on no answer + musicOnHold = "default"; + }; + "jannel" = { + numberFile = config.age.secrets."voip-trunk-ewe2-callerid".path; + trunk = "ewe2"; + displayName = "Jannel"; + routing = { + type = "person"; + person = "jannel"; # ring jannel's phones on their L1 line + # timeout = 30; + }; + mailbox = "person"; # → jannel's personal mailbox on no answer + # musicOnHold = "default"; + }; + # ewe3 DID — uncomment and fill in number when known: + # "ewe3-main" = { + # number = ""; # or: numberFile = ./secrets/did-ewe3; + # trunk = "ewe3"; + # displayName = "..."; + # routing = { type = "all"; }; + # mailbox = "shared"; + # }; + }; extensions = { - "100" = { displayName = "Küche"; }; - "101" = { displayName = "101"; }; - "102" = { displayName = "Flur"; }; "*99" = { mode = "page"; displayName = "Durchsage an alle"; }; - "999" = { mode = "app"; app = "Playback(hello-world)"; }; + # custom app extension example: + # "*00" = { mode = "app"; displayName = "Echo test"; app = "Echo()"; }; }; }; - deployment.targetHost = "telefonmann"; # or IP address + deployment.targetHost = "telefonmann"; + + # Age-encrypted secrets (decrypted on the host at activation time). + age.secrets = + let asteriskSecret = file: { inherit file; owner = "asterisk"; group = "voip-keys"; mode = "0640"; }; + in { + "voip-trunk-ewe-host" = asteriskSecret ../../secrets/voip-trunk-ewe-host.age; + "voip-trunk-ewe1-username" = asteriskSecret ../../secrets/voip-trunk-ewe1-username.age; + "voip-trunk-ewe1-password" = asteriskSecret ../../secrets/voip-trunk-ewe1-password.age; + "voip-trunk-ewe1-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe1-callerid.age; + "voip-trunk-ewe2-username" = asteriskSecret ../../secrets/voip-trunk-ewe2-username.age; + "voip-trunk-ewe2-password" = asteriskSecret ../../secrets/voip-trunk-ewe2-password.age; + "voip-trunk-ewe2-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe2-callerid.age; + "voip-trunk-ewe3-username" = asteriskSecret ../../secrets/voip-trunk-ewe3-username.age; + "voip-trunk-ewe3-password" = asteriskSecret ../../secrets/voip-trunk-ewe3-password.age; + "voip-trunk-ewe3-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe3-callerid.age; + }; } diff --git a/hosts/telefonmann/greetings/anrufbeantworter.wav b/hosts/telefonmann/greetings/anrufbeantworter.wav new file mode 100644 index 0000000..322d5e7 Binary files /dev/null and b/hosts/telefonmann/greetings/anrufbeantworter.wav differ diff --git a/hosts/telefonmann/music/vapor.mp3 b/hosts/telefonmann/music/vapor.mp3 new file mode 100644 index 0000000..8be555f Binary files /dev/null and b/hosts/telefonmann/music/vapor.mp3 differ diff --git a/modules/voip/asterisk.nix b/modules/voip/asterisk.nix index 949546e..0060c45 100644 --- a/modules/voip/asterisk.nix +++ b/modules/voip/asterisk.nix @@ -1,11 +1,162 @@ -{ lib, cfg, models, intercomEntries }: +{ lib, cfg, models, allPhones, intercomEntries, mohDirs, greetingDirs }: let - # Phones that have voicemail enabled - vmPhones = lib.filterAttrs (_: phone: phone.voicemailTimeout != null) cfg.phones; - hasVoicemail = vmPhones != {}; + hasTrunk = cfg.sipTrunks != {}; - pjsip = '' + # Produces either "prefix" (static) or a #exec that reads the file at runtime. + # Use this wherever a config value can be supplied either inline or from a key file. + runtimeLine = prefix: val: file: + if file != null + then ''#exec echo "${prefix}$(cat ${file})"'' + else "${prefix}${val}"; + + hasSharedMailbox = cfg.sharedMailbox != null; + + hasPersonalMailboxes = lib.any (p: p.mailboxExt != null) (lib.attrValues allPhones); + hasAnyMailbox = hasPersonalMailboxes || hasSharedMailbox; + + # Helper to produce a literal Asterisk variable reference like ${EXTEN} + # when used inside a Nix string interpolation: ${av "EXTEN"} → ${EXTEN} + av = name: "\${${name}}"; + + # Group phone keys by extension number (multiple phones can share one extension) + phonesByExtension = lib.foldlAttrs (acc: key: phone: + acc // { ${phone.extension} = (acc.${phone.extension} or []) ++ [ key ]; } + ) {} allPhones; + + # Per-extension metadata derived from allPhones (all phones of the same extension + # share the same personKey and mailboxExt by construction) + extensionInfo = lib.mapAttrs (_ext: keys: + let sample = allPhones.${lib.head keys}; in { + inherit keys; + mailboxExt = sample.mailboxExt; + ringTimeout = + if sample.personKey != null + then cfg.persons.${sample.personKey}.ringTimeout + else 30; + } + ) phonesByExtension; + + # L2 only makes sense for person phones: they need separate lines for + # personal vs family calls and have distinct mailbox MWI per line. + # Shared phones (no personal mailbox) and sip-clients use L1 for everything. + phoneHasL2 = key: + let phone = allPhones.${key}; + in phone.personKey != null && models.${phone.model}.hasProvisioning; + + # Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once) + dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}"; + + # Dial target strings + allL2Endpoints = lib.concatStringsSep "&" + (map dialTarget (lib.attrNames allPhones)); + + personL1Endpoints = personKey: + lib.concatStringsSep "&" + (map (key: "PJSIP/${key}") + (lib.attrNames cfg.persons.${personKey}.phones)); + + personsL2Endpoints = personKeys: + lib.concatStringsSep "&" + (lib.concatMap (personKey: + map dialTarget (lib.attrNames cfg.persons.${personKey}.phones) + ) personKeys); + + # --- Outbound trunk resolution --- + # Find the first DID matching a predicate, returning { id, trunk } or null. + findDid = pred: + lib.foldlAttrs (acc: id: didCfg: + if acc != null then acc + else if pred didCfg then { inherit id; inherit (didCfg) trunk; } + else null + ) null cfg.dids; + + # The shared/family DID: routing.type = "all" + allDid = findDid (d: d.routing.type == "all"); + + # Personal DID for a given person key + personDid = personKey: findDid (d: d.routing.type == "person" && d.routing.person == personKey); + + # Outbound assignment for an L1 endpoint: personal DID if person, else allDid + l1OutboundFor = phone: + if phone.personKey != null + then let pd = personDid phone.personKey; + in if pd != null then pd else allDid + else allDid; + + # Render set_var lines for outbound trunk + caller ID, or empty string if no DID found + outboundVars = did: + if did == null then "" + else + let t = cfg.sipTrunks.${did.trunk}; in + "set_var=OUTBOUND_TRUNK=trunk-${did.trunk}-endpoint\n " + + lib.optionalString (t.callerId != "" || t.callerIdFile != null) + (runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n "); + + # Page: only provisioned phones with an intercom line (auto-answer speakerphone). + # sip-clients have no dedicated intercom endpoint and are excluded. + allPageEndpoints = lib.concatStringsSep "&" + (lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom") + (lib.filterAttrs (key: phone: + models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null + ) allPhones)); + + # --- PJSIP endpoint generators --- + + genL1Endpoint = key: phone: + let + m = models.${phone.model}; + # Personal mailbox takes priority; shared phones watch the shared mailbox + effectiveMailbox = + if phone.mailboxExt != null then phone.mailboxExt + else if hasSharedMailbox then cfg.sharedMailbox.mailboxId + else null; + mailboxLine = lib.optionalString (effectiveMailbox != null) + "mailboxes = ${effectiveMailbox}@voicemail\n "; + vmVar = lib.optionalString (effectiveMailbox != null) + "set_var=VOICEMAIL_MAILBOX=${effectiveMailbox}\n "; + obVars = lib.optionalString hasTrunk (outboundVars (l1OutboundFor phone)); + in '' + [${key}](${m.endpointTemplate}) + auth = auth-${key} + aors = ${key} + ${mailboxLine}${vmVar}${obVars} + [auth-${key}](auth-userpass) + username = ${key} + password = ${phone.password} + + [${key}] + type = aor + max_contacts = ${toString m.maxContacts} + remove_existing = yes + ''; + + genL2Endpoint = key: phone: + let + m = models.${phone.model}; + k = "${key}-l2"; + mailboxLine = lib.optionalString hasSharedMailbox + "mailboxes = ${cfg.sharedMailbox.mailboxId}@voicemail\n "; + vmVar = lib.optionalString hasSharedMailbox + "set_var=VOICEMAIL_MAILBOX=${cfg.sharedMailbox.mailboxId}\n "; + obVars = outboundVars allDid; + in '' + [${k}](${m.endpointTemplate}) + auth = auth-${k} + aors = ${k} + ${mailboxLine}${vmVar}${obVars} + [auth-${k}](auth-userpass) + username = ${k} + password = ${phone.password} + + [${k}] + type = aor + max_contacts = ${toString m.maxContacts} + remove_existing = yes + ''; + + pjsip = + '' [transport-tcp] type = transport protocol = tcp @@ -21,50 +172,38 @@ let context = internal transport = transport-tcp disallow = all - allow = ulaw - allow = alaw - allow = g722 - allow = g726 - allow = ilbc - allow = gsm - direct_media = no + ${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.hardwarePhones}direct_media = no trust_id_inbound = yes + send_pai = yes [endpoint-generic](!) type = endpoint context = internal transport = transport-tcp disallow = all - allow = ulaw - allow = alaw - allow = g722 - direct_media = no + ${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.softClients}direct_media = no + send_pai = yes [auth-userpass](!) type = auth auth_type = userpass - ; --- phones --- + ; --- phones (L1) --- - '' + lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone: - let m = models.${phone.model}; in '' - [${key}](${m.endpointTemplate}) - auth = auth-${key} - aors = ${key} - ${lib.optionalString (phone.voicemailTimeout != null) "mailboxes = ${phone.extension}@voicemail\n set_var=VOICEMAIL_MAILBOX=${phone.extension}"} + '' + + lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones) - [auth-${key}](auth-userpass) - username = ${key} - password = ${phone.password} + + lib.optionalString hasTrunk ( + let provisionedPhones = lib.filterAttrs (key: _: phoneHasL2 key) allPhones; in + lib.optionalString (provisionedPhones != {}) ( + "\n ; --- family line endpoints (L2, provisioned phones only) ---\n\n" + + lib.concatStringsSep "\n" (lib.mapAttrsToList genL2Endpoint provisionedPhones) + ) + ) - [${key}] - type = aor - max_contacts = ${toString m.maxContacts} - remove_existing = yes - '') cfg.phones) - - + lib.concatMapStringsSep "\n" (ic: '' + + lib.concatMapStringsSep "\n" (ic: '' + ; --- intercom --- [${ic.endpoint}](${ic.endpointTemplate}) auth = auth-${ic.endpoint} aors = ${ic.endpoint} @@ -77,43 +216,146 @@ let type = aor max_contacts = ${toString ic.maxContacts} remove_existing = yes - '') intercomEntries; + '') intercomEntries - # Reverse map: extension number -> pjsip endpoint key - extensionToEndpoint = lib.foldlAttrs (acc: key: phone: - acc // { ${phone.extension} = key; } - ) {} cfg.phones; + + lib.optionalString hasTrunk ( + "\n ; --- SIP trunks ---\n" + + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: '' - # All page endpoints: intercom line for provisioned phones, regular for others - allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: phone: - let m = models.${phone.model}; in - if m.hasProvisioning && cfg.intercomPrefix != null - then "PJSIP/${key}-intercom" - else "PJSIP/${key}" - ) cfg.phones); + [trunk-${name}-registration] + type = registration + outbound_auth = trunk-${name}-auth + ${runtimeLine "server_uri = sip:" t.host t.hostFile} + ${let u = if t.usernameFile != null then "$(cat ${t.usernameFile})" else t.username; + h = if t.hostFile != null then "$(cat ${t.hostFile})" else t.host; + in if t.usernameFile != null || t.hostFile != null + then ''#exec echo "client_uri = sip:${u}@${h}"'' + else "client_uri = sip:${u}@${h}"} + retry_interval = 60 + forbidden_retry_interval = 600 + expiration = 3600 - extensions = '' - [internal] - '' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: - if extCfg.mode == "app" - then "exten => ${ext},1,${extCfg.app}" - else if extCfg.mode == "page" - then "exten => ${ext},1,Page(${allPageEndpoints},i,120)" - else if lib.hasAttr ext extensionToEndpoint - then + [trunk-${name}-auth] + type = auth + auth_type = userpass + ${runtimeLine "username = " t.username t.usernameFile} + ${runtimeLine "password = " t.password t.passwordFile} + + [trunk-${name}-endpoint] + type = endpoint + context = from-external + transport = transport-${t.transport} + disallow = all + outbound_auth = trunk-${name}-auth + aors = trunk-${name}-aor + trust_id_inbound = yes + disallow = all + ${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.trunk} + + [trunk-${name}-aor] + type = aor + ${runtimeLine "contact = sip:" t.host t.hostFile} + + [trunk-${name}-identify] + type = identify + endpoint = trunk-${name}-endpoint + ${runtimeLine "match = " t.host t.hostFile} + '') cfg.sipTrunks)); + + # --- Dialplan --- + + internalContext = + "[internal]\n" + # Line extensions (auto-generated from allPhones, grouped by extension) + + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info: + let dialStr = lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys); in + if info.mailboxExt != null + then ''exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout}) + same => n,VoiceMail(${info.mailboxExt}@voicemail,u)'' + else "exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})" + ) extensionInfo) + + # Page and app extensions from cfg.extensions + + "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: + if extCfg.mode == "page" + then "exten => ${ext},1,Page(${allPageEndpoints},i,120)" + else "exten => ${ext},1,${extCfg.app}" + ) cfg.extensions) + + # Auto-generated intercom extensions + + "\n" + lib.concatMapStringsSep "\n" (ic: + "exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)" + ) intercomEntries + + # Voicemail check (*97 — uses VOICEMAIL_MAILBOX set on the endpoint) + + lib.optionalString hasAnyMailbox + "\nexten => *97,1,VoiceMailMain(${av "VOICEMAIL_MAILBOX"}@voicemail,sa(0))" + # Shared mailbox direct check extension + + lib.optionalString hasSharedMailbox + "\nexten => ${cfg.sharedMailbox.checkExtension},1,VoiceMailMain(${cfg.sharedMailbox.mailboxId}@voicemail,sa(0))" + + # Outbound — trunk and caller ID determined by set_var on the originating endpoint + + lib.optionalString hasTrunk '' + +exten => _0.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) + same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) +exten => _00.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) + same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) +exten => _+.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) + same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) +''; + + externalContext = lib.optionalString hasTrunk ( + '' + +[from-external] +; Provider sent INVITE without DID in Request-URI — extract from To header. +; To: +; CUT by ':' field 2 → +49123456789@provider.example.com +; CUT by '@' field 1 → +49123456789 +exten => s,1,Set(DID=${av "PJSIP_HEADER(read,To)"}) + same => n,Set(DID=${av "CUT(DID,:,2)"}) + same => n,Set(DID=${av "CUT(DID,@,1)"}) + same => n,Goto(from-external,${av "DID"},1) + same => n,Hangup(21) +'' + + lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg: let - phoneKey = extensionToEndpoint.${ext}; - phone = cfg.phones.${phoneKey}; - in if phone.voicemailTimeout != null - then ''exten => ${ext},1,Dial(PJSIP/${phoneKey},${toString phone.voicemailTimeout}) - same => n,VoiceMail(${ext}@voicemail,u)'' - else "exten => ${ext},1,Dial(PJSIP/${phoneKey})" - else "exten => ${ext},1,Hangup() ; WARNING: no endpoint assigned to extension ${ext}" - ) cfg.extensions) - + "\n" + lib.concatMapStringsSep "\n" (ic: - "exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)" - ) intercomEntries - + lib.optionalString hasVoicemail "\nexten => *97,1,VoiceMailMain(\${VOICEMAIL_MAILBOX}@voicemail,sa(0))"; + r = didCfg.routing; + dialStr = + if r.type == "all" then allL2Endpoints + else if r.type == "person" then personL1Endpoints r.person + else personsL2Endpoints r.persons; + mohOpt = lib.optionalString (didCfg.musicOnHold != null) "m(${didCfg.musicOnHold})"; + dialArgs = "${dialStr},${toString r.timeout},${mohOpt}"; + noAnsLine = + if didCfg.mailbox == "shared" then + let gdir = greetingDirs.shared; in + lib.optionalString (gdir != null) + "\n same => n,Playback(${gdir}/greeting)" + + "\n same => n,VoiceMail(${cfg.sharedMailbox.mailboxId}@voicemail,${if gdir != null then "s" else "u"})" + else if didCfg.mailbox == "person" then + let + person = cfg.persons.${r.person}; + gdir = greetingDirs.persons.${r.person}; + in + lib.optionalString (gdir != null) + "\n same => n,Playback(${gdir}/greeting)" + + "\n same => n,VoiceMail(${person.extension}@voicemail,${if gdir != null then "s" else "u"})" + else ""; + # Static number: inline it. Runtime number: #exec reads the file and outputs the exten line. + # noAnsLine follows as static text — Asterisk processes #exec output inline with the file, + # so "same =>" on the next line correctly refers to the exten => emitted by #exec. + extenLine = + if didCfg.numberFile != null + then "#exec echo 'exten => '$(cat ${didCfg.numberFile})',1,Dial(${dialArgs})'" + else "exten => ${didCfg.number},1,Dial(${dialArgs})"; + in + "; ${didCfg.displayName} (${id}, trunk: ${didCfg.trunk})\n${extenLine}${noAnsLine}" + ) cfg.dids) + ); + + extensions = internalContext + externalContext; rtp = '' [general] @@ -126,15 +368,31 @@ let format = ulaw [voicemail] - '' + lib.concatStringsSep "\n" (lib.mapAttrsToList (_: phone: - let displayName = cfg.extensions.${phone.extension}.displayName; in - " ${phone.extension} => ,${displayName},,attach=no" - ) vmPhones); + '' + lib.concatStringsSep "\n" ( + lib.mapAttrsToList (_key: person: + lib.optionalString person.mailbox + " ${person.extension} => ,${person.displayName},,attach=no" + ) cfg.persons + ++ lib.optional hasSharedMailbox + " ${cfg.sharedMailbox.mailboxId} => ,${cfg.sharedMailbox.displayName},,attach=no" + ); + + musiconhold = lib.optionalString (cfg.mohClasses != {}) ( + "[general]\n" + + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: '' + [${name}] + mode = files + directory = ${mohDirs.${name}} + sort = ${cls.sort} + '') cfg.mohClasses) + ); in { "pjsip.conf" = pjsip; "extensions.conf" = extensions; "rtp.conf" = rtp; -} // lib.optionalAttrs hasVoicemail { - "voicemail.conf" = voicemail; +} // lib.optionalAttrs hasAnyMailbox { + "voicemail.conf" = voicemail; +} // lib.optionalAttrs (cfg.mohClasses != {}) { + "musiconhold.conf" = musiconhold; } diff --git a/modules/voip/backgrounds.nix b/modules/voip/backgrounds.nix index 1435234..d434adb 100644 --- a/modules/voip/backgrounds.nix +++ b/modules/voip/backgrounds.nix @@ -1,4 +1,4 @@ -{ lib, pkgs, cfg, models }: +{ lib, pkgs, cfg, models, allPhones }: let # Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models @@ -6,7 +6,7 @@ let (lib.mapAttrsToList (_: phone: let m = models.${phone.model}; in { desktop = m.desktopSize; thumbnail = m.thumbnailSize; } - ) cfg.phones)); + ) allPhones)); # Parse "WxH" or "WxHxD" into width and height parseDimensions = size: diff --git a/modules/voip/default.nix b/modules/voip/default.nix index 59cd73b..9d9a3e9 100644 --- a/modules/voip/default.nix +++ b/modules/voip/default.nix @@ -8,9 +8,10 @@ let # 2. For provisioned models, add a template in ./templates/.nix # # Template interface — all provisioned model templates receive these args: - # Required: mac, label, password, displayName, serverAddress, ntpServer + # Required: mac, label, displayName, password, serverAddress, ntpServer # Optional: sipPort (default 5060), directoryPort (default 8080), - # intercomEnabled (default false), intercomPassword (default "") + # intercomEnabled (default false), intercomPassword (default ""), + # familyLineEnabled (default false), familyLineLabel (default "Familie") models = { "cisco-8961" = { endpointTemplate = "endpoint-cisco-8961"; @@ -30,11 +31,89 @@ let }; }; - intercomEntries = import ./intercom.nix { inherit lib cfg models; }; - confFiles = import ./asterisk.nix { inherit lib cfg models intercomEntries; }; - directory = import ./directory.nix { inherit lib pkgs cfg intercomEntries; }; - backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models; }; - tftpRoot = import ./tftp.nix { inherit lib pkgs cfg models backgroundEntries; }; + # Unified view of all physical devices, keyed by SIP identity (MAC or username). + # Each entry carries the fields needed by sub-modules without them having to + # know about sharedPhones vs persons. + allPhones = + lib.mapAttrs (key: p: { + inherit (p) model label password; + extension = p.extension; + displayName = p.displayName; + personKey = null; + mailboxExt = null; # shared phones have no personal mailbox + }) cfg.sharedPhones + // + lib.foldlAttrs (acc: personKey: person: + acc // lib.mapAttrs (_key: ph: { + inherit (ph) model label password; + extension = person.extension; + displayName = person.displayName; + personKey = personKey; + mailboxExt = if person.mailbox then person.extension else null; + }) person.phones + ) {} cfg.persons; + + mohDirs = import ./moh.nix { inherit lib pkgs cfg; }; + greetingDirs = import ./greetings.nix { inherit lib pkgs cfg; }; + intercomEntries = import ./intercom.nix { inherit lib cfg models allPhones; }; + confFiles = import ./asterisk.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; }; + + # True when any *File option is set — Asterisk's execincludes=yes is required in that case. + hasRuntimeSecrets = + lib.any (t: t.hostFile != null || t.usernameFile != null || t.passwordFile != null || t.callerIdFile != null) + (lib.attrValues cfg.sipTrunks) + || lib.any (d: d.numberFile != null) (lib.attrValues cfg.dids); + + # Nginx Lua handler: reads the static HTML template and substitutes every + # @@/path/to/keyfile@@ marker with the file's first line at request time. + luaPageHandler = pkgs.writeText "voip-page.lua" '' + local f = assert(io.open("${diagram.webRoot}/index.html", "rb")) + local html = f:read("*a") + f:close() + -- Placeholders embed the full key file path: @@/var/lib/voip-keys/name@@ + html = html:gsub("@@([^@]+)@@", function(path) + local kf = io.open(path, "r") + if not kf then return "(not yet uploaded)" end + local val = kf:read("*l") + kf:close() + return val or "" + end) + ngx.header.content_type = "text/html; charset=utf-8" + ngx.print(html) + ''; + directory = import ./directory.nix { inherit lib pkgs cfg allPhones intercomEntries; }; + backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models allPhones; }; + provisioningRoot = import ./provisioning.nix { inherit lib pkgs cfg models allPhones backgroundEntries; }; + diagram = import ./diagram.nix { inherit lib pkgs cfg models allPhones intercomEntries; }; + + # Shared option set for a physical phone device. + # isPersonPhone = true → no extension/displayName fields (inherited from person) + # isPersonPhone = false → includes extension and displayName + phoneDeviceOptions = isPersonPhone: { + model = lib.mkOption { + type = lib.types.enum (lib.attrNames models); + description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file)."; + }; + label = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Label shown on the phone screen (max ~12 chars for Cisco). Required for provisioned hardware phones."; + }; + password = lib.mkOption { + type = lib.types.str; + description = "SIP registration password."; + }; + } // lib.optionalAttrs (!isPersonPhone) { + extension = lib.mkOption { + type = lib.types.str; + description = "Internal extension number for this shared phone."; + }; + displayName = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Name shown in the phone directory."; + }; + }; in { options.services.voip = { @@ -89,36 +168,58 @@ in { type = lib.types.attrsOf lib.types.path; }; - phones = lib.mkOption { + sharedPhones = lib.mkOption { default = {}; description = '' - Attrset of phones/clients keyed by SIP identity (username). - For hardware phones (cisco-8961), the key must be the lowercase MAC address (no colons). + Shared/location phones not assigned to a specific person (e.g. hallway, kitchen). + These have their own extension but no personal voicemail mailbox. + For cisco-8961, the key must be the lowercase MAC address (no colons). For sip-client, the key is a free-form username. ''; + type = lib.types.attrsOf (lib.types.submodule { + options = phoneDeviceOptions false; + }); + }; + + persons = lib.mkOption { + default = {}; + description = "People with personal extensions, optional voicemail mailboxes, and their own phones."; type = lib.types.attrsOf (lib.types.submodule { options = { - model = lib.mkOption { - type = lib.types.enum (lib.attrNames models); - description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file)."; - }; extension = lib.mkOption { type = lib.types.str; - description = "Extension number this phone registers as."; + description = "Personal extension number."; }; - label = lib.mkOption { + displayName = lib.mkOption { type = lib.types.str; default = ""; - description = "Label shown on the phone screen. Required for provisioned hardware phones."; + description = "Name shown in the directory and on caller ID."; }; - password = lib.mkOption { - type = lib.types.str; - description = "SIP registration password."; + mailbox = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this person gets a personal voicemail mailbox."; }; - voicemailTimeout = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; + ringTimeout = lib.mkOption { + type = lib.types.ints.positive; + default = 30; + description = "Seconds to ring before going to voicemail (or hanging up if no mailbox)."; + }; + mailboxGreeting = lib.mkOption { + type = lib.types.nullOr lib.types.path; default = null; - description = "Seconds to ring before sending to voicemail. null disables voicemail for this phone."; + description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement."; + }; + phones = lib.mkOption { + default = {}; + description = '' + Phones belonging to this person, keyed by SIP identity. + For cisco-8961, the key must be the lowercase MAC address (no colons). + For sip-client, the key is a free-form username. + ''; + type = lib.types.attrsOf (lib.types.submodule { + options = phoneDeviceOptions true; + }); }; }; }); @@ -130,20 +231,219 @@ in { description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries."; }; + codecs = lib.mkOption { + description = "Codec preference lists for each endpoint class, ordered highest priority first."; + default = {}; + type = lib.types.submodule { + options = { + hardwarePhones = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "g722" "alaw" "ulaw" "ilbc" ]; + description = "Codecs for provisioned hardware phones (e.g. Cisco 8961). Supports G.722, G.711, iLBC. Opus and G.726 not supported. G.729 supported but not useful on LAN."; + }; + softClients = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "opus" "g722" "alaw" "ulaw" ]; + description = "Codecs for software SIP clients. Opus first for best quality on modern softphones."; + }; + trunk = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "alaw" "ulaw" ]; + description = "Codecs offered to SIP trunks. Most providers only support G.711."; + }; + }; + }; + }; + + mohClasses = lib.mkOption { + default = {}; + description = "Music on hold classes. Files are transcoded to ulaw at build time."; + type = lib.types.attrsOf (lib.types.submodule { + options = { + files = lib.mkOption { + type = lib.types.listOf lib.types.path; + description = "Audio files to play (MP3, WAV, etc). Transcoded to 8kHz ulaw automatically."; + }; + sort = lib.mkOption { + type = lib.types.enum [ "random" "alphabetical" ]; + default = "random"; + }; + }; + }); + }; + + sipTrunks = lib.mkOption { + default = {}; + description = "External SIP provider trunks, keyed by a short name (e.g. \"provider-a\")."; + type = lib.types.attrsOf (lib.types.submodule { + options = { + host = lib.mkOption { + type = lib.types.str; + default = ""; + description = "SIP provider hostname or IP address. Use hostFile to read from a file."; + }; + hostFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the SIP provider hostname. Takes precedence over host."; + }; + username = lib.mkOption { + type = lib.types.str; + default = ""; + description = "SIP account username. Use usernameFile to read from a file."; + }; + usernameFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the SIP account username. Takes precedence over username."; + }; + password = lib.mkOption { + type = lib.types.str; + default = ""; + description = "SIP account password. Use passwordFile to read from a file."; + }; + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the SIP account password. Takes precedence over password."; + }; + transport = lib.mkOption { + type = lib.types.enum [ "udp" "tcp" ]; + default = "udp"; + }; + callerId = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Outbound caller ID number presented to the provider. Leave empty to use provider default. Use callerIdFile to read from a file."; + }; + callerIdFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the outbound caller ID. Takes precedence over callerId."; + }; + }; + }); + }; + + sharedMailbox = lib.mkOption { + default = null; + description = "Shared voicemail mailbox accessible by all phones (family answering machine)."; + type = lib.types.nullOr (lib.types.submodule { + options = { + mailboxId = lib.mkOption { + type = lib.types.str; + default = "200"; + description = "Numeric mailbox ID used internally in voicemail.conf and VoiceMail(). Must be numeric."; + }; + checkExtension = lib.mkOption { + type = lib.types.str; + default = "*98"; + description = "Extension dialled to check this shared mailbox directly (via VoiceMailMain)."; + }; + displayName = lib.mkOption { + type = lib.types.str; + default = "Shared"; + description = "Name shown in voicemail configuration."; + }; + greeting = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement."; + }; + }; + }); + }; + + dids = lib.mkOption { + default = {}; + description = "Inbound DID routing. Each DID must reference a key from sipTrunks."; + type = lib.types.attrsOf (lib.types.submodule { + options = { + number = lib.mkOption { + type = lib.types.str; + default = ""; + description = "DID number in E.164 format (e.g. \"+4912345678\"). Use numberFile to read from a file."; + }; + numberFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the DID number. Takes precedence over number."; + }; + trunk = lib.mkOption { + type = lib.types.str; + description = "Key of the sipTrunks entry this DID arrives on."; + }; + displayName = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Human-readable label for this DID (informational only)."; + }; + routing = lib.mkOption { + description = "How inbound calls on this DID are distributed to phones."; + type = lib.types.submodule { + options = { + type = lib.mkOption { + type = lib.types.enum [ "all" "person" "persons" ]; + description = '' + all — ring all phones (sharedPhones + all persons) on their L2 line + person — ring a single person on their L1 line + persons — ring a list of persons on their L2 line + ''; + }; + person = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Person key for routing.type = \"person\"."; + }; + persons = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Person keys for routing.type = \"persons\"."; + }; + timeout = lib.mkOption { + type = lib.types.ints.positive; + default = 30; + description = "Seconds to ring before going to voicemail (or hanging up)."; + }; + }; + }; + }; + mailbox = lib.mkOption { + type = lib.types.enum [ "shared" "person" "none" ]; + default = "shared"; + description = '' + shared — go to sharedMailbox on no answer (requires sharedMailbox to be set) + person — go to the routed person's mailbox on no answer (only valid with routing.type = "person") + none — hang up on no answer + ''; + }; + musicOnHold = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + MOH class name to play to the caller while phones ring, instead of ringback. + Must match a key in mohClasses. null = standard ringback. + ''; + }; + }; + }); + }; + extensions = lib.mkOption { default = {}; - description = "Attrset of extensions keyed by extension number."; + description = '' + Extra extensions: page groups and custom app entries. + Line extensions are auto-generated from sharedPhones and persons — do not declare them here. + ''; type = lib.types.attrsOf (lib.types.submodule { options = { mode = lib.mkOption { - type = lib.types.enum [ "line" "page" "app" ]; - default = "line"; + type = lib.types.enum [ "page" "app" ]; + default = "page"; description = '' Extension mode: - - "line": dials the phone assigned to this extension - "page": one-way announcement to all phones - "app": custom Asterisk dialplan application - Intercom extensions are auto-generated when intercomPrefix is set. ''; }; displayName = lib.mkOption { @@ -164,80 +464,204 @@ in { services.voip.ntpServer = lib.mkDefault cfg.serverAddress; - assertions = - # Every phone's extension must be declared - (lib.mapAttrsToList (key: phone: { - assertion = lib.hasAttr phone.extension cfg.extensions; - message = "services.voip: phone \"${key}\" references extension ${phone.extension} which is not declared in services.voip.extensions"; - }) cfg.phones) - ++ - # Provisioned phones require a MAC address key + # Provisioned sharedPhones require a MAC address key (lib.mapAttrsToList (key: phone: { assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null; - message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key"; - }) cfg.phones) + message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key"; + }) cfg.sharedPhones) ++ - # Provisioned phones require a non-empty label + # Provisioned sharedPhones require a non-empty label (lib.mapAttrsToList (key: phone: { assertion = !models.${phone.model}.hasProvisioning || phone.label != ""; - message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a non-empty label"; - }) cfg.phones) + message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label"; + }) cfg.sharedPhones) ++ - # Provisioned phones require a template - (lib.mapAttrsToList (key: phone: { - assertion = !models.${phone.model}.hasProvisioning || models.${phone.model}.template != null; - message = "services.voip: phone \"${key}\" model \"${phone.model}\" has hasProvisioning=true but no template defined"; - }) cfg.phones) + # Provisioned person phones require a MAC address key + (lib.concatLists (lib.mapAttrsToList (personKey: person: + lib.mapAttrsToList (key: phone: { + assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null; + message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key"; + }) person.phones + ) cfg.persons)) ++ - # intercomPrefix must not collide with user-declared extensions + # Provisioned person phones require a non-empty label + (lib.concatLists (lib.mapAttrsToList (personKey: person: + lib.mapAttrsToList (key: phone: { + assertion = !models.${phone.model}.hasProvisioning || phone.label != ""; + message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a non-empty label"; + }) person.phones + ) cfg.persons)) + ++ + # No duplicate phone keys across sharedPhones and persons.*.phones + [{ + assertion = + let keys = lib.attrNames allPhones; + in lib.length keys == lib.length (lib.unique keys); + message = "services.voip: duplicate phone key detected across sharedPhones and persons.*.phones"; + }] + ++ + # No duplicate extensions across sharedPhones and persons + [{ + assertion = + let + exts = (lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones) + ++ (lib.mapAttrsToList (_: p: p.extension) cfg.persons); + in lib.length exts == lib.length (lib.unique exts); + message = "services.voip: duplicate extension number across sharedPhones and persons"; + }] + ++ + # dids require at least one sipTrunk + (lib.optionals (cfg.dids != {}) [{ + assertion = cfg.sipTrunks != {}; + message = "services.voip: dids are configured but sipTrunks is empty"; + }]) + ++ + # each DID must reference an existing trunk + (lib.mapAttrsToList (did: didCfg: { + assertion = lib.hasAttr didCfg.trunk cfg.sipTrunks; + message = "services.voip: DID ${did} references trunk \"${didCfg.trunk}\" which is not in services.voip.sipTrunks"; + }) cfg.dids) + ++ + # dids with mailbox="shared" require sharedMailbox + (lib.mapAttrsToList (did: didCfg: { + assertion = didCfg.mailbox != "shared" || cfg.sharedMailbox != null; + message = "services.voip: DID ${did} has mailbox=\"shared\" but sharedMailbox is not configured"; + }) cfg.dids) + ++ + # dids with mailbox="person" require routing.type="person" + (lib.mapAttrsToList (did: didCfg: { + assertion = didCfg.mailbox != "person" || didCfg.routing.type == "person"; + message = "services.voip: DID ${did} has mailbox=\"person\" but routing.type is not \"person\""; + }) cfg.dids) + ++ + # dids routing.type="person" — person key must be non-empty + (lib.mapAttrsToList (did: didCfg: { + assertion = didCfg.routing.type != "person" || didCfg.routing.person != ""; + message = "services.voip: DID ${did} has routing.type=\"person\" but routing.person is not set"; + }) cfg.dids) + ++ + # dids routing.type="person" — referenced person must exist + (lib.mapAttrsToList (did: didCfg: { + assertion = didCfg.routing.type != "person" || didCfg.routing.person == "" || lib.hasAttr didCfg.routing.person cfg.persons; + message = "services.voip: DID ${did} references person \"${didCfg.routing.person}\" which is not in services.voip.persons"; + }) cfg.dids) + ++ + # dids routing.type="persons" — persons list must be non-empty + (lib.mapAttrsToList (did: didCfg: { + assertion = didCfg.routing.type != "persons" || didCfg.routing.persons != []; + message = "services.voip: DID ${did} has routing.type=\"persons\" but routing.persons is empty"; + }) cfg.dids) + ++ + # dids routing.type="persons" — all referenced persons must exist + (lib.concatLists (lib.mapAttrsToList (did: didCfg: + lib.optionals (didCfg.routing.type == "persons") + (map (p: { + assertion = lib.hasAttr p cfg.persons; + message = "services.voip: DID ${did} references person \"${p}\" which is not in services.voip.persons"; + }) didCfg.routing.persons) + ) cfg.dids)) + ++ + # dids musicOnHold must reference an existing mohClass + (lib.concatLists (lib.mapAttrsToList (did: didCfg: + lib.optional (didCfg.musicOnHold != null) { + assertion = lib.hasAttr didCfg.musicOnHold cfg.mohClasses; + message = "services.voip: DID ${did} references mohClass \"${didCfg.musicOnHold}\" which is not in services.voip.mohClasses"; + } + ) cfg.dids)) + ++ + # sipTrunks: each required field needs either a literal or a file + (lib.concatLists (lib.mapAttrsToList (name: t: [ + { assertion = t.host != "" || t.hostFile != null; + message = "services.voip: sipTrunks.\"${name}\" requires host or hostFile"; } + { assertion = t.username != "" || t.usernameFile != null; + message = "services.voip: sipTrunks.\"${name}\" requires username or usernameFile"; } + { assertion = t.password != "" || t.passwordFile != null; + message = "services.voip: sipTrunks.\"${name}\" requires password or passwordFile"; } + ]) cfg.sipTrunks)) + ++ + # dids: each DID needs a number either inline or from a file + (lib.mapAttrsToList (id: d: { + assertion = d.number != "" || d.numberFile != null; + message = "services.voip: dids.\"${id}\" requires number or numberFile"; + }) cfg.dids) + ++ + # extensions with mode="app" must have a non-null app field + (lib.mapAttrsToList (ext: extCfg: { + assertion = extCfg.mode != "app" || extCfg.app != null; + message = "services.voip: extension \"${ext}\" has mode=\"app\" but app is not set"; + }) cfg.extensions) + ++ + # intercomPrefix must not collide with any declared extension (lib.optionals (cfg.intercomPrefix != null) - (lib.mapAttrsToList (key: phone: + (lib.mapAttrsToList (_key: phone: let ext = "${cfg.intercomPrefix}${phone.extension}"; in { assertion = !lib.hasAttr ext cfg.extensions; message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix"; } - ) (lib.filterAttrs (_: phone: models.${phone.model}.hasProvisioning) cfg.phones))); + ) allPhones)); services.asterisk = { - enable = true; - confFiles = confFiles; + enable = true; + confFiles = confFiles; + # execincludes=yes is required when any *File option is in use. + extraConfig = lib.optionalString hasRuntimeSecrets '' + [options] + execincludes=yes + ''; }; services.atftpd = { enable = true; - root = "${tftpRoot}"; + root = "${provisioningRoot}"; extraOptions = [ "--verbose=7" ]; }; services.nginx = { - enable = true; + enable = true; + # OpenResty bundles nginx + LuaJIT + resty.core and all required libraries. + # Needed for request-time secret substitution in the status page. + package = lib.mkIf hasRuntimeSecrets (lib.mkDefault pkgs.openresty); + # Cisco phones fetch provisioning files (SEP*.cnf.xml, dialplan-*.xml, + # backgrounds) over TFTP (primary) and HTTP port 6970 (fallback). + # Both serve the same Nix-built provisioning root. + virtualHosts."voip-provisioning" = { + listen = [{ addr = "0.0.0.0"; port = 6970; }]; + locations."/" = { + root = "${provisioningRoot}"; + extraConfig = "autoindex off;"; + }; + }; virtualHosts."voip-directory" = { listen = [{ addr = "0.0.0.0"; port = cfg.directoryPort; }]; - locations."= /directory.xml" = { - alias = "${directory.menuFile}"; - extraConfig = "default_type text/xml;"; - }; - locations."= /directory-list.xml" = { - alias = "${directory.listFile}"; - extraConfig = "default_type text/xml;"; - }; - locations."= /intercom.xml" = { - alias = "${directory.intercomFile}"; - extraConfig = "default_type text/xml;"; - }; - locations."= /voicemail.xml" = { - alias = "${directory.voicemailFile}"; - extraConfig = "default_type text/xml;"; + locations = { + "= /directory.xml" = { alias = "${directory.menuFile}"; extraConfig = "default_type text/xml;"; }; + "= /directory-list.xml" = { alias = "${directory.listFile}"; extraConfig = "default_type text/xml;"; }; + "= /intercom.xml" = { alias = "${directory.intercomFile}"; extraConfig = "default_type text/xml;"; }; + "/" = { + root = "${diagram.webRoot}"; + extraConfig = lib.optionalString (!hasRuntimeSecrets) "index index.html;"; + }; + } // lib.optionalAttrs hasRuntimeSecrets { + # Exact-match the index so the Lua handler intercepts it before the + # prefix location /. Other assets (voip.dot, SVG) fall through to /. + "= /" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; }; + "= /index.html" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; }; }; }; }; + # voip-keys group: both asterisk (#exec reads) and nginx (Lua reads) need access. + # Key files must be deployed with group = "voip-keys" and permissions = "0640". + users.groups.voip-keys = {}; + users.users.asterisk.extraGroups = [ "voip-keys" ]; + users.users.nginx.extraGroups = [ "voip-keys" ]; + systemd.tmpfiles.rules = [ "d /var/log/nginx 0750 nginx nginx -" ]; networking.firewall = { - allowedTCPPorts = [ cfg.sipPort cfg.directoryPort ]; - allowedUDPPorts = [ 69 ]; + allowedTCPPorts = [ cfg.sipPort cfg.directoryPort 6970 ]; + allowedUDPPorts = [ cfg.sipPort 69 ]; allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }]; }; }; diff --git a/modules/voip/diagram.nix b/modules/voip/diagram.nix new file mode 100644 index 0000000..9541ba2 --- /dev/null +++ b/modules/voip/diagram.nix @@ -0,0 +1,390 @@ +{ lib, pkgs, cfg, models, allPhones, intercomEntries }: + +let + hasTrunk = cfg.sipTrunks != {}; + + # For HTML table cells: produces either the literal value or a @@/full/path@@ + # marker that the nginx Lua handler opens directly at request time. + rtv = val: file: if file != null then "@@${file}@@" else val; + + # For DOT node labels (end up in SVG): never use @@ markers — graphviz may + # split long strings at hyphens across elements, breaking substitution + # and corrupting the SVG. Show the key filename as a static label instead. + rtvLabel = val: file: if file != null then baseNameOf file else val; + + # Sanitize a string for use as a DOT node identifier + nid = prefix: s: "${prefix}_${lib.replaceStrings + [ "-" "+" "*" "@" "." " " "/" ] + [ "_" "p" "s" "at" "_" "_" "_" ] s}"; + + # Whether a phone gets an L2 line (same logic as asterisk.nix) + phoneHasL2 = key: + let phone = allPhones.${key}; + in phone.personKey != null && models.${phone.model}.hasProvisioning; + + # ── Nodes ─────────────────────────────────────────────────────────────── + + trunkNodes = lib.mapAttrsToList (name: t: + '' ${nid "trunk" name} [label="${name}\n${rtvLabel t.host t.hostFile}" shape=box style="rounded,filled" fillcolor="#AED6F1"]'' + ) cfg.sipTrunks; + + didNodes = lib.mapAttrsToList (id: didCfg: + let numLabel = rtvLabel didCfg.number didCfg.numberFile; in + '' ${nid "did" id} [label="${numLabel}\n${didCfg.displayName}" shape=box style="rounded,filled" fillcolor="#FAD7A0"]'' + ) cfg.dids; + + personNodes = lib.mapAttrsToList (key: person: + '' ${nid "person" key} [label="${person.displayName}\next. ${person.extension}" shape=ellipse style=filled fillcolor="#A9DFBF"]'' + ) cfg.persons; + + phoneNodes = lib.mapAttrsToList (key: phone: + let + modelLabel = if phone.personKey != null then + cfg.persons.${phone.personKey}.phones.${key}.model + else + cfg.sharedPhones.${key}.model; + isSoftClient = modelLabel == "sip-client"; + style = if isSoftClient then "dashed,filled" else "filled"; + lines = + [ phone.displayName ] + ++ lib.optional (phone.personKey == null) "ext. ${phone.extension}" + ++ [ modelLabel ] + ++ lib.optional (!isSoftClient) "(${key})" + ++ lib.optional isSoftClient key; + in + '' ${nid "phone" key} [label="${lib.concatStringsSep "\\n" lines}" shape=box style="${style}" fillcolor="#D5D8DC"]'' + ) allPhones; + + mailboxNodes = + lib.optional (cfg.sharedMailbox != null) + '' ${nid "mbox" cfg.sharedMailbox.mailboxId} [label="${cfg.sharedMailbox.displayName}\nbox ${cfg.sharedMailbox.mailboxId}" shape=cylinder style=filled fillcolor="#D7BDE2"]'' + ++ lib.mapAttrsToList (key: person: + lib.optionalString person.mailbox + '' ${nid "mbox" person.extension} [label="${person.displayName}\nbox ${person.extension}" shape=cylinder style=filled fillcolor="#D7BDE2"]'' + ) cfg.persons; + + extensionNodes = lib.mapAttrsToList (ext: extCfg: + let icon = if extCfg.mode == "page" then "📢 " else "⚙ "; in + '' ${nid "ext" ext} [label="${ext}\n${extCfg.displayName}\n(${extCfg.mode})" shape=diamond style=filled fillcolor="#D6EAF8"]'' + ) cfg.extensions; + + intercomNodes = map (ic: + '' ${nid "ic" ic.extension} [label="${ic.extension}\n${ic.displayName}" shape=diamond style=filled fillcolor="#FDEBD0"]'' + ) intercomEntries; + + # ── Edges ─────────────────────────────────────────────────────────────── + + # Trunk → DID + trunkDidEdges = lib.mapAttrsToList (id: didCfg: + '' ${nid "trunk" didCfg.trunk} -> ${nid "did" id}'' + ) cfg.dids; + + # DID → phone targets (routing) + didRoutingEdges = lib.concatLists (lib.mapAttrsToList (id: didCfg: + let + r = didCfg.routing; + lbl = label: ''label="${label}" fontsize=9''; + edge = target: lineLabel: + '' ${nid "did" id} -> ${nid "phone" target} [${lbl lineLabel}]''; + in + if r.type == "all" then + lib.mapAttrsToList (key: _: + edge key (if phoneHasL2 key then "L2" else "L1") + ) allPhones + else if r.type == "person" then + lib.mapAttrsToList (key: _: edge key "L1") + cfg.persons.${r.person}.phones + else # persons + lib.concatMap (personKey: + lib.mapAttrsToList (key: _: edge key "L2") + cfg.persons.${personKey}.phones + ) r.persons + ) cfg.dids); + + # DID → mailbox (no-answer fallback, dashed) + didMailboxEdges = lib.mapAttrsToList (id: didCfg: + let + r = didCfg.routing; + dashAttr = ''style=dashed color="#999999" fontsize=9 label="no answer"''; + in + if didCfg.mailbox == "shared" then + '' ${nid "did" id} -> ${nid "mbox" cfg.sharedMailbox.mailboxId} [${dashAttr}]'' + else if didCfg.mailbox == "person" then + '' ${nid "did" id} -> ${nid "mbox" cfg.persons.${r.person}.extension} [${dashAttr}]'' + else "" + ) cfg.dids; + + # Person → their phones + personPhoneEdges = lib.concatLists (lib.mapAttrsToList (key: person: + lib.mapAttrsToList (pkey: _: + '' ${nid "person" key} -> ${nid "phone" pkey} [arrowhead=open]'' + ) person.phones + ) cfg.persons); + + # Intercom edges: phone → intercom extension + intercomEdges = map (ic: + '' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]'' + ) intercomEntries; + + # Page extension → all phones + pagePhones = lib.filterAttrs (key: phone: + models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null + ) allPhones; + + pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg: + lib.optionals (extCfg.mode == "page") + (lib.mapAttrsToList (key: _: + '' ${nid "ext" ext} -> ${nid "phone" key} [style=dotted color="#5D6D7E"]'' + ) pagePhones) + ) cfg.extensions); + + # ── Assemble DOT ──────────────────────────────────────────────────────── + + dotSource = '' + digraph voip { + rankdir=LR; + graph [fontname="Helvetica,Arial,sans-serif" fontsize=11 splines=ortho]; + node [fontname="Helvetica,Arial,sans-serif" fontsize=10]; + edge [fontname="Helvetica,Arial,sans-serif" fontsize=9]; + + subgraph cluster_trunks { + label="SIP Trunks"; style=filled; fillcolor="#EBF5FB"; + ${lib.concatStringsSep "\n" trunkNodes} + } + + subgraph cluster_dids { + label="DIDs (Inbound)"; style=filled; fillcolor="#FEF9E7"; + ${lib.concatStringsSep "\n" didNodes} + } + + ${lib.optionalString (cfg.persons != {}) '' + subgraph cluster_persons { + label="Persons"; style=filled; fillcolor="#EAFAF1"; + ${lib.concatStringsSep "\n" personNodes} + } + ''} + + subgraph cluster_phones { + label="Phones"; style=filled; fillcolor="#F2F3F4"; + ${lib.concatStringsSep "\n" phoneNodes} + } + + ${lib.optionalString (cfg.sharedMailbox != null || lib.any (p: p.mailbox) (lib.attrValues cfg.persons)) '' + subgraph cluster_mailboxes { + label="Voicemail"; style=filled; fillcolor="#F5EEF8"; + ${lib.concatStringsSep "\n" mailboxNodes} + } + ''} + + ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) '' + subgraph cluster_extensions { + label="Extensions"; style=filled; fillcolor="#EBF5FB"; + ${lib.concatStringsSep "\n" extensionNodes} + ${lib.concatStringsSep "\n" intercomNodes} + } + ''} + + // Trunk → DID + ${lib.concatStringsSep "\n" trunkDidEdges} + + // DID routing → phones + ${lib.concatStringsSep "\n" didRoutingEdges} + + // No-answer → mailbox + ${lib.concatStringsSep "\n" didMailboxEdges} + + // Person → phones + ${lib.concatStringsSep "\n" personPhoneEdges} + + // Intercom + ${lib.concatStringsSep "\n" intercomEdges} + + // Page + ${lib.concatStringsSep "\n" pageEdges} + } + ''; + + dotFile = pkgs.writeText "voip.dot" dotSource; + + svgFile = pkgs.runCommand "voip-diagram.svg" { + nativeBuildInputs = [ pkgs.graphviz ]; + } "dot -Tsvg ${dotFile} -o $out"; + + # HTML head — everything up to and including the diagram container opening tag + htmlHead = pkgs.writeText "voip-head.html" '' + + + + + + ${cfg.directoryName} — VoIP + + + +

${cfg.directoryName} — VoIP Routing

+
+
+

Routing Diagram

+
+ ''; + + # HTML tail — closes the diagram div and adds all info tables + htmlTail = pkgs.writeText "voip-tail.html" '' +
+
+ + ${lib.optionalString hasTrunk '' +
+

SIP Trunks

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: + let callerIdCell = + if t.callerIdFile != null || t.callerId != "" then rtv t.callerId t.callerIdFile + else "provider default"; + in + '''' + ) cfg.sipTrunks)} +
NameHostUsernameTransportCaller ID
${name}${rtv t.host t.hostFile}${rtv t.username t.usernameFile}${t.transport}${callerIdCell}
+
+ +
+

DIDs (Inbound)

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg: + let r = didCfg.routing; + numCell = if didCfg.numberFile != null then "@@${didCfg.numberFile}@@" + else if didCfg.number != "" then "${didCfg.number}" + else "(none)"; + routeStr = if r.type == "all" then "all phones (L2)" + else if r.type == "person" then "→ ${r.person} (L1)" + else "→ ${lib.concatStringsSep ", " r.persons} (L2)"; + mohCell = if didCfg.musicOnHold != null then didCfg.musicOnHold else "ringback"; + in + '''' + ) cfg.dids)} +
IDNumberNameTrunkRoutingTimeoutMailboxMusic on Hold
${id}${numCell}${didCfg.displayName}${didCfg.trunk}${routeStr}${toString didCfg.routing.timeout}s${didCfg.mailbox}${mohCell}
+
+ ''} + +
+

Persons

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: person: + let + devList = lib.concatStringsSep "
" (lib.mapAttrsToList (pkey: ph: + "${pkey} (${ph.model})" + ) person.phones); + mailboxCell = if person.mailbox then "ext. ${person.extension}" else "none"; + greetingCell = if person.mailboxGreeting != null then "custom" else "default"; + in + '''' + ) cfg.persons)} +
KeyNameExtensionMailboxRing timeoutGreetingDevices
${key}${person.displayName}${person.extension}${mailboxCell}${toString person.ringTimeout}s${greetingCell}${devList}
+
+ + ${lib.optionalString (cfg.sharedPhones != {}) '' +
+

Shared Phones

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone: + '''' + ) cfg.sharedPhones)} +
Key / MACNameExtensionModelLabel
${key}${phone.displayName}${phone.extension}${phone.model}${phone.label}
+
+ ''} + + ${lib.optionalString (cfg.sharedMailbox != null) '' +
+

Shared Mailbox

+ + + + + + + + +
NameMailbox IDCheck extensionGreeting
${cfg.sharedMailbox.displayName}${cfg.sharedMailbox.mailboxId}${cfg.sharedMailbox.checkExtension}${if cfg.sharedMailbox.greeting != null then "custom" else "default"}
+
+ ''} + + ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) '' +
+

Extensions

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: + let detail = if extCfg.mode == "page" then "all phones" else extCfg.app; in + '''' + ) cfg.extensions)} + ${lib.concatStringsSep "\n" (map (ic: + '''' + ) intercomEntries)} +
ExtensionNameTypeDetail
${ext}${extCfg.displayName}${extCfg.mode}${detail}
${ic.extension}${ic.displayName}intercom${ic.phoneKey}
+
+ ''} + + ${lib.optionalString (cfg.mohClasses != {}) '' +
+

Music on Hold

+ + + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: + '''' + ) cfg.mohClasses)} +
ClassFilesSort
${name}${toString (lib.length cls.files)}${cls.sort}
+
+ ''} + +
+

Codecs

+ + + + + +
Endpoint typePreference order
Hardware phones${lib.concatStringsSep " › " cfg.codecs.hardwarePhones}
Soft clients${lib.concatStringsSep " › " cfg.codecs.softClients}
Trunks${lib.concatStringsSep " › " cfg.codecs.trunk}
+
+ +
+

Server

+ + + + + + + ${lib.optionalString (cfg.intercomPrefix != null) + ''''} +
ParameterValue
Address${cfg.serverAddress}
SIP port${toString cfg.sipPort}
RTP range${toString cfg.rtpStart} – ${toString cfg.rtpEnd}
Directory port${toString cfg.directoryPort}
Intercom prefix${cfg.intercomPrefix}
+
+
+ + + ''; + + # Web root directory: index.html with inlined SVG (head + svg + tail), plus raw .dot + webRoot = pkgs.runCommand "voip-webroot" {} '' + mkdir -p $out + cat ${htmlHead} ${svgFile} ${htmlTail} > $out/index.html + cp ${dotFile} $out/voip.dot + ''; + +in { inherit webRoot svgFile dotFile; } diff --git a/modules/voip/directory.nix b/modules/voip/directory.nix index 4a84463..58cfbf6 100644 --- a/modules/voip/directory.nix +++ b/modules/voip/directory.nix @@ -1,11 +1,20 @@ -{ lib, pkgs, cfg, intercomEntries }: +{ lib, pkgs, cfg, allPhones, intercomEntries }: let baseUrl = "http://${cfg.serverAddress}:${toString cfg.directoryPort}"; hasPageExtensions = lib.any (e: e.mode == "page") (lib.attrValues cfg.extensions); + # Deduplicated directory entries: one per extension, using the displayName from + # allPhones (all phones sharing an extension have the same displayName). + extensionEntries = + lib.attrValues (lib.foldlAttrs (acc: _key: phone: + if lib.hasAttr phone.extension acc || phone.displayName == "" then acc + else acc // { ${phone.extension} = { inherit (phone) extension displayName; }; } + ) {} allPhones); + menuXml = '' + ${cfg.directoryName} Telefonbuch Ihre Wahl @@ -23,21 +32,22 @@ let ''; listXml = '' + ${cfg.directoryName} Telefonbuch Ihre Wahl - '' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: - lib.optionalString (extCfg.mode == "line" && extCfg.displayName != "") '' + '' + lib.concatMapStringsSep "\n" (e: '' - ${extCfg.displayName} - ${ext} + ${e.displayName} + ${e.extension} - '') cfg.extensions) + '') extensionEntries + '' ''; intercomXml = '' + Intercom / Durchsage Ihre Wahl @@ -59,14 +69,15 @@ let ''; voicemailMenuXml = '' + - + ''; in { - menuFile = pkgs.writeText "directory.xml" menuXml; - listFile = pkgs.writeText "directory-list.xml" listXml; - intercomFile = pkgs.writeText "intercom.xml" intercomXml; - voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml; + menuFile = pkgs.writeText "directory.xml" menuXml; + listFile = pkgs.writeText "directory-list.xml" listXml; + intercomFile = pkgs.writeText "intercom.xml" intercomXml; + voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml; } diff --git a/modules/voip/greetings.nix b/modules/voip/greetings.nix new file mode 100644 index 0000000..fff7221 --- /dev/null +++ b/modules/voip/greetings.nix @@ -0,0 +1,34 @@ +{ lib, pkgs, cfg }: + +# Transcode voicemail greeting audio files to multiple formats so Asterisk can +# play them without transcoding regardless of the channel's active codec. +# Playback() is called with the path minus extension; Asterisk finds the best. +# +# Returns: +# { shared : path | null — greeting dir for the shared mailbox +# , persons : { key : path | null } — greeting dir per person key +# } + +let + mkGreetingDir = name: src: + pkgs.runCommand "greeting-${name}" { + nativeBuildInputs = [ pkgs.ffmpeg ]; + } '' + mkdir -p $out + ffmpeg -i ${src} -ar 8000 -ac 1 -f mulaw $out/greeting.ulaw + ffmpeg -i ${src} -ar 8000 -ac 1 -f alaw $out/greeting.alaw + ffmpeg -i ${src} -ar 16000 -ac 1 -acodec adpcm_g722 $out/greeting.g722 + ''; + +in { + shared = + if cfg.sharedMailbox != null && cfg.sharedMailbox.greeting != null + then mkGreetingDir "shared" cfg.sharedMailbox.greeting + else null; + + persons = lib.mapAttrs (key: person: + if person.mailbox && person.mailboxGreeting != null + then mkGreetingDir key person.mailboxGreeting + else null + ) cfg.persons; +} diff --git a/modules/voip/intercom.nix b/modules/voip/intercom.nix index abf19e1..9123f13 100644 --- a/modules/voip/intercom.nix +++ b/modules/voip/intercom.nix @@ -1,20 +1,16 @@ -{ lib, cfg, models }: +{ lib, cfg, models, allPhones }: if cfg.intercomPrefix == null then [] else lib.concatLists (lib.mapAttrsToList (key: phone: - let - m = models.${phone.model}; - ext = phone.extension; - # cfg.extensions.${ext} is guaranteed to exist by the phone→extension assertion - extCfg = cfg.extensions.${ext}; - in lib.optional m.hasProvisioning { - extension = "${cfg.intercomPrefix}${ext}"; - endpoint = "${key}-intercom"; - phoneKey = key; - target = ext; - displayName = "Intercom ${extCfg.displayName}"; - password = phone.password; + let m = models.${phone.model}; in + lib.optional m.hasProvisioning { + extension = "${cfg.intercomPrefix}${phone.extension}"; + endpoint = "${key}-intercom"; + phoneKey = key; + target = phone.extension; + displayName = "Intercom ${phone.displayName}"; + password = phone.password; endpointTemplate = m.endpointTemplate; - maxContacts = m.maxContacts; + maxContacts = m.maxContacts; } -) cfg.phones) +) allPhones) diff --git a/modules/voip/moh.nix b/modules/voip/moh.nix new file mode 100644 index 0000000..91a7c87 --- /dev/null +++ b/modules/voip/moh.nix @@ -0,0 +1,23 @@ +{ lib, pkgs, cfg }: + +# Generate one directory per MOH class containing each source file transcoded +# to multiple formats. Asterisk's mode=files picks the best format at runtime +# based on the channel's active codec, avoiding transcoding where possible. +# +# Format variants per file: +# N.ulaw — G.711 μ-law 8kHz (universal narrowband) +# N.alaw — G.711 A-law 8kHz (European narrowband) +# N.g722 — G.722 16kHz (wideband, no transcoding for G.722 calls) + +lib.mapAttrs (className: cls: + pkgs.runCommand "moh-${className}" { + nativeBuildInputs = [ pkgs.ffmpeg ]; + } (lib.concatStringsSep "\n" ( + [ "mkdir -p $out" ] + ++ lib.concatLists (lib.imap1 (i: src: [ + "ffmpeg -i ${src} -ar 8000 -ac 1 -f mulaw $out/${toString i}.ulaw" + "ffmpeg -i ${src} -ar 8000 -ac 1 -f alaw $out/${toString i}.alaw" + "ffmpeg -i ${src} -ar 16000 -ac 1 -acodec adpcm_g722 $out/${toString i}.g722" + ]) cls.files) + )) +) cfg.mohClasses diff --git a/modules/voip/provisioning.nix b/modules/voip/provisioning.nix new file mode 100644 index 0000000..ab20c98 --- /dev/null +++ b/modules/voip/provisioning.nix @@ -0,0 +1,98 @@ +{ lib, pkgs, cfg, models, allPhones, backgroundEntries }: + +let + hasTrunk = cfg.sipTrunks != {}; + + # Page extensions for the intercom button auto-dial + pageExtension = + let pages = lib.attrNames (lib.filterAttrs (_: e: e.mode == "page") cfg.extensions); + in if pages != [] then lib.head pages else null; + + hasIntercomButton = cfg.intercomPrefix != null && pageExtension != null; + + # Collect all internal extension numbers to generate exact-match patterns. + # This tells the phone "this number is complete" so it dials immediately + # rather than waiting for the timeout or firing on the first digit. + allExtensions = lib.unique ( + lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones + ++ lib.mapAttrsToList (_: p: p.extension) cfg.persons + ); + + # Star extensions: intercom prefix + each extension, plus custom extensions. + allStarExtensions = + (lib.optionals (cfg.intercomPrefix != null) + (map (ext: "${cfg.intercomPrefix}${ext}") allExtensions)) + ++ lib.attrNames cfg.extensions + ++ lib.optional (cfg.sharedMailbox != null) cfg.sharedMailbox.checkExtension; + + # Generate the dial template for a phone, given its intercom lineIndex. + # The lineIndex is derived from familyLineEnabled — the same condition used + # in the Cisco template for button layout, so both are always in sync. + # + # Pattern evaluation: the phone tests templates top-to-bottom and dials as + # soon as digits match a pattern with timeout="0", or after the timeout + # elapses for patterns with timeout > 0. Explicit patterns must come before + # the catch-all "." so the phone doesn't fire early on a partial match. + mkDialplanXml = key: intercomLineIndex: + let + # Stable UUID derived from a hash of all inputs that affect this dialplan. + # Changes whenever extensions, trunk config, or intercom config changes, + # causing phones to reload the file. Format: 8-4-4-4-12 hex chars. + h = builtins.hashString "sha256" (builtins.toJSON { + inherit key allExtensions allStarExtensions hasTrunk hasIntercomButton intercomLineIndex; + }); + versionStamp = "${builtins.substring 0 8 h}-${builtins.substring 8 4 h}-${builtins.substring 12 4 h}-${builtins.substring 16 4 h}-${builtins.substring 20 12 h}"; + + # Exact-match a complete extension number — dial immediately. + extTemplate = ext: "