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": {
|
||||
"agenix": {
|
||||
"inputs": {
|
||||
"darwin": "darwin",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770165109,
|
||||
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
|
||||
"owner": "ryantm",
|
||||
"repo": "agenix",
|
||||
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ryantm",
|
||||
"repo": "agenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1744478979,
|
||||
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
|
||||
"owner": "lnl7",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "lnl7",
|
||||
"ref": "master",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"disko": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
|
|
@ -20,6 +65,27 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1745494811,
|
||||
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1774709303,
|
||||
|
|
@ -38,9 +104,25 @@
|
|||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"disko": "disko",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
|
|
|||
19
flake.nix
19
flake.nix
|
|
@ -5,15 +5,22 @@
|
|||
url = "github:nix-community/disko";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
agenix = {
|
||||
url = "github:ryantm/agenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, disko, ... }:
|
||||
outputs = { self, nixpkgs, disko, agenix, ... }:
|
||||
let
|
||||
pkgs = import nixpkgs { system = "x86_64-linux"; };
|
||||
|
||||
# Helper to build a NixOS host config from hosts/<name>/
|
||||
mkHost = name: system: nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = system; }
|
||||
disko.nixosModules.disko
|
||||
agenix.nixosModules.default
|
||||
./modules/common.nix
|
||||
./hosts/${name}
|
||||
];
|
||||
|
|
@ -28,10 +35,17 @@
|
|||
(name: cfg: mkHost name cfg.system)
|
||||
hosts;
|
||||
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.colmena
|
||||
agenix.packages.x86_64-linux.default
|
||||
];
|
||||
};
|
||||
|
||||
# colmena hive for ongoing deployments
|
||||
colmena = {
|
||||
meta = {
|
||||
nixpkgs = import nixpkgs { system = "x86_64-linux"; }; # fallback for colmena internals
|
||||
nixpkgs = pkgs;
|
||||
specialArgs = { inherit disko; };
|
||||
};
|
||||
} // nixpkgs.lib.mapAttrs (name: cfg: {
|
||||
|
|
@ -43,6 +57,7 @@
|
|||
imports = [
|
||||
{ nixpkgs.hostPlatform = cfg.system; }
|
||||
disko.nixosModules.disko
|
||||
agenix.nixosModules.default
|
||||
./modules/common.nix
|
||||
./hosts/${name}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{ ... }: {
|
||||
{ config, ... }: {
|
||||
imports = [
|
||||
./hardware.nix
|
||||
./disko.nix
|
||||
|
|
@ -20,25 +20,46 @@
|
|||
serverAddress = "10.0.10.2";
|
||||
ntpServer = "10.0.10.1";
|
||||
|
||||
phones = {
|
||||
# directoryName = "tel.baubs.net"; # shown in phone directory title and HTML page header
|
||||
# directoryPort = 8080; # HTTP port for directory, intercom XML, and status page
|
||||
# sipPort = 5060; # SIP TCP/UDP port
|
||||
# rtpStart = 10000; # RTP port range start
|
||||
# rtpEnd = 20000; # RTP port range end
|
||||
|
||||
sharedPhones = {
|
||||
"e0899d946ccc" = {
|
||||
model = "cisco-8961";
|
||||
extension = "100";
|
||||
label = "Küchentelefon";
|
||||
password = "changeme100";
|
||||
voicemailTimeout = 10;
|
||||
model = "cisco-8961";
|
||||
extension = "20";
|
||||
displayName = "Küche";
|
||||
label = "Küchentelefon"; # shown on the phone screen (max ~12 chars)
|
||||
password = "changeme100";
|
||||
};
|
||||
"e0899d947650" = {
|
||||
model = "cisco-8961";
|
||||
extension = "102";
|
||||
label = "Flur";
|
||||
password = "changeme100";
|
||||
voicemailTimeout = 10;
|
||||
model = "cisco-8961";
|
||||
extension = "22";
|
||||
displayName = "Flur";
|
||||
label = "Flur";
|
||||
password = "changeme100";
|
||||
};
|
||||
"101" = {
|
||||
model = "sip-client";
|
||||
extension = "101";
|
||||
password = "changeme101";
|
||||
"fromschofon" = {
|
||||
model = "sip-client";
|
||||
extension = "23";
|
||||
displayName = "Frosch";
|
||||
label = "Frosch";
|
||||
password = "changeme102";
|
||||
};
|
||||
};
|
||||
|
||||
persons = {
|
||||
"jannel" = {
|
||||
extension = "21";
|
||||
displayName = "Jannel";
|
||||
mailbox = true;
|
||||
# ringTimeout = 30; # seconds to ring before going to voicemail
|
||||
mailboxGreeting = ./greetings/anrufbeantworter.wav;
|
||||
phones = {
|
||||
"jannel-mobile" = { model = "sip-client"; password = "changeme101"; };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -46,16 +67,107 @@
|
|||
"Wombel" = ./backgrounds/wombel.png;
|
||||
};
|
||||
|
||||
intercomPrefix = "*80";
|
||||
intercomPrefix = "*80"; # generates e.g. *8020, *8021, *8022 per extension
|
||||
|
||||
codecs = {
|
||||
trunk = [ "g722" "alaw" "ulaw" ];
|
||||
# hardwarePhones = [ "g722" "alaw" "ulaw" "ilbc" ]; # default; Cisco 8961 supported set
|
||||
# softClients = [ "opus" "g722" "alaw" "ulaw" ]; # default; opus first for best quality
|
||||
};
|
||||
|
||||
mohClasses = {
|
||||
"default" = {
|
||||
files = [ ./music/vapor.mp3 ];
|
||||
sort = "random";
|
||||
# sort = "alphabetical";
|
||||
};
|
||||
# add more classes here and reference them per-DID via musicOnHold = "classname"
|
||||
};
|
||||
|
||||
sipTrunks = {
|
||||
"ewe1" = {
|
||||
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
|
||||
usernameFile = config.age.secrets."voip-trunk-ewe1-username".path;
|
||||
passwordFile = config.age.secrets."voip-trunk-ewe1-password".path;
|
||||
callerIdFile = config.age.secrets."voip-trunk-ewe1-callerid".path;
|
||||
};
|
||||
"ewe2" = {
|
||||
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
|
||||
usernameFile = config.age.secrets."voip-trunk-ewe2-username".path;
|
||||
passwordFile = config.age.secrets."voip-trunk-ewe2-password".path;
|
||||
callerIdFile = config.age.secrets."voip-trunk-ewe2-callerid".path;
|
||||
};
|
||||
"ewe3" = {
|
||||
hostFile = config.age.secrets."voip-trunk-ewe-host".path;
|
||||
usernameFile = config.age.secrets."voip-trunk-ewe3-username".path;
|
||||
passwordFile = config.age.secrets."voip-trunk-ewe3-password".path;
|
||||
callerIdFile = config.age.secrets."voip-trunk-ewe3-callerid".path;
|
||||
};
|
||||
};
|
||||
|
||||
sharedMailbox = {
|
||||
mailboxId = "200";
|
||||
checkExtension = "*98";
|
||||
displayName = "Baubse";
|
||||
greeting = ./greetings/anrufbeantworter.wav;
|
||||
};
|
||||
|
||||
dids = {
|
||||
"baubse" = {
|
||||
numberFile = config.age.secrets."voip-trunk-ewe1-callerid".path;
|
||||
trunk = "ewe1";
|
||||
displayName = "Baubse";
|
||||
routing = {
|
||||
type = "all"; # ring all phones (sharedPhones + persons) on their L2 line
|
||||
# timeout = 30; # seconds to ring before falling through to mailbox
|
||||
};
|
||||
mailbox = "shared"; # → sharedMailbox on no answer
|
||||
musicOnHold = "default";
|
||||
};
|
||||
"jannel" = {
|
||||
numberFile = config.age.secrets."voip-trunk-ewe2-callerid".path;
|
||||
trunk = "ewe2";
|
||||
displayName = "Jannel";
|
||||
routing = {
|
||||
type = "person";
|
||||
person = "jannel"; # ring jannel's phones on their L1 line
|
||||
# timeout = 30;
|
||||
};
|
||||
mailbox = "person"; # → jannel's personal mailbox on no answer
|
||||
# musicOnHold = "default";
|
||||
};
|
||||
# ewe3 DID — uncomment and fill in number when known:
|
||||
# "ewe3-main" = {
|
||||
# number = ""; # or: numberFile = ./secrets/did-ewe3;
|
||||
# trunk = "ewe3";
|
||||
# displayName = "...";
|
||||
# routing = { type = "all"; };
|
||||
# mailbox = "shared";
|
||||
# };
|
||||
};
|
||||
|
||||
extensions = {
|
||||
"100" = { displayName = "Küche"; };
|
||||
"101" = { displayName = "101"; };
|
||||
"102" = { displayName = "Flur"; };
|
||||
"*99" = { mode = "page"; displayName = "Durchsage an alle"; };
|
||||
"999" = { mode = "app"; app = "Playback(hello-world)"; };
|
||||
# custom app extension example:
|
||||
# "*00" = { mode = "app"; displayName = "Echo test"; app = "Echo()"; };
|
||||
};
|
||||
};
|
||||
|
||||
deployment.targetHost = "telefonmann"; # or IP address
|
||||
deployment.targetHost = "telefonmann";
|
||||
|
||||
# Age-encrypted secrets (decrypted on the host at activation time).
|
||||
age.secrets =
|
||||
let asteriskSecret = file: { inherit file; owner = "asterisk"; group = "voip-keys"; mode = "0640"; };
|
||||
in {
|
||||
"voip-trunk-ewe-host" = asteriskSecret ../../secrets/voip-trunk-ewe-host.age;
|
||||
"voip-trunk-ewe1-username" = asteriskSecret ../../secrets/voip-trunk-ewe1-username.age;
|
||||
"voip-trunk-ewe1-password" = asteriskSecret ../../secrets/voip-trunk-ewe1-password.age;
|
||||
"voip-trunk-ewe1-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe1-callerid.age;
|
||||
"voip-trunk-ewe2-username" = asteriskSecret ../../secrets/voip-trunk-ewe2-username.age;
|
||||
"voip-trunk-ewe2-password" = asteriskSecret ../../secrets/voip-trunk-ewe2-password.age;
|
||||
"voip-trunk-ewe2-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe2-callerid.age;
|
||||
"voip-trunk-ewe3-username" = asteriskSecret ../../secrets/voip-trunk-ewe3-username.age;
|
||||
"voip-trunk-ewe3-password" = asteriskSecret ../../secrets/voip-trunk-ewe3-password.age;
|
||||
"voip-trunk-ewe3-callerid" = asteriskSecret ../../secrets/voip-trunk-ewe3-callerid.age;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
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
|
||||
# Phones that have voicemail enabled
|
||||
vmPhones = lib.filterAttrs (_: phone: phone.voicemailTimeout != null) cfg.phones;
|
||||
hasVoicemail = vmPhones != {};
|
||||
hasTrunk = cfg.sipTrunks != {};
|
||||
|
||||
pjsip = ''
|
||||
# Produces either "prefix<val>" (static) or a #exec that reads the file at runtime.
|
||||
# Use this wherever a config value can be supplied either inline or from a key file.
|
||||
runtimeLine = prefix: val: file:
|
||||
if file != null
|
||||
then ''#exec echo "${prefix}$(cat ${file})"''
|
||||
else "${prefix}${val}";
|
||||
|
||||
hasSharedMailbox = cfg.sharedMailbox != null;
|
||||
|
||||
hasPersonalMailboxes = lib.any (p: p.mailboxExt != null) (lib.attrValues allPhones);
|
||||
hasAnyMailbox = hasPersonalMailboxes || hasSharedMailbox;
|
||||
|
||||
# Helper to produce a literal Asterisk variable reference like ${EXTEN}
|
||||
# when used inside a Nix string interpolation: ${av "EXTEN"} → ${EXTEN}
|
||||
av = name: "\${${name}}";
|
||||
|
||||
# Group phone keys by extension number (multiple phones can share one extension)
|
||||
phonesByExtension = lib.foldlAttrs (acc: key: phone:
|
||||
acc // { ${phone.extension} = (acc.${phone.extension} or []) ++ [ key ]; }
|
||||
) {} allPhones;
|
||||
|
||||
# Per-extension metadata derived from allPhones (all phones of the same extension
|
||||
# share the same personKey and mailboxExt by construction)
|
||||
extensionInfo = lib.mapAttrs (_ext: keys:
|
||||
let sample = allPhones.${lib.head keys}; in {
|
||||
inherit keys;
|
||||
mailboxExt = sample.mailboxExt;
|
||||
ringTimeout =
|
||||
if sample.personKey != null
|
||||
then cfg.persons.${sample.personKey}.ringTimeout
|
||||
else 30;
|
||||
}
|
||||
) phonesByExtension;
|
||||
|
||||
# L2 only makes sense for person phones: they need separate lines for
|
||||
# personal vs family calls and have distinct mailbox MWI per line.
|
||||
# Shared phones (no personal mailbox) and sip-clients use L1 for everything.
|
||||
phoneHasL2 = key:
|
||||
let phone = allPhones.${key};
|
||||
in phone.personKey != null && models.${phone.model}.hasProvisioning;
|
||||
|
||||
# Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once)
|
||||
dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}";
|
||||
|
||||
# Dial target strings
|
||||
allL2Endpoints = lib.concatStringsSep "&"
|
||||
(map dialTarget (lib.attrNames allPhones));
|
||||
|
||||
personL1Endpoints = personKey:
|
||||
lib.concatStringsSep "&"
|
||||
(map (key: "PJSIP/${key}")
|
||||
(lib.attrNames cfg.persons.${personKey}.phones));
|
||||
|
||||
personsL2Endpoints = personKeys:
|
||||
lib.concatStringsSep "&"
|
||||
(lib.concatMap (personKey:
|
||||
map dialTarget (lib.attrNames cfg.persons.${personKey}.phones)
|
||||
) personKeys);
|
||||
|
||||
# --- Outbound trunk resolution ---
|
||||
# Find the first DID matching a predicate, returning { id, trunk } or null.
|
||||
findDid = pred:
|
||||
lib.foldlAttrs (acc: id: didCfg:
|
||||
if acc != null then acc
|
||||
else if pred didCfg then { inherit id; inherit (didCfg) trunk; }
|
||||
else null
|
||||
) null cfg.dids;
|
||||
|
||||
# The shared/family DID: routing.type = "all"
|
||||
allDid = findDid (d: d.routing.type == "all");
|
||||
|
||||
# Personal DID for a given person key
|
||||
personDid = personKey: findDid (d: d.routing.type == "person" && d.routing.person == personKey);
|
||||
|
||||
# Outbound assignment for an L1 endpoint: personal DID if person, else allDid
|
||||
l1OutboundFor = phone:
|
||||
if phone.personKey != null
|
||||
then let pd = personDid phone.personKey;
|
||||
in if pd != null then pd else allDid
|
||||
else allDid;
|
||||
|
||||
# Render set_var lines for outbound trunk + caller ID, or empty string if no DID found
|
||||
outboundVars = did:
|
||||
if did == null then ""
|
||||
else
|
||||
let t = cfg.sipTrunks.${did.trunk}; in
|
||||
"set_var=OUTBOUND_TRUNK=trunk-${did.trunk}-endpoint\n "
|
||||
+ lib.optionalString (t.callerId != "" || t.callerIdFile != null)
|
||||
(runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n ");
|
||||
|
||||
# Page: only provisioned phones with an intercom line (auto-answer speakerphone).
|
||||
# sip-clients have no dedicated intercom endpoint and are excluded.
|
||||
allPageEndpoints = lib.concatStringsSep "&"
|
||||
(lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom")
|
||||
(lib.filterAttrs (key: phone:
|
||||
models.${phone.model}.hasProvisioning && cfg.intercomPrefix != null
|
||||
) allPhones));
|
||||
|
||||
# --- PJSIP endpoint generators ---
|
||||
|
||||
genL1Endpoint = key: phone:
|
||||
let
|
||||
m = models.${phone.model};
|
||||
# Personal mailbox takes priority; shared phones watch the shared mailbox
|
||||
effectiveMailbox =
|
||||
if phone.mailboxExt != null then phone.mailboxExt
|
||||
else if hasSharedMailbox then cfg.sharedMailbox.mailboxId
|
||||
else null;
|
||||
mailboxLine = lib.optionalString (effectiveMailbox != null)
|
||||
"mailboxes = ${effectiveMailbox}@voicemail\n ";
|
||||
vmVar = lib.optionalString (effectiveMailbox != null)
|
||||
"set_var=VOICEMAIL_MAILBOX=${effectiveMailbox}\n ";
|
||||
obVars = lib.optionalString hasTrunk (outboundVars (l1OutboundFor phone));
|
||||
in ''
|
||||
[${key}](${m.endpointTemplate})
|
||||
auth = auth-${key}
|
||||
aors = ${key}
|
||||
${mailboxLine}${vmVar}${obVars}
|
||||
[auth-${key}](auth-userpass)
|
||||
username = ${key}
|
||||
password = ${phone.password}
|
||||
|
||||
[${key}]
|
||||
type = aor
|
||||
max_contacts = ${toString m.maxContacts}
|
||||
remove_existing = yes
|
||||
'';
|
||||
|
||||
genL2Endpoint = key: phone:
|
||||
let
|
||||
m = models.${phone.model};
|
||||
k = "${key}-l2";
|
||||
mailboxLine = lib.optionalString hasSharedMailbox
|
||||
"mailboxes = ${cfg.sharedMailbox.mailboxId}@voicemail\n ";
|
||||
vmVar = lib.optionalString hasSharedMailbox
|
||||
"set_var=VOICEMAIL_MAILBOX=${cfg.sharedMailbox.mailboxId}\n ";
|
||||
obVars = outboundVars allDid;
|
||||
in ''
|
||||
[${k}](${m.endpointTemplate})
|
||||
auth = auth-${k}
|
||||
aors = ${k}
|
||||
${mailboxLine}${vmVar}${obVars}
|
||||
[auth-${k}](auth-userpass)
|
||||
username = ${k}
|
||||
password = ${phone.password}
|
||||
|
||||
[${k}]
|
||||
type = aor
|
||||
max_contacts = ${toString m.maxContacts}
|
||||
remove_existing = yes
|
||||
'';
|
||||
|
||||
pjsip =
|
||||
''
|
||||
[transport-tcp]
|
||||
type = transport
|
||||
protocol = tcp
|
||||
|
|
@ -21,50 +172,38 @@ let
|
|||
context = internal
|
||||
transport = transport-tcp
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
allow = alaw
|
||||
allow = g722
|
||||
allow = g726
|
||||
allow = ilbc
|
||||
allow = gsm
|
||||
direct_media = no
|
||||
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.hardwarePhones}direct_media = no
|
||||
trust_id_inbound = yes
|
||||
send_pai = yes
|
||||
|
||||
[endpoint-generic](!)
|
||||
type = endpoint
|
||||
context = internal
|
||||
transport = transport-tcp
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
allow = alaw
|
||||
allow = g722
|
||||
direct_media = no
|
||||
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.softClients}direct_media = no
|
||||
send_pai = yes
|
||||
|
||||
[auth-userpass](!)
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
|
||||
; --- phones ---
|
||||
; --- phones (L1) ---
|
||||
|
||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
|
||||
let m = models.${phone.model}; in ''
|
||||
[${key}](${m.endpointTemplate})
|
||||
auth = auth-${key}
|
||||
aors = ${key}
|
||||
${lib.optionalString (phone.voicemailTimeout != null) "mailboxes = ${phone.extension}@voicemail\n set_var=VOICEMAIL_MAILBOX=${phone.extension}"}
|
||||
''
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones)
|
||||
|
||||
[auth-${key}](auth-userpass)
|
||||
username = ${key}
|
||||
password = ${phone.password}
|
||||
+ lib.optionalString hasTrunk (
|
||||
let provisionedPhones = lib.filterAttrs (key: _: phoneHasL2 key) allPhones; in
|
||||
lib.optionalString (provisionedPhones != {}) (
|
||||
"\n ; --- family line endpoints (L2, provisioned phones only) ---\n\n"
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList genL2Endpoint provisionedPhones)
|
||||
)
|
||||
)
|
||||
|
||||
[${key}]
|
||||
type = aor
|
||||
max_contacts = ${toString m.maxContacts}
|
||||
remove_existing = yes
|
||||
'') cfg.phones)
|
||||
|
||||
+ lib.concatMapStringsSep "\n" (ic: ''
|
||||
+ lib.concatMapStringsSep "\n" (ic: ''
|
||||
|
||||
; --- intercom ---
|
||||
[${ic.endpoint}](${ic.endpointTemplate})
|
||||
auth = auth-${ic.endpoint}
|
||||
aors = ${ic.endpoint}
|
||||
|
|
@ -77,43 +216,146 @@ let
|
|||
type = aor
|
||||
max_contacts = ${toString ic.maxContacts}
|
||||
remove_existing = yes
|
||||
'') intercomEntries;
|
||||
'') intercomEntries
|
||||
|
||||
# Reverse map: extension number -> pjsip endpoint key
|
||||
extensionToEndpoint = lib.foldlAttrs (acc: key: phone:
|
||||
acc // { ${phone.extension} = key; }
|
||||
) {} cfg.phones;
|
||||
+ lib.optionalString hasTrunk (
|
||||
"\n ; --- SIP trunks ---\n"
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: ''
|
||||
|
||||
# All page endpoints: intercom line for provisioned phones, regular for others
|
||||
allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: phone:
|
||||
let m = models.${phone.model}; in
|
||||
if m.hasProvisioning && cfg.intercomPrefix != null
|
||||
then "PJSIP/${key}-intercom"
|
||||
else "PJSIP/${key}"
|
||||
) cfg.phones);
|
||||
[trunk-${name}-registration]
|
||||
type = registration
|
||||
outbound_auth = trunk-${name}-auth
|
||||
${runtimeLine "server_uri = sip:" t.host t.hostFile}
|
||||
${let u = if t.usernameFile != null then "$(cat ${t.usernameFile})" else t.username;
|
||||
h = if t.hostFile != null then "$(cat ${t.hostFile})" else t.host;
|
||||
in if t.usernameFile != null || t.hostFile != null
|
||||
then ''#exec echo "client_uri = sip:${u}@${h}"''
|
||||
else "client_uri = sip:${u}@${h}"}
|
||||
retry_interval = 60
|
||||
forbidden_retry_interval = 600
|
||||
expiration = 3600
|
||||
|
||||
extensions = ''
|
||||
[internal]
|
||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
|
||||
if extCfg.mode == "app"
|
||||
then "exten => ${ext},1,${extCfg.app}"
|
||||
else if extCfg.mode == "page"
|
||||
then "exten => ${ext},1,Page(${allPageEndpoints},i,120)"
|
||||
else if lib.hasAttr ext extensionToEndpoint
|
||||
then
|
||||
[trunk-${name}-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
${runtimeLine "username = " t.username t.usernameFile}
|
||||
${runtimeLine "password = " t.password t.passwordFile}
|
||||
|
||||
[trunk-${name}-endpoint]
|
||||
type = endpoint
|
||||
context = from-external
|
||||
transport = transport-${t.transport}
|
||||
disallow = all
|
||||
outbound_auth = trunk-${name}-auth
|
||||
aors = trunk-${name}-aor
|
||||
trust_id_inbound = yes
|
||||
disallow = all
|
||||
${lib.concatMapStrings (c: "allow = ${c}\n ") cfg.codecs.trunk}
|
||||
|
||||
[trunk-${name}-aor]
|
||||
type = aor
|
||||
${runtimeLine "contact = sip:" t.host t.hostFile}
|
||||
|
||||
[trunk-${name}-identify]
|
||||
type = identify
|
||||
endpoint = trunk-${name}-endpoint
|
||||
${runtimeLine "match = " t.host t.hostFile}
|
||||
'') cfg.sipTrunks));
|
||||
|
||||
# --- Dialplan ---
|
||||
|
||||
internalContext =
|
||||
"[internal]\n"
|
||||
# Line extensions (auto-generated from allPhones, grouped by extension)
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info:
|
||||
let dialStr = lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys); in
|
||||
if info.mailboxExt != null
|
||||
then ''exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})
|
||||
same => n,VoiceMail(${info.mailboxExt}@voicemail,u)''
|
||||
else "exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})"
|
||||
) extensionInfo)
|
||||
|
||||
# Page and app extensions from cfg.extensions
|
||||
+ "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
|
||||
if extCfg.mode == "page"
|
||||
then "exten => ${ext},1,Page(${allPageEndpoints},i,120)"
|
||||
else "exten => ${ext},1,${extCfg.app}"
|
||||
) cfg.extensions)
|
||||
|
||||
# Auto-generated intercom extensions
|
||||
+ "\n" + lib.concatMapStringsSep "\n" (ic:
|
||||
"exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)"
|
||||
) intercomEntries
|
||||
|
||||
# Voicemail check (*97 — uses VOICEMAIL_MAILBOX set on the endpoint)
|
||||
+ lib.optionalString hasAnyMailbox
|
||||
"\nexten => *97,1,VoiceMailMain(${av "VOICEMAIL_MAILBOX"}@voicemail,sa(0))"
|
||||
# Shared mailbox direct check extension
|
||||
+ lib.optionalString hasSharedMailbox
|
||||
"\nexten => ${cfg.sharedMailbox.checkExtension},1,VoiceMailMain(${cfg.sharedMailbox.mailboxId}@voicemail,sa(0))"
|
||||
|
||||
# Outbound — trunk and caller ID determined by set_var on the originating endpoint
|
||||
+ lib.optionalString hasTrunk ''
|
||||
|
||||
exten => _0.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
|
||||
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
|
||||
exten => _00.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
|
||||
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
|
||||
exten => _+.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"})
|
||||
same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"})
|
||||
'';
|
||||
|
||||
externalContext = lib.optionalString hasTrunk (
|
||||
''
|
||||
|
||||
[from-external]
|
||||
; Provider sent INVITE without DID in Request-URI — extract from To header.
|
||||
; To: <sip:+49123456789@provider.example.com:5060;user=phone>
|
||||
; CUT by ':' field 2 → +49123456789@provider.example.com
|
||||
; CUT by '@' field 1 → +49123456789
|
||||
exten => s,1,Set(DID=${av "PJSIP_HEADER(read,To)"})
|
||||
same => n,Set(DID=${av "CUT(DID,:,2)"})
|
||||
same => n,Set(DID=${av "CUT(DID,@,1)"})
|
||||
same => n,Goto(from-external,${av "DID"},1)
|
||||
same => n,Hangup(21)
|
||||
''
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg:
|
||||
let
|
||||
phoneKey = extensionToEndpoint.${ext};
|
||||
phone = cfg.phones.${phoneKey};
|
||||
in if phone.voicemailTimeout != null
|
||||
then ''exten => ${ext},1,Dial(PJSIP/${phoneKey},${toString phone.voicemailTimeout})
|
||||
same => n,VoiceMail(${ext}@voicemail,u)''
|
||||
else "exten => ${ext},1,Dial(PJSIP/${phoneKey})"
|
||||
else "exten => ${ext},1,Hangup() ; WARNING: no endpoint assigned to extension ${ext}"
|
||||
) cfg.extensions)
|
||||
+ "\n" + lib.concatMapStringsSep "\n" (ic:
|
||||
"exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)"
|
||||
) intercomEntries
|
||||
+ lib.optionalString hasVoicemail "\nexten => *97,1,VoiceMailMain(\${VOICEMAIL_MAILBOX}@voicemail,sa(0))";
|
||||
r = didCfg.routing;
|
||||
dialStr =
|
||||
if r.type == "all" then allL2Endpoints
|
||||
else if r.type == "person" then personL1Endpoints r.person
|
||||
else personsL2Endpoints r.persons;
|
||||
mohOpt = lib.optionalString (didCfg.musicOnHold != null) "m(${didCfg.musicOnHold})";
|
||||
dialArgs = "${dialStr},${toString r.timeout},${mohOpt}";
|
||||
noAnsLine =
|
||||
if didCfg.mailbox == "shared" then
|
||||
let gdir = greetingDirs.shared; in
|
||||
lib.optionalString (gdir != null)
|
||||
"\n same => n,Playback(${gdir}/greeting)"
|
||||
+ "\n same => n,VoiceMail(${cfg.sharedMailbox.mailboxId}@voicemail,${if gdir != null then "s" else "u"})"
|
||||
else if didCfg.mailbox == "person" then
|
||||
let
|
||||
person = cfg.persons.${r.person};
|
||||
gdir = greetingDirs.persons.${r.person};
|
||||
in
|
||||
lib.optionalString (gdir != null)
|
||||
"\n same => n,Playback(${gdir}/greeting)"
|
||||
+ "\n same => n,VoiceMail(${person.extension}@voicemail,${if gdir != null then "s" else "u"})"
|
||||
else "";
|
||||
# Static number: inline it. Runtime number: #exec reads the file and outputs the exten line.
|
||||
# noAnsLine follows as static text — Asterisk processes #exec output inline with the file,
|
||||
# so "same =>" on the next line correctly refers to the exten => emitted by #exec.
|
||||
extenLine =
|
||||
if didCfg.numberFile != null
|
||||
then "#exec echo 'exten => '$(cat ${didCfg.numberFile})',1,Dial(${dialArgs})'"
|
||||
else "exten => ${didCfg.number},1,Dial(${dialArgs})";
|
||||
in
|
||||
"; ${didCfg.displayName} (${id}, trunk: ${didCfg.trunk})\n${extenLine}${noAnsLine}"
|
||||
) cfg.dids)
|
||||
);
|
||||
|
||||
extensions = internalContext + externalContext;
|
||||
|
||||
rtp = ''
|
||||
[general]
|
||||
|
|
@ -126,15 +368,31 @@ let
|
|||
format = ulaw
|
||||
|
||||
[voicemail]
|
||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (_: phone:
|
||||
let displayName = cfg.extensions.${phone.extension}.displayName; in
|
||||
" ${phone.extension} => ,${displayName},,attach=no"
|
||||
) vmPhones);
|
||||
'' + lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (_key: person:
|
||||
lib.optionalString person.mailbox
|
||||
" ${person.extension} => ,${person.displayName},,attach=no"
|
||||
) cfg.persons
|
||||
++ lib.optional hasSharedMailbox
|
||||
" ${cfg.sharedMailbox.mailboxId} => ,${cfg.sharedMailbox.displayName},,attach=no"
|
||||
);
|
||||
|
||||
musiconhold = lib.optionalString (cfg.mohClasses != {}) (
|
||||
"[general]\n"
|
||||
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: ''
|
||||
[${name}]
|
||||
mode = files
|
||||
directory = ${mohDirs.${name}}
|
||||
sort = ${cls.sort}
|
||||
'') cfg.mohClasses)
|
||||
);
|
||||
|
||||
in {
|
||||
"pjsip.conf" = pjsip;
|
||||
"extensions.conf" = extensions;
|
||||
"rtp.conf" = rtp;
|
||||
} // lib.optionalAttrs hasVoicemail {
|
||||
"voicemail.conf" = voicemail;
|
||||
} // lib.optionalAttrs hasAnyMailbox {
|
||||
"voicemail.conf" = voicemail;
|
||||
} // lib.optionalAttrs (cfg.mohClasses != {}) {
|
||||
"musiconhold.conf" = musiconhold;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{ lib, pkgs, cfg, models }:
|
||||
{ lib, pkgs, cfg, models, allPhones }:
|
||||
|
||||
let
|
||||
# Collect unique (desktopSize, thumbnailSize) pairs from provisioned phone models
|
||||
|
|
@ -6,7 +6,7 @@ let
|
|||
(lib.mapAttrsToList (_: phone:
|
||||
let m = models.${phone.model}; in
|
||||
{ desktop = m.desktopSize; thumbnail = m.thumbnailSize; }
|
||||
) cfg.phones));
|
||||
) allPhones));
|
||||
|
||||
# Parse "WxH" or "WxHxD" into width and height
|
||||
parseDimensions = size:
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ let
|
|||
# 2. For provisioned models, add a template in ./templates/<model>.nix
|
||||
#
|
||||
# Template interface — all provisioned model templates receive these args:
|
||||
# Required: mac, label, password, displayName, serverAddress, ntpServer
|
||||
# Required: mac, label, displayName, password, serverAddress, ntpServer
|
||||
# Optional: sipPort (default 5060), directoryPort (default 8080),
|
||||
# intercomEnabled (default false), intercomPassword (default "")
|
||||
# intercomEnabled (default false), intercomPassword (default ""),
|
||||
# familyLineEnabled (default false), familyLineLabel (default "Familie")
|
||||
models = {
|
||||
"cisco-8961" = {
|
||||
endpointTemplate = "endpoint-cisco-8961";
|
||||
|
|
@ -30,11 +31,89 @@ let
|
|||
};
|
||||
};
|
||||
|
||||
intercomEntries = import ./intercom.nix { inherit lib cfg models; };
|
||||
confFiles = import ./asterisk.nix { inherit lib cfg models intercomEntries; };
|
||||
directory = import ./directory.nix { inherit lib pkgs cfg intercomEntries; };
|
||||
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models; };
|
||||
tftpRoot = import ./tftp.nix { inherit lib pkgs cfg models backgroundEntries; };
|
||||
# Unified view of all physical devices, keyed by SIP identity (MAC or username).
|
||||
# Each entry carries the fields needed by sub-modules without them having to
|
||||
# know about sharedPhones vs persons.
|
||||
allPhones =
|
||||
lib.mapAttrs (key: p: {
|
||||
inherit (p) model label password;
|
||||
extension = p.extension;
|
||||
displayName = p.displayName;
|
||||
personKey = null;
|
||||
mailboxExt = null; # shared phones have no personal mailbox
|
||||
}) cfg.sharedPhones
|
||||
//
|
||||
lib.foldlAttrs (acc: personKey: person:
|
||||
acc // lib.mapAttrs (_key: ph: {
|
||||
inherit (ph) model label password;
|
||||
extension = person.extension;
|
||||
displayName = person.displayName;
|
||||
personKey = personKey;
|
||||
mailboxExt = if person.mailbox then person.extension else null;
|
||||
}) person.phones
|
||||
) {} cfg.persons;
|
||||
|
||||
mohDirs = import ./moh.nix { inherit lib pkgs cfg; };
|
||||
greetingDirs = import ./greetings.nix { inherit lib pkgs cfg; };
|
||||
intercomEntries = import ./intercom.nix { inherit lib cfg models allPhones; };
|
||||
confFiles = import ./asterisk.nix { inherit lib cfg models allPhones intercomEntries mohDirs greetingDirs; };
|
||||
|
||||
# True when any *File option is set — Asterisk's execincludes=yes is required in that case.
|
||||
hasRuntimeSecrets =
|
||||
lib.any (t: t.hostFile != null || t.usernameFile != null || t.passwordFile != null || t.callerIdFile != null)
|
||||
(lib.attrValues cfg.sipTrunks)
|
||||
|| lib.any (d: d.numberFile != null) (lib.attrValues cfg.dids);
|
||||
|
||||
# Nginx Lua handler: reads the static HTML template and substitutes every
|
||||
# @@/path/to/keyfile@@ marker with the file's first line at request time.
|
||||
luaPageHandler = pkgs.writeText "voip-page.lua" ''
|
||||
local f = assert(io.open("${diagram.webRoot}/index.html", "rb"))
|
||||
local html = f:read("*a")
|
||||
f:close()
|
||||
-- Placeholders embed the full key file path: @@/var/lib/voip-keys/name@@
|
||||
html = html:gsub("@@([^@]+)@@", function(path)
|
||||
local kf = io.open(path, "r")
|
||||
if not kf then return "<em>(not yet uploaded)</em>" end
|
||||
local val = kf:read("*l")
|
||||
kf:close()
|
||||
return val or ""
|
||||
end)
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.print(html)
|
||||
'';
|
||||
directory = import ./directory.nix { inherit lib pkgs cfg allPhones intercomEntries; };
|
||||
backgroundEntries = import ./backgrounds.nix { inherit lib pkgs cfg models allPhones; };
|
||||
provisioningRoot = import ./provisioning.nix { inherit lib pkgs cfg models allPhones backgroundEntries; };
|
||||
diagram = import ./diagram.nix { inherit lib pkgs cfg models allPhones intercomEntries; };
|
||||
|
||||
# Shared option set for a physical phone device.
|
||||
# isPersonPhone = true → no extension/displayName fields (inherited from person)
|
||||
# isPersonPhone = false → includes extension and displayName
|
||||
phoneDeviceOptions = isPersonPhone: {
|
||||
model = lib.mkOption {
|
||||
type = lib.types.enum (lib.attrNames models);
|
||||
description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file).";
|
||||
};
|
||||
label = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Label shown on the phone screen (max ~12 chars for Cisco). Required for provisioned hardware phones.";
|
||||
};
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "SIP registration password.";
|
||||
};
|
||||
} // lib.optionalAttrs (!isPersonPhone) {
|
||||
extension = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Internal extension number for this shared phone.";
|
||||
};
|
||||
displayName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Name shown in the phone directory.";
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
options.services.voip = {
|
||||
|
|
@ -89,36 +168,58 @@ in {
|
|||
type = lib.types.attrsOf lib.types.path;
|
||||
};
|
||||
|
||||
phones = lib.mkOption {
|
||||
sharedPhones = lib.mkOption {
|
||||
default = {};
|
||||
description = ''
|
||||
Attrset of phones/clients keyed by SIP identity (username).
|
||||
For hardware phones (cisco-8961), the key must be the lowercase MAC address (no colons).
|
||||
Shared/location phones not assigned to a specific person (e.g. hallway, kitchen).
|
||||
These have their own extension but no personal voicemail mailbox.
|
||||
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
||||
For sip-client, the key is a free-form username.
|
||||
'';
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = phoneDeviceOptions false;
|
||||
});
|
||||
};
|
||||
|
||||
persons = lib.mkOption {
|
||||
default = {};
|
||||
description = "People with personal extensions, optional voicemail mailboxes, and their own phones.";
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
model = lib.mkOption {
|
||||
type = lib.types.enum (lib.attrNames models);
|
||||
description = "Phone model. Use \"sip-client\" for software SIP clients (no provisioning file).";
|
||||
};
|
||||
extension = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Extension number this phone registers as.";
|
||||
description = "Personal extension number.";
|
||||
};
|
||||
label = lib.mkOption {
|
||||
displayName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Label shown on the phone screen. Required for provisioned hardware phones.";
|
||||
description = "Name shown in the directory and on caller ID.";
|
||||
};
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "SIP registration password.";
|
||||
mailbox = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether this person gets a personal voicemail mailbox.";
|
||||
};
|
||||
voicemailTimeout = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
ringTimeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 30;
|
||||
description = "Seconds to ring before going to voicemail (or hanging up if no mailbox).";
|
||||
};
|
||||
mailboxGreeting = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Seconds to ring before sending to voicemail. null disables voicemail for this phone.";
|
||||
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
||||
};
|
||||
phones = lib.mkOption {
|
||||
default = {};
|
||||
description = ''
|
||||
Phones belonging to this person, keyed by SIP identity.
|
||||
For cisco-8961, the key must be the lowercase MAC address (no colons).
|
||||
For sip-client, the key is a free-form username.
|
||||
'';
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = phoneDeviceOptions true;
|
||||
});
|
||||
};
|
||||
};
|
||||
});
|
||||
|
|
@ -130,20 +231,219 @@ in {
|
|||
description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries.";
|
||||
};
|
||||
|
||||
codecs = lib.mkOption {
|
||||
description = "Codec preference lists for each endpoint class, ordered highest priority first.";
|
||||
default = {};
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
hardwarePhones = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "g722" "alaw" "ulaw" "ilbc" ];
|
||||
description = "Codecs for provisioned hardware phones (e.g. Cisco 8961). Supports G.722, G.711, iLBC. Opus and G.726 not supported. G.729 supported but not useful on LAN.";
|
||||
};
|
||||
softClients = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "opus" "g722" "alaw" "ulaw" ];
|
||||
description = "Codecs for software SIP clients. Opus first for best quality on modern softphones.";
|
||||
};
|
||||
trunk = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "alaw" "ulaw" ];
|
||||
description = "Codecs offered to SIP trunks. Most providers only support G.711.";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
mohClasses = lib.mkOption {
|
||||
default = {};
|
||||
description = "Music on hold classes. Files are transcoded to ulaw at build time.";
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
files = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.path;
|
||||
description = "Audio files to play (MP3, WAV, etc). Transcoded to 8kHz ulaw automatically.";
|
||||
};
|
||||
sort = lib.mkOption {
|
||||
type = lib.types.enum [ "random" "alphabetical" ];
|
||||
default = "random";
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
sipTrunks = lib.mkOption {
|
||||
default = {};
|
||||
description = "External SIP provider trunks, keyed by a short name (e.g. \"provider-a\").";
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "SIP provider hostname or IP address. Use hostFile to read from a file.";
|
||||
};
|
||||
hostFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the SIP provider hostname. Takes precedence over host.";
|
||||
};
|
||||
username = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "SIP account username. Use usernameFile to read from a file.";
|
||||
};
|
||||
usernameFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the SIP account username. Takes precedence over username.";
|
||||
};
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "SIP account password. Use passwordFile to read from a file.";
|
||||
};
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the SIP account password. Takes precedence over password.";
|
||||
};
|
||||
transport = lib.mkOption {
|
||||
type = lib.types.enum [ "udp" "tcp" ];
|
||||
default = "udp";
|
||||
};
|
||||
callerId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Outbound caller ID number presented to the provider. Leave empty to use provider default. Use callerIdFile to read from a file.";
|
||||
};
|
||||
callerIdFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the outbound caller ID. Takes precedence over callerId.";
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
sharedMailbox = lib.mkOption {
|
||||
default = null;
|
||||
description = "Shared voicemail mailbox accessible by all phones (family answering machine).";
|
||||
type = lib.types.nullOr (lib.types.submodule {
|
||||
options = {
|
||||
mailboxId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "200";
|
||||
description = "Numeric mailbox ID used internally in voicemail.conf and VoiceMail(). Must be numeric.";
|
||||
};
|
||||
checkExtension = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "*98";
|
||||
description = "Extension dialled to check this shared mailbox directly (via VoiceMailMain).";
|
||||
};
|
||||
displayName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "Shared";
|
||||
description = "Name shown in voicemail configuration.";
|
||||
};
|
||||
greeting = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Audio file played to callers before the voicemail beep. Transcoded to multiple formats at build time. null = Asterisk's default announcement.";
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
dids = lib.mkOption {
|
||||
default = {};
|
||||
description = "Inbound DID routing. Each DID must reference a key from sipTrunks.";
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
number = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "DID number in E.164 format (e.g. \"+4912345678\"). Use numberFile to read from a file.";
|
||||
};
|
||||
numberFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the DID number. Takes precedence over number.";
|
||||
};
|
||||
trunk = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Key of the sipTrunks entry this DID arrives on.";
|
||||
};
|
||||
displayName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Human-readable label for this DID (informational only).";
|
||||
};
|
||||
routing = lib.mkOption {
|
||||
description = "How inbound calls on this DID are distributed to phones.";
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "all" "person" "persons" ];
|
||||
description = ''
|
||||
all — ring all phones (sharedPhones + all persons) on their L2 line
|
||||
person — ring a single person on their L1 line
|
||||
persons — ring a list of persons on their L2 line
|
||||
'';
|
||||
};
|
||||
person = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Person key for routing.type = \"person\".";
|
||||
};
|
||||
persons = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [];
|
||||
description = "Person keys for routing.type = \"persons\".";
|
||||
};
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 30;
|
||||
description = "Seconds to ring before going to voicemail (or hanging up).";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
mailbox = lib.mkOption {
|
||||
type = lib.types.enum [ "shared" "person" "none" ];
|
||||
default = "shared";
|
||||
description = ''
|
||||
shared — go to sharedMailbox on no answer (requires sharedMailbox to be set)
|
||||
person — go to the routed person's mailbox on no answer (only valid with routing.type = "person")
|
||||
none — hang up on no answer
|
||||
'';
|
||||
};
|
||||
musicOnHold = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
MOH class name to play to the caller while phones ring, instead of ringback.
|
||||
Must match a key in mohClasses. null = standard ringback.
|
||||
'';
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
extensions = lib.mkOption {
|
||||
default = {};
|
||||
description = "Attrset of extensions keyed by extension number.";
|
||||
description = ''
|
||||
Extra extensions: page groups and custom app entries.
|
||||
Line extensions are auto-generated from sharedPhones and persons — do not declare them here.
|
||||
'';
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "line" "page" "app" ];
|
||||
default = "line";
|
||||
type = lib.types.enum [ "page" "app" ];
|
||||
default = "page";
|
||||
description = ''
|
||||
Extension mode:
|
||||
- "line": dials the phone assigned to this extension
|
||||
- "page": one-way announcement to all phones
|
||||
- "app": custom Asterisk dialplan application
|
||||
Intercom extensions are auto-generated when intercomPrefix is set.
|
||||
'';
|
||||
};
|
||||
displayName = lib.mkOption {
|
||||
|
|
@ -164,80 +464,204 @@ in {
|
|||
|
||||
services.voip.ntpServer = lib.mkDefault cfg.serverAddress;
|
||||
|
||||
|
||||
assertions =
|
||||
# Every phone's extension must be declared
|
||||
(lib.mapAttrsToList (key: phone: {
|
||||
assertion = lib.hasAttr phone.extension cfg.extensions;
|
||||
message = "services.voip: phone \"${key}\" references extension ${phone.extension} which is not declared in services.voip.extensions";
|
||||
}) cfg.phones)
|
||||
++
|
||||
# Provisioned phones require a MAC address key
|
||||
# Provisioned sharedPhones require a MAC address key
|
||||
(lib.mapAttrsToList (key: phone: {
|
||||
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
||||
message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||
}) cfg.phones)
|
||||
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||
}) cfg.sharedPhones)
|
||||
++
|
||||
# Provisioned phones require a non-empty label
|
||||
# Provisioned sharedPhones require a non-empty label
|
||||
(lib.mapAttrsToList (key: phone: {
|
||||
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
||||
message = "services.voip: phone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||
}) cfg.phones)
|
||||
message = "services.voip: sharedPhone \"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||
}) cfg.sharedPhones)
|
||||
++
|
||||
# Provisioned phones require a template
|
||||
(lib.mapAttrsToList (key: phone: {
|
||||
assertion = !models.${phone.model}.hasProvisioning || models.${phone.model}.template != null;
|
||||
message = "services.voip: phone \"${key}\" model \"${phone.model}\" has hasProvisioning=true but no template defined";
|
||||
}) cfg.phones)
|
||||
# Provisioned person phones require a MAC address key
|
||||
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
||||
lib.mapAttrsToList (key: phone: {
|
||||
assertion = !models.${phone.model}.hasProvisioning || builtins.match "[0-9a-f]{12}" key != null;
|
||||
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a lowercase 12-char hex MAC address as key";
|
||||
}) person.phones
|
||||
) cfg.persons))
|
||||
++
|
||||
# intercomPrefix must not collide with user-declared extensions
|
||||
# Provisioned person phones require a non-empty label
|
||||
(lib.concatLists (lib.mapAttrsToList (personKey: person:
|
||||
lib.mapAttrsToList (key: phone: {
|
||||
assertion = !models.${phone.model}.hasProvisioning || phone.label != "";
|
||||
message = "services.voip: persons.${personKey}.phones.\"${key}\" (model ${phone.model}) requires a non-empty label";
|
||||
}) person.phones
|
||||
) cfg.persons))
|
||||
++
|
||||
# No duplicate phone keys across sharedPhones and persons.*.phones
|
||||
[{
|
||||
assertion =
|
||||
let keys = lib.attrNames allPhones;
|
||||
in lib.length keys == lib.length (lib.unique keys);
|
||||
message = "services.voip: duplicate phone key detected across sharedPhones and persons.*.phones";
|
||||
}]
|
||||
++
|
||||
# No duplicate extensions across sharedPhones and persons
|
||||
[{
|
||||
assertion =
|
||||
let
|
||||
exts = (lib.mapAttrsToList (_: p: p.extension) cfg.sharedPhones)
|
||||
++ (lib.mapAttrsToList (_: p: p.extension) cfg.persons);
|
||||
in lib.length exts == lib.length (lib.unique exts);
|
||||
message = "services.voip: duplicate extension number across sharedPhones and persons";
|
||||
}]
|
||||
++
|
||||
# dids require at least one sipTrunk
|
||||
(lib.optionals (cfg.dids != {}) [{
|
||||
assertion = cfg.sipTrunks != {};
|
||||
message = "services.voip: dids are configured but sipTrunks is empty";
|
||||
}])
|
||||
++
|
||||
# each DID must reference an existing trunk
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = lib.hasAttr didCfg.trunk cfg.sipTrunks;
|
||||
message = "services.voip: DID ${did} references trunk \"${didCfg.trunk}\" which is not in services.voip.sipTrunks";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids with mailbox="shared" require sharedMailbox
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = didCfg.mailbox != "shared" || cfg.sharedMailbox != null;
|
||||
message = "services.voip: DID ${did} has mailbox=\"shared\" but sharedMailbox is not configured";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids with mailbox="person" require routing.type="person"
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = didCfg.mailbox != "person" || didCfg.routing.type == "person";
|
||||
message = "services.voip: DID ${did} has mailbox=\"person\" but routing.type is not \"person\"";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids routing.type="person" — person key must be non-empty
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = didCfg.routing.type != "person" || didCfg.routing.person != "";
|
||||
message = "services.voip: DID ${did} has routing.type=\"person\" but routing.person is not set";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids routing.type="person" — referenced person must exist
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = didCfg.routing.type != "person" || didCfg.routing.person == "" || lib.hasAttr didCfg.routing.person cfg.persons;
|
||||
message = "services.voip: DID ${did} references person \"${didCfg.routing.person}\" which is not in services.voip.persons";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids routing.type="persons" — persons list must be non-empty
|
||||
(lib.mapAttrsToList (did: didCfg: {
|
||||
assertion = didCfg.routing.type != "persons" || didCfg.routing.persons != [];
|
||||
message = "services.voip: DID ${did} has routing.type=\"persons\" but routing.persons is empty";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# dids routing.type="persons" — all referenced persons must exist
|
||||
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
||||
lib.optionals (didCfg.routing.type == "persons")
|
||||
(map (p: {
|
||||
assertion = lib.hasAttr p cfg.persons;
|
||||
message = "services.voip: DID ${did} references person \"${p}\" which is not in services.voip.persons";
|
||||
}) didCfg.routing.persons)
|
||||
) cfg.dids))
|
||||
++
|
||||
# dids musicOnHold must reference an existing mohClass
|
||||
(lib.concatLists (lib.mapAttrsToList (did: didCfg:
|
||||
lib.optional (didCfg.musicOnHold != null) {
|
||||
assertion = lib.hasAttr didCfg.musicOnHold cfg.mohClasses;
|
||||
message = "services.voip: DID ${did} references mohClass \"${didCfg.musicOnHold}\" which is not in services.voip.mohClasses";
|
||||
}
|
||||
) cfg.dids))
|
||||
++
|
||||
# sipTrunks: each required field needs either a literal or a file
|
||||
(lib.concatLists (lib.mapAttrsToList (name: t: [
|
||||
{ assertion = t.host != "" || t.hostFile != null;
|
||||
message = "services.voip: sipTrunks.\"${name}\" requires host or hostFile"; }
|
||||
{ assertion = t.username != "" || t.usernameFile != null;
|
||||
message = "services.voip: sipTrunks.\"${name}\" requires username or usernameFile"; }
|
||||
{ assertion = t.password != "" || t.passwordFile != null;
|
||||
message = "services.voip: sipTrunks.\"${name}\" requires password or passwordFile"; }
|
||||
]) cfg.sipTrunks))
|
||||
++
|
||||
# dids: each DID needs a number either inline or from a file
|
||||
(lib.mapAttrsToList (id: d: {
|
||||
assertion = d.number != "" || d.numberFile != null;
|
||||
message = "services.voip: dids.\"${id}\" requires number or numberFile";
|
||||
}) cfg.dids)
|
||||
++
|
||||
# extensions with mode="app" must have a non-null app field
|
||||
(lib.mapAttrsToList (ext: extCfg: {
|
||||
assertion = extCfg.mode != "app" || extCfg.app != null;
|
||||
message = "services.voip: extension \"${ext}\" has mode=\"app\" but app is not set";
|
||||
}) cfg.extensions)
|
||||
++
|
||||
# intercomPrefix must not collide with any declared extension
|
||||
(lib.optionals (cfg.intercomPrefix != null)
|
||||
(lib.mapAttrsToList (key: phone:
|
||||
(lib.mapAttrsToList (_key: phone:
|
||||
let ext = "${cfg.intercomPrefix}${phone.extension}"; in {
|
||||
assertion = !lib.hasAttr ext cfg.extensions;
|
||||
message = "services.voip: auto-generated intercom extension \"${ext}\" collides with a declared extension; rename it or change intercomPrefix";
|
||||
}
|
||||
) (lib.filterAttrs (_: phone: models.${phone.model}.hasProvisioning) cfg.phones)));
|
||||
) allPhones));
|
||||
|
||||
services.asterisk = {
|
||||
enable = true;
|
||||
confFiles = confFiles;
|
||||
enable = true;
|
||||
confFiles = confFiles;
|
||||
# execincludes=yes is required when any *File option is in use.
|
||||
extraConfig = lib.optionalString hasRuntimeSecrets ''
|
||||
[options]
|
||||
execincludes=yes
|
||||
'';
|
||||
};
|
||||
|
||||
services.atftpd = {
|
||||
enable = true;
|
||||
root = "${tftpRoot}";
|
||||
root = "${provisioningRoot}";
|
||||
extraOptions = [ "--verbose=7" ];
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
enable = true;
|
||||
# OpenResty bundles nginx + LuaJIT + resty.core and all required libraries.
|
||||
# Needed for request-time secret substitution in the status page.
|
||||
package = lib.mkIf hasRuntimeSecrets (lib.mkDefault pkgs.openresty);
|
||||
# Cisco phones fetch provisioning files (SEP*.cnf.xml, dialplan-*.xml,
|
||||
# backgrounds) over TFTP (primary) and HTTP port 6970 (fallback).
|
||||
# Both serve the same Nix-built provisioning root.
|
||||
virtualHosts."voip-provisioning" = {
|
||||
listen = [{ addr = "0.0.0.0"; port = 6970; }];
|
||||
locations."/" = {
|
||||
root = "${provisioningRoot}";
|
||||
extraConfig = "autoindex off;";
|
||||
};
|
||||
};
|
||||
virtualHosts."voip-directory" = {
|
||||
listen = [{ addr = "0.0.0.0"; port = cfg.directoryPort; }];
|
||||
locations."= /directory.xml" = {
|
||||
alias = "${directory.menuFile}";
|
||||
extraConfig = "default_type text/xml;";
|
||||
};
|
||||
locations."= /directory-list.xml" = {
|
||||
alias = "${directory.listFile}";
|
||||
extraConfig = "default_type text/xml;";
|
||||
};
|
||||
locations."= /intercom.xml" = {
|
||||
alias = "${directory.intercomFile}";
|
||||
extraConfig = "default_type text/xml;";
|
||||
};
|
||||
locations."= /voicemail.xml" = {
|
||||
alias = "${directory.voicemailFile}";
|
||||
extraConfig = "default_type text/xml;";
|
||||
locations = {
|
||||
"= /directory.xml" = { alias = "${directory.menuFile}"; extraConfig = "default_type text/xml;"; };
|
||||
"= /directory-list.xml" = { alias = "${directory.listFile}"; extraConfig = "default_type text/xml;"; };
|
||||
"= /intercom.xml" = { alias = "${directory.intercomFile}"; extraConfig = "default_type text/xml;"; };
|
||||
"/" = {
|
||||
root = "${diagram.webRoot}";
|
||||
extraConfig = lib.optionalString (!hasRuntimeSecrets) "index index.html;";
|
||||
};
|
||||
} // lib.optionalAttrs hasRuntimeSecrets {
|
||||
# Exact-match the index so the Lua handler intercepts it before the
|
||||
# prefix location /. Other assets (voip.dot, SVG) fall through to /.
|
||||
"= /" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
|
||||
"= /index.html" = { extraConfig = "default_type text/html;\ncontent_by_lua_file ${luaPageHandler};"; };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# voip-keys group: both asterisk (#exec reads) and nginx (Lua reads) need access.
|
||||
# Key files must be deployed with group = "voip-keys" and permissions = "0640".
|
||||
users.groups.voip-keys = {};
|
||||
users.users.asterisk.extraGroups = [ "voip-keys" ];
|
||||
users.users.nginx.extraGroups = [ "voip-keys" ];
|
||||
|
||||
systemd.tmpfiles.rules = [ "d /var/log/nginx 0750 nginx nginx -" ];
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort ];
|
||||
allowedUDPPorts = [ 69 ];
|
||||
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort 6970 ];
|
||||
allowedUDPPorts = [ cfg.sipPort 69 ];
|
||||
allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
390
modules/voip/diagram.nix
Normal file
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
|
||||
baseUrl = "http://${cfg.serverAddress}:${toString cfg.directoryPort}";
|
||||
|
||||
hasPageExtensions = lib.any (e: e.mode == "page") (lib.attrValues cfg.extensions);
|
||||
|
||||
# Deduplicated directory entries: one per extension, using the displayName from
|
||||
# allPhones (all phones sharing an extension have the same displayName).
|
||||
extensionEntries =
|
||||
lib.attrValues (lib.foldlAttrs (acc: _key: phone:
|
||||
if lib.hasAttr phone.extension acc || phone.displayName == "" then acc
|
||||
else acc // { ${phone.extension} = { inherit (phone) extension displayName; }; }
|
||||
) {} allPhones);
|
||||
|
||||
menuXml = ''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CiscoIPPhoneMenu>
|
||||
<Title>${cfg.directoryName} Telefonbuch</Title>
|
||||
<Prompt>Ihre Wahl</Prompt>
|
||||
|
|
@ -23,21 +32,22 @@ let
|
|||
'';
|
||||
|
||||
listXml = ''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CiscoIPPhoneDirectory>
|
||||
<Title>${cfg.directoryName} Telefonbuch</Title>
|
||||
<Prompt>Ihre Wahl</Prompt>
|
||||
'' + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
|
||||
lib.optionalString (extCfg.mode == "line" && extCfg.displayName != "") ''
|
||||
'' + lib.concatMapStringsSep "\n" (e: ''
|
||||
<DirectoryEntry>
|
||||
<Name>${extCfg.displayName}</Name>
|
||||
<Telephone>${ext}</Telephone>
|
||||
<Name>${e.displayName}</Name>
|
||||
<Telephone>${e.extension}</Telephone>
|
||||
</DirectoryEntry>
|
||||
'') cfg.extensions)
|
||||
'') extensionEntries
|
||||
+ ''
|
||||
</CiscoIPPhoneDirectory>
|
||||
'';
|
||||
|
||||
intercomXml = ''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CiscoIPPhoneDirectory>
|
||||
<Title>Intercom / Durchsage</Title>
|
||||
<Prompt>Ihre Wahl</Prompt>
|
||||
|
|
@ -59,14 +69,15 @@ let
|
|||
'';
|
||||
|
||||
voicemailMenuXml = ''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CiscoIPPhoneExecute>
|
||||
<ExecuteItem Priority="0" URL="Dial:997"/>
|
||||
<ExecuteItem Priority="0" URL="Dial:*97"/>
|
||||
</CiscoIPPhoneExecute>
|
||||
'';
|
||||
|
||||
in {
|
||||
menuFile = pkgs.writeText "directory.xml" menuXml;
|
||||
listFile = pkgs.writeText "directory-list.xml" listXml;
|
||||
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
|
||||
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
|
||||
menuFile = pkgs.writeText "directory.xml" menuXml;
|
||||
listFile = pkgs.writeText "directory-list.xml" listXml;
|
||||
intercomFile = pkgs.writeText "intercom.xml" intercomXml;
|
||||
voicemailFile = pkgs.writeText "voicemail.xml" voicemailMenuXml;
|
||||
}
|
||||
|
|
|
|||
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 []
|
||||
else lib.concatLists (lib.mapAttrsToList (key: phone:
|
||||
let
|
||||
m = models.${phone.model};
|
||||
ext = phone.extension;
|
||||
# cfg.extensions.${ext} is guaranteed to exist by the phone→extension assertion
|
||||
extCfg = cfg.extensions.${ext};
|
||||
in lib.optional m.hasProvisioning {
|
||||
extension = "${cfg.intercomPrefix}${ext}";
|
||||
endpoint = "${key}-intercom";
|
||||
phoneKey = key;
|
||||
target = ext;
|
||||
displayName = "Intercom ${extCfg.displayName}";
|
||||
password = phone.password;
|
||||
let m = models.${phone.model}; in
|
||||
lib.optional m.hasProvisioning {
|
||||
extension = "${cfg.intercomPrefix}${phone.extension}";
|
||||
endpoint = "${key}-intercom";
|
||||
phoneKey = key;
|
||||
target = phone.extension;
|
||||
displayName = "Intercom ${phone.displayName}";
|
||||
password = phone.password;
|
||||
endpointTemplate = m.endpointTemplate;
|
||||
maxContacts = m.maxContacts;
|
||||
maxContacts = m.maxContacts;
|
||||
}
|
||||
) cfg.phones)
|
||||
) allPhones)
|
||||
|
|
|
|||
23
modules/voip/moh.nix
Normal file
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>
|
||||
<deviceProtocol>SIP</deviceProtocol>
|
||||
<sshUserId>admin</sshUserId>
|
||||
|
|
@ -68,7 +87,7 @@
|
|||
</sipStack>
|
||||
<transferOnhookEnabled>false</transferOnhookEnabled>
|
||||
<kpml>3</kpml>
|
||||
<phoneLabel>${builtins.substring 0 12 label}</phoneLabel>
|
||||
<phoneLabel>${(builtins.substring 0 12 label)}</phoneLabel>
|
||||
<stutterMsgWaiting>1</stutterMsgWaiting>
|
||||
<callStats>false</callStats>
|
||||
<sipLines>
|
||||
|
|
@ -95,8 +114,32 @@
|
|||
<dialedNumber>true</dialedNumber>
|
||||
</forwardCallInfoDisplay>
|
||||
</line>
|
||||
${if intercomEnabled then ''
|
||||
${if familyLineEnabled then ''
|
||||
<line button="2" lineIndex="2">
|
||||
<featureID>9</featureID>
|
||||
<featureLabel>${familyLineLabel}</featureLabel>
|
||||
<proxy>USECALLMANAGER</proxy>
|
||||
<port>${toString sipPort}</port>
|
||||
<name>${mac}-l2</name>
|
||||
<displayName>${familyLineLabel}</displayName>
|
||||
<autoAnswer>
|
||||
<autoAnswerEnabled>2</autoAnswerEnabled>
|
||||
</autoAnswer>
|
||||
<callWaiting>3</callWaiting>
|
||||
<authName>${mac}-l2</authName>
|
||||
<authPassword>${familyLinePassword}</authPassword>
|
||||
<messageWaitingLampPolicy>3</messageWaitingLampPolicy>
|
||||
<messagesNumber>*97</messagesNumber>
|
||||
<contact>${mac}-l2</contact>
|
||||
<forwardCallInfoDisplay>
|
||||
<callerName>true</callerName>
|
||||
<callerNumber>true</callerNumber>
|
||||
<redirectedNumber>true</redirectedNumber>
|
||||
<dialedNumber>true</dialedNumber>
|
||||
</forwardCallInfoDisplay>
|
||||
</line>
|
||||
'' else ""}${if intercomEnabled then ''
|
||||
<line button="${toString intercomButton}" lineIndex="${toString intercomLineIndex}">
|
||||
<featureID>23</featureID>
|
||||
<featureLabel>Intercom</featureLabel>
|
||||
<proxy>USECALLMANAGER</proxy>
|
||||
|
|
@ -119,8 +162,9 @@ ${if intercomEnabled then ''
|
|||
<startMediaPort>16348</startMediaPort>
|
||||
<stopMediaPort>20134</stopMediaPort>
|
||||
<dscpForAudio>184</dscpForAudio>
|
||||
<dialTemplate>dialplan.xml</dialTemplate>
|
||||
<dialTemplate>${dialplanFile}</dialTemplate>
|
||||
</sipProfile>
|
||||
<MissedCallLoggingOption>1</MissedCallLoggingOption>
|
||||
<commonProfile>
|
||||
<phonePassword></phonePassword>
|
||||
<backgroundImageAccess>true</backgroundImageAccess>
|
||||
|
|
@ -152,6 +196,24 @@ ${if intercomEnabled then ''
|
|||
<informationURL></informationURL>
|
||||
<phoneServices useHTTPS="false">
|
||||
<provisioning>2</provisioning>
|
||||
<phoneService type="1" category="0">
|
||||
<name>Missed Calls</name>
|
||||
<url>Application:Cisco/MissedCalls</url>
|
||||
<vendor></vendor>
|
||||
<version></version>
|
||||
</phoneService>
|
||||
<phoneService type="1" category="0">
|
||||
<name>Received Calls</name>
|
||||
<url>Application:Cisco/ReceivedCalls</url>
|
||||
<vendor></vendor>
|
||||
<version></version>
|
||||
</phoneService>
|
||||
<phoneService type="1" category="0">
|
||||
<name>Placed Calls</name>
|
||||
<url>Application:Cisco/PlacedCalls</url>
|
||||
<vendor></vendor>
|
||||
<version></version>
|
||||
</phoneService>
|
||||
<phoneService type="2" category="0">
|
||||
<name>Voicemail</name>
|
||||
<url>Application:Cisco/Voicemail</url>
|
||||
|
|
|
|||
|
|
@ -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