feat: add voip stack

This commit is contained in:
Jan-Henrik 2026-04-04 16:13:24 +02:00
parent aa22874883
commit 3e48221fbf
27 changed files with 1787 additions and 227 deletions

View file

@ -1,5 +1,50 @@
{
"nodes": {
"agenix": {
"inputs": {
"darwin": "darwin",
"home-manager": "home-manager",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1770165109,
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
"owner": "ryantm",
"repo": "agenix",
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
"type": "github"
},
"original": {
"owner": "ryantm",
"repo": "agenix",
"type": "github"
}
},
"darwin": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1744478979,
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
"owner": "lnl7",
"repo": "nix-darwin",
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
"type": "github"
},
"original": {
"owner": "lnl7",
"ref": "master",
"repo": "nix-darwin",
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
@ -20,6 +65,27 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1745494811,
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1774709303,
@ -38,9 +104,25 @@
},
"root": {
"inputs": {
"agenix": "agenix",
"disko": "disko",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View file

@ -5,15 +5,22 @@
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
agenix = {
url = "github:ryantm/agenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, disko, ... }:
outputs = { self, nixpkgs, disko, agenix, ... }:
let
pkgs = import nixpkgs { system = "x86_64-linux"; };
# Helper to build a NixOS host config from hosts/<name>/
mkHost = name: system: nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = system; }
disko.nixosModules.disko
agenix.nixosModules.default
./modules/common.nix
./hosts/${name}
];
@ -28,10 +35,17 @@
(name: cfg: mkHost name cfg.system)
hosts;
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [
pkgs.colmena
agenix.packages.x86_64-linux.default
];
};
# colmena hive for ongoing deployments
colmena = {
meta = {
nixpkgs = import nixpkgs { system = "x86_64-linux"; }; # fallback for colmena internals
nixpkgs = pkgs;
specialArgs = { inherit disko; };
};
} // nixpkgs.lib.mapAttrs (name: cfg: {
@ -43,6 +57,7 @@
imports = [
{ nixpkgs.hostPlatform = cfg.system; }
disko.nixosModules.disko
agenix.nixosModules.default
./modules/common.nix
./hosts/${name}
];

View file

@ -1,4 +1,4 @@
{ ... }: {
{ config, ... }: {
imports = [
./hardware.nix
./disko.nix
@ -20,25 +20,46 @@
serverAddress = "10.0.10.2";
ntpServer = "10.0.10.1";
phones = {
# directoryName = "tel.baubs.net"; # shown in phone directory title and HTML page header
# directoryPort = 8080; # HTTP port for directory, intercom XML, and status page
# sipPort = 5060; # SIP TCP/UDP port
# rtpStart = 10000; # RTP port range start
# rtpEnd = 20000; # RTP port range end
sharedPhones = {
"e0899d946ccc" = {
model = "cisco-8961";
extension = "100";
label = "Küchentelefon";
password = "changeme100";
voicemailTimeout = 10;
model = "cisco-8961";
extension = "20";
displayName = "Küche";
label = "Küchentelefon"; # shown on the phone screen (max ~12 chars)
password = "changeme100";
};
"e0899d947650" = {
model = "cisco-8961";
extension = "102";
label = "Flur";
password = "changeme100";
voicemailTimeout = 10;
model = "cisco-8961";
extension = "22";
displayName = "Flur";
label = "Flur";
password = "changeme100";
};
"101" = {
model = "sip-client";
extension = "101";
password = "changeme101";
"fromschofon" = {
model = "sip-client";
extension = "23";
displayName = "Frosch";
label = "Frosch";
password = "changeme102";
};
};
persons = {
"jannel" = {
extension = "21";
displayName = "Jannel";
mailbox = true;
# ringTimeout = 30; # seconds to ring before going to voicemail
mailboxGreeting = ./greetings/anrufbeantworter.wav;
phones = {
"jannel-mobile" = { model = "sip-client"; password = "changeme101"; };
};
};
};
@ -46,16 +67,107 @@
"Wombel" = ./backgrounds/wombel.png;
};
intercomPrefix = "*80";
intercomPrefix = "*80"; # generates e.g. *8020, *8021, *8022 per extension
codecs = {
trunk = [ "g722" "alaw" "ulaw" ];
# hardwarePhones = [ "g722" "alaw" "ulaw" "ilbc" ]; # default; Cisco 8961 supported set
# softClients = [ "opus" "g722" "alaw" "ulaw" ]; # default; opus first for best quality
};
mohClasses = {
"default" = {
files = [ ./music/vapor.mp3 ];
sort = "random";
# sort = "alphabetical";
};
# add more classes here and reference them per-DID via musicOnHold = "classname"
};
sipTrunks = {
"ewe1" = {
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
usernameFile = config.age.secrets."voip-trunk-ewe1-username".path;
passwordFile = config.age.secrets."voip-trunk-ewe1-password".path;
callerIdFile = config.age.secrets."voip-trunk-ewe1-callerid".path;
};
"ewe2" = {
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
usernameFile = config.age.secrets."voip-trunk-ewe2-username".path;
passwordFile = config.age.secrets."voip-trunk-ewe2-password".path;
callerIdFile = config.age.secrets."voip-trunk-ewe2-callerid".path;
};
"ewe3" = {
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
usernameFile = config.age.secrets."voip-trunk-ewe3-username".path;
passwordFile = config.age.secrets."voip-trunk-ewe3-password".path;
callerIdFile = config.age.secrets."voip-trunk-ewe3-callerid".path;
};
};
sharedMailbox = {
mailboxId = "200";
checkExtension = "*98";
displayName = "Baubse";
greeting = ./greetings/anrufbeantworter.wav;
};
dids = {
"baubse" = {
numberFile = config.age.secrets."voip-trunk-ewe1-callerid".path;
trunk = "ewe1";
displayName = "Baubse";
routing = {
type = "all"; # ring all phones (sharedPhones + persons) on their L2 line
# timeout = 30; # seconds to ring before falling through to mailbox
};
mailbox = "shared"; # → sharedMailbox on no answer
musicOnHold = "default";
};
"jannel" = {
numberFile = config.age.secrets."voip-trunk-ewe2-callerid".path;
trunk = "ewe2";
displayName = "Jannel";
routing = {
type = "person";
person = "jannel"; # ring jannel's phones on their L1 line
# timeout = 30;
};
mailbox = "person"; # → jannel's personal mailbox on no answer
# musicOnHold = "default";
};
# ewe3 DID — uncomment and fill in number when known:
# "ewe3-main" = {
# number = ""; # or: numberFile = ./secrets/did-ewe3;
# trunk = "ewe3";
# displayName = "...";
# routing = { type = "all"; };
# mailbox = "shared";
# };
};
extensions = {
"100" = { displayName = "Küche"; };
"101" = { displayName = "101"; };
"102" = { displayName = "Flur"; };
"*99" = { mode = "page"; displayName = "Durchsage an alle"; };
"999" = { mode = "app"; app = "Playback(hello-world)"; };
# custom app extension example:
# "*00" = { mode = "app"; displayName = "Echo test"; app = "Echo()"; };
};
};
deployment.targetHost = "telefonmann"; # or IP address
deployment.targetHost = "telefonmann";
# Age-encrypted secrets (decrypted on the host at activation time).
age.secrets =
let asteriskSecret = file: { inherit file; owner = "asterisk"; group = "voip-keys"; mode = "0640"; };
in {
"voip-trunk-ewe-host" = asteriskSecret ../../secrets/voip-trunk-ewe-host.age;
"voip-trunk-ewe1-username" = asteriskSecret ../../secrets/voip-trunk-ewe1-username.age;
"voip-trunk-ewe1-password" = asteriskSecret ../../secrets/voip-trunk-ewe1-password.age;
"voip-trunk-ewe1-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe1-callerid.age;
"voip-trunk-ewe2-username" = asteriskSecret ../../secrets/voip-trunk-ewe2-username.age;
"voip-trunk-ewe2-password" = asteriskSecret ../../secrets/voip-trunk-ewe2-password.age;
"voip-trunk-ewe2-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe2-callerid.age;
"voip-trunk-ewe3-username" = asteriskSecret ../../secrets/voip-trunk-ewe3-username.age;
"voip-trunk-ewe3-password" = asteriskSecret ../../secrets/voip-trunk-ewe3-password.age;
"voip-trunk-ewe3-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe3-callerid.age;
};
}

Binary file not shown.

Binary file not shown.

View file

@ -1,11 +1,162 @@
{ lib, cfg, models, intercomEntries }:
{ lib, cfg, models, allPhones, intercomEntries, mohDirs, greetingDirs }:
let
# Phones that have voicemail enabled
vmPhones = lib.filterAttrs (_: phone: phone.voicemailTimeout != null) cfg.phones;
hasVoicemail = vmPhones != {};
hasTrunk = cfg.sipTrunks != {};
pjsip = ''
# Produces either "prefix<val>" (static) or a #exec that reads the file at runtime.
# Use this wherever a config value can be supplied either inline or from a key file.
runtimeLine = prefix: val: file:
if file != null
then ''#exec echo "${prefix}$(cat ${file})"''
else "${prefix}${val}";
hasSharedMailbox = cfg.sharedMailbox != null;
hasPersonalMailboxes = lib.any (p: p.mailboxExt != null) (lib.attrValues allPhones);
hasAnyMailbox = hasPersonalMailboxes || hasSharedMailbox;
# Helper to produce a literal Asterisk variable reference like ${EXTEN}
# when used inside a Nix string interpolation: ${av "EXTEN"} → ${EXTEN}
av = name: "\${${name}}";
# Group phone keys by extension number (multiple phones can share one extension)
phonesByExtension = lib.foldlAttrs (acc: key: phone:
acc // { ${phone.extension} = (acc.${phone.extension} or []) ++ [ key ]; }
) {} allPhones;
# Per-extension metadata derived from allPhones (all phones of the same extension
# share the same personKey and mailboxExt by construction)
extensionInfo = lib.mapAttrs (_ext: keys:
let sample = allPhones.${lib.head keys}; in {
inherit keys;
mailboxExt = sample.mailboxExt;
ringTimeout =
if sample.personKey != null
then cfg.persons.${sample.personKey}.ringTimeout
else 30;
}
) phonesByExtension;
# L2 only makes sense for person phones: they need separate lines for
# personal vs family calls and have distinct mailbox MWI per line.
# Shared phones (no personal mailbox) and sip-clients use L1 for everything.
phoneHasL2 = key:
let phone = allPhones.${key};
in phone.personKey != null && models.${phone.model}.hasProvisioning;
# Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once)
dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}";
# Dial target strings
allL2Endpoints = lib.concatStringsSep "&"
(map dialTarget (lib.attrNames allPhones));
personL1Endpoints = personKey:
lib.concatStringsSep "&"
(map (key: "PJSIP/${key}")
(lib.attrNames cfg.persons.${personKey}.phones));
personsL2Endpoints = personKeys:
lib.concatStringsSep "&"
(lib.concatMap (personKey:
map dialTarget (lib.attrNames cfg.persons.${personKey}.phones)
) personKeys);
# --- Outbound trunk resolution ---
# Find the first DID matching a predicate, returning { id, trunk } or null.
findDid = pred:
lib.foldlAttrs (acc: id: didCfg:
if acc != null then acc
else if pred didCfg then { inherit id; inherit (didCfg) trunk; }
else null
) null cfg.dids;
# The shared/family DID: routing.type = "all"
allDid = findDid (d: d.routing.type == "all");
# Personal DID for a given person key
personDid = personKey: findDid (d: d.routing.type == "person" && d.routing.person == personKey);
# Outbound assignment for an L1 endpoint: personal DID if person, else allDid
l1OutboundFor = phone:
if phone.personKey != null
then let pd = personDid phone.personKey;
in if pd != null then pd else allDid
else allDid;
# Render set_var lines for outbound trunk + caller ID, or empty string if no DID found
outboundVars = did:
if did == null then ""
else
let t = cfg.sipTrunks.${did.trunk}; in
"set_var=OUTBOUND_TRUNK=trunk-${did.trunk}-endpoint\n "
+ lib.optionalString (t.callerId != "" || t.callerIdFile != null)
(runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n ");
# Page: only provisioned phones with an intercom line (auto-answer speakerphone).
# sip-clients have no dedicated intercom endpoint and are excluded.
allPageEndpoints = lib.concatStringsSep "&"
(lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom")
(lib.filterAttrs (key: phone:
models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null
) allPhones));
# --- PJSIP endpoint generators ---
genL1Endpoint = key: phone:
let
m = models.${phone.model};
# Personal mailbox takes priority; shared phones watch the shared mailbox
effectiveMailbox =
if phone.mailboxExt != null then phone.mailboxExt
else if hasSharedMailbox then cfg.sharedMailbox.mailboxId
else null;
mailboxLine = lib.optionalString (effectiveMailbox != null)
"mailboxes = ${effectiveMailbox}@voicemail\n ";
vmVar = lib.optionalString (effectiveMailbox != null)
"set_var=VOICEMAIL_MAILBOX=${effectiveMailbox}\n ";
obVars = lib.optionalString hasTrunk (outboundVars (l1OutboundFor phone));
in ''
[${key}](${m.endpointTemplate})
auth = auth-${key}
aors = ${key}
${mailboxLine}${vmVar}${obVars}
[auth-${key}](auth-userpass)
username = ${key}
password = ${phone.password}
[${key}]
type = aor
max_contacts = ${toString m.maxContacts}
remove_existing = yes
'';
genL2Endpoint = key: phone:
let
m = models.${phone.model};
k = "${key}-l2";
mailboxLine = lib.optionalString hasSharedMailbox
"mailboxes = ${cfg.sharedMailbox.mailboxId}@voicemail\n ";
vmVar = lib.optionalString hasSharedMailbox
"set_var=VOICEMAIL_MAILBOX=${cfg.sharedMailbox.mailboxId}\n ";
obVars = outboundVars allDid;
in ''
[${k}](${m.endpointTemplate})
auth = auth-${k}
aors = ${k}
${mailboxLine}${vmVar}${obVars}
[auth-${k}](auth-userpass)
username = ${k}
password = ${phone.password}
[${k}]
type = aor
max_contacts = ${toString m.maxContacts}
remove_existing = yes
'';
pjsip =
''
[transport-tcp]
type = transport
protocol = tcp
@ -21,50 +172,38 @@ let
context = internal
transport = transport-tcp
disallow = all
allow = ulaw
allow = alaw
allow = g722
allow = g726
allow = ilbc
allow = gsm
direct_media = no
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.hardwarePhones}direct_media = no
trust_id_inbound = yes
send_pai = yes
[endpoint-generic](!)
type = endpoint
context = internal
transport = transport-tcp
disallow = all
allow = ulaw
allow = alaw
allow = g722
direct_media = no
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.softClients}direct_media = no
send_pai = yes
[auth-userpass](!)
type = auth
auth_type = userpass
; --- phones ---
; --- phones (L1) ---
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
let m = models.${phone.model}; in ''
[${key}](${m.endpointTemplate})
auth = auth-${key}
aors = ${key}
${lib.optionalString (phone.voicemailTimeout != null) "mailboxes = ${phone.extension}@voicemail\n set_var=VOICEMAIL_MAILBOX=${phone.extension}"}
''
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones)
[auth-${key}](auth-userpass)
username = ${key}
password = ${phone.password}
+ lib.optionalString hasTrunk (
let provisionedPhones = lib.filterAttrs (key: _: phoneHasL2 key) allPhones; in
lib.optionalString (provisionedPhones != {}) (
"\n ; --- family line endpoints (L2, provisioned phones only) ---\n\n"
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL2Endpoint provisionedPhones)
)
)
[${key}]
type = aor
max_contacts = ${toString m.maxContacts}
remove_existing = yes
'') cfg.phones)
+ lib.concatMapStringsSep "\n" (ic: ''
+ lib.concatMapStringsSep "\n" (ic: ''
; --- intercom ---
[${ic.endpoint}](${ic.endpointTemplate})
auth = auth-${ic.endpoint}
aors = ${ic.endpoint}
@ -77,43 +216,146 @@ let
type = aor
max_contacts = ${toString ic.maxContacts}
remove_existing = yes
'') intercomEntries;
'') intercomEntries
# Reverse map: extension number -> pjsip endpoint key
extensionToEndpoint = lib.foldlAttrs (acc: key: phone:
acc // { ${phone.extension} = key; }
) {} cfg.phones;
+ lib.optionalString hasTrunk (
"\n ; --- SIP trunks ---\n"
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: ''
# All page endpoints: intercom line for provisioned phones, regular for others
allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: phone:
let m = models.${phone.model}; in
if m.hasProvisioning && cfg.intercomPrefix != null
then "PJSIP/${key}-intercom"
else "PJSIP/${key}"
) cfg.phones);
[trunk-${name}-registration]
type = registration
outbound_auth = trunk-${name}-auth
${runtimeLine "server_uri = sip:" t.host t.hostFile}
${let u = if t.usernameFile != null then "$(cat ${t.usernameFile})" else t.username;
h = if t.hostFile != null then "$(cat ${t.hostFile})" else t.host;
in if t.usernameFile != null || t.hostFile != null
then ''#exec echo "client_uri = sip:${u}@${h}"''
else "client_uri = sip:${u}@${h}"}
retry_interval = 60
forbidden_retry_interval = 600
expiration = 3600
extensions = ''
[internal]
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
if extCfg.mode == "app"
then "exten => ${ext},1,${extCfg.app}"
else if extCfg.mode == "page"
then "exten => ${ext},1,Page(${allPageEndpoints},i,120)"
else if lib.hasAttr ext extensionToEndpoint
then
[trunk-${name}-auth]
type = auth
auth_type = userpass
${runtimeLine "username = " t.username t.usernameFile}
${runtimeLine "password = " t.password t.passwordFile}
[trunk-${name}-endpoint]
type = endpoint
context = from-external
transport = transport-${t.transport}
disallow = all
outbound_auth = trunk-${name}-auth
aors = trunk-${name}-aor
trust_id_inbound = yes
disallow = all
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.trunk}
[trunk-${name}-aor]
type = aor
${runtimeLine "contact = sip:" t.host t.hostFile}
[trunk-${name}-identify]
type = identify
endpoint = trunk-${name}-endpoint
${runtimeLine "match = " t.host t.hostFile}
'') cfg.sipTrunks));
# --- Dialplan ---
internalContext =
"[internal]\n"
# Line extensions (auto-generated from allPhones, grouped by extension)
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info:
let dialStr = lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys); in
if info.mailboxExt != null
then ''exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})
same => n,VoiceMail(${info.mailboxExt}@voicemail,u)''
else "exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})"
) extensionInfo)
# Page and app extensions from cfg.extensions
+ "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
if extCfg.mode == "page"
then "exten => ${ext},1,Page(${allPageEndpoints},i,120)"
else "exten => ${ext},1,${extCfg.app}"
) cfg.extensions)
# Auto-generated intercom extensions
+ "\n" + lib.concatMapStringsSep "\n" (ic:
"exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)"
) intercomEntries
# Voicemail check (*97 — uses VOICEMAIL_MAILBOX set on the endpoint)
+ lib.optionalString hasAnyMailbox
"\nexten => *97,1,VoiceMailMain(${av "VOICEMAIL_MAILBOX"}@voicemail,sa(0))"
# Shared mailbox direct check extension
+ lib.optionalString hasSharedMailbox
"\nexten => ${cfg.sharedMailbox.checkExtension},1,VoiceMailMain(${cfg.sharedMailbox.mailboxId}@voicemail,sa(0))"
# Outbound — trunk and caller ID determined by set_var on the originating endpoint
+ lib.optionalString hasTrunk ''
exten => _0.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
exten => _00.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
exten => _+.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
'';
externalContext = lib.optionalString hasTrunk (
''
[from-external]
; Provider sent INVITE without DID in Request-URI extract from To header.
; To: <sip:+49123456789@provider.example.com:5060;user=phone>
; CUT by ':' field 2 +49123456789@provider.example.com
; CUT by '@' field 1 +49123456789
exten => s,1,Set(DID=${av "PJSIP_HEADER(read,To)"})
same => n,Set(DID=${av "CUT(DID,:,2)"})
same => n,Set(DID=${av "CUT(DID,@,1)"})
same => n,Goto(from-external,${av "DID"},1)
same => n,Hangup(21)
''
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg:
let
phoneKey = extensionToEndpoint.${ext};
phone = cfg.phones.${phoneKey};
in if phone.voicemailTimeout != null
then ''exten => ${ext},1,Dial(PJSIP/${phoneKey},${toString phone.voicemailTimeout})
same => n,VoiceMail(${ext}@voicemail,u)''
else "exten => ${ext},1,Dial(PJSIP/${phoneKey})"
else "exten => ${ext},1,Hangup() ; WARNING: no endpoint assigned to extension ${ext}"
) cfg.extensions)
+ "\n" + lib.concatMapStringsSep "\n" (ic:
"exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)"
) intercomEntries
+ lib.optionalString hasVoicemail "\nexten => *97,1,VoiceMailMain(\${VOICEMAIL_MAILBOX}@voicemail,sa(0))";
r = didCfg.routing;
dialStr =
if r.type == "all" then allL2Endpoints
else if r.type == "person" then personL1Endpoints r.person
else personsL2Endpoints r.persons;
mohOpt = lib.optionalString (didCfg.musicOnHold != null) "m(${didCfg.musicOnHold})";
dialArgs = "${dialStr},${toString r.timeout},${mohOpt}";
noAnsLine =
if didCfg.mailbox == "shared" then
let gdir = greetingDirs.shared; in
lib.optionalString (gdir != null)
"\n same => n,Playback(${gdir}/greeting)"
+ "\n same => n,VoiceMail(${cfg.sharedMailbox.mailboxId}@voicemail,${if gdir != null then "s" else "u"})"
else if didCfg.mailbox == "person" then
let
person = cfg.persons.${r.person};
gdir = greetingDirs.persons.${r.person};
in
lib.optionalString (gdir != null)
"\n same => n,Playback(${gdir}/greeting)"
+ "\n same => n,VoiceMail(${person.extension}@voicemail,${if gdir != null then "s" else "u"})"
else "";
# Static number: inline it. Runtime number: #exec reads the file and outputs the exten line.
# noAnsLine follows as static text — Asterisk processes #exec output inline with the file,
# so "same =>" on the next line correctly refers to the exten => emitted by #exec.
extenLine =
if didCfg.numberFile != null
then "#exec echo 'exten => '$(cat ${didCfg.numberFile})',1,Dial(${dialArgs})'"
else "exten => ${didCfg.number},1,Dial(${dialArgs})";
in
"; ${didCfg.displayName} (${id}, trunk: ${didCfg.trunk})\n${extenLine}${noAnsLine}"
) cfg.dids)
);
extensions = internalContext + externalContext;
rtp = ''
[general]
@ -126,15 +368,31 @@ let
format = ulaw
[voicemail]
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (_: phone:
let displayName = cfg.extensions.${phone.extension}.displayName; in
" ${phone.extension} => ,${displayName},,attach=no"
) vmPhones);
'' + lib.concatStringsSep "\n" (
lib.mapAttrsToList (_key: person:
lib.optionalString person.mailbox
" ${person.extension} => ,${person.displayName},,attach=no"
) cfg.persons
++ lib.optional hasSharedMailbox
" ${cfg.sharedMailbox.mailboxId} => ,${cfg.sharedMailbox.displayName},,attach=no"
);
musiconhold = lib.optionalString (cfg.mohClasses != {}) (
"[general]\n"
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: ''
[${name}]
mode = files
directory = ${mohDirs.${name}}
sort = ${cls.sort}
'') cfg.mohClasses)
);
in {
"pjsip.conf" = pjsip;
"extensions.conf" = extensions;
"rtp.conf" = rtp;
} // lib.optionalAttrs hasVoicemail {
"voicemail.conf" = voicemail;
} // lib.optionalAttrs hasAnyMailbox {
"voicemail.conf" = voicemail;
} // lib.optionalAttrs (cfg.mohClasses != {}) {
"musiconhold.conf" = musiconhold;
}

