{ lib, pkgs, config, ... }: let cfg = config.services.voip; # 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; }; }; # 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; }; 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; }; # 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) ''; # 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\"."; }; }; }); }; }; 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)); services.asterisk = { 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 = "${provisioningRoot}"; extraOptions = [ "--verbose=7" ]; }; services.nginx = { 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;"; }; "= /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 6970 ]; allowedUDPPorts = [ cfg.sipPort 69 ]; allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }]; }; }; }