osb.sh

ProjectsN° 02Home phone system

The House PBX

My IVR and home phone system. Asterisk 22 in Docker on my Linux server, a voip.ms trunk over TLS, a desk phone, two softphones and a rotary phone, and a menu where pressing 1 gets you the cat.

Diagram of the house phone system, from the phone network through voip.ms and the UniFi UDM Pro to the Asterisk container, the phones, and a rotary phone in my room.

Why phones

Phones run in the family. My aunt was a switchboard operator, connecting calls by hand. My own passion for phone systems comes from my time with AT&T, where I worked as an engineer on lines of racks for the DMS: bay after bay of switching equipment that every call ran through. You learn a lot about a phone call when you’ve seen what it takes to carry one.

I’ve run Linux since college, so when it came time for a house phone, the answer was a real PBX on my own server. Not a cordless phone plugged into the wall, but a switch of my own, with a dial plan I wrote, answering the house number.

What it is

When someone calls the house, Asterisk answers with a menu: “Welcome to the Moreno Residence. Press 1 for Augie. Press 2 for Gabriel.” Augie is my cat, and his line plays meows on a loop for two minutes before hanging up. Pressing 2 rings every phone in the house at once. If nobody picks up, the call goes to voicemail.

Under that, it’s a complete small-office PBX:

  • Asterisk 22 LTS, built from source with no FreePBX and no web GUI. Everything is a text file.
  • PJSIP only. The old chan_sip driver is gone as of Asterisk 21, so nothing here depends on it.
  • One SIP trunk from voip.ms carries the house number in and out, signaled over TLS, with e911 set up for the address.
  • Three extensions: a desk phone on 100 and Linphone softphones on 101 and 102.
  • A pastel green rotary phone in my room, wired to a Grandstream HT812v2 ATA that turns it into a SIP phone.
  • Voicemail, group pickup, an echo test, and the feature codes you’d expect on an office system.
  • Docker, on my own Linux server at home.
  • Gigabit fiber into a UniFi Dream Machine Pro, which handles NAT and the firewall.

The layout

The diagram at the top of the page (Fig. 1) shows the whole path, from the phone network to the phones on my desk. A few decisions shape it:

An encrypted trunk. Signaling between the PBX and voip.ms runs over TLS, so the logins and call setup that cross the internet can’t be read on the way.

No port forwards. The PBX registers outward to voip.ms, and that registration keeps a pinhole open in the UDM Pro’s NAT. Inbound calls arrive through the pinhole. Port 5060 is never exposed to the internet, which is how most home PBXs get found and abused.

Host networking, not a Docker bridge. RTP (the audio) needs a range of UDP ports. Pushing 200 ports through Docker’s userland proxy is slow and confuses NAT, and SIP puts IP addresses inside its messages, so a second layer of NAT breaks them. With network_mode: host, Asterisk sees the real interface and the addresses in its SIP headers are correct.

A narrow, fixed port range. Audio uses UDP 10000–10200 and nothing else. That’s enough for about 100 simultaneous streams, which is plenty for a house, and it makes firewall rules easy to write and easy to read:

; rtp.conf
[general]
rtpstart=10000
rtpend=10200
strictrtp=yes
icesupport=no

What a caller hears

The IVR call flow: greeting, press 1 for Augie’s meow loop, press 2 to ring the house, voicemail on no answer

The dial plan is short enough to read in one sitting. Calls from the trunk land in [from-voipms], which sends them to the menu:

[from-voipms]
exten => ${DID_NUMBER},1,NoOp(Inbound: ${CALLERID(all)})
 same => n,Set(CHANNEL(tonezone)=us)
 same => n,Goto(ivr,s,1)

; Catch-all if voip.ms presents 1+number or 's'
exten => _X!,1,Goto(ivr,s,1)
exten => s,1,Goto(ivr,s,1)

The menu answers, plays the greeting, and waits six seconds for a digit. If the custom greeting file is missing, it falls back to Asterisk’s stock prompts, so the menu never plays silence:

[ivr]
exten => s,1,Answer()
 same => n,Wait(1)
 same => n,Set(LOOPS=0)
 same => n(menu),GotoIf($[${STAT(e,/var/lib/asterisk/sounds/en/custom/moreno-welcome.wav)}]?custom:stock)
 same => n(custom),Background(custom/moreno-welcome)
 same => n,Goto(wait)
 same => n(stock),Background(welcome&if-u-know-ext-dial)
 same => n(wait),WaitExten(6)

exten => 1,1,Goto(augie-meow,s,1)
exten => 2,1,Goto(ring-gabriel,s,1)

; No input: replay once, then default to ringing the house
exten => t,1,Set(LOOPS=$[${LOOPS}+1])
 same => n,GotoIf($[${LOOPS} >= 2]?ring-gabriel,s,1:ivr,s,menu)

; Invalid digit
exten => i,1,Playback(pbx-invalid)
 same => n,Goto(ivr,s,menu)

Augie’s line uses an absolute timeout, so it hangs up after two minutes no matter what:

[augie-meow]
exten => s,1,NoOp(Caller ${CALLERID(num)} is talking to Augie)
 same => n,Set(TIMEOUT(absolute)=120)      ; 2 minutes of cat, then hangup
 same => n(loop),Playback(custom/meow)
 same => n,Goto(loop)
exten => T,1,Hangup()

