nix/modules/voip/default.nix

668 lines
28 KiB
Nix

{ 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/<model>.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; };
# 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 "<em>(not yet uploaded)</em>" 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 = {
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; }];
};
};
}