View file

@ -1,4 +1,4 @@
{ lib, pkgs, cfg, models }:
{ lib, pkgs, cfg, models, allPhones }:
let
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
@ -6,7 +6,7 @@ let
(lib.mapAttrsToList (_: phone:
let m = models.${phone.model}; in
{ desktop = m.desktopSize; thumbnail = m.thumbnailSize; }
) cfg.phones));
) allPhones));
# Parse "WxH" or "WxHxD" into width and height
parseDimensions = size:

View file

@ -8,9 +8,10 @@ let
# 2. For provisioned models, add a template in ./templates/<model>.nix
#
# Template interface — all provisioned model templates receive these args:
# Required: mac, label, password, displayName, serverAddress, ntpServer
# Required: mac, label, displayName, password, serverAddress, ntpServer
# Optional: sipPort (default 5060), directoryPort (default 8080),
# intercomEnabled (default false), intercomPassword (default "")
# intercomEnabled (default false), intercomPassword (default ""),
# familyLineEnabled (default false), familyLineLabel (default "Familie")
models = {
"cisco-8961" = {
endpointTemplate = "endpoint-cisco-8961";
@ -30,11 +31,89 @@ let
};
};
intercomEntries = import ./intercom.nix { inherit lib cfg models; };
confFiles = import ./asterisk.nix { inherit lib cfg models intercomEntries; };
directory = import ./directory.nix { inherit lib pkgs cfg intercomEntries; };
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models; };
tftpRoot = import ./tftp.nix { inherit lib pkgs cfg models backgroundEntries; };
# Unified view of all physical devices, keyed by SIP identity (MAC or username).
# Each entry carries the fields needed by sub-modules without them having to
# know about sharedPhones vs persons.
allPhones =
lib.mapAttrs (key: p: {
inherit (p) model label password;
extension = p.extension;
displayName = p.displayName;
personKey = null;
mailboxExt = null; # shared phones have no personal mailbox
}) cfg.sharedPhones
//
lib.foldlAttrs (acc: personKey: person:
acc // lib.mapAttrs (_key: ph: {
inherit (ph) model label password;
extension = person.extension;
displayName = person.displayName;
personKey = personKey;
mailboxExt = if person.mailbox then person.extension else null;
}) person.phones
) {} cfg.persons;
mohDirs = import ./moh.nix { inherit lib pkgs cfg; };
greetingDirs = import ./greetings.nix { inherit lib pkgs cfg; };
intercomEntries = import ./intercom.nix { inherit lib cfg models allPhones; };
confFiles = import ./asterisk.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; };
# True when any *File option is set — Asterisk's execincludes=yes is required in that case.
hasRuntimeSecrets =
lib.any (t: t.hostFile != null || t.usernameFile != null || t.passwordFile != null || t.callerIdFile != null)
(lib.attrValues cfg.sipTrunks)
|| lib.any (d: d.numberFile != null) (lib.attrValues cfg.dids);
# Nginx Lua handler: reads the static HTML template and substitutes every
# @@/path/to/keyfile@@ marker with the file's first line at request time.
luaPageHandler = pkgs.writeText "voip-page.lua" ''
local f = assert(io.open("${diagram.webRoot}/index.html", "rb"))
local html = f:read("*a")
f:close()
-- Placeholders embed the full key file path: @@/var/lib/voip-keys/name@@
html = html:gsub("@@([^@]+)@@", function(path)
local kf = io.open(path, "r")
if not kf then return "<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 = {
@ -89,36 +168,58 @@ in {
type = lib.types.attrsOf lib.types.path;
};
phones = lib.mkOption {
sharedPhones = lib.mkOption {
default = {};
description = ''
Attrset of phones/clients keyed by SIP identity (username).
For hardware phones (cisco-8961), the key must be the lowercase MAC address (no colons).
Shared/location phones not assigned to a specific person (e.g. hallway, kitchen).
These have their own extension but no personal voicemail mailbox.
For cisco-8961, the key must be the lowercase MAC address (no colons).
For sip-client, the key is a free-form username.
'';
type = lib.types.attrsOf (lib.types.submodule {
options = phoneDeviceOptions false;
});
};
persons = lib.mkOption {
default = {};
description = "People with personal extensions, optional voicemail mailboxes, and their own phones.";
type = lib.types.attrsOf (lib.types.submodule {
options = {
model = lib.mkOption {
type = lib.types.enum (lib.attrNames models);
description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file).";
};
extension = lib.mkOption {
type = lib.types.str;
description = "Extension number this phone registers as.";
description = "Personal extension number.";
};
label = lib.mkOption {
displayName = lib.mkOption {
type = lib.types.str;
default = "";
description = "Label shown on the phone screen. Required for provisioned hardware phones.";
description = "Name shown in the directory and on caller ID.";
};
password = lib.mkOption {
type = lib.types.str;
description = "SIP registration password.";
mailbox = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether this person gets a personal voicemail mailbox.";
};
voicemailTimeout = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
ringTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "Seconds to ring before going to voicemail (or hanging up if no mailbox).";
};
mailboxGreeting = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Seconds to ring before sending to voicemail. null disables voicemail for this phone.";
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
};
phones = lib.mkOption {
default = {};
description = ''
Phones belonging to this person, keyed by SIP identity.
For cisco-8961, the key must be the lowercase MAC address (no colons).
For sip-client, the key is a free-form username.
'';
type = lib.types.attrsOf (lib.types.submodule {
options = phoneDeviceOptions true;
});
};
};
});
@ -130,20 +231,219 @@ in {
description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries.";
};
codecs = lib.mkOption {
description = "Codec preference lists for each endpoint class, ordered highest priority first.";
default = {};
type = lib.types.submodule {
options = {
hardwarePhones = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "g722" "alaw" "ulaw" "ilbc" ];
description = "Codecs for provisioned hardware phones (e.g. Cisco 8961). Supports G.722, G.711, iLBC. Opus and G.726 not supported. G.729 supported but not useful on LAN.";
};
softClients = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "opus" "g722" "alaw" "ulaw" ];
description = "Codecs for software SIP clients. Opus first for best quality on modern softphones.";
};
trunk = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "alaw" "ulaw" ];
description = "Codecs offered to SIP trunks. Most providers only support G.711.";
};
};
};
};
mohClasses = lib.mkOption {
default = {};
description = "Music on hold classes. Files are transcoded to ulaw at build time.";
type = lib.types.attrsOf (lib.types.submodule {
options = {
files = lib.mkOption {
type = lib.types.listOf lib.types.path;
description = "Audio files to play (MP3, WAV, etc). Transcoded to 8kHz ulaw automatically.";
};
sort = lib.mkOption {
type = lib.types.enum [ "random" "alphabetical" ];
default = "random";
};
};
});
};
sipTrunks = lib.mkOption {
default = {};
description = "External SIP provider trunks, keyed by a short name (e.g. \"provider-a\").";
type = lib.types.attrsOf (lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
default = "";
description = "SIP provider hostname or IP address. Use hostFile to read from a file.";
};
hostFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the SIP provider hostname. Takes precedence over host.";
};
username = lib.mkOption {
type = lib.types.str;
default = "";
description = "SIP account username. Use usernameFile to read from a file.";
};
usernameFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the SIP account username. Takes precedence over username.";
};
password = lib.mkOption {
type = lib.types.str;
default = "";
description = "SIP account password. Use passwordFile to read from a file.";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the SIP account password. Takes precedence over password.";
};
transport = lib.mkOption {
type = lib.types.enum [ "udp" "tcp" ];
default = "udp";
};
callerId = lib.mkOption {
type = lib.types.str;
default = "";
description = "Outbound caller ID number presented to the provider. Leave empty to use provider default. Use callerIdFile to read from a file.";
};
callerIdFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the outbound caller ID. Takes precedence over callerId.";
};
};
});
};
sharedMailbox = lib.mkOption {
default = null;
description = "Shared voicemail mailbox accessible by all phones (family answering machine).";
type = lib.types.nullOr (lib.types.submodule {
options = {
mailboxId = lib.mkOption {
type = lib.types.str;
default = "200";
description = "Numeric mailbox ID used internally in voicemail.conf and VoiceMail(). Must be numeric.";
};
checkExtension = lib.mkOption {
type = lib.types.str;
default = "*98";
description = "Extension dialled to check this shared mailbox directly (via VoiceMailMain).";
};
displayName = lib.mkOption {
type = lib.types.str;
default = "Shared";
description = "Name shown in voicemail configuration.";
};
greeting = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
};
};
});
};
dids = lib.mkOption {
default = {};
description = "Inbound DID routing. Each DID must reference a key from sipTrunks.";
type = lib.types.attrsOf (lib.types.submodule {
options = {
number = lib.mkOption {
type = lib.types.str;
default = "";
description = "DID number in E.164 format (e.g. \"+4912345678\"). Use numberFile to read from a file.";
};
numberFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the DID number. Takes precedence over number.";
};
trunk = lib.mkOption {
type = lib.types.str;
description = "Key of the sipTrunks entry this DID arrives on.";
};
displayName = lib.mkOption {
type = lib.types.str;
default = "";
description = "Human-readable label for this DID (informational only).";
};
routing = lib.mkOption {
description = "How inbound calls on this DID are distributed to phones.";
type = lib.types.submodule {
options = {
type = lib.mkOption {
type = lib.types.enum [ "all" "person" "persons" ];
description = ''
all ring all phones (sharedPhones + all persons) on their L2 line
person ring a single person on their L1 line
persons ring a list of persons on their L2 line
'';
};
person = lib.mkOption {
type = lib.types.str;
default = "";
description = "Person key for routing.type = \"person\".";
};
persons = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Person keys for routing.type = \"persons\".";
};
timeout = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "Seconds to ring before going to voicemail (or hanging up).";
};
};
};
};
mailbox = lib.mkOption {
type = lib.types.enum [ "shared" "person" "none" ];
default = "shared";
description = ''
shared go to sharedMailbox on no answer (requires sharedMailbox to be set)
person go to the routed person's mailbox on no answer (only valid with routing.type = "person")
none hang up on no answer
'';
};
musicOnHold = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
MOH class name to play to the caller while phones ring, instead of ringback.
Must match a key in mohClasses. null = standard ringback.
'';
};
};
});
};
extensions = lib.mkOption {
default = {};
description = "Attrset of extensions keyed by extension number.";
description = ''
Extra extensions: page groups and custom app entries.
Line extensions are auto-generated from sharedPhones and persons do not declare them here.
'';
type = lib.types.attrsOf (lib.types.submodule {
options = {
mode = lib.mkOption {
type = lib.types.enum [ "line" "page" "app" ];
default = "line";
type = lib.types.enum [ "page" "app" ];
default = "page";
description = ''
Extension mode:
- "line": dials the phone assigned to this extension
- "page": one-way announcement to all phones
- "app": custom Asterisk dialplan application
Intercom extensions are auto-generated when intercomPrefix is set.
'';
};
displayName = lib.mkOption {
@ -164,80 +464,204 @@ in {
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
assertions =
# Every phone's extension must be declared
(lib.mapAttrsToList (key: phone: {
assertion = lib.hasAttr phone.extension cfg.extensions;
message = "services.voip: phone \"${key}\" references extension ${phone.extension} which is not declared in services.voip.extensions";
}) cfg.phones)
++
# Provisioned phones require a MAC address key
# Provisioned sharedPhones require a MAC address key
(lib.mapAttrsToList (key: phone: {
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
}) cfg.phones)
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
}) cfg.sharedPhones)
++
# Provisioned phones require a non-empty label
# Provisioned sharedPhones require a non-empty label
(lib.mapAttrsToList (key: phone: {
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a non-empty label";
}) cfg.phones)
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label";
}) cfg.sharedPhones)
++
# Provisioned phones require a template
(lib.mapAttrsToList (key: phone: {
assertion = !models.${phone.model}.hasProvisioning || models.${phone.model}.template != null;
message = "services.voip: phone \"${key}\" model \"${phone.model}\" has hasProvisioning=true but no template defined";
}) cfg.phones)
# Provisioned person phones require a MAC address key
(lib.concatLists (lib.mapAttrsToList (personKey: person:
lib.mapAttrsToList (key: phone: {
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
}) person.phones
) cfg.persons))
++
# intercomPrefix must not collide with user-declared extensions
# Provisioned person phones require a non-empty label
(lib.concatLists (lib.mapAttrsToList (personKey: person:
lib.mapAttrsToList (key: phone: {
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a non-empty label";
}) person.phones
) cfg.persons))
++
# No duplicate phone keys across sharedPhones and persons.*.phones
[{
assertion =
let keys = lib.attrNames allPhones;
in lib.length keys == lib.length (lib.unique keys);
message = "services.voip: duplicate phone key detected across sharedPhones and persons.*.phones";
}]
++
# No duplicate extensions across sharedPhones and persons
[{
assertion =
let
exts = (lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones)
++ (lib.mapAttrsToList (_: p: p.extension) cfg.persons);
in lib.length exts == lib.length (lib.unique exts);
message = "services.voip: duplicate extension number across sharedPhones and persons";
}]
++
# dids require at least one sipTrunk
(lib.optionals (cfg.dids != {}) [{
assertion = cfg.sipTrunks != {};
message = "services.voip: dids are configured but sipTrunks is empty";
}])
++
# each DID must reference an existing trunk
(lib.mapAttrsToList (did: didCfg: {
assertion = lib.hasAttr didCfg.trunk cfg.sipTrunks;
message = "services.voip: DID ${did} references trunk \"${didCfg.trunk}\" which is not in services.voip.sipTrunks";
}) cfg.dids)
++
# dids with mailbox="shared" require sharedMailbox
(lib.mapAttrsToList (did: didCfg: {
assertion = didCfg.mailbox != "shared" || cfg.sharedMailbox != null;
message = "services.voip: DID ${did} has mailbox=\"shared\" but sharedMailbox is not configured";
}) cfg.dids)
++
# dids with mailbox="person" require routing.type="person"
(lib.mapAttrsToList (did: didCfg: {
assertion = didCfg.mailbox != "person" || didCfg.routing.type == "person";
message = "services.voip: DID ${did} has mailbox=\"person\" but routing.type is not \"person\"";
}) cfg.dids)
++
# dids routing.type="person" — person key must be non-empty
(lib.mapAttrsToList (did: didCfg: {
assertion = didCfg.routing.type != "person" || didCfg.routing.person != "";
message = "services.voip: DID ${did} has routing.type=\"person\" but routing.person is not set";
}) cfg.dids)
++
# dids routing.type="person" — referenced person must exist
(lib.mapAttrsToList (did: didCfg: {
assertion = didCfg.routing.type != "person" || didCfg.routing.person == "" || lib.hasAttr didCfg.routing.person cfg.persons;
message = "services.voip: DID ${did} references person \"${didCfg.routing.person}\" which is not in services.voip.persons";
}) cfg.dids)
++
# dids routing.type="persons" — persons list must be non-empty
(lib.mapAttrsToList (did: didCfg: {
assertion = didCfg.routing.type != "persons" || didCfg.routing.persons != [];
message = "services.voip: DID ${did} has routing.type=\"persons\" but routing.persons is empty";
}) cfg.dids)
++
# dids routing.type="persons" — all referenced persons must exist
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
lib.optionals (didCfg.routing.type == "persons")
(map (p: {
assertion = lib.hasAttr p cfg.persons;
message = "services.voip: DID ${did} references person \"${p}\" which is not in services.voip.persons";
}) didCfg.routing.persons)
) cfg.dids))
++
# dids musicOnHold must reference an existing mohClass
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
lib.optional (didCfg.musicOnHold != null) {
assertion = lib.hasAttr didCfg.musicOnHold cfg.mohClasses;
message = "services.voip: DID ${did} references mohClass \"${didCfg.musicOnHold}\" which is not in services.voip.mohClasses";
}
) cfg.dids))
++
# sipTrunks: each required field needs either a literal or a file
(lib.concatLists (lib.mapAttrsToList (name: t: [
{ assertion = t.host != "" || t.hostFile != null;
message = "services.voip: sipTrunks.\"${name}\" requires host or hostFile"; }
{ assertion = t.username != "" || t.usernameFile != null;
message = "services.voip: sipTrunks.\"${name}\" requires username or usernameFile"; }
{ assertion = t.password != "" || t.passwordFile != null;
message = "services.voip: sipTrunks.\"${name}\" requires password or passwordFile"; }
]) cfg.sipTrunks))
++
# dids: each DID needs a number either inline or from a file
(lib.mapAttrsToList (id: d: {
assertion = d.number != "" || d.numberFile != null;
message = "services.voip: dids.\"${id}\" requires number or numberFile";
}) cfg.dids)
++
# extensions with mode="app" must have a non-null app field
(lib.mapAttrsToList (ext: extCfg: {
assertion = extCfg.mode != "app" || extCfg.app != null;
message = "services.voip: extension \"${ext}\" has mode=\"app\" but app is not set";
}) cfg.extensions)
++
# intercomPrefix must not collide with any declared extension
(lib.optionals (cfg.intercomPrefix != null)
(lib.mapAttrsToList (key: phone:
(lib.mapAttrsToList (_key: phone:
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
assertion = !lib.hasAttr ext cfg.extensions;
message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix";
}
) (lib.filterAttrs (_: phone: models.${phone.model}.hasProvisioning) cfg.phones)));
) allPhones));
services.asterisk = {
enable = true;
confFiles = confFiles;
enable = true;
confFiles = confFiles;
# execincludes=yes is required when any *File option is in use.
extraConfig = lib.optionalString hasRuntimeSecrets ''
[options]
execincludes=yes
'';
};
services.atftpd = {
enable = true;
root = "${tftpRoot}";
root = "${provisioningRoot}";
extraOptions = [ "--verbose=7" ];
};
services.nginx = {
enable = true;
enable = true;
# OpenResty bundles nginx + LuaJIT + resty.core and all required libraries.
# Needed for request-time secret substitution in the status page.
package = lib.mkIf hasRuntimeSecrets (lib.mkDefault pkgs.openresty);
# Cisco phones fetch provisioning files (SEP*.cnf.xml, dialplan-*.xml,
# backgrounds) over TFTP (primary) and HTTP port 6970 (fallback).
# Both serve the same Nix-built provisioning root.
virtualHosts."voip-provisioning" = {
listen = [{ addr = "0.0.0.0"; port = 6970; }];
locations."/" = {
root = "${provisioningRoot}";
extraConfig = "autoindex off;";
};
};
virtualHosts."voip-directory" = {
listen = [{ addr = "0.0.0.0"; port = cfg.directoryPort; }];
locations."= /directory.xml" = {
alias = "${directory.menuFile}";
extraConfig = "default_type text/xml;";
};
locations."= /directory-list.xml" = {
alias = "${directory.listFile}";
extraConfig = "default_type text/xml;";
};
locations."= /intercom.xml" = {
alias = "${directory.intercomFile}";
extraConfig = "default_type text/xml;";
};
locations."= /voicemail.xml" = {
alias = "${directory.voicemailFile}";
extraConfig = "default_type text/xml;";
locations = {
"= /directory.xml" = { alias = "${directory.menuFile}"; extraConfig = "default_type text/xml;"; };
"= /directory-list.xml" = { alias = "${directory.listFile}"; extraConfig = "default_type text/xml;"; };
"= /intercom.xml" = { alias = "${directory.intercomFile}"; extraConfig = "default_type text/xml;"; };
"/" = {
root = "${diagram.webRoot}";
extraConfig = lib.optionalString (!hasRuntimeSecrets) "index index.html;";
};
} // lib.optionalAttrs hasRuntimeSecrets {
# Exact-match the index so the Lua handler intercepts it before the
# prefix location /. Other assets (voip.dot, SVG) fall through to /.
"= /" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
"= /index.html" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
};
};
};
# voip-keys group: both asterisk (#exec reads) and nginx (Lua reads) need access.
# Key files must be deployed with group = "voip-keys" and permissions = "0640".
users.groups.voip-keys = {};
users.users.asterisk.extraGroups = [ "voip-keys" ];
users.users.nginx.extraGroups = [ "voip-keys" ];
systemd.tmpfiles.rules = [ "d /var/log/nginx 0750 nginx nginx -" ];
networking.firewall = {
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort ];
allowedUDPPorts = [ 69 ];
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort 6970 ];
allowedUDPPorts = [ cfg.sipPort 69 ];
allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }];
};
};