Option 2 rings all three phones together for 25 seconds, then sends the call to voicemail:

[ring-gabriel]
exten => s,1,Dial(PJSIP/100&PJSIP/101&PJSIP/102,${RINGTIME},r)
 same => n,VoiceMail(100@default,u)
 same => n,Hangup()

Here’s the menu as Asterisk loaded it, straight from the CLI:

dialplan show ivr in the Asterisk CLI, listing the menu’s five extensions and fourteen priorities

Inside the house

The phones themselves dial from [internal], which has the usual office features:

Dial What happens
100–102 Call another phone in the house
0 Ring the whole house
*8 Pick up a call ringing on another phone
*97 Check voicemail
*43 Echo test, to hear your own audio come back
*22 Call Augie’s line from inside, to test it
10 or 11 digits, 011… Out through voip.ms, with caller ID set to the house number
911, 933 Emergency, and voip.ms’s e911 address readback test

Outbound calls always present the house number, whatever the phone sends:

exten => _NXXNXXXXXX,1,Set(CALLERID(num)=${DID_NUMBER})
 same => n,Dial(PJSIP/1${EXTEN}@voipms,60,r)
 same => n,Hangup()

How it’s built

How the container builds its config at boot: env file, templates and static configs go through entrypoint.sh into /etc/asterisk

The image. The Dockerfile compiles Asterisk 22 from source in a builder stage and copies only the result into a slim Debian runtime. Modules I don’t need are left out, and OpenSSL and SRTP support are compiled in for encryption:

RUN cd asterisk-22* && \
    ./configure --with-pjproject-bundled --with-jansson --with-ssl --with-srtp && \
    make menuselect.makeopts && \
    menuselect/menuselect \
      --disable chan_dahdi --disable chan_ooh323 --disable app_voicemail_imap \
      --disable app_voicemail_odbc \
      menuselect.makeopts && \
    make -j"$(nproc)" && \
    make install DESTDIR=/out

Sound files are downloaded during the build in ulaw, the same codec the trunk uses, so prompts play with no transcoding.

The config. Every secret and the house number live in one env file. At boot, entrypoint.sh renders the templates into /etc/asterisk. The important detail is that envsubst is given an explicit list of variables. Without the list, it would also replace Asterisk’s own ${EXTEN} and ${CALLERID(...)} and quietly break the dial plan:

# envsubst is restricted to an explicit variable list so it never
# touches Asterisk's own ${EXTEN}, ${CALLERID(...)}, etc. in the dialplan.
VARS='${SIP_GATEWAY_HOST} ${SIP_GATEWAY_USERNAME} ${SIP_GATEWAY_PASSWORD} ${DID_NUMBER} ${LAN_CIDR} ${HARDPHONE_SECRET} ${SOFTPHONE1_SECRET} ${SOFTPHONE2_SECRET}'

for tpl in /asterisk-templates/*.tpl; do
    out="/etc/asterisk/$(basename "${tpl}" .tpl)"
    envsubst "${VARS}" < "${tpl}" > "${out}"
done

exec asterisk -f -U asterisk -G asterisk -vvv

The container. Compose mounts the configs read-only, keeps voicemail and call records on named volumes so they survive rebuilds, and checks health by asking Asterisk for its uptime:

services:
  asterisk:
    build: .
    container_name: asterisk
    restart: unless-stopped
    network_mode: host
    env_file:
      - docker-compose.env
    volumes:
      - ./config/templates:/asterisk-templates:ro
      - ./config/static:/asterisk-static:ro
      - ./config/sounds:/var/lib/asterisk/sounds/en/custom:ro
      - asterisk-spool:/var/spool/asterisk      # voicemail, CDR, recordings
      - asterisk-logs:/var/log/asterisk
    healthcheck:
      test: ["CMD", "asterisk", "-rx", "core show uptime"]
      interval: 60s

The attack surface stays small too. Unused channel drivers and the REST interface are never loaded, and Asterisk’s security events go to their own log, ready for fail2ban:

; modules.conf
noload => chan_console.so
noload => chan_motif.so
noload => res_phoneprov.so
noload => res_ari.so
noload => res_ari_events.so

; logger.conf
security => security

Bringing it up

I tested it in a fixed order, each step proving one more piece of the path:

  1. *43 echo test from each phone: local audio works in both directions.
  2. Call 101 from the desk phone: phone-to-phone calls work.
  3. Call my cell: the trunk carries audio out.
  4. Call the house from my cell: inbound routing, the menu and the ring group work.
  5. Dial 933: voip.ms reads back the e911 address on file.

When something misbehaved, pjsip set logger on showed the full SIP conversation.

Running it

It runs day to day without attention. A snapshot from the CLI:

Asterisk CLI showing version 22.10.1, three days of uptime, the ring group, one voicemail box and eight calls processed

On caller ID: the inbound number passes straight through from the trunk. Names come from a CNAM lookup on voip.ms, and numbers saved in the voip.ms phone book resolve for free, so family calls cost nothing to look up.

What’s next

  • Encryption on the LAN. The trunk already uses TLS. Next is TLS and SRTP for the phones inside the house, one device at a time.
  • A better greeting. The current prompt is a placeholder. A properly produced voice is next.
  • The real Augie. Replace the stand-in meow with a recording of my actual cat.
  • Voicemail to email, once SMTP is set up on the host.

From the notebook

Build log

No entries yet. They'll show up here as the project moves along.