diff --git a/cookbooks/asterisk/Makefile b/cookbooks/asterisk/Makefile new file mode 100644 index 0000000..14560f8 --- /dev/null +++ b/cookbooks/asterisk/Makefile @@ -0,0 +1,10 @@ +## +## Makefile for Asterisk quadlet +## + +# Asterisk and its TFTP server run as UID 10036 (asterisk) / GID 10000 (itix-svc) on the host +PROJECT_UID = 10036 +PROJECT_GID = 10000 + +# Include common Makefile +include ../../scripts/common.mk diff --git a/cookbooks/asterisk/README.md b/cookbooks/asterisk/README.md new file mode 100644 index 0000000..b3cec3c --- /dev/null +++ b/cookbooks/asterisk/README.md @@ -0,0 +1,116 @@ +# Podman Quadlet: Asterisk + +## Overview + +[Asterisk](https://www.asterisk.org/) is an open source PBX. This cookbook runs it as a SIP server for +phones on the local network, together with the TFTP server the phones fetch their configuration and +firmware from. + +This cookbook: + +- Builds a container image locally, from Alpine Linux 3.24, with the Asterisk 22 (LTS) and dnsmasq + packages of the distribution. +- Runs Asterisk directly as a dedicated, unprivileged UID/GID (no user namespace mapping), with + PJSIP as its only channel driver. +- Runs dnsmasq as a TFTP server only (no DNS, no DHCP), from the same image, as the same user. +- Loads an explicit list of Asterisk modules: what calls between SIP phones need, and nothing else. +- Includes a timer to rebuild the container image daily. + +It does not provide voicemail, call recording, conferencing, a trunk to an operator, nor a web +interface. + +## Prerequisites + +- The `base` cookbook (implicit dependency), for the virtiofs mount. +- A virtiofs mount on `/var/lib/virtiofs/data`, which holds the TFTP root. +- 1 vCPU and 1 GB of RAM are enough for a household (not measured beyond that). +- Network access at the first boot, to pull `docker.io/library/alpine:3.24` and the Alpine packages. + +## Files provided by the operator + +| Path | Content | +|------|---------| +| `/etc/quadlets/asterisk/etc/pjsip.conf` | Transport, endpoints, authentication and AOR of each phone. Required. | +| `/etc/quadlets/asterisk/etc/extensions.conf` | Dialplan. Required. | +| `/etc/quadlets/asterisk/etc/indications.conf` | Call progress tones of the country. Optional: without it, Asterisk logs a warning and uses no tones of its own; the phones play theirs. | +| `/var/lib/virtiofs/data/asterisk/tftp/` | Configuration files and firmware images of the phones, as each vendor names them. Optional: the TFTP server also runs on an empty directory, so that files written later are served without a restart. | + +Asterisk does not start without `pjsip.conf` and `extensions.conf`. Examples of both are in +`config/examples/etc/`. + +The TFTP root is on the virtiofs mount rather than under `/etc/quadlets`, so that the host can write +it: a firmware image weighs tens of megabytes, which an Ignition file cannot carry. The files must be +readable by UID 10036. + +The other files of `/etc/quadlets/asterisk/etc/` are part of the cookbook. Some are empty on purpose: +Asterisk reads them at startup whether or not the feature is used, and logs an error when they are +missing. + +## Ports + +| Port | Protocol | Service | +|------|----------|---------| +| 5060 | UDP | SIP | +| 10000-10999 | UDP | RTP (set in `rtp.conf`) | +| 69 | UDP | TFTP | + +Both containers use host networking. + +## UID and GID + +| UID | GID | Usage | +|-----|-----|-------| +| 10036 | 10000 | Asterisk and dnsmasq | + +## Usage + +In a separate terminal, follow the logs. + +```sh +sudo make tail-logs +``` + +Install the Podman Quadlets and start Asterisk. + +```sh +sudo make clean install +``` + +The **asterisk-build.service** builds the image, then **asterisk.service** starts. Check the +endpoints and the dialplan: + +```sh +sudo podman exec asterisk asterisk -rx "pjsip show endpoints" +sudo podman exec asterisk asterisk -rx "dialplan show" +``` + +Make a phone ring without a second phone (`res_clioriginate`): + +```sh +sudo podman exec asterisk asterisk -rx "channel originate PJSIP/101 application Wait 10" +``` + +Trace the SIP messages: + +```sh +sudo podman exec asterisk asterisk -rx "pjsip set logger on" +``` + +Finally, remove the quadlets, their configuration and their data. + +```sh +sudo make uninstall clean +``` + +## Known messages + +At every start, `res_pjsip` logs twice: `There are no local system nameservers configured, resorting +to system resolution`. Measured on Alpine 3.24 with a populated `/etc/resolv.conf`. Name resolution +still works through the system resolver, and calls between phones addressed by IP do not use it. + +## Container image updates + +The daily rebuild pulls a newer `alpine:3.24` when there is one, and the containers restart on the +new image through `AutoUpdate=local`. A rebuild reuses the cached package layer as long as the base +image is unchanged: an Asterisk update published in the Alpine repository between two releases of +the base image is not picked up until the next one. diff --git a/cookbooks/asterisk/asterisk-build.timer b/cookbooks/asterisk/asterisk-build.timer new file mode 100644 index 0000000..0fecd41 --- /dev/null +++ b/cookbooks/asterisk/asterisk-build.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Rebuild the Asterisk container image +PartOf=asterisk.target + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=asterisk.target diff --git a/cookbooks/asterisk/asterisk-tftp.container b/cookbooks/asterisk/asterisk-tftp.container new file mode 100644 index 0000000..2c6247f --- /dev/null +++ b/cookbooks/asterisk/asterisk-tftp.container @@ -0,0 +1,50 @@ +[Unit] +Description=Asterisk - TFTP server for the provisioning of the phones +Documentation=https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html + +Wants=asterisk-build.service +After=network.target asterisk-build.service var-lib-virtiofs-data.mount +Requires=var-lib-virtiofs-data.mount + +# The provisioning files are operator-provided, on the precious-data mount so +# that the host can write them -- firmware images do not fit in an Ignition +# file. No condition on their presence: the host may write them after the +# first boot, and a unit skipped by its condition is not started again when +# the files arrive. An empty TFTP root only answers "file not found". + +# Start/stop this unit when the target is started/stopped +PartOf=asterisk.target + +[Container] +ContainerName=asterisk-tftp +Image=localhost/asterisk:latest +AutoUpdate=local + +User=10036 +Group=10000 + +# TFTP listens on 69/udp, a privileged port. +AddCapability=CAP_NET_BIND_SERVICE + +# Host networking: TFTP answers from an ephemeral port, which port publishing +# does not follow. +Network=host + +# dnsmasq as a TFTP server and nothing else: +# --port=0 disables DNS, and no dhcp-range means no DHCP; +# --conf-file=/dev/null ignores the configuration file of the image; +# --pid-file= (empty) writes no PID file, which this user could not write. +Entrypoint=/usr/sbin/dnsmasq +Exec=--keep-in-foreground --conf-file=/dev/null --pid-file= --port=0 --log-facility=- --enable-tftp --tftp-root=/var/lib/virtiofs/data/asterisk/tftp + +# Read-only: the phones fetch, they never upload. +Volume=/var/lib/virtiofs/data/asterisk/tftp:/var/lib/virtiofs/data/asterisk/tftp:ro,z + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=300 +TimeoutStopSec=30 + +[Install] +WantedBy=asterisk.target diff --git a/cookbooks/asterisk/asterisk.build b/cookbooks/asterisk/asterisk.build new file mode 100644 index 0000000..a39cbce --- /dev/null +++ b/cookbooks/asterisk/asterisk.build @@ -0,0 +1,28 @@ +[Unit] +Description=Build of the Asterisk container image +Documentation=https://www.asterisk.org/ +Wants=network-online.target +After=network-online.target + +# Keep trying. A build that fails because the network is not there yet would +# otherwise never run again, and the default start limit (5 attempts in 10 s) +# turns the unit "failed" for good. Same reasoning as the image units of the +# other cookbooks. +StartLimitIntervalSec=0 + +# Stopping the target stops a build that is still retrying. +PartOf=asterisk.target + +[Build] +File=/etc/quadlets/asterisk/container/Containerfile +ImageTag=localhost/asterisk:latest +SetWorkingDirectory=/etc/quadlets/asterisk/container + +# Pull the base image only when the registry has a newer one. Pull errors are +# ignored when a local image exists, so a rebuild without network keeps the +# current image instead of failing. +Pull=newer + +[Service] +Restart=on-failure +RestartSec=60 diff --git a/cookbooks/asterisk/asterisk.container b/cookbooks/asterisk/asterisk.container new file mode 100644 index 0000000..fe207fc --- /dev/null +++ b/cookbooks/asterisk/asterisk.container @@ -0,0 +1,58 @@ +[Unit] +Description=Asterisk PBX +Documentation=https://docs.asterisk.org/ + +# Wants= and not Requires= on the build: a failed build -- no network at boot -- +# must not keep a container whose image is already in local storage from +# starting. A genuinely missing image fails the container, and Restart= below +# retries until the build has produced it. +Wants=asterisk-build.service +After=network.target asterisk-build.service + +# The SIP endpoints and the dialplan are operator-provided: without them, +# Asterisk would start and accept no phone. +ConditionPathExists=/etc/quadlets/asterisk/etc/pjsip.conf +ConditionPathExists=/etc/quadlets/asterisk/etc/extensions.conf + +# Start/stop this unit when the target is started/stopped +PartOf=asterisk.target + +[Container] +ContainerName=asterisk +Image=localhost/asterisk:latest +AutoUpdate=local + +# No need for root: SIP listens on 5060 and RTP on 10000-10999, all above 1024. +User=10036 +Group=10000 + +# Host networking: RTP takes a range of a thousand UDP ports, which port +# publishing handles badly, and SIP must see the real addresses of the phones. +Network=host + +# Configuration (read-only). The etc/ sub-directory only, never the whole +# /etc/quadlets/asterisk: ":Z" relabels what it mounts, and the build context +# next to it must stay readable by the build. +Volume=/etc/quadlets/asterisk/etc:/etc/asterisk:ro,Z + +# Internal database and spool. Nothing precious: no voicemail, no recordings. +Volume=/var/lib/quadlets/asterisk:/var/lib/quadlets/asterisk:Z + +# Control socket, used by "asterisk -rx" and by the health check. +Tmpfs=/run/asterisk:rw,mode=1777 + +# Health check: the CLI answers only when the core is up. +HealthCmd=asterisk -rx "core show uptime" +HealthInterval=30s +HealthTimeout=10s +HealthStartPeriod=30s +HealthRetries=3 + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=300 +TimeoutStopSec=30 + +[Install] +WantedBy=asterisk.target diff --git a/cookbooks/asterisk/asterisk.target b/cookbooks/asterisk/asterisk.target new file mode 100644 index 0000000..172f9af --- /dev/null +++ b/cookbooks/asterisk/asterisk.target @@ -0,0 +1,12 @@ +[Unit] +Description=Asterisk Service Target +Documentation=man:systemd.target(5) +Requires=asterisk.service asterisk-build.timer +Wants=asterisk-tftp.service +After=asterisk.service asterisk-tftp.service asterisk-build.timer + +# Allow isolation - can stop/start this target independently +AllowIsolate=yes + +[Install] +WantedBy=multi-user.target diff --git a/cookbooks/asterisk/config/container/Containerfile b/cookbooks/asterisk/config/container/Containerfile new file mode 100644 index 0000000..b1f1568 --- /dev/null +++ b/cookbooks/asterisk/config/container/Containerfile @@ -0,0 +1,15 @@ +FROM docker.io/library/alpine:3.24 + +# Asterisk 22 (the LTS branch), PJSIP included: Alpine bundles pjproject in the +# main package. dnsmasq serves the provisioning files of the phones over TFTP, +# from the same image, so that a single build keeps both up to date. +RUN apk add --no-cache asterisk dnsmasq + +# /etc/asterisk is empty: the sample files are in asterisk-sample-config, which +# is not installed. The configuration is mounted read-only there at run time. + +# SIP signalling. RTP uses 10000-10999/udp, as set in rtp.conf. +EXPOSE 5060/udp + +ENTRYPOINT [ "/usr/sbin/asterisk" ] +CMD [ "-f", "-n", "-vv" ] diff --git a/cookbooks/asterisk/config/etc/acl.conf b/cookbooks/asterisk/config/etc/acl.conf new file mode 100644 index 0000000..0085585 --- /dev/null +++ b/cookbooks/asterisk/config/etc/acl.conf @@ -0,0 +1,3 @@ +; Core configuration files Asterisk reads at startup whether or not the +; feature is used. Present and empty, so that a missing file does not log an +; error at every start. diff --git a/cookbooks/asterisk/config/etc/asterisk.conf b/cookbooks/asterisk/config/etc/asterisk.conf new file mode 100644 index 0000000..4698174 --- /dev/null +++ b/cookbooks/asterisk/config/etc/asterisk.conf @@ -0,0 +1,20 @@ +; Asterisk runs as UID 10036, which owns nothing in the image: every directory +; it writes to is moved out of the image, onto a path the container mounts. +; +; - /var/lib/quadlets/asterisk: the internal database and the spool, mounted +; from the host. Nothing there is precious: no voicemail, no recordings. +; - /run/asterisk: the control socket, on a tmpfs. +; +; The read-only directories stay where the Alpine package puts them. +[directories] +astetcdir => /etc/asterisk +astmoddir => /usr/lib/asterisk/modules +astvarlibdir => /var/lib/asterisk +astdbdir => /var/lib/quadlets/asterisk +astkeydir => /var/lib/asterisk +astdatadir => /var/lib/asterisk +astagidir => /var/lib/asterisk/agi-bin +astspooldir => /var/lib/quadlets/asterisk/spool +astrundir => /run/asterisk +astlogdir => /var/lib/quadlets/asterisk/log +astsbindir => /usr/sbin diff --git a/cookbooks/asterisk/config/etc/ccss.conf b/cookbooks/asterisk/config/etc/ccss.conf new file mode 100644 index 0000000..289813b --- /dev/null +++ b/cookbooks/asterisk/config/etc/ccss.conf @@ -0,0 +1,4 @@ +; Core configuration files Asterisk reads at startup whether or not the +; feature is used. Present and empty, so that a missing file does not log an +; error at every start. +[general] diff --git a/cookbooks/asterisk/config/etc/cdr.conf b/cookbooks/asterisk/config/etc/cdr.conf new file mode 100644 index 0000000..48491e8 --- /dev/null +++ b/cookbooks/asterisk/config/etc/cdr.conf @@ -0,0 +1,3 @@ +; No call detail records: nothing reads them. +[general] +enable = no diff --git a/cookbooks/asterisk/config/etc/cel.conf b/cookbooks/asterisk/config/etc/cel.conf new file mode 100644 index 0000000..5afeaaa --- /dev/null +++ b/cookbooks/asterisk/config/etc/cel.conf @@ -0,0 +1,3 @@ +; No channel event logging: nothing reads it. +[general] +enable = no diff --git a/cookbooks/asterisk/config/etc/features.conf b/cookbooks/asterisk/config/etc/features.conf new file mode 100644 index 0000000..f03b05d --- /dev/null +++ b/cookbooks/asterisk/config/etc/features.conf @@ -0,0 +1,3 @@ +; Default transfer and pickup settings. The phones transfer with SIP REFER, +; handled by res_pjsip_refer, not with DTMF feature codes. +[general] diff --git a/cookbooks/asterisk/config/etc/logger.conf b/cookbooks/asterisk/config/etc/logger.conf new file mode 100644 index 0000000..c27b4b9 --- /dev/null +++ b/cookbooks/asterisk/config/etc/logger.conf @@ -0,0 +1,6 @@ +; Everything goes to the console, i.e. to the standard output of the container, +; i.e. to the journal. No log file: there is no log rotation in the container. +[general] + +[logfiles] +console => notice,warning,error,verbose diff --git a/cookbooks/asterisk/config/etc/modules.conf b/cookbooks/asterisk/config/etc/modules.conf new file mode 100644 index 0000000..d2a0481 --- /dev/null +++ b/cookbooks/asterisk/config/etc/modules.conf @@ -0,0 +1,51 @@ +; An explicit list rather than autoload: autoload brings some three hundred +; modules, most of which have no configuration and decline to load with an +; error, which buries the messages that matter. +; +; What is listed is what calls between local SIP phones need: PJSIP, RTP, the +; static dialplan, Dial() and the codecs the phones offer. A feature that needs +; another module needs a line here. +[modules] +autoload = no + +; Configuration storage for PJSIP +load => res_sorcery_config.so +load => res_sorcery_memory.so +load => res_sorcery_astdb.so + +; PJSIP: transport, registration, authentication, calls +load => res_pjproject.so +load => res_pjsip.so +load => res_pjsip_session.so +load => res_pjsip_pubsub.so +load => res_pjsip_authenticator_digest.so +load => res_pjsip_endpoint_identifier_user.so +load => res_pjsip_registrar.so +load => res_pjsip_sdp_rtp.so +load => res_pjsip_caller_id.so +load => res_pjsip_dtmf_info.so +load => res_pjsip_rfc3326.so +load => res_pjsip_diversion.so +load => res_pjsip_header_funcs.so +; Transfer button of the phones (SIP REFER) +load => res_pjsip_refer.so +load => res_pjsip_logger.so +load => chan_pjsip.so + +; Media +load => res_rtp_asterisk.so +load => res_timing_timerfd.so +load => bridge_simple.so +load => bridge_native_rtp.so +load => codec_g722.so +load => codec_alaw.so +load => codec_ulaw.so +load => codec_a_mu.so + +; Dialplan +load => pbx_config.so +load => app_dial.so +load => func_callerid.so + +; "channel originate" from the CLI, to test a phone without a second one +load => res_clioriginate.so diff --git a/cookbooks/asterisk/config/etc/pjproject.conf b/cookbooks/asterisk/config/etc/pjproject.conf new file mode 100644 index 0000000..2a678a0 --- /dev/null +++ b/cookbooks/asterisk/config/etc/pjproject.conf @@ -0,0 +1,4 @@ +; Core configuration files Asterisk reads at startup whether or not the +; feature is used. Present and empty, so that a missing file does not log an +; error at every start. + diff --git a/cookbooks/asterisk/config/etc/rtp.conf b/cookbooks/asterisk/config/etc/rtp.conf new file mode 100644 index 0000000..81602dd --- /dev/null +++ b/cookbooks/asterisk/config/etc/rtp.conf @@ -0,0 +1,5 @@ +; One thousand ports, i.e. five hundred simultaneous calls: far more than a +; household needs, and a range small enough to be named in a firewall rule. +[general] +rtpstart = 10000 +rtpend = 10999 diff --git a/cookbooks/asterisk/config/etc/udptl.conf b/cookbooks/asterisk/config/etc/udptl.conf new file mode 100644 index 0000000..289813b --- /dev/null +++ b/cookbooks/asterisk/config/etc/udptl.conf @@ -0,0 +1,4 @@ +; Core configuration files Asterisk reads at startup whether or not the +; feature is used. Present and empty, so that a missing file does not log an +; error at every start. +[general] diff --git a/cookbooks/asterisk/config/examples/etc/extensions.conf b/cookbooks/asterisk/config/examples/etc/extensions.conf new file mode 100644 index 0000000..0cd4f26 --- /dev/null +++ b/cookbooks/asterisk/config/examples/etc/extensions.conf @@ -0,0 +1,11 @@ +; Example: the two phones of pjsip.conf call each other. Any other number is +; rejected: there is no route out. +[general] +static = yes +writeprotect = yes + +[phones] +exten => _10[12],1,Dial(PJSIP/${EXTEN},30) + same => n,Hangup() + +exten => _X.,1,Hangup(1) diff --git a/cookbooks/asterisk/config/examples/etc/pjsip.conf b/cookbooks/asterisk/config/examples/etc/pjsip.conf new file mode 100644 index 0000000..f618691 --- /dev/null +++ b/cookbooks/asterisk/config/examples/etc/pjsip.conf @@ -0,0 +1,48 @@ +; Example: two phones, 101 and 102, registering over UDP. +; The operator provides the real file, with real secrets. +[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:5060 + +[phone](!) +type = endpoint +context = phones +disallow = all +allow = g722 +allow = alaw +allow = ulaw +direct_media = no +dtmf_mode = rfc4733 + +[phone-auth](!) +type = auth +auth_type = userpass + +[phone-aor](!) +type = aor +max_contacts = 1 +remove_existing = yes +qualify_frequency = 60 + +[101](phone) +auth = 101 +aors = 101 +callerid = "Phone 101" <101> + +[101](phone-auth) +username = 101 +password = example-only-101 + +[101](phone-aor) + +[102](phone) +auth = 102 +aors = 102 +callerid = "Phone 102" <102> + +[102](phone-auth) +username = 102 +password = example-only-102 + +[102](phone-aor) diff --git a/cookbooks/asterisk/overlay.bu b/cookbooks/asterisk/overlay.bu new file mode 100644 index 0000000..8fe52f0 --- /dev/null +++ b/cookbooks/asterisk/overlay.bu @@ -0,0 +1,9 @@ +variant: fcos +version: 1.4.0 +passwd: + users: + - name: asterisk + uid: 10036 + gecos: Asterisk PBX + home_dir: /var/lib/quadlets/asterisk + primary_group: itix-svc diff --git a/cookbooks/asterisk/tmpfiles.d/asterisk.conf b/cookbooks/asterisk/tmpfiles.d/asterisk.conf new file mode 100644 index 0000000..c9b0d20 --- /dev/null +++ b/cookbooks/asterisk/tmpfiles.d/asterisk.conf @@ -0,0 +1,11 @@ +# Internal database, spool and logs of Asterisk. Not precious: no voicemail, +# no recordings. +d /var/lib/quadlets/asterisk 0750 10036 10000 - +d /var/lib/quadlets/asterisk/spool 0750 10036 10000 - +d /var/lib/quadlets/asterisk/log 0750 10036 10000 - + +# Provisioning files of the phones, served over TFTP. Operator-provided, on the +# precious-data mount so that the host can write them. The "$" marks it for +# removal on `systemd-tmpfiles --purge` (i.e. `make uninstall`). +d$ /var/lib/virtiofs/data/asterisk 0750 10036 10000 - +d$ /var/lib/virtiofs/data/asterisk/tftp 0750 10036 10000 -