390
modules/voip/diagram.nix Normal file
View file

@ -0,0 +1,390 @@
{ lib, pkgs, cfg, models, allPhones, intercomEntries }:
let
hasTrunk = cfg.sipTrunks != {};
# For HTML table cells: produces either the literal value or a @@/full/path@@
# marker that the nginx Lua handler opens directly at request time.
rtv = val: file: if file != null then "@@${file}@@" else val;
# For DOT node labels (end up in SVG): never use @@ markers — graphviz may
# split long strings at hyphens across <tspan> elements, breaking substitution
# and corrupting the SVG. Show the key filename as a static label instead.
rtvLabel = val: file: if file != null then baseNameOf file else val;
# Sanitize a string for use as a DOT node identifier
nid = prefix: s: "${prefix}_${lib.replaceStrings
[ "-" "+" "*" "@" "." " " "/" ]
[ "_" "p" "s" "at" "_" "_" "_" ] s}";
# Whether a phone gets an L2 line (same logic as asterisk.nix)
phoneHasL2 = key:
let phone = allPhones.${key};
in phone.personKey != null && models.${phone.model}.hasProvisioning;
# ── Nodes ───────────────────────────────────────────────────────────────
trunkNodes = lib.mapAttrsToList (name: t:
'' ${nid "trunk" name} [label="${name}\n${rtvLabel t.host t.hostFile}" shape=box style="rounded,filled" fillcolor="#AED6F1"]''
) cfg.sipTrunks;
didNodes = lib.mapAttrsToList (id: didCfg:
let numLabel = rtvLabel didCfg.number didCfg.numberFile; in
'' ${nid "did" id} [label="${numLabel}\n${didCfg.displayName}" shape=box style="rounded,filled" fillcolor="#FAD7A0"]''
) cfg.dids;
personNodes = lib.mapAttrsToList (key: person:
'' ${nid "person" key} [label="${person.displayName}\next. ${person.extension}" shape=ellipse style=filled fillcolor="#A9DFBF"]''
) cfg.persons;
phoneNodes = lib.mapAttrsToList (key: phone:
let
modelLabel = if phone.personKey != null then
cfg.persons.${phone.personKey}.phones.${key}.model
else
cfg.sharedPhones.${key}.model;
isSoftClient = modelLabel == "sip-client";
style = if isSoftClient then "dashed,filled" else "filled";
lines =
[ phone.displayName ]
++ lib.optional (phone.personKey == null) "ext. ${phone.extension}"
++ [ modelLabel ]
++ lib.optional (!isSoftClient) "(${key})"
++ lib.optional isSoftClient key;
in
'' ${nid "phone" key} [label="${lib.concatStringsSep "\\n" lines}" shape=box style="${style}" fillcolor="#D5D8DC"]''
) allPhones;
mailboxNodes =
lib.optional (cfg.sharedMailbox != null)
'' ${nid "mbox" cfg.sharedMailbox.mailboxId} [label="${cfg.sharedMailbox.displayName}\nbox ${cfg.sharedMailbox.mailboxId}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
++ lib.mapAttrsToList (key: person:
lib.optionalString person.mailbox
'' ${nid "mbox" person.extension} [label="${person.displayName}\nbox ${person.extension}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
) cfg.persons;
extensionNodes = lib.mapAttrsToList (ext: extCfg:
let icon = if extCfg.mode == "page" then "📢 " else " "; in
'' ${nid "ext" ext} [label="${ext}\n${extCfg.displayName}\n(${extCfg.mode})" shape=diamond style=filled fillcolor="#D6EAF8"]''
) cfg.extensions;
intercomNodes = map (ic:
'' ${nid "ic" ic.extension} [label="${ic.extension}\n${ic.displayName}" shape=diamond style=filled fillcolor="#FDEBD0"]''
) intercomEntries;
# ── Edges ───────────────────────────────────────────────────────────────
# Trunk → DID
trunkDidEdges = lib.mapAttrsToList (id: didCfg:
'' ${nid "trunk" didCfg.trunk} -> ${nid "did" id}''
) cfg.dids;
# DID → phone targets (routing)
didRoutingEdges = lib.concatLists (lib.mapAttrsToList (id: didCfg:
let
r = didCfg.routing;
lbl = label: ''label="${label}" fontsize=9'';
edge = target: lineLabel:
'' ${nid "did" id} -> ${nid "phone" target} [${lbl lineLabel}]'';
in
if r.type == "all" then
lib.mapAttrsToList (key: _:
edge key (if phoneHasL2 key then "L2" else "L1")
) allPhones
else if r.type == "person" then
lib.mapAttrsToList (key: _: edge key "L1")
cfg.persons.${r.person}.phones
else # persons
lib.concatMap (personKey:
lib.mapAttrsToList (key: _: edge key "L2")
cfg.persons.${personKey}.phones
) r.persons
) cfg.dids);
# DID → mailbox (no-answer fallback, dashed)
didMailboxEdges = lib.mapAttrsToList (id: didCfg:
let
r = didCfg.routing;
dashAttr = ''style=dashed color="#999999" fontsize=9 label="no answer"'';
in
if didCfg.mailbox == "shared" then
'' ${nid "did" id} -> ${nid "mbox" cfg.sharedMailbox.mailboxId} [${dashAttr}]''
else if didCfg.mailbox == "person" then
'' ${nid "did" id} -> ${nid "mbox" cfg.persons.${r.person}.extension} [${dashAttr}]''
else ""
) cfg.dids;
# Person → their phones
personPhoneEdges = lib.concatLists (lib.mapAttrsToList (key: person:
lib.mapAttrsToList (pkey: _:
'' ${nid "person" key} -> ${nid "phone" pkey} [arrowhead=open]''
) person.phones
) cfg.persons);
# Intercom edges: phone → intercom extension
intercomEdges = map (ic:
'' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]''
) intercomEntries;
# Page extension → all phones
pagePhones = lib.filterAttrs (key: phone:
models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null
) allPhones;
pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg:
lib.optionals (extCfg.mode == "page")
(lib.mapAttrsToList (key: _:
'' ${nid "ext" ext} -> ${nid "phone" key} [style=dotted color="#5D6D7E"]''
) pagePhones)
) cfg.extensions);
# ── Assemble DOT ────────────────────────────────────────────────────────
dotSource = ''
digraph voip {
rankdir=LR;
graph [fontname="Helvetica,Arial,sans-serif" fontsize=11 splines=ortho];
node [fontname="Helvetica,Arial,sans-serif" fontsize=10];
edge [fontname="Helvetica,Arial,sans-serif" fontsize=9];
subgraph cluster_trunks {
label="SIP Trunks"; style=filled; fillcolor="#EBF5FB";
${lib.concatStringsSep "\n" trunkNodes}
}
subgraph cluster_dids {
label="DIDs (Inbound)"; style=filled; fillcolor="#FEF9E7";
${lib.concatStringsSep "\n" didNodes}
}
${lib.optionalString (cfg.persons != {}) ''
subgraph cluster_persons {
label="Persons"; style=filled; fillcolor="#EAFAF1";
${lib.concatStringsSep "\n" personNodes}
}
''}
subgraph cluster_phones {
label="Phones"; style=filled; fillcolor="#F2F3F4";
${lib.concatStringsSep "\n" phoneNodes}
}
${lib.optionalString (cfg.sharedMailbox != null || lib.any (p: p.mailbox) (lib.attrValues cfg.persons)) ''
subgraph cluster_mailboxes {
label="Voicemail"; style=filled; fillcolor="#F5EEF8";
${lib.concatStringsSep "\n" mailboxNodes}
}
''}
${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
subgraph cluster_extensions {
label="Extensions"; style=filled; fillcolor="#EBF5FB";
${lib.concatStringsSep "\n" extensionNodes}
${lib.concatStringsSep "\n" intercomNodes}
}
''}
// Trunk DID
${lib.concatStringsSep "\n" trunkDidEdges}
// DID routing phones
${lib.concatStringsSep "\n" didRoutingEdges}
// No-answer mailbox
${lib.concatStringsSep "\n" didMailboxEdges}
// Person phones
${lib.concatStringsSep "\n" personPhoneEdges}
// Intercom
${lib.concatStringsSep "\n" intercomEdges}
// Page
${lib.concatStringsSep "\n" pageEdges}
}
'';
dotFile = pkgs.writeText "voip.dot" dotSource;
svgFile = pkgs.runCommand "voip-diagram.svg" {
nativeBuildInputs = [ pkgs.graphviz ];
} "dot -Tsvg ${dotFile} -o $out";
# HTML head — everything up to and including the diagram container opening tag
htmlHead = pkgs.writeText "voip-head.html" ''
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${cfg.directoryName} VoIP</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
header { background: #2c3e50; color: white; padding: 1em 2em; }
header h1 { margin: 0; font-size: 1.4em; font-weight: 500; }
main { padding: 2em; }
.card { background: white; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 1.5em; margin-bottom: 1.5em; overflow: auto; }
.card h2 { margin: 0 0 1em; font-size: 1em; color: #555; text-transform: uppercase; letter-spacing: .05em; }
.diagram svg { max-width: 100%; height: auto; display: block; }
table { border-collapse: collapse; width: 100%; font-size: .9em; }
th { text-align: left; padding: .4em .8em; background: #f0f0f0; border-bottom: 2px solid #ddd; }
td { padding: .4em .8em; border-bottom: 1px solid #eee; }
code { background: #f0f0f0; padding: .1em .3em; border-radius: 3px; font-size: .9em; }
</style>
</head>
<body>
<header><h1>${cfg.directoryName} VoIP Routing</h1></header>
<main>
<div class="card">
<h2>Routing Diagram</h2>
<div class="diagram">
'';
# HTML tail — closes the diagram div and adds all info tables
htmlTail = pkgs.writeText "voip-tail.html" ''
</div>
</div>
${lib.optionalString hasTrunk ''
<div class="card">
<h2>SIP Trunks</h2>
<table>
<tr><th>Name</th><th>Host</th><th>Username</th><th>Transport</th><th>Caller ID</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t:
let callerIdCell =
if t.callerIdFile != null || t.callerId != "" then rtv t.callerId t.callerIdFile
else "<em>provider default</em>";
in
''<tr><td><code>${name}</code></td><td>${rtv t.host t.hostFile}</td><td><code>${rtv t.username t.usernameFile}</code></td><td>${t.transport}</td><td>${callerIdCell}</td></tr>''
) cfg.sipTrunks)}
</table>
</div>
<div class="card">
<h2>DIDs (Inbound)</h2>
<table>
<tr><th>ID</th><th>Number</th><th>Name</th><th>Trunk</th><th>Routing</th><th>Timeout</th><th>Mailbox</th><th>Music on Hold</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg:
let r = didCfg.routing;
numCell = if didCfg.numberFile != null then "<code>@@${didCfg.numberFile}@@</code>"
else if didCfg.number != "" then "<code>${didCfg.number}</code>"
else "<em>(none)</em>";
routeStr = if r.type == "all" then "all phones (L2)"
else if r.type == "person" then " ${r.person} (L1)"
else " ${lib.concatStringsSep ", " r.persons} (L2)";
mohCell = if didCfg.musicOnHold != null then didCfg.musicOnHold else "<em>ringback</em>";
in
''<tr><td><code>${id}</code></td><td>${numCell}</td><td>${didCfg.displayName}</td><td><code>${didCfg.trunk}</code></td><td>${routeStr}</td><td>${toString didCfg.routing.timeout}s</td><td>${didCfg.mailbox}</td><td>${mohCell}</td></tr>''
) cfg.dids)}
</table>
</div>
''}
<div class="card">
<h2>Persons</h2>
<table>
<tr><th>Key</th><th>Name</th><th>Extension</th><th>Mailbox</th><th>Ring timeout</th><th>Greeting</th><th>Devices</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: person:
let
devList = lib.concatStringsSep "<br>" (lib.mapAttrsToList (pkey: ph:
"<code>${pkey}</code> (${ph.model})"
) person.phones);
mailboxCell = if person.mailbox then "ext. ${person.extension}" else "<em>none</em>";
greetingCell = if person.mailboxGreeting != null then "custom" else "<em>default</em>";
in
''<tr><td><code>${key}</code></td><td>${person.displayName}</td><td><code>${person.extension}</code></td><td>${mailboxCell}</td><td>${toString person.ringTimeout}s</td><td>${greetingCell}</td><td>${devList}</td></tr>''
) cfg.persons)}
</table>
</div>
${lib.optionalString (cfg.sharedPhones != {}) ''
<div class="card">
<h2>Shared Phones</h2>
<table>
<tr><th>Key / MAC</th><th>Name</th><th>Extension</th><th>Model</th><th>Label</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
''<tr><td><code>${key}</code></td><td>${phone.displayName}</td><td><code>${phone.extension}</code></td><td>${phone.model}</td><td>${phone.label}</td></tr>''
) cfg.sharedPhones)}
</table>
</div>
''}
${lib.optionalString (cfg.sharedMailbox != null) ''
<div class="card">
<h2>Shared Mailbox</h2>
<table>
<tr><th>Name</th><th>Mailbox ID</th><th>Check extension</th><th>Greeting</th></tr>
<tr>
<td>${cfg.sharedMailbox.displayName}</td>
<td><code>${cfg.sharedMailbox.mailboxId}</code></td>
<td><code>${cfg.sharedMailbox.checkExtension}</code></td>
<td>${if cfg.sharedMailbox.greeting != null then "custom" else "<em>default</em>"}</td>
</tr>
</table>
</div>
''}
${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
<div class="card">
<h2>Extensions</h2>
<table>
<tr><th>Extension</th><th>Name</th><th>Type</th><th>Detail</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
let detail = if extCfg.mode == "page" then "all phones" else extCfg.app; in
''<tr><td><code>${ext}</code></td><td>${extCfg.displayName}</td><td>${extCfg.mode}</td><td><code>${detail}</code></td></tr>''
) cfg.extensions)}
${lib.concatStringsSep "\n" (map (ic:
''<tr><td><code>${ic.extension}</code></td><td>${ic.displayName}</td><td>intercom</td><td> <code>${ic.phoneKey}</code></td></tr>''
) intercomEntries)}
</table>
</div>
''}
${lib.optionalString (cfg.mohClasses != {}) ''
<div class="card">
<h2>Music on Hold</h2>
<table>
<tr><th>Class</th><th>Files</th><th>Sort</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls:
''<tr><td><code>${name}</code></td><td>${toString (lib.length cls.files)}</td><td>${cls.sort}</td></tr>''
) cfg.mohClasses)}
</table>
</div>
''}
<div class="card">
<h2>Codecs</h2>
<table>
<tr><th>Endpoint type</th><th>Preference order</th></tr>
<tr><td>Hardware phones</td><td><code>${lib.concatStringsSep " " cfg.codecs.hardwarePhones}</code></td></tr>
<tr><td>Soft clients</td><td><code>${lib.concatStringsSep " " cfg.codecs.softClients}</code></td></tr>
<tr><td>Trunks</td><td><code>${lib.concatStringsSep " " cfg.codecs.trunk}</code></td></tr>
</table>
</div>
<div class="card">
<h2>Server</h2>
<table>
<tr><th>Parameter</th><th>Value</th></tr>
<tr><td>Address</td><td><code>${cfg.serverAddress}</code></td></tr>
<tr><td>SIP port</td><td><code>${toString cfg.sipPort}</code></td></tr>
<tr><td>RTP range</td><td><code>${toString cfg.rtpStart} ${toString cfg.rtpEnd}</code></td></tr>
<tr><td>Directory port</td><td><code>${toString cfg.directoryPort}</code></td></tr>
${lib.optionalString (cfg.intercomPrefix != null)
''<tr><td>Intercom prefix</td><td><code>${cfg.intercomPrefix}</code></td></tr>''}
</table>
</div>
</main>
</body>
</html>
'';
# Web root directory: index.html with inlined SVG (head + svg + tail), plus raw .dot
webRoot = pkgs.runCommand "voip-webroot" {} ''
mkdir -p $out
cat ${htmlHead} ${svgFile} ${htmlTail} > $out/index.html
cp ${dotFile} $out/voip.dot
'';
in { inherit webRoot svgFile dotFile; }

