From d2b4eb483f73f05411fe9b5bd3943ad74b12e182 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 4 Apr 2026 20:22:18 +0200 Subject: [PATCH] refactor: restructure voip module --- modules/voip/assertions.nix | 137 +++++ .../{asterisk.nix => asterisk/default.nix} | 7 +- modules/voip/{ => asterisk}/greetings.nix | 0 modules/voip/{ => asterisk}/moh.nix | 0 modules/voip/{diagram.nix => dashboard.nix} | 6 +- modules/voip/default.nix | 578 +----------------- modules/voip/intercom.nix | 16 - modules/voip/options.nix | 349 +++++++++++ modules/voip/phones.nix | 95 +++ modules/voip/provisioning.nix | 98 --- .../voip/{ => provisioning}/backgrounds.nix | 12 +- modules/voip/provisioning/default.nix | 70 +++ modules/voip/{ => provisioning}/directory.nix | 0 .../provisioning/templates/cisco-8961.nix | 276 +++++++++ .../provisioning/templates/cisco-base.nix | 11 + modules/voip/templates/cisco-8961.nix | 234 ------- 16 files changed, 965 insertions(+), 924 deletions(-) create mode 100644 modules/voip/assertions.nix rename modules/voip/{asterisk.nix => asterisk/default.nix} (97%) rename modules/voip/{ => asterisk}/greetings.nix (100%) rename modules/voip/{ => asterisk}/moh.nix (100%) rename modules/voip/{diagram.nix => dashboard.nix} (98%) delete mode 100644 modules/voip/intercom.nix create mode 100644 modules/voip/options.nix create mode 100644 modules/voip/phones.nix delete mode 100644 modules/voip/provisioning.nix rename modules/voip/{ => provisioning}/backgrounds.nix (84%) create mode 100644 modules/voip/provisioning/default.nix rename modules/voip/{ => provisioning}/directory.nix (100%) create mode 100644 modules/voip/provisioning/templates/cisco-8961.nix create mode 100644 modules/voip/provisioning/templates/cisco-base.nix delete mode 100644 modules/voip/templates/cisco-8961.nix diff --git a/modules/voip/assertions.nix b/modules/voip/assertions.nix new file mode 100644 index 0000000..09d4c9f --- /dev/null +++ b/modules/voip/assertions.nix @@ -0,0 +1,137 @@ +{ lib, cfg, models, allPhones }: + +# 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: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key"; +}) cfg.sharedPhones) +++ +# Provisioned sharedPhones require a non-empty label +(lib.mapAttrsToList (key: phone: { + assertion = !models.${phone.model}.hasProvisioning || phone.label != ""; + message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label"; +}) cfg.sharedPhones) +++ +# 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)) +++ +# 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: + 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"; + } + ) allPhones)) diff --git a/modules/voip/asterisk.nix b/modules/voip/asterisk/default.nix similarity index 97% rename from modules/voip/asterisk.nix rename to modules/voip/asterisk/default.nix index c310c03..968efb6 100644 --- a/modules/voip/asterisk.nix +++ b/modules/voip/asterisk/default.nix @@ -42,7 +42,7 @@ let # 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; + in phone.personKey != null && models.${phone.model}.hasMultiLine; # 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}"; @@ -93,12 +93,11 @@ let + 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. + # Page: only intercom-capable phones with an intercom line (auto-answer speakerphone). allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom") (lib.filterAttrs (key: phone: - models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null + models.${phone.model}.hasIntercom && cfg.intercomPrefix != null ) allPhones)); # --- PJSIP endpoint generators --- diff --git a/modules/voip/greetings.nix b/modules/voip/asterisk/greetings.nix similarity index 100% rename from modules/voip/greetings.nix rename to modules/voip/asterisk/greetings.nix diff --git a/modules/voip/moh.nix b/modules/voip/asterisk/moh.nix similarity index 100% rename from modules/voip/moh.nix rename to modules/voip/asterisk/moh.nix diff --git a/modules/voip/diagram.nix b/modules/voip/dashboard.nix similarity index 98% rename from modules/voip/diagram.nix rename to modules/voip/dashboard.nix index 9541ba2..299774c 100644 --- a/modules/voip/diagram.nix +++ b/modules/voip/dashboard.nix @@ -20,7 +20,7 @@ let # 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; + in phone.personKey != null && models.${phone.model}.hasMultiLine; # ── Nodes ─────────────────────────────────────────────────────────────── @@ -126,9 +126,9 @@ let '' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]'' ) intercomEntries; - # Page extension → all phones + # Page extension → intercom-capable phones pagePhones = lib.filterAttrs (key: phone: - models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null + models.${phone.model}.hasIntercom && cfg.intercomPrefix != null ) allPhones; pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg: diff --git a/modules/voip/default.nix b/modules/voip/default.nix index 0bef781..70ab2f2 100644 --- a/modules/voip/default.nix +++ b/modules/voip/default.nix @@ -1,66 +1,21 @@ { lib, pkgs, config, ... }: let - cfg = config.services.voip; + cfg = config.services.voip; + phones = import ./phones.nix { inherit lib; }; - # Per-model config. Adding a new hardware model: - # 1. Add an entry here with all required fields - # 2. For provisioned models, add a template in ./templates/.nix - # - # Template interface — all provisioned model templates receive these args: - # Required: mac, label, displayName, password, serverAddress, ntpServer - # Optional: sipPort (default 5060), directoryPort (default 8080), - # intercomEnabled (default false), intercomPassword (default ""), - # familyLineEnabled (default false), familyLineLabel (default "Familie") - models = { - "cisco-8961" = { - endpointTemplate = "endpoint-cisco-8961"; - maxContacts = 1; - hasProvisioning = true; - desktopSize = "640x480x24"; - thumbnailSize = "123x111"; - template = import ./templates/cisco-8961.nix; - }; - "sip-client" = { - endpointTemplate = "endpoint-generic"; - maxContacts = 1; - hasProvisioning = false; - desktopSize = null; - thumbnailSize = null; - template = null; - }; - }; + inherit (phones) models; + allPhones = phones.mkAllPhones cfg; + intercomEntries = phones.mkIntercomEntries cfg allPhones; - # 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 ./asterisk/moh.nix { inherit lib pkgs cfg; }; + greetingDirs = import ./asterisk/greetings.nix { inherit lib pkgs cfg; }; + confFiles = import ./asterisk/default.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; }; - 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; }; - 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; }; + directory = import ./provisioning/directory.nix { inherit lib pkgs cfg allPhones intercomEntries; }; + provisioningRoot = import ./provisioning/default.nix { inherit lib pkgs cfg models allPhones; }; + + diagram = import ./dashboard.nix { inherit lib pkgs cfg models allPhones intercomEntries; }; # True when any *File option is set — Asterisk's execincludes=yes is required in that case. hasRuntimeSecrets = @@ -85,520 +40,15 @@ let ngx.header.content_type = "text/html; charset=utf-8" ngx.print(html) ''; - # 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 = { - enable = lib.mkEnableOption "VoIP provisioning (Asterisk + TFTP)"; - - serverAddress = lib.mkOption { - type = lib.types.str; - description = "IP address or hostname of this server (used in phone configs and SIP)."; - }; - - ntpServer = lib.mkOption { - type = lib.types.str; - description = "NTP server for phones. Defaults to serverAddress."; - default = ""; - }; - - sipPort = lib.mkOption { - type = lib.types.port; - default = 5060; - }; - - rtpStart = lib.mkOption { - type = lib.types.port; - default = 10000; - }; - - rtpEnd = lib.mkOption { - type = lib.types.port; - default = 20000; - }; - - directoryName = lib.mkOption { - type = lib.types.str; - default = "tel.baubs.net"; - description = "Name shown in the phone directory title."; - }; - - directoryPort = lib.mkOption { - type = lib.types.port; - default = 8080; - description = "HTTP port for the phone directory and services."; - }; - - backgroundImages = lib.mkOption { - default = {}; - description = '' - Attrset of background images keyed by display name. - Value is a path to any image file — it will be resized automatically - to the correct dimensions for each phone model during build. - For best results, use a 4:3 aspect ratio source image. - ''; - type = lib.types.attrsOf lib.types.path; - }; - - sharedPhones = lib.mkOption { - default = {}; - description = '' - 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 = { - extension = lib.mkOption { - type = lib.types.str; - description = "Personal extension number."; - }; - displayName = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Name shown in the directory and on caller ID."; - }; - mailbox = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether this person gets a personal voicemail mailbox."; - }; - 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 = "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; - }); - }; - }; - }); - }; - - intercomPrefix = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - 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 = '' - 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 [ "page" "app" ]; - default = "page"; - description = '' - Extension mode: - - "page": one-way announcement to all phones - - "app": custom Asterisk dialplan application - ''; - }; - displayName = lib.mkOption { - type = lib.types.str; - default = ""; - }; - app = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Verbatim Asterisk dialplan app. Required for mode = \"app\"."; - }; - }; - }); - }; - }; + imports = [ ./options.nix ]; config = lib.mkIf cfg.enable { services.voip.ntpServer = lib.mkDefault cfg.serverAddress; - assertions = - # 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: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key"; - }) cfg.sharedPhones) - ++ - # Provisioned sharedPhones require a non-empty label - (lib.mapAttrsToList (key: phone: { - assertion = !models.${phone.model}.hasProvisioning || phone.label != ""; - message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label"; - }) cfg.sharedPhones) - ++ - # 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)) - ++ - # 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: - 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"; - } - ) allPhones)); + assertions = import ./assertions.nix { inherit lib cfg models allPhones; }; services.asterisk = { enable = true; diff --git a/modules/voip/intercom.nix b/modules/voip/intercom.nix deleted file mode 100644 index 9123f13..0000000 --- a/modules/voip/intercom.nix +++ /dev/null @@ -1,16 +0,0 @@ -{ lib, cfg, models, allPhones }: - -if cfg.intercomPrefix == null then [] -else lib.concatLists (lib.mapAttrsToList (key: phone: - 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; - } -) allPhones) diff --git a/modules/voip/options.nix b/modules/voip/options.nix new file mode 100644 index 0000000..7e4f86d --- /dev/null +++ b/modules/voip/options.nix @@ -0,0 +1,349 @@ +{ lib, ... }: + +let + phones = import ./phones.nix { inherit lib; }; +in { + options.services.voip = { + enable = lib.mkEnableOption "VoIP provisioning (Asterisk + TFTP)"; + + serverAddress = lib.mkOption { + type = lib.types.str; + description = "IP address or hostname of this server (used in phone configs and SIP)."; + }; + + ntpServer = lib.mkOption { + type = lib.types.str; + description = "NTP server for phones. Defaults to serverAddress."; + default = ""; + }; + + sipPort = lib.mkOption { + type = lib.types.port; + default = 5060; + }; + + rtpStart = lib.mkOption { + type = lib.types.port; + default = 10000; + }; + + rtpEnd = lib.mkOption { + type = lib.types.port; + default = 20000; + }; + + directoryName = lib.mkOption { + type = lib.types.str; + default = "tel.baubs.net"; + description = "Name shown in the phone directory title."; + }; + + directoryPort = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "HTTP port for the phone directory and services."; + }; + + backgroundImages = lib.mkOption { + default = {}; + description = '' + Attrset of background images keyed by display name. + Value is a path to any image file — it will be resized automatically + to the correct dimensions for each phone model during build. + For best results, use a 4:3 aspect ratio source image. + ''; + type = lib.types.attrsOf lib.types.path; + }; + + sharedPhones = lib.mkOption { + default = {}; + description = '' + 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 = phones.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 = { + extension = lib.mkOption { + type = lib.types.str; + description = "Personal extension number."; + }; + displayName = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Name shown in the directory and on caller ID."; + }; + mailbox = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this person gets a personal voicemail mailbox."; + }; + 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 = "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 = phones.phoneDeviceOptions true; + }); + }; + }; + }); + }; + + intercomPrefix = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + 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 = '' + 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 [ "page" "app" ]; + default = "page"; + description = '' + Extension mode: + - "page": one-way announcement to all phones + - "app": custom Asterisk dialplan application + ''; + }; + displayName = lib.mkOption { + type = lib.types.str; + default = ""; + }; + app = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Verbatim Asterisk dialplan app. Required for mode = \"app\"."; + }; + }; + }); + }; + }; +} diff --git a/modules/voip/phones.nix b/modules/voip/phones.nix new file mode 100644 index 0000000..d87f3f2 --- /dev/null +++ b/modules/voip/phones.nix @@ -0,0 +1,95 @@ +{ lib }: + +let + # Per-model config. Adding a new hardware model: + # 1. Add an entry here (PBX-relevant fields only) + # 2. Add a provisioning template in ./provisioning/templates/.nix + # exporting: desktopSize, thumbnailSize, mkConfig, mkDialplan + models = { + "cisco-8961" = { + endpointTemplate = "endpoint-cisco-8961"; + maxContacts = 1; + hasProvisioning = true; + hasIntercom = true; # auto-answer speakerphone line + hasMultiLine = true; # separate L2 line for family/shared DID + }; + "sip-client" = { + endpointTemplate = "endpoint-generic"; + maxContacts = 1; + hasProvisioning = false; + hasIntercom = false; + hasMultiLine = false; + }; + }; + + # 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."; + }; + }; + + # 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. + mkAllPhones = cfg: + 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; + + # Auto-generate intercom extensions for provisioned phones. + mkIntercomEntries = cfg: allPhones: + if cfg.intercomPrefix == null then [] + else lib.concatLists (lib.mapAttrsToList (key: phone: + let m = models.${phone.model}; in + lib.optional m.hasIntercom { + 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; + } + ) allPhones); + +in { + inherit models phoneDeviceOptions mkAllPhones mkIntercomEntries; +} diff --git a/modules/voip/provisioning.nix b/modules/voip/provisioning.nix deleted file mode 100644 index ab20c98..0000000 --- a/modules/voip/provisioning.nix +++ /dev/null @@ -1,98 +0,0 @@ -{ 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: "