{ 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/.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; }]; }; }; }