View file

@ -1,11 +1,20 @@
{ lib, pkgs, cfg, intercomEntries }:
{ lib, pkgs, cfg, allPhones, intercomEntries }:
let
baseUrl = "http://${cfg.serverAddress}:${toString cfg.directoryPort}";
hasPageExtensions = lib.any (e: e.mode == "page") (lib.attrValues cfg.extensions);
# Deduplicated directory entries: one per extension, using the displayName from
# allPhones (all phones sharing an extension have the same displayName).
extensionEntries =
lib.attrValues (lib.foldlAttrs (acc: _key: phone:
if lib.hasAttr phone.extension acc || phone.displayName == "" then acc
else acc // { ${phone.extension} = { inherit (phone) extension displayName; }; }
) {} allPhones);
menuXml = ''
<?xml version="1.0" encoding="UTF-8"?>
<CiscoIPPhoneMenu>
<Title>${cfg.directoryName} Telefonbuch</Title>
<Prompt>Ihre Wahl</Prompt>
@ -23,21 +32,22 @@ let
'';
listXml = ''
<?xml version="1.0" encoding="UTF-8"?>
<CiscoIPPhoneDirectory>
<Title>${cfg.directoryName} Telefonbuch</Title>
<Prompt>Ihre Wahl</Prompt>
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
lib.optionalString (extCfg.mode == "line" && extCfg.displayName != "") ''
'' + lib.concatMapStringsSep "\n" (e: ''
<DirectoryEntry>
<Name>${extCfg.displayName}</Name>
<Telephone>${ext}</Telephone>
<Name>${e.displayName}</Name>
<Telephone>${e.extension}</Telephone>
</DirectoryEntry>
'') cfg.extensions)
'') extensionEntries
+ ''
</CiscoIPPhoneDirectory>
'';
intercomXml = ''
<?xml version="1.0" encoding="UTF-8"?>
<CiscoIPPhoneDirectory>
<Title>Intercom / Durchsage</Title>
<Prompt>Ihre Wahl</Prompt>
@ -59,14 +69,15 @@ let
'';
voicemailMenuXml = ''
<?xml version="1.0" encoding="UTF-8"?>
<CiscoIPPhoneExecute>
<ExecuteItem Priority="0" URL="Dial:997"/>
<ExecuteItem Priority="0" URL="Dial:*97"/>
</CiscoIPPhoneExecute>
'';
in {
menuFile = pkgs.writeText "directory.xml" menuXml;
listFile = pkgs.writeText "directory-list.xml" listXml;
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
menuFile = pkgs.writeText "directory.xml" menuXml;
listFile = pkgs.writeText "directory-list.xml" listXml;
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
}

