667 lines
28 KiB
Nix
667 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; };
|
|
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 "<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)
|
|
'';
|
|
# 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; }];
|
|
};
|
|
};
|
|
}
|