infra_wiki

Operate the office phone

How the office SPA504G reaches voip.ms through the folly PBX, and how to check it, change it, and debug a call that fails.

The office Cisco SPA504G has four lines. Each registers to an Asterisk PBX on folly, and the PBX carries each line to its own voip.ms sub-account over TLS. The phone talks only to the PBX and holds no voip.ms credential.

How it fits together#

Linevoip.ms sub-accountHandset SIP port
1168847_cathy5060
2168847_recorded5060
3168847_sandbox5060
4168847_19945063
  • Handset โ†’ PBX. Each line registers as lineN to the PBX's LoadBalancer address. The PBX identifies a line by that username and admits it only from the handset's reserved address, so the lines carry no password. Both addresses are CATHY_IP and PBX_SIP_VIP in clusters/folly/config/cluster-settings.yaml.
  • PBX โ†’ voip.ms. One registration per sub-account to a numbered server over TLS on 5061, with the certificate verified and SRTP mandatory. Every call is G.722 end to end. The passwords live in 1Password item voip.ms sub accounts in the homelab vault, one concealed field per sub-account, labelled with the username.
  • Everything declared in git. Asterisk config is under clusters/folly/apps/pbx/config/ and clusters/base/apps/pbx/config/; the handset's profile is clusters/folly/apps/pbx/provision/cathy.xml; the image is nix/images/asterisk.nix, built by .github/workflows/nix-images.yml.

Every line depends on folly being up โ€” dial tone, and 911. A folly outage leaves the desk phone with no lines at all; a cell phone is the emergency backup.

A merge under clusters/folly/apps/pbx/ or clusters/base/apps/pbx/ rolls the pod, and Asterisk hangs up every call within 15 seconds of the stop. Merge only when core show channels shows no active call, and not within an hour of a 911 call from the handset: a roll forgets GLOBAL(LAST911), and a callback to a screened line meets the screen. Line 4 screens unknown callers; Screen callers on the office phone covers the screen, the contacts and the star codes.

Check its health#

  • Grafana โ†’ PBX. Trunk and line registration, calls, and four log panels: call flow, errors, SIP messages, and the handset's profile fetches.

  • The Switchboard board at https://switchboard.lolwtf.ca. Each call in progress with its IVR stage, each line's handset and trunk registration, and the calls that ended since the board started. See Switchboard board.

  • Alerts in clusters/folly/monitoring/pbx-rules.yaml go to Discord: PBXDown, PBXTrunkNotRegistered per sub-account, and PBXHandsetOffline per line.

  • The Asterisk CLI. Pass -C, or the CLI looks for its control socket in the wrong directory:

    kubectl --context folly -n pbx exec deploy/pbx -c asterisk -- \
      /bin/asterisk -C /etc/asterisk/asterisk.conf -rx 'pjsip show registrations'

    pjsip show contacts, pjsip show endpoints and โ€” during a call โ€” pjsip show channelstats (per-leg packet counts, loss and jitter) are the others worth knowing. The image has no grep; filter outside the exec.

Change the phone#

Edit clusters/folly/apps/pbx/provision/cathy.xml and merge. The profile is partial: the phone changes only the settings the file names and leaves everything else alone. Never change a setting by hand on the phone โ€” its next resync puts the file's value back.

The phone resyncs hourly. To apply a change now, have it fetch the profile from a host that can reach the Management network โ€” the UDM, through Inspect the UniFi network:

curl "http://<CATHY_IP>/admin/resync?http://<PBX_SIP_VIP>/cathy.xml"

The fetch shows up in the provision container's log and on the dashboard. http://<CATHY_IP>/admin/spacfg.xml returns the phone's whole running config. It includes every setting the phone has, so read it through a filter rather than printing it.

Rotate the board's ARI password#

The Switchboard board reads the PBX as the ARI user in clusters/folly/apps/pbx/config/ari.conf. The password is the SOPS Secret pbx-ari, in clusters/folly/apps/pbx/ari-secret.sops.yaml. The board restarts when that Secret changes. The PBX reads it only when its pod starts, because Reloader watches only pbx-secrets on the PBX. Until the PBX restarts, the board shows ARI disconnected (unauthorized), and a restart of the PBX is the fix for that state whatever caused it.

  1. From the repo root, write a new password straight into the encrypted file. Nothing prints it:

    SOPS_AGE_KEY_FILE=~/.config/age/keys.txt bash -c 'set -euo pipefail
    f=clusters/folly/apps/pbx/ari-secret.sops.yaml
    pw=$(head -c 24 /dev/urandom | od -An -vtx1 | tr -d " \n")
    printf "apiVersion: v1\nkind: Secret\nmetadata:\n  name: pbx-ari\n  namespace: pbx\ntype: Opaque\nstringData:\n  PBX_ARI_PASSWORD: \"%s\"\n" "$pw" |
      sops encrypt --filename-override "$f" /dev/stdin >"$f"'
  2. Merge, and wait for Flux to apply it.

  3. When core show channels shows no active call, restart the PBX:

    kubectl --context folly -n pbx rollout restart deploy/pbx