View file

@ -0,0 +1,34 @@
{ lib, pkgs, cfg }:
# Transcode voicemail greeting audio files to multiple formats so Asterisk can
# play them without transcoding regardless of the channel's active codec.
# Playback() is called with the path minus extension; Asterisk finds the best.
#
# Returns:
# { shared : path | null — greeting dir for the shared mailbox
# , persons : { key : path | null } — greeting dir per person key
# }
let
mkGreetingDir = name: src:
pkgs.runCommand "greeting-${name}" {
nativeBuildInputs = [ pkgs.ffmpeg ];
} ''
mkdir -p $out
ffmpeg -i ${src} -ar 8000 -ac 1 -f mulaw $out/greeting.ulaw
ffmpeg -i ${src} -ar 8000 -ac 1 -f alaw $out/greeting.alaw
ffmpeg -i ${src} -ar 16000 -ac 1 -acodec adpcm_g722 $out/greeting.g722
'';
in {
shared =
if cfg.sharedMailbox != null && cfg.sharedMailbox.greeting != null
then mkGreetingDir "shared" cfg.sharedMailbox.greeting
else null;
persons = lib.mapAttrs (key: person:
if person.mailbox && person.mailboxGreeting != null
then mkGreetingDir key person.mailboxGreeting
else null
) cfg.persons;
}

