402 lines
14 KiB
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;
|
|
}
|