Read the SIP#

PJSIP logs every SIP message the PBX sends and receives. In VictoriaLogs:

{namespace="pbx", container="asterisk"}

Vector removes the SRTP key from every a=crypto line and every Authorization header before storing them (clusters/base/monitoring/vector.yaml). The pod's own stdout is not redacted โ€” read kubectl logs output through a filter that drops those lines.

Diagnose a failed call#

SymptomCause
Call ... to extension 's' rejectedvoip.ms delivers a call to a sub-account's registered contact as s, not as its digits. from-voipms must answer s.
No matching endpoint found on an INVITE to the DID's digits with an X-Dest-User headervoip.ms sends this sub-account's calls in the DID form, and no identify matches its X-Dest-User. voip.ms sets the form, and registrations stay green. Each folly trunk needs its (trunk-identify) section; pjsip show identifies lists one per trunk.
No inbound INVITE arrives at allThe DID's POP in the voip.ms portal is not the server the trunk registers to.
vms-*: Couldn't negotiate stream ... (nothing)The trunk's codecs or SRTP profile do not overlap voip.ms's offer. Read the offer's m= and a=crypto lines.
lineN: Couldn't negotiate stream ..., handset shows 488The handset offers SRTP on every call (Secure_Call_Setting is phone-wide); the line endpoint must accept SDES.
Call connects, silent both waysThe RTP port range in config/rtp.conf and the ingress range for the pod's own RTP ports in clusters/base/apps/pbx/network-policy.yaml disagree. rtp show settings shows what Asterisk uses.
401 โ†’ 100 Trying โ†’ bare 503 on one sub-account while another worksvoip.ms fraud protection holding a sub-account that started a new calling pattern. Settings look identical; voip.ms support releases it.
REFER from vms-1994 answered 4xx, 5xx or 603, or a NOTIFY whose sipfrag is a final status of 300 or more, or that ends the subscription below 200voip.ms refused to move a screened caller to the internal extension of 168847_elevenlabs, and folly dials Jonathan itself, or holds the caller in a sink after a 401 or 407. A NOTIFY of 100 Trying or 180 Ringing is progress, and any 2xx is the transfer done. The log has kind=troll-miss site=offsite why=FAILURE-<code>. When 168847_1994 starts sending REFERs, or 168847_elevenlabs takes its first calls, the refusal can be the fraud hold above; voip.ms support releases it.
A line will not registerThe source address reaching the PBX is not the handset's: check the VIP's externalTrafficPolicy: Local and both policies naming CATHY_IP.
A caller on line 4 hears "press five", or is held in a queueLine 4 screens every caller who is not a contact. See Screen callers on the office phone.
elevenlabs: no reply to the INVITE, and no TLS errorThe PBX cannot reach sip.rtc.elevenlabs.io on 5061. Check the egress policy and DNS.
elevenlabs: SSL_ERROR_SSL (Handshake)The TLS handshake or certificate check failed. Check ca_list_file and verify_server in transport-tls against the server's certificate.
elevenlabs: 401 twice, then the leg endsElevenLabs rejects the credentials: the reconciler on offsite has not re-sent the item, or the pod has not restarted since it changed. Reloader does not watch pbx-elevenlabs.
elevenlabs: 404agent-did is not the number imported at ElevenLabs, leading + included.
The agent answers, then silence, and the call ends 30 s laterIts audio never reaches the pod, and rtp_timeout ends the leg.

Techniques that settle it#

  • Start captures before the test call. Hubble's per-node buffer on folly covers under a minute. Run hubble observe --namespace pbx --follow in the node's cilium-agent, then place the call.
  • Isolate one variable at a time with a throwaway Asterisk. A local Asterisk with no registration can send the same INVITE with one thing changed โ€” transport, account, caller ID โ€” and read voip.ms's verdict. That is how the fraud hold above is told apart from a config fault.
  • Prove TLS without credentials. Qualify-only OPTIONS to the server exercise the handshake and certificate check and carry no auth. Never trial a REGISTER with an unproven password: failed authentication can get the shared public address blocked, and that address carries every line.
  • Validate what the cluster runs, not what you wrote. flux build piped into kubectl apply --server-side --dry-run=server catches both Flux's variable substitution and schema typing, which kubectl kustomize never sees. mise run pbx:check boots the image's Asterisk against each site's rendered config with no route off the machine. It fails when a PJSIP object does not load, when 911 stops reaching a line's trunk, when an inbound call can reach a trunk, when a trunk identifies by anything but header or a folly trunk stops taking voip.ms's DID-form call, when a prompt is missing from pbx-sounds, or when the open, contact, 911-callback, press-5 or spam route changes. On offsite, it fails when a caller stops reaching the agent with its headers, when a miss or a cap answers the caller, or when the held count changes. .github/workflows/pbx.yml runs it on every PBX change.

Edit on GitHub

โ†‘โ†“ move โ†ต open esc close