View file

@ -1,20 +1,16 @@
{ lib, cfg, models }:
{ lib, cfg, models, allPhones }:
if cfg.intercomPrefix == null then []
else lib.concatLists (lib.mapAttrsToList (key: phone:
let
m = models.${phone.model};
ext = phone.extension;
# cfg.extensions.${ext} is guaranteed to exist by the phone→extension assertion
extCfg = cfg.extensions.${ext};
in lib.optional m.hasProvisioning {
extension = "${cfg.intercomPrefix}${ext}";
endpoint = "${key}-intercom";
phoneKey = key;
target = ext;
displayName = "Intercom ${extCfg.displayName}";
password = phone.password;
let m = models.${phone.model}; in
lib.optional m.hasProvisioning {
extension = "${cfg.intercomPrefix}${phone.extension}";
endpoint = "${key}-intercom";
phoneKey = key;
target = phone.extension;
displayName = "Intercom ${phone.displayName}";
password = phone.password;
endpointTemplate = m.endpointTemplate;
maxContacts = m.maxContacts;
maxContacts = m.maxContacts;
}
) cfg.phones)
) allPhones)

23
modules/voip/moh.nix Normal file
View file

@ -0,0 +1,23 @@
{ lib, pkgs, cfg }:
# Generate one directory per MOH class containing each source file transcoded
# to multiple formats. Asterisk's mode=files picks the best format at runtime
# based on the channel's active codec, avoiding transcoding where possible.
#
# Format variants per file:
# N.ulaw — G.711 μ-law 8kHz (universal narrowband)
# N.alaw — G.711 A-law 8kHz (European narrowband)
# N.g722 — G.722 16kHz (wideband, no transcoding for G.722 calls)
lib.mapAttrs (className: cls:
pkgs.runCommand "moh-${className}" {
nativeBuildInputs = [ pkgs.ffmpeg ];
} (lib.concatStringsSep "\n" (
[ "mkdir -p $out" ]
++ lib.concatLists (lib.imap1 (i: src: [
"ffmpeg -i ${src} -ar 8000 -ac 1 -f mulaw $out/${toString i}.ulaw"
"ffmpeg -i ${src} -ar 8000 -ac 1 -f alaw $out/${toString i}.alaw"
"ffmpeg -i ${src} -ar 16000 -ac 1 -acodec adpcm_g722 $out/${toString i}.g722"
]) cls.files)
))
) cfg.mohClasses

