NixOS modules for Asterisk: a PBX made of extensions, trunks, ring groups, queues and business hours, with no dialplan to write, on top of Asterisk's config files as Nix options. Comes with a provisioning server for phones.
Intended for anything from a household intercom to a small office PBX with SIP trunks, conferences and queues.
| Docs | What |
|---|---|
| Quick start | two phones calling each other |
| examples/ | complete configs, most run in a VM test |
| PROVISIONING.md | phones that fetch their config from the PBX |
nix build .#docs |
the reference of every option |
- Add asterix to your flake. And optionally, a secrets manager such as sops-nix or agenix.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
asterix = {
url = "github:tetra-fox/asterix";
inputs.nixpkgs.follows = "nixpkgs";
};
# not required, agenix works, or plaintext if you're a menace
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, asterix, sops-nix, ... }: {
nixosConfigurations.pbx = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
asterix.nixosModules.default
sops-nix.nixosModules.sops
./configuration.nix
./pbx.nix
];
};
};
}- Write
pbx.nix: two phones that call each other by dialing 101 and 102, with their passwords insecrets.yaml.
# pbx.nix
{ config, lib, ... }:
let
inherit (config.lib.asterisk) secret;
in
{
sops.defaultSopsFile = ./secrets.yaml; # with keys sip-101 and sip-102
sops.secrets = lib.genAttrs [ "sip-101" "sip-102" ] (_: {
reloadUnits = [ "asterisk.service" ];
});
services.asterix = {
enable = true;
openFirewall = true;
extensions = {
"101".password = secret config.sops.secrets.sip-101.path;
"102".password = secret config.sops.secrets.sip-102.path;
};
};
}-
Deploy, then register a phone:
Setting Value Server the host's address, port 5060, UDP or TCP User 101or102Password sip-101orsip-102fromsecrets.yamlasterisk -rx "pjsip show endpoints"shows them. -
On a host with a public address, narrow the firewall.
openFirewallopens SIP and RTP on every interface. Describe the phones' network inservices.asterix.networks, with its interface, address and subnets, as the intercom example does.
| Feature | What |
|---|---|
| A PBX without dialplan | extensions, trunks, voicemail, ring groups, queues, conferences, voice menus, paging, business hours, call routes and call recording, in services.asterix |
| Asterisk as Nix options | under it, services.asterisk: PJSIP, dialplan, voicemail, conferences, queues, music on hold, call features, AMI, ARI and call records. Anything else goes in settings |
| A bad config fails the build | Asterisk boots the new config in the build sandbox, and any error or warning fails it, as does a dialplan that uses a missing application, sound or extension (checkConfig) |
| Reloads, not restarts | deploys reload where they can, so calls stay up |
| Several networks | each gets its own transport and firewall rules, and its phones register only from its subnets. Asterisk relays the audio between them |
| NAT | on either side, plus STUN and TURN |
| Sandboxed | systemd-analyze security rates it 1.5 |
| Phone provisioning | Cisco, Fanvil, Grandstream, Poly, Snom and Yealink devices, in services.asterix.phones: PROVISIONING.md |
Give config.lib.asterisk.secret the path of a file outside the Nix store.
The file can stay root-only, and any secret manager works. With
sops-nix:
sops.secrets.sip-101.reloadUnits = [ "asterisk.service" ]; # reload on change, no restart
services.asterix.extensions."101".password = config.lib.asterisk.secret config.sops.secrets.sip-101.path;agenix has no reloadUnits. Reload on the encrypted file instead, which
changes when the secret does:
age.secrets.sip-101.file = ./secrets/sip-101.age;
systemd.services.asterisk.reloadTriggers = [ ./secrets/sip-101.age ];
services.asterix.extensions."101".password = config.lib.asterisk.secret config.age.secrets.sip-101.path;A reload re-reads secrets on systemd 260 and later (NixOS 26.05). Older
systemd keeps the old copy until a restart, so use restartUnits or
restartTriggers there instead.
| Case | What happens |
|---|---|
| a plain string instead | works, but lands in the world-readable Nix store, and the module warns |
a secret in a longer value, like "${secret path},Front desk" |
works, but Asterisk splits it at commas, so it won't start if the secret has one |
| a secret in a dialplan argument | Asterisk logs it everywhere (verbose output, core show channels, CDR, CEL, AMI). To check a PIN, use Authenticate(/run/credentials/asterisk.service/<name>) with services.asterisk.credentials.<name> and app_authenticate.so loaded |
| more than 256 secret files and credentials | the build fails: that's systemd's cap per service |
| a crash | core dumps are off, since they'd hold every secret. systemd.services.asterisk.serviceConfig.LimitCORE = "infinity"; turns them on |
At runtime, secrets are readable by root, the asterisk user and group,
programs the dialplan starts, AMI users with the write classes listed in
services.asterisk.ami.users.<name>.write, and ARI users (voicemail PINs
during a call, every PJSIP password if res_ari_asterisk.so is loaded).
services.asterix is the PBX. nixosModules.default brings it, with the
core under it.
| Object | Option |
|---|---|
| networks the phones and trunks are on | services.asterix.networks |
| extensions, each with its own mailbox | services.asterix.extensions |
| other mailboxes, and voicemail limits | services.asterix.voicemail |
| SIP trunks | services.asterix.trunks |
| ring groups | services.asterix.ringGroups |
| queues | services.asterix.queues |
| conference rooms | services.asterix.conferences |
| voice menus | services.asterix.ivrs |
| announcements | services.asterix.announcements |
| paging groups | services.asterix.paging |
| opening hours | services.asterix.hours |
| do not disturb, call forwarding and record codes | services.asterix.featureCodes |
| call parking | services.asterix.parking |
| calls in, by number | services.asterix.inbound |
| callers turned away on calls from trunks | services.asterix.blocklist |
| calls out, by prefix | services.asterix.outbound |
| emergency numbers | services.asterix.emergency |
| how long recordings are kept, and the notice before them | services.asterix.recordings |
| phones that fetch their config from the PBX | services.asterix.phones |
# pbx.nix: extensions 201 and 202, and calls to and from a provider
{ config, lib, ... }:
let
secret = name: config.lib.asterisk.secret config.sops.secrets.${name}.path;
in
{
sops.defaultSopsFile = ./secrets.yaml;
sops.secrets = lib.genAttrs [ "sip-201" "sip-202" "vm-201" "vm-202" "sip-trunk" ] (_: {
reloadUnits = [ "asterisk.service" ];
});
services.asterix = {
enable = true;
extensions = {
"201" = { name = "Reception"; password = secret "sip-201"; voicemail.pin = secret "vm-201"; };
"202" = { name = "Sales"; password = secret "sip-202"; voicemail.pin = secret "vm-202"; };
};
trunks.provider = { host = "sip.provider.example"; username = "5551000"; password = secret "sip-trunk"; };
ringGroups.front = { members = [ "201" "202" ]; noAnswer.voicemail = "201"; };
hours.office = {
timezone = "America/Los_Angeles";
open = [ { days = "mon-fri"; time = "09:00-17:00"; } ];
closeEarly = "*28";
};
inbound."5551000" = {
trunk = "provider";
hours = "office";
open.ringGroup = "front";
closed.voicemail = "201";
};
outbound = { prefix = "9"; trunk = "provider"; callerId = "5551000"; };
# 911 with and without the 9, out through the same trunk
emergency.numbers = [ "911" ];
};
}| Rule | What |
|---|---|
| networks | without networks, SIP is open on every address, to anyone. With several, each extension and trunk names its network. Once every network with extensions lists subnets, requests from elsewhere are refused |
| contexts | each object gets its own, pbx-<kind>-<name>. phones dial from pbx-internal |
| overrides | everything the PBX generates is a default on services.asterisk, which a definition there or settings overrides |
| refused at evaluation | a number with two owners, a missing trunk, member or destination, a loop of destinations that no key press breaks |
| emergency numbers | no default, since they depend on where the PBX is. outbound requires them, or emergency.numbers = [ ]; for a PBX that makes no emergency calls |
| feature codes | none until set, such as FreePBX's *76 for do not disturb and *72/*73 to forward every call. A phone sets them for its own extension. Forwards go to extensions, or out through outbound, never to an emergency number |
| call parking | off until set, such as FreePBX's parking.number = "70"; with slots 71 to 78. A phone parks a call by transferring it to the number, and any phone takes it back by dialling its slot, which also has a busy lamp. A call left parked past timeout rings the phone that parked it, then goes to that extension's noAnswer |
| call recording | record on an extension, inbound number, ring group or queue records its calls from the answer, once per call, into /var/lib/asterisk/spool/monitor. On other calls, a phone starts and stops one with featureCodes.record, such as FreePBX's *1. No notice and no expiry until set, since the laws differ |
Voice menus, announcements and paging groups:
services.asterix = {
ivrs.main = {
number = "700";
prompt.text = "For sales, press 1. For the front desk, press 2.";
options = {
"1".extension = "202";
"2".ringGroup = "front";
};
noInput.announcement = "goodbye";
};
announcements.goodbye.prompt.text = "Nobody chose. Goodbye.";
paging.all = { number = "650"; members = [ "201" "202" ]; };
};| Object | What |
|---|---|
| voice menu | plays a prompt, recorded or spoken by flite at build time, and sends each key to a destination. { ivr = "main"; } works as a destination anywhere, like in inbound |
after attempts prompts, a caller who pressed nothing goes to noInput, one who pressed an unknown key to invalid |
|
| announcement | plays a prompt repeat times, then sends the call to destination, by default a hangup |
| paging | calls several phones and asks them to auto-answer. Pages are one-way unless duplex is set, skip phones in a call, and only reach phones set to allow auto-answer |
small-office.nix is a complete example.
services.asterisk writes Asterisk's config files from Nix options. The PBX
writes into it, and what the PBX has no option for goes there. A context
destination sends a call into dialplan of your own:
services.asterix.ivrs.main.options."9".context.context = "weasels";
services.asterisk.dialplan.contexts.weasels.extensions.s = [ "Answer()" "Playback(tt-weasels)" "Hangup()" ];To run the core alone, import asterix.nixosModules.asterisk instead of
nixosModules.default. core-only.nix is the quick
start written that way.
| To | Use |
|---|---|
| write any Asterisk config file | services.asterisk.settings."<file>", one attribute per section |
| change a section the options generate | the same, with the section's id from the table below |
| add raw text | services.asterisk.extraConfig."<file>" |
services.asterisk.settings."followme.conf"."101".number = [ "5551234,30" ];| File | Section ids |
|---|---|
| pjsip.conf | endpoint:<name>, auth:<name>, aor:<name>, identify:<name>, registration:<name>, transport:<name>, tcp-transport:<name>, acl:<name> |
| confbridge.conf | bridge:<name>, user:<name>, menu:<name> |
| extensions.conf | the context name |
services.asterisk.settings."pjsip.conf"."endpoint:101".direct_media = true;Asterisk's ${VAR} looks like Nix interpolation, so escape it. Semicolons
need no escaping. Each of these produces Dial(PJSIP/${EXTEN},30):
"Dial(PJSIP/\${EXTEN},30)"
''Dial(PJSIP/''${EXTEN},30)''
"Dial(PJSIP/${var "EXTEN"},30)" # var = config.lib.asterisk.dialplan.var| Helper | Gives | From |
|---|---|---|
var "NAME" |
${NAME} |
config.lib.asterisk.dialplan, or asterix.lib outside NixOS |
app "Name" [args] |
a step calling Name(args) |
the same |
A dialplan with both:
{ config, ... }:
let
dp = config.lib.asterisk.dialplan;
in
{
services.asterisk.dialplan = {
globals.TRUNK = "PJSIP/provider";
contexts = {
internal = {
includes = [ "outbound" ];
hints."101" = "PJSIP/101";
extensions = {
"101" = [
"Dial(PJSIP/101,20)"
{ app = "VoiceMail"; args = [ "101@default" "u" ]; label = "vm"; }
"Hangup()"
];
"800" = [ "Answer()" (dp.app "ConfBridge" [ "800" ]) ];
};
};
outbound.extensions."_9X." = [ "Dial(\${TRUNK}/\${EXTEN:1})" ];
};
};
sops.secrets.vm-101.reloadUnits = [ "asterisk.service" ];
services.asterisk.voicemail.mailboxes."101".pin = config.lib.asterisk.secret config.sops.secrets.vm-101.path;
}renders as:
[internal]
include => outbound
exten => 101,hint,PJSIP/101
exten => 101,1,Dial(PJSIP/101,20)
same => n(vm),VoiceMail(101@default,u)
same => n,Hangup()
exten => 800,1,Answer()
same => n,ConfBridge(800)nix flake check # every check, VM tests included
nix fmt
nix develop # Rust toolchain for the provisioning server
# the same checks, evaluated in parallel
nix run nixpkgs#nix-fast-build -- --flake .#checks.x86_64-linux| To | Run |
|---|---|
| build the checks against another Asterisk | nix build .#packageChecks.asterisk_23.roundtrip (also asterisk_20, asterisk_22) |
| run every non-VM check against all of them | python3 tests/campaign/packages.py DIR, logs in DIR. Add --nixpkgs REF for another nixpkgs |
The checks build against nixpkgs' asterisk otherwise.