nix/modules/voip/asterisk/default.nix

402 lines
14 KiB
Nix

{ lib, cfg, models, allPhones, intercomEntries, mohDirs, greetingDirs }:
let
hasTrunk = cfg.sipTrunks != {};
# 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;
personKey = sample.personKey;
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 && phone.sharedLine && models.${phone.model}.hasMultiLine;
# Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once)
dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}";
# Dial target strings
# L2 endpoints for "all" DID routing: shared phones (always) + persons with sharedLine = true
allL2Endpoints = lib.concatStringsSep "&"
(map dialTarget
(lib.attrNames (lib.filterAttrs (_: phone:
phone.personKey == null || phone.sharedLine
) 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 intercom-capable phones with an intercom line (auto-answer speakerphone).
allPageEndpoints = lib.concatStringsSep "&"
(lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom")
(lib.filterAttrs (key: phone:
models.${phone.model}.hasIntercom && 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}
callerid = ${phone.displayName} <${phone.extension}>
${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
bind = 0.0.0.0:${toString cfg.sipPort}
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:${toString cfg.sipPort}
; --- endpoint templates ---
${lib.concatStringsSep "\n" (lib.mapAttrsToList (_: m: ''
[${m.endpointTemplate}](!)
type = endpoint
context = internal
transport = transport-tcp
disallow = all
${lib.concatMapStrings (c: "allow = ${c}\n ") m.codecs}
direct_media = no
send_pai = yes
'') models)}
[auth-userpass](!)
type = auth
auth_type = userpass
; --- phones (L1) ---
''
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones)
+ 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)
)
)
+ lib.concatMapStringsSep "\n" (ic: ''
; --- intercom ---
[${ic.endpoint}](${ic.endpointTemplate})
auth = auth-${ic.endpoint}
aors = ${ic.endpoint}
[auth-${ic.endpoint}](auth-userpass)
username = ${ic.endpoint}
password = ${ic.password}
[${ic.endpoint}]
type = aor
max_contacts = ${toString ic.maxContacts}
remove_existing = yes
'') intercomEntries
+ lib.optionalString hasTrunk (
"\n ; --- SIP trunks ---\n"
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: ''
[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
[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}
outbound_auth = trunk-${name}-auth
aors = trunk-${name}-aor
trust_id_inbound = yes
disallow = all
${lib.concatMapStrings (c: "allow = ${c}\n ") t.codecs}
[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)
# BLF hints for person extensions
+ "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info:
lib.optionalString (info.personKey != null)
"exten => ${ext},hint,${lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys)}"
) 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 if extCfg.mode == "all" then "exten => ${ext},1,Dial(${allL2Endpoints},30)"
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
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]
rtpstart = ${toString cfg.rtpStart}
rtpend = ${toString cfg.rtpEnd}
'';
voicemail = ''
[general]
format = ulaw
[voicemail]
'' + 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 hasAnyMailbox {
"voicemail.conf" = voicemail;
} // lib.optionalAttrs (cfg.mohClasses != {}) {
"musiconhold.conf" = musiconhold;
}