nix/modules/voip/default.nix

244 lines
8.9 KiB
Nix

{ lib, pkgs, config, ... }:
let
cfg = config.services.voip;
# Per-model config. Adding a new hardware model:
# 1. Add an entry here with all required fields
# 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
# Optional: sipPort (default 5060), directoryPort (default 8080),
# intercomEnabled (default false), intercomPassword (default "")
models = {
"cisco-8961" = {
endpointTemplate = "endpoint-cisco-8961";
maxContacts = 1;
hasProvisioning = true;
desktopSize = "640x480x24";
thumbnailSize = "123x111";
template = import ./templates/cisco-8961.nix;
};
"sip-client" = {
endpointTemplate = "endpoint-generic";
maxContacts = 1;
hasProvisioning = false;
desktopSize = null;
thumbnailSize = null;
template = null;
};
};
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; };
in {
options.services.voip = {
enable = lib.mkEnableOption "VoIP provisioning (Asterisk + TFTP)";
serverAddress = lib.mkOption {
type = lib.types.str;
description = "IP address or hostname of this server (used in phone configs and SIP).";
};
ntpServer = lib.mkOption {
type = lib.types.str;
description = "NTP server for phones. Defaults to serverAddress.";
default = "";
};
sipPort = lib.mkOption {
type = lib.types.port;
default = 5060;
};
rtpStart = lib.mkOption {
type = lib.types.port;
default = 10000;
};
rtpEnd = lib.mkOption {
type = lib.types.port;
default = 20000;
};
directoryName = lib.mkOption {
type = lib.types.str;
default = "tel.baubs.net";
description = "Name shown in the phone directory title.";
};
directoryPort = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "HTTP port for the phone directory and services.";
};
backgroundImages = lib.mkOption {
default = {};
description = ''
Attrset of background images keyed by display name.
Value is a path to any image file it will be resized automatically
to the correct dimensions for each phone model during build.
For best results, use a 4:3 aspect ratio source image.
'';
type = lib.types.attrsOf lib.types.path;
};
phones = 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).
For sip-client, the key is a free-form username.
'';
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.";
};
label = lib.mkOption {
type = lib.types.str;
default = "";
description = "Label shown on the phone screen. Required for provisioned hardware phones.";
};
password = lib.mkOption {
type = lib.types.str;
description = "SIP registration password.";
};
voicemailTimeout = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
description = "Seconds to ring before sending to voicemail. null disables voicemail for this phone.";
};
};
});
};
intercomPrefix = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Dial prefix for auto-generated intercom extensions. e.g. \"*80\" generates *80100 for ext 100. Only intercom-capable (provisioned) phones get entries.";
};
extensions = lib.mkOption {
default = {};
description = "Attrset of extensions keyed by extension number.";
type = lib.types.attrsOf (lib.types.submodule {
options = {
mode = lib.mkOption {
type = lib.types.enum [ "line" "page" "app" ];
default = "line";
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 {
type = lib.types.str;
default = "";
};
app = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Verbatim Asterisk dialplan app. Required for mode = \"app\".";
};
};
});
};
};
config = lib.mkIf cfg.enable {
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
(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)
++
# Provisioned phones 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)
++
# 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)
++
# intercomPrefix must not collide with user-declared extensions
(lib.optionals (cfg.intercomPrefix != null)
(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)));
services.asterisk = {
enable = true;
confFiles = confFiles;
};
services.atftpd = {
enable = true;
root = "${tftpRoot}";
extraOptions = [ "--verbose=7" ];
};
services.nginx = {
enable = true;
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;";
};
};
};
systemd.tmpfiles.rules = [ "d /var/log/nginx 0750 nginx nginx -" ];
networking.firewall = {
allowedTCPPorts = [ cfg.sipPort cfg.directoryPort ];
allowedUDPPorts = [ 69 ];
allowedUDPPortRanges = [{ from = cfg.rtpStart; to = cfg.rtpEnd; }];
};
};
}