View file

@ -0,0 +1,98 @@
{ lib, pkgs, cfg, models, allPhones, backgroundEntries }:
let
hasTrunk = cfg.sipTrunks != {};
# Page extensions for the intercom button auto-dial
pageExtension =
let pages = lib.attrNames (lib.filterAttrs (_: e: e.mode == "page") cfg.extensions);
in if pages != [] then lib.head pages else null;
hasIntercomButton = cfg.intercomPrefix != null && pageExtension != null;
# Collect all internal extension numbers to generate exact-match patterns.
# This tells the phone "this number is complete" so it dials immediately
# rather than waiting for the timeout or firing on the first digit.
allExtensions = lib.unique (
lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones
++ lib.mapAttrsToList (_: p: p.extension) cfg.persons
);
# Star extensions: intercom prefix + each extension, plus custom extensions.
allStarExtensions =
(lib.optionals (cfg.intercomPrefix != null)
(map (ext: "${cfg.intercomPrefix}${ext}") allExtensions))
++ lib.attrNames cfg.extensions
++ lib.optional (cfg.sharedMailbox != null) cfg.sharedMailbox.checkExtension;
# Generate the dial template for a phone, given its intercom lineIndex.
# The lineIndex is derived from familyLineEnabled — the same condition used
# in the Cisco template for button layout, so both are always in sync.
#
# Pattern evaluation: the phone tests templates top-to-bottom and dials as
# soon as digits match a pattern with timeout="0", or after the timeout
# elapses for patterns with timeout > 0. Explicit patterns must come before
# the catch-all "." so the phone doesn't fire early on a partial match.
mkDialplanXml = key: intercomLineIndex:
let
# Stable UUID derived from a hash of all inputs that affect this dialplan.
# Changes whenever extensions, trunk config, or intercom config changes,
# causing phones to reload the file. Format: 8-4-4-4-12 hex chars.
h = builtins.hashString "sha256" (builtins.toJSON {
inherit key allExtensions allStarExtensions hasTrunk hasIntercomButton intercomLineIndex;
});
versionStamp = "${builtins.substring 0 8 h}-${builtins.substring 8 4 h}-${builtins.substring 12 4 h}-${builtins.substring 16 4 h}-${builtins.substring 20 12 h}";
# Exact-match a complete extension number — dial immediately.
extTemplate = ext: " <TEMPLATE match=\"${ext}\" timeout=\"0\" />";
in ''
<dialTemplate>
<versionStamp>${versionStamp}</versionStamp>
${lib.optionalString hasIntercomButton
" <TEMPLATE match=\"\" timeout=\"0\" rewrite=\"${pageExtension}\" line=\"${toString intercomLineIndex}\" />"}
${lib.concatMapStrings (ext: extTemplate ext + "\n") allExtensions}
${lib.concatMapStrings (ext: extTemplate ext + "\n") allStarExtensions}
<TEMPLATE match=".." timeout="5" />
</dialTemplate>
'';
in
pkgs.linkFarm "voip-tftp-root" (
lib.concatLists (lib.mapAttrsToList (key: phone:
let m = models.${phone.model}; in
lib.optionals m.hasProvisioning (
let
upperKey = lib.toUpper key;
familyLineEnabled = hasTrunk && phone.personKey != null;
intercomLineIndex = if familyLineEnabled then 3 else 2;
dialplanFilename = "dialplan-${upperKey}.xml";
xml = m.template ({
mac = key;
inherit (phone) label password displayName;
serverAddress = cfg.serverAddress;
ntpServer = cfg.ntpServer;
sipPort = cfg.sipPort;
directoryPort = cfg.directoryPort;
dialplanFile = dialplanFilename;
} // lib.optionalAttrs (cfg.intercomPrefix != null) {
intercomEnabled = true;
intercomPassword = phone.password;
} // lib.optionalAttrs familyLineEnabled {
inherit familyLineEnabled;
familyLineLabel =
if cfg.sharedMailbox != null
then cfg.sharedMailbox.displayName
else "Familie";
familyLinePassword = phone.password;
});
in [
{ name = "SEP${upperKey}.cnf.xml";
path = pkgs.writeText "SEP${upperKey}.cnf.xml" xml; }
{ name = dialplanFilename;
path = pkgs.writeText dialplanFilename (mkDialplanXml key intercomLineIndex); }
]
)
) allPhones)
++ backgroundEntries
)

View file

