feat: add voip stack
This commit is contained in:
parent
aa22874883
commit
3e48221fbf
27 changed files with 1787 additions and 227 deletions
82
flake.lock
82
flake.lock
|
|
@ -1,5 +1,50 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"disko": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
|
|
@ -20,6 +65,27 @@
|
||||||
"type": "github"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774709303,
|
"lastModified": 1774709303,
|
||||||
|
|
@ -38,9 +104,25 @@
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"agenix": "agenix",
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
"nixpkgs": "nixpkgs"
|
"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",
|
"root": "root",
|
||||||
|
|
|
||||||
19
flake.nix
19
flake.nix
|
|
@ -5,15 +5,22 @@
|
||||||
url = "github:nix-community/disko";
|
url = "github:nix-community/disko";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
agenix = {
|
||||||
|
url = "github:ryantm/agenix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, disko, ... }:
|
outputs = { self, nixpkgs, disko, agenix, ... }:
|
||||||
let
|
let
|
||||||
|
pkgs = import nixpkgs { system = "x86_64-linux"; };
|
||||||
|
|
||||||
# Helper to build a NixOS host config from hosts/<name>/
|
# Helper to build a NixOS host config from hosts/<name>/
|
||||||
mkHost = name: system: nixpkgs.lib.nixosSystem {
|
mkHost = name: system: nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = system; }
|
{ nixpkgs.hostPlatform = system; }
|
||||||
disko.nixosModules.disko
|
disko.nixosModules.disko
|
||||||
|
agenix.nixosModules.default
|
||||||
./modules/common.nix
|
./modules/common.nix
|
||||||
./hosts/${name}
|
./hosts/${name}
|
||||||
];
|
];
|
||||||
|
|
@ -28,10 +35,17 @@
|
||||||
(name: cfg: mkHost name cfg.system)
|
(name: cfg: mkHost name cfg.system)
|
||||||
hosts;
|
hosts;
|
||||||
|
|
||||||
|
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.colmena
|
||||||
|
agenix.packages.x86_64-linux.default
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
# colmena hive for ongoing deployments
|
# colmena hive for ongoing deployments
|
||||||
colmena = {
|
colmena = {
|
||||||
meta = {
|
meta = {
|
||||||
nixpkgs = import nixpkgs { system = "x86_64-linux"; }; # fallback for colmena internals
|
nixpkgs = pkgs;
|
||||||
specialArgs = { inherit disko; };
|
specialArgs = { inherit disko; };
|
||||||
};
|
};
|
||||||
} // nixpkgs.lib.mapAttrs (name: cfg: {
|
} // nixpkgs.lib.mapAttrs (name: cfg: {
|
||||||
|
|
@ -43,6 +57,7 @@
|
||||||
imports = [
|
imports = [
|
||||||
{ nixpkgs.hostPlatform = cfg.system; }
|
{ nixpkgs.hostPlatform = cfg.system; }
|
||||||
disko.nixosModules.disko
|
disko.nixosModules.disko
|
||||||
|
agenix.nixosModules.default
|
||||||
./modules/common.nix
|
./modules/common.nix
|
||||||
./hosts/${name}
|
./hosts/${name}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{ ... }: {
|
{ config, ... }: {
|
||||||
imports = [
|
imports = [
|
||||||
./hardware.nix
|
./hardware.nix
|
||||||
./disko.nix
|
./disko.nix
|
||||||
|
|
@ -20,25 +20,46 @@
|
||||||
serverAddress = "10.0.10.2";
|
serverAddress = "10.0.10.2";
|
||||||
ntpServer = "10.0.10.1";
|
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" = {
|
"e0899d946ccc" = {
|
||||||
model = "cisco-8961";
|
model = "cisco-8961";
|
||||||
extension = "100";
|
extension = "20";
|
||||||
label = "Küchentelefon";
|
displayName = "Küche";
|
||||||
password = "changeme100";
|
label = "Küchentelefon"; # shown on the phone screen (max ~12 chars)
|
||||||
voicemailTimeout = 10;
|
password = "changeme100";
|
||||||
};
|
};
|
||||||
"e0899d947650" = {
|
"e0899d947650" = {
|
||||||
model = "cisco-8961";
|
model = "cisco-8961";
|
||||||
extension = "102";
|
extension = "22";
|
||||||
label = "Flur";
|
displayName = "Flur";
|
||||||
password = "changeme100";
|
label = "Flur";
|
||||||
voicemailTimeout = 10;
|
password = "changeme100";
|
||||||
};
|
};
|
||||||
"101" = {
|
"fromschofon" = {
|
||||||
model = "sip-client";
|
model = "sip-client";
|
||||||
extension = "101";
|
extension = "23";
|
||||||
password = "changeme101";
|
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;
|
"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 = {
|
extensions = {
|
||||||
"100" = { displayName = "Küche"; };
|
|
||||||
"101" = { displayName = "101"; };
|
|
||||||
"102" = { displayName = "Flur"; };
|
|
||||||
"*99" = { mode = "page"; displayName = "Durchsage an alle"; };
|
"*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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
hosts/telefonmann/greetings/anrufbeantworter.wav
Normal file
BIN
hosts/telefonmann/greetings/anrufbeantworter.wav
Normal file
Binary file not shown.
BIN
hosts/telefonmann/music/vapor.mp3
Normal file
BIN
hosts/telefonmann/music/vapor.mp3
Normal file
Binary file not shown.
|
|
@ -1,11 +1,162 @@
|
||||||
{ lib, cfg, models, intercomEntries }:
|
{ lib, cfg, models, allPhones, intercomEntries, mohDirs, greetingDirs }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Phones that have voicemail enabled
|
hasTrunk = cfg.sipTrunks != {};
|
||||||
vmPhones = lib.filterAttrs (_: phone: phone.voicemailTimeout != null) cfg.phones;
|
|
||||||
hasVoicemail = vmPhones != {};
|
|
||||||
|
|
||||||
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]
|
[transport-tcp]
|
||||||
type = transport
|
type = transport
|
||||||
protocol = tcp
|
protocol = tcp
|
||||||
|
|
@ -21,50 +172,38 @@ let
|
||||||
context = internal
|
context = internal
|
||||||
transport = transport-tcp
|
transport = transport-tcp
|
||||||
disallow = all
|
disallow = all
|
||||||
allow = ulaw
|
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.hardwarePhones}direct_media = no
|
||||||
allow = alaw
|
|
||||||
allow = g722
|
|
||||||
allow = g726
|
|
||||||
allow = ilbc
|
|
||||||
allow = gsm
|
|
||||||
direct_media = no
|
|
||||||
trust_id_inbound = yes
|
trust_id_inbound = yes
|
||||||
|
send_pai = yes
|
||||||
|
|
||||||
[endpoint-generic](!)
|
[endpoint-generic](!)
|
||||||
type = endpoint
|
type = endpoint
|
||||||
context = internal
|
context = internal
|
||||||
transport = transport-tcp
|
transport = transport-tcp
|
||||||
disallow = all
|
disallow = all
|
||||||
allow = ulaw
|
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.softClients}direct_media = no
|
||||||
allow = alaw
|
send_pai = yes
|
||||||
allow = g722
|
|
||||||
direct_media = no
|
|
||||||
|
|
||||||
[auth-userpass](!)
|
[auth-userpass](!)
|
||||||
type = auth
|
type = auth
|
||||||
auth_type = userpass
|
auth_type = userpass
|
||||||
|
|
||||||
; --- phones ---
|
; --- phones (L1) ---
|
||||||
|
|
||||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
|
''
|
||||||
let m = models.${phone.model}; in ''
|
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones)
|
||||||
[${key}](${m.endpointTemplate})
|
|
||||||
auth = auth-${key}
|
|
||||||
aors = ${key}
|
|
||||||
${lib.optionalString (phone.voicemailTimeout != null) "mailboxes = ${phone.extension}@voicemail\n set_var=VOICEMAIL_MAILBOX=${phone.extension}"}
|
|
||||||
|
|
||||||
[auth-${key}](auth-userpass)
|
+ lib.optionalString hasTrunk (
|
||||||
username = ${key}
|
let provisionedPhones = lib.filterAttrs (key: _: phoneHasL2 key) allPhones; in
|
||||||
password = ${phone.password}
|
lib.optionalString (provisionedPhones != {}) (
|
||||||
|
"\n ; --- family line endpoints (L2, provisioned phones only) ---\n\n"
|
||||||
|
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL2Endpoint provisionedPhones)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
[${key}]
|
+ lib.concatMapStringsSep "\n" (ic: ''
|
||||||
type = aor
|
|
||||||
max_contacts = ${toString m.maxContacts}
|
|
||||||
remove_existing = yes
|
|
||||||
'') cfg.phones)
|
|
||||||
|
|
||||||
+ lib.concatMapStringsSep "\n" (ic: ''
|
|
||||||
|
|
||||||
|
; --- intercom ---
|
||||||
[${ic.endpoint}](${ic.endpointTemplate})
|
[${ic.endpoint}](${ic.endpointTemplate})
|
||||||
auth = auth-${ic.endpoint}
|
auth = auth-${ic.endpoint}
|
||||||
aors = ${ic.endpoint}
|
aors = ${ic.endpoint}
|
||||||
|
|
@ -77,43 +216,146 @@ let
|
||||||
type = aor
|
type = aor
|
||||||
max_contacts = ${toString ic.maxContacts}
|
max_contacts = ${toString ic.maxContacts}
|
||||||
remove_existing = yes
|
remove_existing = yes
|
||||||
'') intercomEntries;
|
'') intercomEntries
|
||||||
|
|
||||||
# Reverse map: extension number -> pjsip endpoint key
|
+ lib.optionalString hasTrunk (
|
||||||
extensionToEndpoint = lib.foldlAttrs (acc: key: phone:
|
"\n ; --- SIP trunks ---\n"
|
||||||
acc // { ${phone.extension} = key; }
|
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: ''
|
||||||
) {} cfg.phones;
|
|
||||||
|
|
||||||
# All page endpoints: intercom line for provisioned phones, regular for others
|
[trunk-${name}-registration]
|
||||||
allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: phone:
|
type = registration
|
||||||
let m = models.${phone.model}; in
|
outbound_auth = trunk-${name}-auth
|
||||||
if m.hasProvisioning && cfg.intercomPrefix != null
|
${runtimeLine "server_uri = sip:" t.host t.hostFile}
|
||||||
then "PJSIP/${key}-intercom"
|
${let u = if t.usernameFile != null then "$(cat ${t.usernameFile})" else t.username;
|
||||||
else "PJSIP/${key}"
|
h = if t.hostFile != null then "$(cat ${t.hostFile})" else t.host;
|
||||||
) cfg.phones);
|
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 = ''
|
[trunk-${name}-auth]
|
||||||
[internal]
|
type = auth
|
||||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
|
auth_type = userpass
|
||||||
if extCfg.mode == "app"
|
${runtimeLine "username = " t.username t.usernameFile}
|
||||||
then "exten => ${ext},1,${extCfg.app}"
|
${runtimeLine "password = " t.password t.passwordFile}
|
||||||
else if extCfg.mode == "page"
|
|
||||||
then "exten => ${ext},1,Page(${allPageEndpoints},i,120)"
|
[trunk-${name}-endpoint]
|
||||||
else if lib.hasAttr ext extensionToEndpoint
|
type = endpoint
|
||||||
then
|
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
|
let
|
||||||
phoneKey = extensionToEndpoint.${ext};
|
r = didCfg.routing;
|
||||||
phone = cfg.phones.${phoneKey};
|
dialStr =
|
||||||
in if phone.voicemailTimeout != null
|
if r.type == "all" then allL2Endpoints
|
||||||
then ''exten => ${ext},1,Dial(PJSIP/${phoneKey},${toString phone.voicemailTimeout})
|
else if r.type == "person" then personL1Endpoints r.person
|
||||||
same => n,VoiceMail(${ext}@voicemail,u)''
|
else personsL2Endpoints r.persons;
|
||||||
else "exten => ${ext},1,Dial(PJSIP/${phoneKey})"
|
mohOpt = lib.optionalString (didCfg.musicOnHold != null) "m(${didCfg.musicOnHold})";
|
||||||
else "exten => ${ext},1,Hangup() ; WARNING: no endpoint assigned to extension ${ext}"
|
dialArgs = "${dialStr},${toString r.timeout},${mohOpt}";
|
||||||
) cfg.extensions)
|
noAnsLine =
|
||||||
+ "\n" + lib.concatMapStringsSep "\n" (ic:
|
if didCfg.mailbox == "shared" then
|
||||||
"exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)"
|
let gdir = greetingDirs.shared; in
|
||||||
) intercomEntries
|
lib.optionalString (gdir != null)
|
||||||
+ lib.optionalString hasVoicemail "\nexten => *97,1,VoiceMailMain(\${VOICEMAIL_MAILBOX}@voicemail,sa(0))";
|
"\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 = ''
|
rtp = ''
|
||||||
[general]
|
[general]
|
||||||
|
|
@ -126,15 +368,31 @@ let
|
||||||
format = ulaw
|
format = ulaw
|
||||||
|
|
||||||
[voicemail]
|
[voicemail]
|
||||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (_: phone:
|
'' + lib.concatStringsSep "\n" (
|
||||||
let displayName = cfg.extensions.${phone.extension}.displayName; in
|
lib.mapAttrsToList (_key: person:
|
||||||
" ${phone.extension} => ,${displayName},,attach=no"
|
lib.optionalString person.mailbox
|
||||||
) vmPhones);
|
" ${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 {
|
in {
|
||||||
"pjsip.conf" = pjsip;
|
"pjsip.conf" = pjsip;
|
||||||
"extensions.conf" = extensions;
|
"extensions.conf" = extensions;
|
||||||
"rtp.conf" = rtp;
|
"rtp.conf" = rtp;
|
||||||
} // lib.optionalAttrs hasVoicemail {
|
} // lib.optionalAttrs hasAnyMailbox {
|
||||||
"voicemail.conf" = voicemail;
|
"voicemail.conf" = voicemail;
|
||||||
|
} // lib.optionalAttrs (cfg.mohClasses != {}) {
|
||||||
|
"musiconhold.conf" = musiconhold;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{ lib, pkgs, cfg, models }:
|
{ lib, pkgs, cfg, models, allPhones }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
|
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
|
||||||
|
|
@ -6,7 +6,7 @@ let
|
||||||
(lib.mapAttrsToList (_: phone:
|
(lib.mapAttrsToList (_: phone:
|
||||||
let m = models.${phone.model}; in
|
let m = models.${phone.model}; in
|
||||||
{ desktop = m.desktopSize; thumbnail = m.thumbnailSize; }
|
{ desktop = m.desktopSize; thumbnail = m.thumbnailSize; }
|
||||||
) cfg.phones));
|
) allPhones));
|
||||||
|
|
||||||
# Parse "WxH" or "WxHxD" into width and height
|
# Parse "WxH" or "WxHxD" into width and height
|
||||||
parseDimensions = size:
|
parseDimensions = size:
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ let
|
||||||
# 2. For provisioned models, add a template in ./templates/<model>.nix
|
# 2. For provisioned models, add a template in ./templates/<model>.nix
|
||||||
#
|
#
|
||||||
# Template interface — all provisioned model templates receive these args:
|
# 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),
|
# Optional: sipPort (default 5060), directoryPort (default 8080),
|
||||||
# intercomEnabled (default false), intercomPassword (default "")
|
# intercomEnabled (default false), intercomPassword (default ""),
|
||||||
|
# familyLineEnabled (default false), familyLineLabel (default "Familie")
|
||||||
models = {
|
models = {
|
||||||
"cisco-8961" = {
|
"cisco-8961" = {
|
||||||
endpointTemplate = "endpoint-cisco-8961";
|
endpointTemplate = "endpoint-cisco-8961";
|
||||||
|
|
@ -30,11 +31,89 @@ let
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
intercomEntries = import ./intercom.nix { inherit lib cfg models; };
|
# Unified view of all physical devices, keyed by SIP identity (MAC or username).
|
||||||
confFiles = import ./asterisk.nix { inherit lib cfg models intercomEntries; };
|
# Each entry carries the fields needed by sub-modules without them having to
|
||||||
directory = import ./directory.nix { inherit lib pkgs cfg intercomEntries; };
|
# know about sharedPhones vs persons.
|
||||||
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models; };
|
allPhones =
|
||||||
tftpRoot = import ./tftp.nix { inherit lib pkgs cfg models backgroundEntries; };
|
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 {
|
in {
|
||||||
options.services.voip = {
|
options.services.voip = {
|
||||||
|
|
@ -89,36 +168,58 @@ in {
|
||||||
type = lib.types.attrsOf lib.types.path;
|
type = lib.types.attrsOf lib.types.path;
|
||||||
};
|
};
|
||||||
|
|
||||||
phones = lib.mkOption {
|
sharedPhones = lib.mkOption {
|
||||||
default = {};
|
default = {};
|
||||||
description = ''
|
description = ''
|
||||||
Attrset of phones/clients keyed by SIP identity (username).
|
Shared/location phones not assigned to a specific person (e.g. hallway, kitchen).
|
||||||
For hardware phones (cisco-8961), the key must be the lowercase MAC address (no colons).
|
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.
|
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 {
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
options = {
|
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 {
|
extension = lib.mkOption {
|
||||||
type = lib.types.str;
|
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;
|
type = lib.types.str;
|
||||||
default = "";
|
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 {
|
mailbox = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.bool;
|
||||||
description = "SIP registration password.";
|
default = true;
|
||||||
|
description = "Whether this person gets a personal voicemail mailbox.";
|
||||||
};
|
};
|
||||||
voicemailTimeout = lib.mkOption {
|
ringTimeout = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.ints.positive;
|
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;
|
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.";
|
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 {
|
extensions = lib.mkOption {
|
||||||
default = {};
|
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 {
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
options = {
|
options = {
|
||||||
mode = lib.mkOption {
|
mode = lib.mkOption {
|
||||||
type = lib.types.enum [ "line" "page" "app" ];
|
type = lib.types.enum [ "page" "app" ];
|
||||||
default = "line";
|
default = "page";
|
||||||
description = ''
|
description = ''
|
||||||
Extension mode:
|
Extension mode:
|
||||||
- "line": dials the phone assigned to this extension
|
|
||||||
- "page": one-way announcement to all phones
|
- "page": one-way announcement to all phones
|
||||||
- "app": custom Asterisk dialplan application
|
- "app": custom Asterisk dialplan application
|
||||||
Intercom extensions are auto-generated when intercomPrefix is set.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
displayName = lib.mkOption {
|
displayName = lib.mkOption {
|
||||||
|
|
@ -164,80 +464,204 @@ in {
|
||||||
|
|
||||||
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
|
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
|
||||||
|
|
||||||
|
|
||||||
assertions =
|
assertions =
|
||||||
# Every phone's extension must be declared
|
# Provisioned sharedPhones require a MAC address key
|
||||||
(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
|
|
||||||
(lib.mapAttrsToList (key: phone: {
|
(lib.mapAttrsToList (key: phone: {
|
||||||
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
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";
|
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||||
}) cfg.phones)
|
}) cfg.sharedPhones)
|
||||||
++
|
++
|
||||||
# Provisioned phones require a non-empty label
|
# Provisioned sharedPhones require a non-empty label
|
||||||
(lib.mapAttrsToList (key: phone: {
|
(lib.mapAttrsToList (key: phone: {
|
||||||
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
||||||
message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||||
}) cfg.phones)
|
}) cfg.sharedPhones)
|
||||||
++
|
++
|
||||||
# Provisioned phones require a template
|
# Provisioned person phones require a MAC address key
|
||||||
(lib.mapAttrsToList (key: phone: {
|
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
||||||
assertion = !models.${phone.model}.hasProvisioning || models.${phone.model}.template != null;
|
lib.mapAttrsToList (key: phone: {
|
||||||
message = "services.voip: phone \"${key}\" model \"${phone.model}\" has hasProvisioning=true but no template defined";
|
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
||||||
}) cfg.phones)
|
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.optionals (cfg.intercomPrefix != null)
|
||||||
(lib.mapAttrsToList (key: phone:
|
(lib.mapAttrsToList (_key: phone:
|
||||||
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
|
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
|
||||||
assertion = !lib.hasAttr ext cfg.extensions;
|
assertion = !lib.hasAttr ext cfg.extensions;
|
||||||
message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix";
|
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 = {
|
services.asterisk = {
|
||||||
enable = true;
|
enable = true;
|
||||||
confFiles = confFiles;
|
confFiles = confFiles;
|
||||||
|
# execincludes=yes is required when any *File option is in use.
|
||||||
|
extraConfig = lib.optionalString hasRuntimeSecrets ''
|
||||||
|
[options]
|
||||||
|
execincludes=yes
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
services.atftpd = {
|
services.atftpd = {
|
||||||
enable = true;
|
enable = true;
|
||||||
root = "${tftpRoot}";
|
root = "${provisioningRoot}";
|
||||||
extraOptions = [ "--verbose=7" ];
|
extraOptions = [ "--verbose=7" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.nginx = {
|
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" = {
|
virtualHosts."voip-directory" = {
|
||||||
listen = [{ addr = "0.0.0.0"; port = cfg.directoryPort; }];
|
listen = [{ addr = "0.0.0.0"; port = cfg.directoryPort; }];
|
||||||
locations."= /directory.xml" = {
|
locations = {
|
||||||
alias = "${directory.menuFile}";
|
"= /directory.xml" = { alias = "${directory.menuFile}"; extraConfig = "default_type text/xml;"; };
|
||||||
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;"; };
|
||||||
locations."= /directory-list.xml" = {
|
"/" = {
|
||||||
alias = "${directory.listFile}";
|
root = "${diagram.webRoot}";
|
||||||
extraConfig = "default_type text/xml;";
|
extraConfig = lib.optionalString (!hasRuntimeSecrets) "index index.html;";
|
||||||
};
|
};
|
||||||
locations."= /intercom.xml" = {
|
} // lib.optionalAttrs hasRuntimeSecrets {
|
||||||
alias = "${directory.intercomFile}";
|
# Exact-match the index so the Lua handler intercepts it before the
|
||||||
extraConfig = "default_type text/xml;";
|
# prefix location /. Other assets (voip.dot, SVG) fall through to /.
|
||||||
};
|
"= /" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
|
||||||
locations."= /voicemail.xml" = {
|
"= /index.html" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
|
||||||
alias = "${directory.voicemailFile}";
|
|
||||||
extraConfig = "default_type text/xml;";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# 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 -" ];
|
systemd.tmpfiles.rules = [ "d /var/log/nginx 0750 nginx nginx -" ];
|
||||||
|
|
||||||
networking.firewall = {
|
networking.firewall = {
|
||||||
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort ];
|
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort 6970 ];
|
||||||
allowedUDPPorts = [ 69 ];
|
allowedUDPPorts = [ cfg.sipPort 69 ];
|
||||||
allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }];
|
allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
390
modules/voip/diagram.nix
Normal file
390
modules/voip/diagram.nix
Normal 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; }
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
{ lib, pkgs, cfg, intercomEntries }:
|
{ lib, pkgs, cfg, allPhones, intercomEntries }:
|
||||||
|
|
||||||
let
|
let
|
||||||
baseUrl = "http://${cfg.serverAddress}:${toString cfg.directoryPort}";
|
baseUrl = "http://${cfg.serverAddress}:${toString cfg.directoryPort}";
|
||||||
|
|
||||||
hasPageExtensions = lib.any (e: e.mode == "page") (lib.attrValues cfg.extensions);
|
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 = ''
|
menuXml = ''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<CiscoIPPhoneMenu>
|
<CiscoIPPhoneMenu>
|
||||||
<Title>${cfg.directoryName} Telefonbuch</Title>
|
<Title>${cfg.directoryName} Telefonbuch</Title>
|
||||||
<Prompt>Ihre Wahl</Prompt>
|
<Prompt>Ihre Wahl</Prompt>
|
||||||
|
|
@ -23,21 +32,22 @@ let
|
||||||
'';
|
'';
|
||||||
|
|
||||||
listXml = ''
|
listXml = ''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<CiscoIPPhoneDirectory>
|
<CiscoIPPhoneDirectory>
|
||||||
<Title>${cfg.directoryName} Telefonbuch</Title>
|
<Title>${cfg.directoryName} Telefonbuch</Title>
|
||||||
<Prompt>Ihre Wahl</Prompt>
|
<Prompt>Ihre Wahl</Prompt>
|
||||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
|
'' + lib.concatMapStringsSep "\n" (e: ''
|
||||||
lib.optionalString (extCfg.mode == "line" && extCfg.displayName != "") ''
|
|
||||||
<DirectoryEntry>
|
<DirectoryEntry>
|
||||||
<Name>${extCfg.displayName}</Name>
|
<Name>${e.displayName}</Name>
|
||||||
<Telephone>${ext}</Telephone>
|
<Telephone>${e.extension}</Telephone>
|
||||||
</DirectoryEntry>
|
</DirectoryEntry>
|
||||||
'') cfg.extensions)
|
'') extensionEntries
|
||||||
+ ''
|
+ ''
|
||||||
</CiscoIPPhoneDirectory>
|
</CiscoIPPhoneDirectory>
|
||||||
'';
|
'';
|
||||||
|
|
||||||
intercomXml = ''
|
intercomXml = ''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<CiscoIPPhoneDirectory>
|
<CiscoIPPhoneDirectory>
|
||||||
<Title>Intercom / Durchsage</Title>
|
<Title>Intercom / Durchsage</Title>
|
||||||
<Prompt>Ihre Wahl</Prompt>
|
<Prompt>Ihre Wahl</Prompt>
|
||||||
|
|
@ -59,14 +69,15 @@ let
|
||||||
'';
|
'';
|
||||||
|
|
||||||
voicemailMenuXml = ''
|
voicemailMenuXml = ''
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<CiscoIPPhoneExecute>
|
<CiscoIPPhoneExecute>
|
||||||
<ExecuteItem Priority="0" URL="Dial:997"/>
|
<ExecuteItem Priority="0" URL="Dial:*97"/>
|
||||||
</CiscoIPPhoneExecute>
|
</CiscoIPPhoneExecute>
|
||||||
'';
|
'';
|
||||||
|
|
||||||
in {
|
in {
|
||||||
menuFile = pkgs.writeText "directory.xml" menuXml;
|
menuFile = pkgs.writeText "directory.xml" menuXml;
|
||||||
listFile = pkgs.writeText "directory-list.xml" listXml;
|
listFile = pkgs.writeText "directory-list.xml" listXml;
|
||||||
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
|
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
|
||||||
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
|
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
modules/voip/greetings.nix
Normal file
34
modules/voip/greetings.nix
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,16 @@
|
||||||
{ lib, cfg, models }:
|
{ lib, cfg, models, allPhones }:
|
||||||
|
|
||||||
if cfg.intercomPrefix == null then []
|
if cfg.intercomPrefix == null then []
|
||||||
else lib.concatLists (lib.mapAttrsToList (key: phone:
|
else lib.concatLists (lib.mapAttrsToList (key: phone:
|
||||||
let
|
let m = models.${phone.model}; in
|
||||||
m = models.${phone.model};
|
lib.optional m.hasProvisioning {
|
||||||
ext = phone.extension;
|
extension = "${cfg.intercomPrefix}${phone.extension}";
|
||||||
# cfg.extensions.${ext} is guaranteed to exist by the phone→extension assertion
|
endpoint = "${key}-intercom";
|
||||||
extCfg = cfg.extensions.${ext};
|
phoneKey = key;
|
||||||
in lib.optional m.hasProvisioning {
|
target = phone.extension;
|
||||||
extension = "${cfg.intercomPrefix}${ext}";
|
displayName = "Intercom ${phone.displayName}";
|
||||||
endpoint = "${key}-intercom";
|
password = phone.password;
|
||||||
phoneKey = key;
|
|
||||||
target = ext;
|
|
||||||
displayName = "Intercom ${extCfg.displayName}";
|
|
||||||
password = phone.password;
|
|
||||||
endpointTemplate = m.endpointTemplate;
|
endpointTemplate = m.endpointTemplate;
|
||||||
maxContacts = m.maxContacts;
|
maxContacts = m.maxContacts;
|
||||||
}
|
}
|
||||||
) cfg.phones)
|
) allPhones)
|
||||||
|
|
|
||||||
23
modules/voip/moh.nix
Normal file
23
modules/voip/moh.nix
Normal 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
|
||||||
98
modules/voip/provisioning.nix
Normal file
98
modules/voip/provisioning.nix
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -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>
|
<device>
|
||||||
<deviceProtocol>SIP</deviceProtocol>
|
<deviceProtocol>SIP</deviceProtocol>
|
||||||
<sshUserId>admin</sshUserId>
|
<sshUserId>admin</sshUserId>
|
||||||
|
|
@ -68,7 +87,7 @@
|
||||||
</sipStack>
|
</sipStack>
|
||||||
<transferOnhookEnabled>false</transferOnhookEnabled>
|
<transferOnhookEnabled>false</transferOnhookEnabled>
|
||||||
<kpml>3</kpml>
|
<kpml>3</kpml>
|
||||||
<phoneLabel>${builtins.substring 0 12 label}</phoneLabel>
|
<phoneLabel>${(builtins.substring 0 12 label)}</phoneLabel>
|
||||||
<stutterMsgWaiting>1</stutterMsgWaiting>
|
<stutterMsgWaiting>1</stutterMsgWaiting>
|
||||||
<callStats>false</callStats>
|
<callStats>false</callStats>
|
||||||
<sipLines>
|
<sipLines>
|
||||||
|
|
@ -95,8 +114,32 @@
|
||||||
<dialedNumber>true</dialedNumber>
|
<dialedNumber>true</dialedNumber>
|
||||||
</forwardCallInfoDisplay>
|
</forwardCallInfoDisplay>
|
||||||
</line>
|
</line>
|
||||||
${if intercomEnabled then ''
|
${if familyLineEnabled then ''
|
||||||
<line button="2" lineIndex="2">
|
<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>
|
<featureID>23</featureID>
|
||||||
<featureLabel>Intercom</featureLabel>
|
<featureLabel>Intercom</featureLabel>
|
||||||
<proxy>USECALLMANAGER</proxy>
|
<proxy>USECALLMANAGER</proxy>
|
||||||
|
|
@ -119,8 +162,9 @@ ${if intercomEnabled then ''
|
||||||
<startMediaPort>16348</startMediaPort>
|
<startMediaPort>16348</startMediaPort>
|
||||||
<stopMediaPort>20134</stopMediaPort>
|
<stopMediaPort>20134</stopMediaPort>
|
||||||
<dscpForAudio>184</dscpForAudio>
|
<dscpForAudio>184</dscpForAudio>
|
||||||
<dialTemplate>dialplan.xml</dialTemplate>
|
<dialTemplate>${dialplanFile}</dialTemplate>
|
||||||
</sipProfile>
|
</sipProfile>
|
||||||
|
<MissedCallLoggingOption>1</MissedCallLoggingOption>
|
||||||
<commonProfile>
|
<commonProfile>
|
||||||
<phonePassword></phonePassword>
|
<phonePassword></phonePassword>
|
||||||
<backgroundImageAccess>true</backgroundImageAccess>
|
<backgroundImageAccess>true</backgroundImageAccess>
|
||||||
|
|
@ -152,6 +196,24 @@ ${if intercomEnabled then ''
|
||||||
<informationURL></informationURL>
|
<informationURL></informationURL>
|
||||||
<phoneServices useHTTPS="false">
|
<phoneServices useHTTPS="false">
|
||||||
<provisioning>2</provisioning>
|
<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">
|
<phoneService type="2" category="0">
|
||||||
<name>Voicemail</name>
|
<name>Voicemail</name>
|
||||||
<url>Application:Cisco/Voicemail</url>
|
<url>Application:Cisco/Voicemail</url>
|
||||||
|
|
|
||||||
|
|
@ -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
19
secrets/secrets.nix
Normal 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 ];
|
||||||
|
}
|
||||||
7
secrets/voip-trunk-ewe-host.age
Normal file
7
secrets/voip-trunk-ewe-host.age
Normal 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{;º'‘æ.´ …€
|
||||||
7
secrets/voip-trunk-ewe1-callerid.age
Normal file
7
secrets/voip-trunk-ewe1-callerid.age
Normal 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
|
||||||
7
secrets/voip-trunk-ewe1-password.age
Normal file
7
secrets/voip-trunk-ewe1-password.age
Normal 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â
|
||||||
8
secrets/voip-trunk-ewe1-username.age
Normal file
8
secrets/voip-trunk-ewe1-username.age
Normal 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{
|
||||||
7
secrets/voip-trunk-ewe2-callerid.age
Normal file
7
secrets/voip-trunk-ewe2-callerid.age
Normal 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ã
|
||||||
BIN
secrets/voip-trunk-ewe2-password.age
Normal file
BIN
secrets/voip-trunk-ewe2-password.age
Normal file
Binary file not shown.
7
secrets/voip-trunk-ewe2-username.age
Normal file
7
secrets/voip-trunk-ewe2-username.age
Normal 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é¨
|
||||||
8
secrets/voip-trunk-ewe3-callerid.age
Normal file
8
secrets/voip-trunk-ewe3-callerid.age
Normal 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¬_Ë“¨
|
||||||
7
secrets/voip-trunk-ewe3-password.age
Normal file
7
secrets/voip-trunk-ewe3-password.age
Normal 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•Óý
|
||||||
7
secrets/voip-trunk-ewe3-username.age
Normal file
7
secrets/voip-trunk-ewe3-username.age
Normal 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Ý€þ
|
||||||
Loading…
Reference in a new issue