@ -1,5 +1,24 @@
{ mac, label, displayName, password, serverAddress, ntpServer, sipPort ? 5060, directoryPort ? 8080, intercomEnabled ? false, intercomPassword ? "" }:
{ mac, label, displayName, password, serverAddress, ntpServer
, sipPort ? 5060
, directoryPort ? 8080
, familyLineEnabled ? false
, familyLineLabel ? "Familie"
, familyLinePassword ? ""
, intercomEnabled ? false
, intercomPassword ? ""
, dialplanFile ? "dialplan.xml"
}:
let
# Line button assignments:
# button 1 / lineIndex 1 — personal/location L1 line (always present)
# button 2 / lineIndex 2 — family L2 line (when familyLineEnabled)
# button N / lineIndex N — intercom (when intercomEnabled; N = 2 or 3)
intercomButton = if familyLineEnabled then 3 else 2;
intercomLineIndex = intercomButton;
in
''
<?xml version="1.0" encoding="UTF-8"?>
<device>
<deviceProtocol>SIP</deviceProtocol>
<sshUserId>admin</sshUserId>
@ -68,7 +87,7 @@
</sipStack>
<transferOnhookEnabled>false</transferOnhookEnabled>
<kpml>3</kpml>
<phoneLabel>${builtins.substring 0 12 label}</phoneLabel>
<phoneLabel>${(builtins.substring 0 12 label)}</phoneLabel>
<stutterMsgWaiting>1</stutterMsgWaiting>
<callStats>false</callStats>
<sipLines>
@ -95,8 +114,32 @@
<dialedNumber>true</dialedNumber>
</forwardCallInfoDisplay>
</line>
${if intercomEnabled then ''
${if familyLineEnabled then ''
<line button="2" lineIndex="2">
<featureID>9</featureID>
<featureLabel>${familyLineLabel}</featureLabel>
<proxy>USECALLMANAGER</proxy>
<port>${toString sipPort}</port>
<name>${mac}-l2</name>
<displayName>${familyLineLabel}</displayName>
<autoAnswer>
<autoAnswerEnabled>2</autoAnswerEnabled>
</autoAnswer>
<callWaiting>3</callWaiting>
<authName>${mac}-l2</authName>
<authPassword>${familyLinePassword}</authPassword>
<messageWaitingLampPolicy>3</messageWaitingLampPolicy>
<messagesNumber>*97</messagesNumber>
<contact>${mac}-l2</contact>
<forwardCallInfoDisplay>
<callerName>true</callerName>
<callerNumber>true</callerNumber>
<redirectedNumber>true</redirectedNumber>
<dialedNumber>true</dialedNumber>
</forwardCallInfoDisplay>
</line>
'' else ""}${if intercomEnabled then ''
<line button="${toString intercomButton}" lineIndex="${toString intercomLineIndex}">
<featureID>23</featureID>
<featureLabel>Intercom</featureLabel>
<proxy>USECALLMANAGER</proxy>
@ -119,8 +162,9 @@ ${if intercomEnabled then ''
<startMediaPort>16348</startMediaPort>
<stopMediaPort>20134</stopMediaPort>
<dscpForAudio>184</dscpForAudio>
<dialTemplate>dialplan.xml</dialTemplate>
<dialTemplate>${dialplanFile}</dialTemplate>
</sipProfile>
<MissedCallLoggingOption>1</MissedCallLoggingOption>
<commonProfile>
<phonePassword></phonePassword>
<backgroundImageAccess>true</backgroundImageAccess>
@ -152,6 +196,24 @@ ${if intercomEnabled then ''
<informationURL></informationURL>
<phoneServices useHTTPS="false">
<provisioning>2</provisioning>
<phoneService type="1" category="0">
<name>Missed Calls</name>
<url>Application:Cisco/MissedCalls</url>
<vendor></vendor>
<version></version>
</phoneService>
<phoneService type="1" category="0">
<name>Received Calls</name>
<url>Application:Cisco/ReceivedCalls</url>
<vendor></vendor>
<version></version>
</phoneService>
<phoneService type="1" category="0">
<name>Placed Calls</name>
<url>Application:Cisco/PlacedCalls</url>
<vendor></vendor>
<version></version>
</phoneService>
<phoneService type="2" category="0">
<name>Voicemail</name>
<url>Application:Cisco/Voicemail</url>

View file

@ -1,29 +0,0 @@
{ lib, pkgs, cfg, models, backgroundEntries }:
pkgs.linkFarm "voip-tftp-root" (
lib.concatLists (lib.mapAttrsToList (key: phone:
let m = models.${phone.model}; in
lib.optional m.hasProvisioning (
let
upperKey = lib.toUpper key;
ext = cfg.extensions.${phone.extension};
xml = m.template ({
mac = key;
inherit (phone) label password;
displayName = ext.displayName;
serverAddress = cfg.serverAddress;
ntpServer = cfg.ntpServer;
sipPort = cfg.sipPort;
directoryPort = cfg.directoryPort;
} // lib.optionalAttrs (cfg.intercomPrefix != null) {
intercomEnabled = true;
intercomPassword = phone.password;
});
in {
name = "SEP${upperKey}.cnf.xml";
path = pkgs.writeText "SEP${upperKey}.cnf.xml" xml;
}
)
) cfg.phones)
++ backgroundEntries
)

19
secrets/secrets.nix Normal file
View file

@ -0,0 +1,19 @@
let
jbruhn = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH7v2e1uLxfqu7zuWLgUdsxE+fBxkjxYNuwhfKduO34U offis\jbruhn@it1002077";
users = [ jbruhn ];
telefonmann = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEwgqWVjNOgBygI1uaG8P6wQlfr91A+FJS/EHYZbYWlX";
systems = [ telefonmann ];
in
{
"voip-trunk-ewe-host.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe1-username.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe1-password.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe1-callerid.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe2-username.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe2-password.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe2-callerid.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe3-username.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe3-password.age".publicKeys = users ++ [ telefonmann ];
"voip-trunk-ewe3-callerid.age".publicKeys = users ++ [ telefonmann ];
}

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg aTDzgRT6xEL9BaIhqfpEgQKjGPfahKSHh3uYOm7n32g
rrTgEzAxcyWeC9Qwrw/Tp1GsE902mmGcs8/rKbflobs
-> ssh-ed25519 Gfi4hQ 5qBnv/8OonFL8JgrgfIsi254IKX5q6oVV8/4epFlNEI
nNe7V6St3VLUJ1xdK8iJBROia7CAQfMaGgGUiv++fns
--- qo99nBZJS8fqU+vfdxg5CME+HZdTrWlga0hgEQqqUTM
\­j€ÃÜSÁBÚhòºù[²\áH#DbbÕhšŽ-¢ìï] ŠS~^°Eb{;º'‘æ.´ …€

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg cV6nDhAKb7tWgyx4nKFT2tYnlQJzLszqnQVoHiye7lw
oGMbfNIBSFVmts2IeCTBgDVlHDjqgIpNmmwjFXP/XNo
-> ssh-ed25519 Gfi4hQ iEwAaUJd1gnYhsbi6LutKk/KHr3YripvY5CUfV6StRA
GffKWdz87M1XRYIsImXsx0Rpxz0O6113rClVpNZFmqs
--- li/YIWk6iDAtMYCNf1kFq46i0jpy5bgI80t98bRqGFQ
ÛOÚDø(Ò¢Ãñ».¦ÊšÔ݉iµu.÷#>Çès6$dbJ€"ä߆ÎOçv

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg CSy6l/EdpaJZhQKFBV/P1OEy7OfewLrcV+xxBFVoPHQ
ZceDzPQH8Z5mu3xuCcOdM6hiP+yD9LtWyqVRBzT08oU
-> ssh-ed25519 Gfi4hQ co95lRFjdU3uakIPvS8Mj7aGhYamlouyj/2cGZJ8o3Y
SvHzNxzHJbHMUmJENu934Wuy27s8yotCa+yqZngTr6s
--- FIIm6l+HiK2Eh4Tkmr08rZhhFtE+x5hUHM9TWgP0BPQ
*Où>þ<18>E‰Þ €TÇ'£×¿©˜½²=‡ø8¡—Tâ

View file

@ -0,0 +1,8 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg ZXCCEKHvyj3V/dGDNgMZNxrcu3Kpqk6T/KcE9KvI60g
X8EoTT5COPZUjJ6VvQLZDzZs63K/RyIZanFQx5USFrw
-> ssh-ed25519 Gfi4hQ riBFzxyTj4ph6tpL6mC+2OoCnJFTxxnsPUbX6BrF8lU
BuhSoMAroN+YkPxziSMUQtmJrcOfUlfqn9bgfkpbJxo
--- IQA291DTeWEMPkmDXjHzSZ6gg7mUu19laesubfmG1to
ÜÄCùàdcW<63>òô>/e÷€Qå•M|)D…I>×€9¢Vå
ÑMNj¨¿E{

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg bbVG5NDvMUfqP5bAjKakl3XtsAyv4Mo1xqsshyqgsyU
Y06IQn9fX37uPanqUSr8h9GZhlkFNhX5XQCdSJZtAJE
-> ssh-ed25519 Gfi4hQ 8JvFmMpFX5RK+sn29l3vwC852h7CwPjpaN/XrjIq/3I
LVHOqggZFi562Rd3Fr2ePbTyFDsIuROShUq3T6LVnBo
--- WXzMczGriwzzcUkJhMOdVzMRfSCLwfUwzOwRU5z7Tr4
ÚV&Mn\IÔÓ~HeíKWø×PS2GCiÜÍóM%öyvs-ºo1õ5ã

Binary file not shown.

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg R8XRdHbLzVSEYlz2P4d0+imtxZnOZmtG2Hrhs+OkY1Y
b6IUJ890xJEceyCGrrp0xlGmVgtfhn0qvAg19yWnMwY
-> ssh-ed25519 Gfi4hQ YPoImdtxqMO2GjFVeXDzqmtH3JcHxP6CjfeYZD1diHc
mj7b8FSrXiFvcCc2yVHV9GgtZDQvV2Lq/T1V+I4NsH4
--- g+6x7BOXv3hlti1rlXsKoEK17fRSd9ReukGG43t2jrg
 åI;N™;FùãxãuBVˆ: ÉT‡)÷½‰è*…ñn<C3B1> æ:æçcÖ+w¤Oé¨

View file

@ -0,0 +1,8 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg nDn8cdsSuRv/XO5u/h1T8VGpZbkrsdte+ueF1ZUobhw
KSK4TB5HrMHwdHngNM4VGBKfYhWBbKJKY18YCGYjyHw
-> ssh-ed25519 Gfi4hQ M74IR8bxK3/KzzCN00azEFaU9eRhdEB+V+V7GH2Bf3Y
mzKKwWX7su8FpVb48RSM3d3iZZy3SLfOL6/Hzo2yfa0
--- /aLAt2joNYE6oeVrJRK+EumSTnHEb+E5caPS0HLNWy4
<15>7•k
—c,<2C>©¹[dŒK©UÍ àí§`ž¾>LŽ¥ãIÐE¬_Ë“¨

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg +rLrjqElf41gDx5/V0uZeX42jgKq2OdoGIUiZZWcOVo
5Yy9j+zoGrM79TF8N1YdOqtgq7VRW9NbimSs+2tirkE
-> ssh-ed25519 Gfi4hQ v1UuYgI53qKi4+ZxmcOrgTcLBtAcmeyPYJMCLtF6CzQ
bnkNvL9NPbcvautilYVNqxdhdt2xsbjbJh32kQCTJjk
--- s/IQDcSq5lcxgZmz9XIEFNR197RewsdksMTM9MBGOCI
½âꔞQpEQUÊu†¼ÝÞŠÎ9><3E>î‘“.ZÇ<>W•Óý

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 hC2TMg rKE6zb0itefbnwHKXzxZEE3Rt18q+qg9h4/jOJVabDE
sAnTslcN+zxaBT8ZR53IjX9pUP4bAS9kmZfIM7iOpjA
-> ssh-ed25519 Gfi4hQ wT4Jd7ctLUaQtB9oKiV6Ot7pIABvmRfaK/9duUJ0PR4
uaVZZZEpbsceLsMjoLt7lUkM7T5bEJvsw80VHuFIVQA
--- TZv1bkbXqioF48w3n1ayh/oRSAewdvhq5DYk9SABU+g
Ì?uL{wEµ5qÿüÒ·^§3ž­62“³Ì*Ø$¢<>ÃI¾GpÝ€þ