Skip to content

Chan Sofia

Germán Luis Aracil Boned edited this page Jul 3, 2026 · 60 revisions

chan_sofia

chan_sofia is the Sofia-SIP based SIP channel driver for GABPBX. It is the modern SIP implementation in the project and is designed to replace chan_sip while preserving the public compatibility surface that existing systems depend on.

The driver uses the Sofia-SIP NUA API for SIP stack handling and integrates that stack with the GABPBX channel, RTP, realtime, AMI, CLI, dialplan function, SRTP and bridging layers.

Beyond chan_sip

chan_sofia is a drop-in replacement, but it is not limited to what chan_sip did — a real SIP stack underneath let it pick up modern SIP features that chan_sip never had. Highlights (each is documented in its own section below):

  • Registration & routing: SIP Outbound (RFC 5626), Path (RFC 3327), Service-Route (RFC 3608) and GRUU — the registration extensions that make a PBX work cleanly behind edge proxies and SBCs.
  • Presence & voicemail: outbound PUBLISH (RFC 3903) to a central presence server, a generic outbound SUBSCRIBE for any event package, an outbound MWI SUBSCRIBE watcher, and both solicited and unsolicited MWI.
  • Call signalling: reliable provisionals / PRACK (RFC 3262), in-dialog UPDATE (RFC 3311), Q.850 Reason on BYE/CANCEL (RFC 3326), REFER transfer and out-of-dialog SIP MESSAGE.
  • Security: mutual TLS (client-cert verification), TLS hardening, and digest auth with SHA-256 alongside MD5.
  • Diagnostics: per-call SIP history with a verbose call-analysis pass (outcome, failure reason, timeline, negotiated codecs) and a capture filter, plus sip notify, sip qualify peer, sip show channel and sip show channelstats.

These are not on the chan_sip feature list — they are the reason to move.

Design Goal

The primary goal is simple: make chan_sofia a drop-in chan_sip replacement without copying avoidable historical bugs.

Operationally that means:

  • Existing dialplans keep using SIP.
  • Existing peers can stay in the sippeers realtime family.
  • Existing sip show ... CLI habits continue to work.
  • Existing SIP AMI action names continue to work.
  • Existing dialplan functions keep their SIP... names.
  • New behavior should use Sofia-SIP where the library already solves the SIP protocol problem better than local hand-written parsing.

Resource Footprint and Performance vs chan_sip

All figures below are traceable to the source of each driver. They are intended as a deployment-planning baseline, not a benchmark.

Threads

chan_sofia spawns exactly four module-level threads at load time, in load_module at channels/chan_sofia.c:17370-17457:

Thread Role
sofia_thread (sofia_thread_func calling su_root_run) NUA event loop. All inbound and outbound SIP traffic — INVITE, REGISTER, OPTIONS, NOTIFY, PRACK, BYE, CANCEL — is dispatched here. Sofia-SIP's tport_* layer multiplexes UDP, TCP, TLS, WS and WSS sockets on the same loop.
sofia_sched (ast_sched_thread_create) Managed scheduler thread for timed callbacks: T.38 5-second reINVITE abort, REFER transferer-leg BYE defer (32 s safety net), qualify retry.
sofia_reg_thread (sofia_reg_thread_func) Outbound REGISTER refresh when chan_sofia acts as a UAC against upstream providers.
sofia_qualify_tid (sofia_qualify_thread) Periodic OPTIONS keepalive scheduling.

There are no per-peer, per-call, per-contact or per-connection thread creations anywhere else in chan_sofia. grep -nE 'ast_pthread_create|pthread_create' against the source returns only the four sites above.

chan_sip for comparison creates:

  • 1 monitor thread (do_monitor, channels/chan_sip.c:27246) — analogous to sofia_thread.
  • A detached worker thread per persistent SIP TCP/TLS session (sip_tcp_worker_fn at channels/chan_sip.c:26924). This thread stays alive for the lifetime of the TLS connection. A deployment with N registered TLS endpoints carries N additional threads here.
  • A detached short-lived thread per Park (sip_park_thread at channels/chan_sip.c:22777) and per Pickup (sip_pickup_thread at channels/chan_sip.c:22818). These do not accumulate.

Practical consequence: scaling to several thousand TLS endpoints multiplies the chan_sip thread count by that endpoint count plus a constant. On chan_sofia the thread count is constant. Each Linux thread carries an 8 KB minimum pthread stack plus kernel scheduler state, so the per-thread cost adds up at large endpoint counts.

Memory per peer

struct sofia_peer (channels/chan_sofia.c:1267-1735) carries:

  • AST_DECLARE_STRING_FIELDS(...) with about two dozen string fields (name, secret, md5secret, context, host, defaultuser, fromuser, fromdomain, regexten, callbackextension, subscribecontext, accountcode, disallowed_methods, callerid, cid_name, cid_num, cid_tag, nonce, outboundproxy, srtpcipher, mohinterpret, mohsuggest, language, parkinglot, lockuseragent_prefixes). Pooled storage: empty fields point at a shared empty string; populated fields use length + 1 bytes from the per-struct pool. Typical pool footprint: 256-1024 bytes depending on the peer's configuration.
  • A few dozen int fields covering NAT mode, DTMF mode, codec preferences, encryption, call counters, session timer parameters, transfer policy, MWI, qualify and RTP timeouts.
  • Three struct ast_sockaddr slots (addr, src_addr, defaddr), 136 bytes each.
  • Three struct timeval slots for qualify and response timing.
  • One ast_mutex_t.
  • Pointers to optional structures: struct ast_ha *ha, struct ast_ha *contactha, struct ast_ha *directmediaha, struct ast_dnsmgr_entry *dnsmgr, struct ast_variable *chanvars, struct ao2_container *contacts, nua_handle_t *nh, nua_handle_t *qualify_nh, nua_handle_t *mwi_subscription_handle.

Headline estimate for a configured peer that is not yet registered and has no auxiliary lists: about 2-3 KB.

Each registered contact is a separate struct sofia_contact (channels/chan_sofia.c:1112-1121):

struct sofia_contact {
    char contact_uri[256];
    char host[64];
    int port;
    char transport[8];
    char user_agent[64];
    time_t expires;
    struct ast_sockaddr src_addr;
    int active_calls;
};

About 640 bytes per contact including the ao2 ref-count header and hash slot in peer->contacts. Six contacts under a peer therefore add roughly 3.8 KB on top of the bare peer cost.

Optional per-peer structures add memory only when used:

  • Each permit or deny ACL rule in ha, contactha or directmediaha is roughly 24 bytes.
  • A dnsmgr entry exists only when the peer's host= is a hostname rather than an IP literal.
  • Each setvar= or header= entry on a peer adds an ast_variable node (about 40 bytes plus the name and value strings).

Sofia-SIP allocates nua_handle_t storage in its own su_home. A registered peer typically owns one main handle. A peer with qualify=yes carries a second handle for OPTIONS. A peer with mailboxes carries a handle for MWI SUBSCRIBE. Each handle is on the order of 1-2 KB inside Sofia-SIP.

chan_sip's struct sip_peer (channels/sip/include/sip.h:1203) defines around 60 typed fields plus its own AST_DECLARE_STRING_FIELDS block and embedded buffers. Bare cost is similar in order of magnitude (a few KB), with the main difference being that chan_sip inlines a single contact- oriented address into sip_peer while chan_sofia separates the contact into its own refcounted object.

Memory per active call

struct sofia_pvt (channels/chan_sofia.c:1743-1869) carries about 40 typed fields, an AST_DECLARE_STRING_FIELDS block with around 20 strings, several embedded ast_sockaddr slots, fork bookkeeping, scheduler IDs, and pointers to the RTP and SRTP contexts. Direct cost: about 3-5 KB.

Each active call also carries:

  • An ast_channel from the GABPBX core (about 4-6 KB).
  • An ast_rtp_instance for audio (about 4-8 KB).
  • A second ast_rtp_instance when video is in the SDP.
  • One nua_handle_t (about 1-2 KB inside Sofia-SIP).
  • An srtp and optional vsrtp context when encryption=yes (about 2 KB each).

Total per active audio call: roughly 15-25 KB.

When chan_sofia forks an outbound INVITE to multiple live contacts (the typical desk-phone-plus-softphone case), the master sofia_pvt is created once and child sofia_pvt objects are spawned per contact at channels/chan_sofia.c:6151-6353. Each child carries its own nua_handle_t and SDP/SRTP state but does not own the ast_channel; only the master participates in the peer->inUse counter. A ring to six contacts therefore costs roughly six times the per-child overhead (about 35 KB transient extra) and counts as one ringing call against call-limit.

Throughput and scaling ceiling

Both drivers serialise SIP request dispatch through a single thread (do_monitor in chan_sip, sofia_thread in chan_sofia). That means SIP throughput per process is CPU-bound on one core in both cases. Sofia-SIP's implementation is generally faster than the equivalent hand-rolled paths in chan_sip and avoids the per-connection thread context-switch cost on TCP/TLS, but the architectural ceiling is the same: one core per SIP loop.

Deployments that need to scale SIP throughput beyond what one CPU core provides typically front the PBX with an SBC and shard endpoints across PBX instances. That model is unchanged by the choice of SIP driver.

Keeping the single thread fast: O(1) state and bounded offload

The single-thread ceiling above is exactly why the per-event cost on that thread matters. chan_sofia keeps that cost low the way Kamailio keeps its location and dialog stores fast — O(1) data structures — and is beginning to take blocking work off the thread the way Kamailio (asynchronous usrloc/workers) and FreeSWITCH (thread pools) do.

O(1) peer and dialog lookups. Peers and active dialogs are held in real hash tables with proper hash functions, so a deployment with thousands of registered peers and a high call rate never degrades the per-event cost to a linear scan. The bucket caps are sized for a load factor well below 1 into tens of thousands of peers and high concurrent-dialog/presence volume (all prime — primes give an even bucket distribution and drop the modulo's dependence on the hash's low bits):

#define MAX_PEER_BUCKETS        65521  /* prime < 2^16: ~50k peers at load factor < 1 */
#define MAX_PEER_IPPORT_BUCKETS 16381  /* prime: by-source-IP trunk index (static-host trunks only) */
#define MAX_DIALOG_BUCKETS      32749  /* prime: concurrent-dialog headroom */
/* presence 65521, publications 32749, blacklist 4093 — all prime */

The bucket array is allocated once and lives for the process; because it is calloc'd, an empty large table costs almost no resident memory (the unused buckets stay on lazy zero pages) and fills in only as it is used.

Fast string hashing — XXH3-64. Every one of those tables (and every other ao2 container in the core: device state, channels, formats, …) is keyed through ast_str_hash. GabPBX hashes it with XXH3 (64-bit, xxHash 0.8.3) instead of the classic byte-at-a-time multiply hash:

classic byte-wise hash XXH3-64 (this build)
processing one byte per step wide 8-byte words + strong final avalanche
short-key speed baseline markedly faster (fewer steps per key)
distribution good at prime sizes, weak in the low bits excellent at every table size

On real-world short keys (peer names, contexts, device names) XXH3-64 is the fastest of the candidates measured while matching the others on collision rate, so lookups get cheaper with no distribution trade-off. The implementation is a small, self-contained, dependency-free header (include/gabpbx/xxh3_64.h, XXH3-64 only) with the hot path fully inlined — no external library and no extra build flags. The case-insensitive variant (ast_str_case_hash) folds keys to ASCII-lowercase in fixed stack windows, so it never allocates.

Measure it on your own build and hardware:

*CLI> core test hash [iterations]
String hash benchmark
  algorithm  : XXH3-64 (xxHash 0.8.3)
  iterations : 5000000  (cycling 20 sample keys, avg 12.1 bytes)
  ast_str_hash      :   104.28 Mhash/s   1257 MB/s   9.59 ns/hash
  ast_str_case_hash :    56.03 Mhash/s    675 MB/s  17.85 ns/hash

You can inspect live table health (load, longest chain, collisions, chi-squared) with core show ao2 and, for chan_sofia's own tables, sip show hashstats.

Bounded REGISTER offload pool (register_pool, default OFF). The one place a slow back end can stall the whole signaling thread is the realtime-database write that follows each REGISTER: under a registration storm against a slow database, every queued INVITE and OPTIONS waits behind those writes. With register_pool=yes the realtime-DB writes for REGISTER are handed to a small fixed pool of worker lanes keyed by AOR — per-AOR ordering is preserved while the signaling thread returns immediately. The pool is bounded (fixed lane count and a fixed in-flight cap, so it cannot grow without limit under load), ships behind a kill-switch that is OFF by default (behavior is byte-for-byte the legacy inline path until you enable it), and is reversible at runtime with sofia reload.

This is Phase 1 — it offloads only the database write, and it ships "dark" so the win can be measured on a real deployment before it is trusted. The direction is a signaling thread that never blocks on the database, which is what lets Kamailio and FreeSWITCH absorb carrier-scale registration churn; later phases move more of the REGISTER path onto the bounded pool as the Phase 1 numbers justify it.

Where chan_sofia clearly wins

  • Large fleets of TLS endpoints — chan_sip carries a thread per connection, chan_sofia does not.
  • Phones behind misbehaving NATs that rotate the source port on every REGISTER — chan_sofia stores each Contact in a separate refcounted object and evicts the oldest when max_contacts is reached, while still counting the call as one regardless of how many parallel rings fire.
  • Multi-device users on the same SIP credentials — chan_sofia forks the outbound INVITE in parallel and the first device that answers wins; the call-limit counter sees the call as one, not as the number of contacts.

Where the choice is closer

  • Single-core SIP throughput, since both drivers ultimately funnel through one thread.
  • Per-peer config size for a peer with rich setvar/header/ACL lists — both drivers allocate variable-size auxiliary lists; the bulk of the cost is the user-supplied content, not the driver.

Mutual Exclusion With chan_sip

chan_sofia and chan_sip must not be loaded at the same time.

They register the same public names:

  • Channel technology: SIP
  • CLI commands: sip show peers, sip show peer, sip set debug, etc.
  • AMI actions: SIPpeers, SIPshowpeer, SIPqualifypeer, etc.
  • Realtime family: sippeers
  • Dialplan functions: SIPPEER, SIPCHANINFO, SIP_HEADER, CHECKSIPDOMAIN

Use one SIP driver per GABPBX process.

Recommended modules.conf:

noload => chan_sip.so
load => chan_sofia.so

Restart GABPBX after changing the active SIP driver. Do not rely on a simple module reload when switching between chan_sip and chan_sofia.

Concurrency and Reliability

chan_sofia runs all SIP signalling on a single Sofia-SIP event-loop thread. That thread owns the mutable peer and dialog state, and configuration reload is dispatched onto the same thread, so signalling and reload never run at the same time. Every other thread — dialplan, CLI, AMI, bridge/RTP, the scheduler, and the registration and qualify helpers — only reads that state, and does so under a lock.

The lock order is hard and never inverted:

channel-lock -> pvt-lock -> peer-family (the peer ao2 lock and the peer mutex)

The model is written down as a single authoritative block at the top of channels/chan_sofia.c; every inline lock note in the file is an instance of it. The rules that keep the driver safe under load:

  • Freeable peer data (string fields, ACL lists, channel variables) read off the SIP thread is copied under the peer lock before use, because a configuration reload can free and rebuild it. The two global ACL lists, localnet and the contact ACL, have dedicated read-write locks for the same reason — so sip reload is safe while calls are active.
  • An in-dialog request or response carries its dialog as the handle context, but a hangup on the channel thread can free that dialog at the same moment. The dispatcher revalidates the dialog and holds a reference for the whole handler, so the re-INVITE, REFER, INFO, ACK, MESSAGE, BYE and CANCEL handlers can never dereference a freed dialog.
  • A lock is never held across a blocking or channel-locking call (the SIP stack, DNS, music-on-hold, channel-variable application). Such values are copied first, the lock is released, then the call is made.

These paths are verified with a repeatable stress procedure: a SIP call flood together with a sip reload loop, run under a thread-debug (DEBUG_THREADS) build so held locks and lock order can be inspected with core show locks. The current driver passes that load with no crash, deadlock or memory error.

Source Map

chan_sofia is built as one loadable module (chan_sofia.so) from a driver core plus a set of cohesive subsystem modules under channels/sofia/. The build links chan_sofia.o together with every channels/sofia/*.c automatically (a wildcard rule in channels/Makefile), so a new module file is picked up without touching the build system.

channels/chan_sofia.c is the driver core: the channel technology vtable (call/answer/hangup/ indicate/read/write/bridge), module load/unload, the single sofia_thread bootstrap, the sofia_event_callback dispatcher (the handle-magic discriminator that keeps call dialogs, peers and subscriptions from being confused — the central safety seam), digest authentication, the call/dialog request handlers (INVITE/ACK/BYE/CANCEL/RE-INVITE/REFER/INFO/MESSAGE), inbound REGISTER processing, and the configuration and peer lifecycle. These share the per-call and per-peer state too closely to separate, so they stay together.

The subsystems that form clean functional boundaries live in their own files:

channels/chan_sofia.c                 Driver core (vtable, event dispatch, auth, call/register/config)
channels/sofia/sofia_sdp.c            SDP offer/answer: parse, generate, hold, crypto attributes
channels/sofia/sofia_t38.c            T.38 fax negotiation state machine
channels/sofia/sofia_presence.c       Presence / BLF SUBSCRIBE -> NOTIFY engine
channels/sofia/sofia_publish.c        Outbound PUBLISH (RFC 3903) publisher
channels/sofia/sofia_subscribe.c      Outbound MWI SUBSCRIBE (watcher) -> local MWI inject
channels/sofia/sofia_ami.c            AMI manager actions (SIPpeers/SIPshowpeer/SIPshowregistry/...)
channels/sofia/sofia_cli.c            CLI commands (sip show peers/settings/registry, set debug, ...)
channels/sofia/sofia_blacklist.c      Failed-auth blacklist engine
channels/sofia/sdp_crypto.c           SDP a=crypto (SDES) attribute handling
channels/sofia/srtp.c                 SRTP key/policy lifecycle
channels/sofia/include/                Per-module API headers + chan_sofia_internal.h (shared model)
configs/sofia.conf.sample
doc/realtime_sippeers.txt
doc/realtime_sipregs.txt
configs/res_pgsql.conf.sample

channels/sofia/include/chan_sofia_internal.h holds the types and declarations shared across the driver core and the subsystem modules (the peer, per-call and configuration structs, the model constants and enums, and the cross-module function prototypes). Each module keeps its own internal state private and exposes only the narrow API the rest of the driver calls.

Supporting subsystems used by chan_sofia:

main/rtp_engine.c
main/udptl.c
main/manager.c
main/pbx.c
main/channel.c
main/bridging.c
main/dnsmgr.c
main/dsp.c
res/res_srtp.c
codecs/codec_opus.c

Build Requirements

chan_sofia links against Sofia-SIP:

lsofia-sip-ua

The current make rules use:

-I/usr/local/include/sofia-sip-1.13
-L/usr/local/lib -lsofia-sip-ua

Install Sofia-SIP into /usr/local unless you also adjust the build rules.

Typical Sofia-SIP build:

cd ../sofia-sip
./configure --prefix=/usr/local
make
make install
ldconfig

In the internal source layout, Sofia-SIP is kept beside GABPBX:

/home/sources/voip/futurepbx/sofia-sip

From the GABPBX tree, that is normally:

../sofia-sip

If you see this build error:

[CC] chan_sofia.c -> chan_sofia.o
chan_sofia.c:120:10: fatal error: sofia-sip/nua.h: No such file or directory
  120 | #include <sofia-sip/nua.h>
      |          ^~~~~~~~~~~~~~~~~
compilation terminated.
make[1]: *** [/opt/gabpbx/Makefile.rules:108: chan_sofia.o] Error 1
make: *** [Makefile:369: channels] Error 2

then Sofia-SIP headers are missing from the compiler include path. Install Sofia-SIP to /usr/local, then verify:

test -f /usr/local/include/sofia-sip-1.13/sofia-sip/nua.h
test -f /usr/local/lib/libsofia-sip-ua.so || test -f /usr/local/lib/libsofia-sip-ua.a
ldconfig -p | grep sofia-sip-ua

After that, build GABPBX again:

cd /opt/gabpbx
./configure
make menuselect
make -j$(nproc)
make install

Inside make menuselect, enable or verify before running the final build:

Channel Drivers -> chan_sofia

For Opus media with chan_sofia, also enable:

Codec Translators -> codec_opus

Runtime Verification

Start GABPBX:

gabpbx -vvvc

Or connect to an existing instance:

gabpbx -r

Useful checks:

*CLI> module show like sofia
*CLI> sip show settings
*CLI> sip show peers
*CLI> sip show peer <peer>
*CLI> sip show channels

If chan_sofia.so does not load, check:

  • chan_sip.so is not already loaded.
  • Sofia-SIP headers are in /usr/local/include/sofia-sip-1.13.
  • libsofia-sip-ua is in /usr/local/lib.
  • ldconfig has been run after installing Sofia-SIP.
  • res_srtp.so is loaded if SRTP/encryption is required.

Configuration File

Main file:

/etc/gabpbx/sofia.conf

Sample:

configs/sofia.conf.sample

The file follows the sip.conf style. The intent is to let operators migrate from chan_sip with minimal changes.

Minimal configuration:

[general]
context=default
bindaddr=0.0.0.0
bindport=5060
allowguest=no
realm=gabpbx
srvlookup=yes

[phone100]
type=peer
host=dynamic
secret=change-this-secret
context=internal
disallow=all
allow=opus
allow=alaw
allow=ulaw
dtmfmode=auto
nat=force_rport,comedia
directmedia=no
maxcontacts=3
call-limit=3

Dialplan use remains compatible:

exten => 100,1,Dial(SIP/phone100,30)

Some older comments or examples may refer to SOFIA/...; for the drop-in driver interface, prefer the public SIP/... technology.

Architecture

At runtime, chan_sofia is built around these major pieces:

  • A Sofia-SIP NUA root and event thread.
  • Peer storage in ao2_container.
  • Dialog storage in ao2_container.
  • Contact storage per peer for multi-contact registration.
  • RTP glue registered with the GABPBX RTP engine.
  • UDPTL glue for T.38/Fax related paths.
  • A scheduler thread for timed operations that must not block the SIP stack.
  • Registration and qualify worker threads.
  • CLI command registration under the existing sip ... namespace.
  • AMI action registration under the existing SIP... namespace.
  • Dialplan applications and functions for SIP compatibility.

Threading note:

Sofia-SIP handles and NUA operations must run on the Sofia thread. AMI actions that need to touch Sofia-SIP dispatch work back to the Sofia root thread instead of calling NUA directly from the manager thread.

Compatibility Surface

chan_sofia preserves the important external names:

Channel technology: SIP
Realtime family:   sippeers
Config style:      sip.conf compatible, using sofia.conf
CLI namespace:     sip ...
AMI namespace:     SIP...
Dialplan funcs:    SIPPEER, SIPCHANINFO, SIP_HEADER, CHECKSIPDOMAIN

Migration rule:

Existing dialplan and monitoring should keep working when it depends on the
public SIP names, but behavior should be verified in staging before production.

SIP Protocol Coverage

Current source implements or routes the important SIP methods through Sofia-SIP:

  • REGISTER
  • INVITE
  • ACK
  • BYE
  • CANCEL
  • OPTIONS
  • SUBSCRIBE
  • NOTIFY
  • REFER
  • MESSAGE
  • INFO
  • PUBLISH
  • PRACK
  • UPDATE

Primary implemented call flows:

  • In-dialog UPDATE (RFC 3311): an inbound UPDATE on a confirmed dialog is answered directly — a no-SDP UPDATE is a target-refresh (200 OK with no body), and an UPDATE carrying an SDP offer is renegotiated like a re-INVITE (validate-then-commit; 488 if unacceptable) with an SDP answer. The 200 reflects the peer's session-timer policy (RFC 4028) and carries the GRUU target-refresh Contact when gruu is enabled. Glare (an offer we already have in flight) returns 491, and an offer before the dialog is confirmed returns 500 with Retry-After. UPDATE is advertised in Allow. Outbound UPDATE (using it to send our own session-timer refresh) is a planned follow-up.

  • Dynamic endpoint registration.

  • Inbound calls from peers.

  • Outbound calls to peers and trunks.

  • Direct media decisions.

  • RTP setup and codec negotiation.

  • SIP REFER based transfers.

  • Qualify probes.

  • MWI SUBSCRIBE handling.

  • Outbound registration to upstream registrars.

  • Q.850 Reason header (RFC 3326) when use_q850_reason=yes in [general]: the BYE/CANCEL chan_sofia sends carries Reason: Q.850;cause=N;text="..." built from the channel hangup cause, and a received Q.850 Reason on an inbound BYE/CANCEL is mapped back to the AST hangup cause for CDR fidelity. Default off; shown in sip show settings. (Not added to INVITE-rejection responses — a sofia-sip limitation.)

Transport Support

Supported transports on the listener side:

udp
tcp
tls
ws
wss

Connection Keepalive

For connection-oriented transports (TCP, TLS, WS, WSS) the OS socket is kept alive with SO_KEEPALIVE (about 30 seconds) by default — the same socket-level keepalive chan_sip set per session — so a TCP/TLS phone behind NAT keeps a live socket out of the box.

For an application-level keepalive that also refreshes the NAT mapping, enable a periodic CRLF on idle connections:

tcp_keepalive = 30   ; seconds between CRLF keepalives on an idle connection (0 = off)
tcp_pingpong  = 10   ; seconds to wait for a pong before closing a dead connection (0 = off)

tcp_keepalive sends a double-CRLF ping (RFC 5626 §3.5.1) on idle TCP connections; TLS and WS rely on the SO_KEEPALIVE socket option above. tcp_pingpong adds dead-connection detection and requires tcp_keepalive (it is ignored, with a warning, on its own). Both are off by default; changing either takes effect on the next sip reload.

Where transport is controlled

chan_sofia does not gate per-peer inbound transport. The per-peer transport= directive is accepted at config-parse and realtime-load time purely for chan_sip drop-in template compatibility — it is read but not applied. Operators upgrading from chan_sip can leave existing transport=udp, transport=udp,tcp, or empty transport= rows in place without behavioural change and without log noise on cache rebuilds.

Two layers actually determine which transport a SIP message uses:

  1. Per-listener bindings at [general]bindport, tcpbindaddr, tlsbindaddr, wsbindaddr, wssbindaddr and their *_enabled flags. These define which sockets the server opens and accepts on. Configure only the listeners the deployment needs.
  2. Per-Contact at REGISTER-time — the inbound Contact: URL scheme (sip: / sips: / ;transport=tls parameter) carries the transport the registering UA wants you to use for outbound messages to it. chan_sofia stores the per-contact transport derived from the URL and uses it when routing back to that contact.

If a deployment wants transport-based security (e.g. "this peer must use TLS"), the supported model is:

  • Run a dedicated TLS-only listener on its own bindport.
  • Put the peer's host=<ip> or permit/deny ACL on the IP address that reaches that listener.
  • Optionally combine with lockuseragent + lockuseragent_prefixes to pin the device family.

This mirrors the FreeSWITCH model (profile-level listener + cidr= user attribute + tls-only at profile level) and replaces the chan_sip per-peer transport= allowlist, which ran after socket-accept + parse + peer-lookup and provided no real attack-surface reduction.

Listener options include:

bindaddr=0.0.0.0
bindport=5060

tlsbindaddr=0.0.0.0
tlsbindport=5061
tlscertfile=/etc/gabpbx/keys

wsbindaddr=0.0.0.0
wsbindport=5066

wssbindaddr=0.0.0.0
wssbindport=7443

TCP And TLS Listener Configuration

chan_sofia uses Sofia-SIP transport binding directly:

  • bindaddr and bindport build the primary sip: listener.
  • Sofia-SIP opens SIP UDP and SIP TCP on that sip: listener.
  • tlsbindaddr and tlsbindport build the separate sips: TLS listener.
  • tlscertfile is the certificate directory, or the real cert+key file: when it names a file, chan_sofia derives the directory and soft-links the name Sofia-SIP expects (agent.pem) to that file (and cafile.pem to tlscafile), so an externally managed certificate can be used without renaming it. WSS names (wss.pem, ca-bundle.crt) are auto-linked from the TLS ones. Links are created only when missing and never overwrite.
  • tlsenable / wsenable / wssenable (default yes) explicitly enable or disable each listener even when its bind port is set.
  • Certificate immunity: a TLS/WSS listener with no usable certificate is disabled with a logged error instead of failing the whole driver; UDP, TCP and WS keep serving and the module still loads.

Current chan_sofia configuration intentionally does not use the old chan_sip keys tcpenable and tcpbindaddr. Do not rely on those keys for chan_sofia unless the source parser is extended to read them.

Example with SIP TCP on port 60000 and SIP TLS on port 60001:

[general]
bindaddr=0.0.0.0
bindport=60000

tlsbindaddr=0.0.0.0
tlsbindport=60001
tlscertfile=/etc/gabpbx/keys

Because the primary sip: listener opens both UDP and TCP, this example also opens UDP on 60000. If a deployment needs UDP and TCP on different ports, that is a code feature request, not only a configuration change.

TLS Hardening

The TLS listener accepts three optional hardening knobs. tls_ciphers and tls_verify_depth are off by default, so leaving them unset keeps Sofia-SIP's own default with no change in behaviour; tls_min_version defaults to 1.2 (see below).

[general]
tls_min_version  = 1.2                       ; minimum TLS version: 1.0, 1.1, 1.2 or 1.3 (default 1.2)
tls_ciphers      = ECDHE+AESGCM:!aNULL:!MD5  ; OpenSSL cipher list
tls_verify_depth = 3                         ; maximum peer certificate chain depth
  • tls_min_version sets the minimum accepted TLS version and enables that version and every higher one. It defaults to 1.2 (TLS 1.2 and TLS 1.3 accepted, TLS 1.0 and TLS 1.1 refused) — an openssl s_client -tls1_1 attempt is rejected with a protocol-version alert. Set 1.0 or 1.1 only to interoperate with legacy endpoints that cannot negotiate TLS 1.2. (Earlier builds defaulted to TLS 1.3 only, which rejected endpoints that cap at TLS 1.2.)
  • tls_ciphers is passed verbatim to OpenSSL; the default is !eNULL:!aNULL:!EXP:!LOW:!MD5:ALL:@STRENGTH.
  • tls_verify_depth bounds the certificate chain length (Sofia-SIP default is 2) and pairs with tlsverify.

Mutual TLS (mTLS) — verify the client certificate on the inbound listener:

[general]
tlsverify        = yes   ; verify the server cert on OUTBOUND TLS connections
tlsverifyclient  = yes   ; verify the client cert on the INBOUND TLS listener (mTLS)
  • tlsverify (outbound) and tlsverifyclient (inbound) are combined into one verification policy: set both to require certificates in both directions.
  • With tlsverifyclient = yes, a client that does not present a certificate trusted by the CA material in tlscertfile fails the TLS handshake before any SIP is processed.
  • Per-subject pinning (an allowlist of accepted certificate subjects) is a planned follow-up.

These harden the TLS listener only — the WSS listener builds its own context (which already disables SSLv2, SSLv3 and TLS 1.0) and ignores these keys. Changing any of tlsverify, tlsverifyclient, tls_min_version, tls_ciphers or tls_verify_depth requires systemctl restart gabpbx; a sip reload refuses the change with a "listener config changed" message because the TLS context is built when the listener is created.

Peers can then select the desired signaling transport:

[phone-tcp]
type=peer
host=dynamic
transport=tcp

[phone-tls]
type=peer
host=dynamic
transport=tls

TLS expects:

agent.pem
cafile.pem

For a test system, create a self-signed certificate like this:

install -d -m 750 /etc/gabpbx/keys
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
  -subj "/CN=pbx.example.net" \
  -addext "subjectAltName=DNS:pbx.example.net,IP:203.0.113.10" \
  -keyout /etc/gabpbx/keys/agent.key \
  -out /etc/gabpbx/keys/agent.crt
cat /etc/gabpbx/keys/agent.crt /etc/gabpbx/keys/agent.key > /etc/gabpbx/keys/agent.pem
cp /etc/gabpbx/keys/agent.crt /etc/gabpbx/keys/cafile.pem
chmod 600 /etc/gabpbx/keys/agent.key
chmod 640 /etc/gabpbx/keys/agent.pem /etc/gabpbx/keys/cafile.pem /etc/gabpbx/keys/agent.crt

For production, replace the self-signed certificate with a certificate signed by a trusted CA and keep the private key readable only by the GABPBX runtime user.

After changing listener ports, restart GABPBX. A Sofia listener port change is not a safe reload-only operation.

Verify runtime settings:

*CLI> sip show settings

Expected fields for the example above:

Bind Address:           0.0.0.0
Bind Port:              60000
TLS Bind Port:          60001

Verify operating-system sockets:

ss -lntup | grep -E ':(60000|60001)'

Verify the TLS handshake:

openssl s_client -connect 127.0.0.1:60001 -servername pbx.example.net -brief </dev/null

WSS support reuses TLS material and includes helper behavior for expected WSS certificate file layout.

Important WebSocket note:

WebRTC media (DTLS-SRTP, ICE-lite, rtcp-mux and BUNDLE) is implemented as of 1.1.2: a browser registers over WSS and holds two-way audio and two-way video (VP8/H.264). See WebRTC.

For browser-facing deployments, put chan_sofia behind a reverse proxy that enforces:

  • TLS policy
  • Origin allowlist
  • WebSocket subprotocol policy
  • Public HTTPS/WSS entrypoint

NAT external address (externip / media_address)

For a PBX behind NAT, externaddr (a static public IP) or externhost (a DDNS name, re-resolved periodically) is advertised in SDP/Contact for peers outside the localnet ranges. externip is accepted as a chan_sip-compatible alias of externaddr:

[general]
externip   = 203.0.113.1        ; chan_sip name; same as externaddr
localnet   = 192.168.0.0/24

media_address advertises a different address in the SDP c=/o= lines (audio, video and T.38) than the one used for signalling — for multi-homed hosts where media must leave a different interface:

[general]
media_address = 198.51.100.20

It is advertise-only: the RTP sockets still bind bindaddr; only the address offered to the far end changes. media_address wins over externaddr/externip (a deliberate operator override) while direct-media redirection still takes precedence. The value is validated as an IP (a port is stripped; IPv6 is emitted unbracketed in SDP); an invalid value is ignored with a warning.

Registration

chan_sofia supports dynamic peer registration with multi-contact storage.

Core options:

host=dynamic
max_contacts=6
minexpiry=60
maxexpiry=3600
defaultexpiry=120

Behavior:

  • REGISTER can create or update peer contact entries.
  • Multiple contacts can be stored for the same peer.
  • max_contacts limits how many active contacts a peer may use (range 1..6). Matching chan_sip behaviour, when the cap is reached and a new Contact URI arrives, the contact with the earliest expiry (LRU by expires) is evicted and the new one is linked. The phone keeps a valid registration; the stale row is dropped. This is the chan_sip parity path; older builds returned 403 on the rejected REGISTER which broke phones that rotate source ports between REGISTERs.
  • The per-eviction trace Sofia: peer '<name>' at max_contacts=N — evicting oldest contact <uri> is gated behind sip set debug; production runs silent. Turn it on with sip set debug on (global) or sip set debug peer <name> (scoped to a single peer) when investigating REGISTER churn.
  • The per-REGISTER summary "Registered SIP … / Unregistered SIP … / contact moved …" lines are gated by VERBOSITY_ATLEAST(6); default verbose levels stay quiet. Bump the level with core set verbose 6 to surface them. AMI PeerStatus events fire on a separate path and remain available regardless of these display gates.
  • A REGISTER from the same Contact URI as an existing entry refreshes the expires value in place (no eviction needed).
  • Requested expiry below minexpiry can be rejected with 423 Interval Too Brief.
  • Requested expiry above maxexpiry can be capped.
  • Expiry 0 unregisters.
  • Registration state can be saved through realtime.

Per-Contact registration (RFC 3261 §10.3)

Each Contact in a REGISTER is an independent binding, so a peer that registers several devices under one account is handled per the RFC 3261 §10.3 registrar rules:

  • Each Contact binds, refreshes and de-registers on its own. A REGISTER that carries one Contact does not disturb the peer's other bindings.
  • Each binding honours its own ;expires Contact parameter (falling back to the request Expires header, then defaultexpiry), clamped to minexpiry/maxexpiry.
  • expires=0 on a single Contact removes only that binding; the peer's other contacts stay registered. A Contact: * with Expires: 0 clears them all.
  • The 200 OK echoes back each surviving binding with its own remaining expiry.

sip show peer <name> detail reflects the per-binding state — AVAILABLE for a live binding, EXPIRED for one past its expiry, and IN-CALL while a binding carries an active call.

All-expired no-route guard. A call placed to a peer whose bindings have all expired returns CHANUNAVAIL instead of being routed to a stale binding, so a call is never forked into a contact that is known to be gone.

Outbound registration is also supported through register-style peers. Related retry controls:

registertimeout=20
registerattempts=0

registertimeout is the application-level retry interval for outbound registration. It is not the same as a SIP transaction timeout.

Connection-oriented binding flow-close (RFC 5626)

For a connection-oriented binding — WSS, WS, TLS or TCPchan_sofia removes the binding promptly when its underlying connection closes, instead of letting it linger until the SIP registration expires. The registrar watches the transport connection and, when it is torn down, drops the contact reached over that connection and corrects the routing state immediately. This is the RFC 5626 "flow" model: a binding is tied to the flow that created it, so a closed flow is a dead binding.

This matters most for WebSocket browser clients, which reconnect often — a page refresh tears down the old WSS connection and opens a new one. Without flow-close the old Contact would sit stale, and reachable in routing, until its registration expiry, so a call could be routed into a dead socket. With flow-close the stale binding is gone the moment the socket closes and only the live connection's Contact remains.

[general]
flowclose_emit_unregister = no    ; default: silent flow-close

flowclose_emit_unregister (per-peer, or in [general]; default no) controls only the external side-effects of a flow-close — the AMI PeerStatus event, the BLF / hint device-state update, and regexten cleanup:

  • no (default) — the binding is removed and routing state is corrected silently. A browser refresh therefore does not flap the BLF lamp or emit an unregister event for what is really a momentary reconnect.
  • yes — each flow-close additionally fires the full unregister side-effects (an explicit PeerStatus / device-state update each time a flow closes), for deployments that want an event per connection drop.

The binding is always removed and the routing state is always corrected regardless of the option; flowclose_emit_unregister changes only whether the external unregister notifications fire.

Registration Outcome Logging

Every REGISTER attempt is logged at NOTICE with the account, the source IP and the User-Agent, so the registering handset can be identified from the log alone:

Sofia REGISTER OK: user='1001' ip=203.0.113.7:5060 useragent='Example SIP Phone 2.1'
Sofia REGISTER REJECT (unknown peer): user='9999' ip=203.0.113.7:41020 useragent='probe-tool/1.0'
  • Failures always logREJECT with the reason: unknown peer, ACL, auth, interval too brief, user-agent lock, or max contacts.
  • Successes log only on a state change — a new registration, an unregister, or a contact add / remove / move — not on every keepalive refresh, so a fleet of phones re-registering each minute does not flood the log.
  • The line is NOTICE (not a sip set debug or verbose trace), so it appears at default log levels, and is independent of the AMI PeerStatus event.

Monitoring Outbound Registrations

sip show registry lists every outbound registration the PBX maintains (one per register => line) and its live state:

Host                          Username          Refresh   State
sip.provider.com:5060         myuser            3590      Registered
192.0.2.47:60000              test              0         Unregistered (3 attempts)
  • Refresh — seconds until the next refresh (0 when not registered).
  • StateRegistered or Unregistered; the (N attempts) hint appears when an upstream keeps rejecting the digest, which usually means the trunk secret is wrong.

The same data is available to automation through the AMI SofiaShowRegistry action.

Forcing a Re-registration

sip unregister <peer> force-expires a dynamic peer's inbound registration, so the phone has to register again:

*CLI> sip unregister 1001
Unregistered peer '1001'

It clears the peer's contact bindings, marks it unregistered and emits the PeerStatus: Unregistered AMI event and the device-state change (so any BLF lamp watching the peer updates immediately) — without sending anything to the phone. It operates on in-RAM peers only; a realtime peer's database binding is left untouched (the peer simply re-registers, or prune it from realtime separately). Peer-name tab-completion is supported.

Instance advertisement on outbound REGISTER (gruu)

When this server registers to an upstream registrar as a client (a register => trunk: host set, secret set, not host=dynamic), gruu=yes adds a stable +sip.instance parameter to the outbound REGISTER Contact (RFC 5626 §4.1):

[mytrunk]
type=peer
host=registrar.example.com
defaultuser=...
secret=...
gruu=yes

The REGISTER Contact then carries a stable instance URN:

Contact: <sip:*@198.51.100.10:60000>;+sip.instance="<urn:uuid:eef0159c-cd0e-...>"

The URN is derived from this server's Entity ID and the peer name, so it is unique per peer and stable across restarts. A GRUU-capable registrar or a federation layer can then uniquely identify this UA instance — which matters because chan_sofia registers Contacts under a wildcard user, so the instance is the only stable discriminator.

The REGISTER also advertises Supported: gruu (RFC 5627 §4.1) — merged with the stack's default timer, 100rel — which alerts the registrar to mint a GRUU. When a GRUU-capable registrar returns a pub-gruu (and a rotating temp-gruu) for our instance in the REGISTER 200 OK Contact (RFC 5627 §5.2), chan_sofia learns and stores them; sip show peer shows them:

GRUU      : advertised (+sip.instance)
Pub-GRUU  : sip:alice@example.com;gr=urn:uuid:eef0159c-cd0e-...
Temp-GRUU : sip:tgruu.7hs==jd7vn...@example.com;gr

The learned GRUUs are kept as opaque URIs and cleared when the registration goes inactive (unregister/expiry/failure). It does not enable reg-id or the RFC 5626 outbound flow and sends no validation/keepalive OPTIONS.

With use_gruu_contact=yes (the default when gruu=yes), chan_sofia then uses the learned pub-gruu as the Contact of outbound calls and re-INVITEs placed through the peer (RFC 5627 §4.4) — so the far end routes in-dialog and target-refresh requests back to this exact instance via the registrar — and calls on the peer also advertise Supported: gruu in their requests and responses. Set use_gruu_contact=no to keep advertising/learning the GRUU but fall back to the normal sip:user@our-ip Contact (an interop kill-switch). Default off; opt-in per peer.

Service-Route on outbound REGISTER (service_route)

When this server registers to an upstream registrar (a register => trunk) that returns a Service-Route header in the REGISTER 200 OK (RFC 3608), service_route=yes ingests that route set and pre-loads it as a Route on outbound INVITEs through the peer, so calls traverse the registrar's service proxy chain (Kamailio-as-proxy / IMS-style deployments):

[mytrunk]
type=peer
host=registrar.example.com
secret=...
service_route=yes

The learned route is shown in sip show peer and is updated from the latest 200, cleared when the registrar stops returning it or on de-registration. It is applied after any outboundproxy route (RFC 3608 §6.1 ordering), only while the trunk is registered. Scope: outbound INVITEs only — the registrar's domain; PUBLISH/SUBSCRIBE (which may target a different domain) are a documented follow-on. Default off; opt-in per peer (applying a Service-Route diverts outbound routing, so enable it only when your upstream uses it).

Path on inbound REGISTER (path)

When a dynamic peer registers through an edge proxy / SBC that inserts a Path header (RFC 3327), path=yes makes this server (as the registrar) accept and store that Path with the contact and pre-load it as a Route on the requests it sends back to that device — so they traverse the edge proxy to the NATed device:

[myphone]
type=friend
host=dynamic
secret=...
path=yes

The stored Path is shown per-contact in sip show peer, echoed in the REGISTER 200 OK, and used as a Route on outbound INVITEs to the device (including each contact of a multi-contact peer). If a Path is present but the device did not advertise Supported: path, the REGISTER is rejected 420 Bad Extension with Unsupported: path (RFC 3327 §5.3). Default off — accepting a Path is a trust decision (RFC 3327 §7). v1 applies the Path to INVITEs; NOTIFY/OPTIONS/ MESSAGE are a documented follow-on.

Reliable provisional responses / PRACK (rel100, RFC 3262)

Basic 100rel/PRACK already works out of the box: chan_sofia advertises Supported: 100rel, PRACKs a reliable provisional a far end sends on an outbound call, and its 183 Session Progress early-media response is sent reliably whenever the caller advertises Supported: 100rel.

rel100=yes (per-peer, default off) extends that to the other provisionals: 180 Ringing (and any non-183 1xx) is then sent reliably too — with Require: 100rel + RSeq, awaiting the caller's PRACK — for gateways/carriers that want reliable ringing.

[mygateway]
type=peer
host=gw.example.com
rel100=yes

Default off because a few UACs advertise Supported: 100rel but mishandle a reliable 180; the resulting PRACK timeout would fail the INVITE. Shown in sip show peer.

SIP Outbound on register=> trunks (sip_outbound, RFC 5626)

When a peer registers outbound to an upstream registrar (a non-dynamic host plus a secret), sip_outbound=yes makes the REGISTER advertise Supported: outbound, path and carry a stable +sip.instance (a UUID URN derived from the server EID and peer name, persistent across restarts) plus reg-id=1 on the Contact — so a SIP-Outbound-capable registrar/edge proxy maintains the client-initiated flow and routes inbound calls back over it (RFC 5626 §4.2.1):

[mytrunk]
type=peer
host=sip.example.com
defaultuser=...
secret=...
sip_outbound=yes
tcp_keepalive=30   ; if the trunk is TCP/TLS

This uses manual signalling, not the sofia-sip outbound engine (no STUN/OPTIONS probing). The keep-alive that holds the flow open is the connection-oriented CRLF ping — set tcp_keepalive (and tcp_pingpong) for TCP/TLS trunks; a warning is logged if the registrar confirms outbound (Require: outbound in the 200) but no keep-alive is configured or it is slower than the registrar's Flow-Timer. The runtime state (confirmed + Flow-Timer) is shown in sip show peer. Shares the +sip.instance with gruu=yes; default off (opt-in). UDP STUN keep-alive and multi-flow registration are a documented follow-on.

Realtime

The primary realtime family is:

sippeers

Example extconfig.conf style:

sippeers => pgsql,general,sippeers

Registration state can optionally be separated:

sipregs => pgsql,general,sipregs

When sipregs is configured separately, peer configuration remains in sippeers and registration state can be written to sipregs.

Read:

doc/realtime_sippeers.txt
doc/realtime_sipregs.txt

Runtime cache management:

*CLI> sip prune realtime peer <name>
*CLI> sip prune realtime peer all
*CLI> sip prune realtime peer like <pattern>
*CLI> sip prune realtime all

Use this when a realtime peer is edited in SQL and the in-memory cached copy must be dropped without restarting GABPBX.

Authentication

Digest authentication supports:

MD5
SHA-256

Implemented behavior:

  • qop=auth
  • RFC 2069 fallback when the client omits qop
  • nonce TTL
  • stale nonce challenge
  • nonce-count replay protection
  • configurable algorithm offer (auth_algorithms, see below)
  • anti-downgrade verification (only an offered algorithm is accepted)
  • alwaysauthreject to avoid username enumeration
  • AMI AuthFailure event on authentication failure paths

Relevant options:

realm=gabpbx
auth_algorithms=md5
alwaysauthreject=yes
nonce_ttl_seconds=3600
force_invite_auth=no

Security defaults should favor not revealing whether an account exists.

Outbound Authentication (Trunks)

chan_sofia authenticates its own outbound requests to an upstream that challenges them — both the periodic REGISTER from a register => line and every outbound INVITE placed through a peer trunk. When the provider answers 401 Unauthorized or 407 Proxy Authentication Required, the driver computes the digest from the trunk peer's credentials and resends the request with an Authorization / Proxy-Authorization header, and the call or registration proceeds. A single response that carries both a WWW-Authenticate (registrar/endpoint) and a Proxy-Authenticate (proxy) challenge is answered in one step.

The credentials are the trunk peer's defaultuser/username and secret (for a register => directive, the user:password it carries). Use a plaintext secret=: a pre-hashed md5secret= cannot authenticate outbound because the cleartext password is required to compute the response — md5secret stays available for inbound authentication. Outbound attempts are bounded per request, so a wrong credential can never drive an endless challenge loop.

Digest Algorithm Offer (auth_algorithms)

chan_sofia builds the WWW-Authenticate challenge itself, so the algorithm(s) it advertises are operator-selectable in [general]. This is a global switch: the same offer is made to every peer — there is no per-peer override.

auth_algorithms Challenge advertises
both MD5 and SHA-256 (MD5 listed first)
md5 MD5 only
sha256 SHA-256 only (RFC 7616; refuses MD5)

Verification is anti-downgrade: the server accepts only an algorithm it actually offered. A client that answers with an un-advertised algorithm — or that omits algorithm= (which implies MD5) when MD5 was not offered — is rejected with 400 Bad Request. A sha256-only deployment therefore cannot be coerced back to MD5.

The built-in default is both, but the shipped sofia.conf sets md5, because chan_sip only ever challenges with MD5 — md5 is the drop-in-compatible choice and maximises handset interoperability. Set both or sha256 to opt into SHA-256.

A peer configured with md5secret (a pre-hashed MD5 credential) and no plaintext secret can only authenticate with MD5; keep auth_algorithms at md5 or both for such peers.

Lock User-Agent (Per-Peer Hardening)

chan_sofia carries chan_sip's lockuseragent flag and adds an optional prefix-list mode that addresses the cases the strict anchor cannot serve.

Two operating modes, selected by whether lockuseragent_prefixes is empty:

lockuseragent lockuseragent_prefixes Behaviour
no (default) any value No gate. Default.
yes empty Strict-anchor mode (chan_sip parity). First successful REGISTER captures the User-Agent: header; subsequent REGISTERs with a different UA are silently rejected.
yes non-empty Prefix-list mode. Capture is bypassed. A REGISTER passes when its User-Agent: header starts (case-insensitive) with ANY listed prefix.

Configuration:

[peer-with-two-devices]
type=peer
host=dynamic
secret=...
lockuseragent=yes
lockuseragent_prefixes=Yealink SIP-T,Grandstream GRP,MicroSIP/

Properties:

  • Comma-separated, case-insensitive prefix match.
  • The list is the storage; tokenisation/trim/match happens at REGISTER-time, so edits via module reload chan_sofia.so (config-file) or realtime UPDATE + sip prune realtime peer <name> take effect on the very next REGISTER with no restart.
  • On rejection the existing silent 401 challenge fires (the attacker cannot distinguish UA-mismatch from bad-secret), accompanied by an AMI LockUserAgentReject event with MatchPolicy: prefix-list and the configured Prefixes: payload (strict-anchor mode emits MatchPolicy: strict-anchor and an empty Prefixes: field).
  • Setting lockuseragent=no disables the gate regardless of the prefix list; the master switch remains the same.

Use cases:

  • One subscriber with two devices on the same SIP credentials (desk phone + softphone) — list both UA prefixes.
  • Vendor-family lockdowns (any Yealink T-series, any Grandstream GRP-series).
  • Stricter than IP allowlists when SIP credentials are exposed but the UA string of the legitimate device is known and not trivially spoofable in the field.

Display surface:

  • sip show peer <name> shows Lock user-agent, Locked UA (strict mode only, once captured), and UA prefixes (prefix mode only, when set).
  • AMI SIPshowpeer emits Lockuseragent, LockedUserAgent, and LockUserAgentPrefixes fields.

Blacklist

chan_sofia includes a local SIP blacklist for repeated bad registration or authentication behavior.

Current behavior:

  • Default table size: 1024 entries (sofia_blacklist_max).
  • Default lock threshold: 5 failures.
  • Data structure: ao2_container.
  • Locked IPs are silently dropped.
  • No SIP error response is sent to locked IPs.
  • When the table is full, the entry with the oldest last_seen is evicted (LRU) to make room for the new offender. The previous "FULL → reject" behaviour let an attacker rotating IPs past the cap escape blacklisting entirely; LRU eviction keeps the defence working under sustained attack at the cost of forgetting the least-recently-seen offender. A NOTICE-level log entry is written on every eviction so the operator can size sofia_blacklist_max appropriately if the eviction rate is high.

CLI:

*CLI> sip show blacklist
*CLI> sip blacklist search <IP>
*CLI> sip blacklist delete <IP>
*CLI> sip blacklist clear

Configuration ([general] in sofia.conf):

; blacklist_ban - minutes a blocked IP stays banned after its last failure (and how
; long an accumulating failure counter is remembered). Default 1440 (24h).
; 0 = banning disabled entirely. Applied live on `sofia reload` (no restart).
blacklist_ban = 1440

The silent-drop behavior is intentional and matches the desired security posture: once an address is locked, the PBX should look dead to that source.

NAT And Addressing

Important options:

externaddr=203.0.113.1
externhost=pbx.example.net
externrefresh=10
localnet=192.168.0.0/24
localnet=10.0.0.0/8
nat=force_rport,comedia
directmedia=no

Behavior:

  • Local peers matching localnet keep local SDP addresses.
  • Peers outside local networks can receive external SDP addresses.
  • force_rport supports symmetric response routing.
  • comedia supports learning RTP source addresses from received media.
  • directmedia=no keeps RTP anchored through GABPBX.

NAT-aware in-dialog and SDP routing (chan_sip parity):

  • Inbound INVITE matching falls back to a source-IP search of peers when the From-username does not name a configured peer. This lets host=<ip> trunks be identified by their SBC source address even when the carrier uses the dialled number as the From-user. Without this fallback the default-deny alwaysauthreject path emits a 401 the trunk cannot answer.
  • The SDP c= host on incoming responses is replaced with peer->src_addr for peers with nat=force_rport or comedia when the advertised host is a private LAN address. RTP is then sent to the public NAT-mapped source the phone actually registered from instead of the unroutable LAN address many NAT'd UAs leak in their SDP.
  • The outbound INVITE handle disables sofia-sip's auto-ACK for NAT'd peers and emits a manual ACK pinned to peer->src_addr via NUTAG_PROXY. Without this override the 2xx-ACK would route to the LAN address in the 200 OK Contact: URI and never arrive, leaving the phone to retransmit 200 OK until the dialog dies. The same NUTAG_PROXY override is applied to in-dialog BYE and REFER.
  • The outbound INVITE to a registered NAT'd peer (and each forked-contact INVITE, plus any directmedia re-INVITE) is itself routed to the contact's learned public source via an operation-level NUTAG_PROXY, while the Request-URI keeps the (private) Contact. A registered PBX or phone behind NAT advertises a private LAN address in its Contact; without this override the call INVITE would be sent to that private address and black-hole, so the device never rings. The proxy is applied on the request operation itself - not only at handle creation, which does not steer the initial INVITE in this stack - and is copied onto the winning leg after a parallel fork so later re-INVITEs keep routing to the public source. The Request-URI is left as the Contact, matching chan_sip (which sends to the registered source); a non-NAT peer is unaffected.

DNS manager integration:

  • Hostname-based peers can use async DNS tracking.
  • Address changes can produce AMI DnsManagerUpdate.

Media And Codecs

chan_sofia integrates with the GABPBX RTP engine.

Supported media behavior:

  • Codec offer/answer processing.
  • Static RTP payloads for classic codecs.
  • Dynamic RTP payloads for codecs such as Opus.
  • DTMF mode handling.
  • Inband DTMF through DSP when configured.
  • RTP timeout, hold timeout and keepalive.
  • Direct media policy.
  • Hold detection and bridge hold signaling.

Common peer media options:

disallow=all
allow=opus
allow=alaw
allow=ulaw
dtmfmode=auto
directmedia=no
rtptimeout=30
rtpholdtimeout=300
rtpkeepalive=0

Opus requires codec_opus and libopus. The codec module provides the Opus translator and runtime CLI:

*CLI> opus show

DTMF handling (dtmfmode, chan_sip parity)

dtmfmode selects how DTMF digits are carried, and chan_sofia follows chan_sip semantics end to end:

dtmfmode=auto        ; rfc2833 | info | inband | auto
  • rfc2833 — out-of-band telephone-event (RFC 4733/2833). The telephone-event payload is offered in SDP only for the RFC 2833 modes (rfc2833, and the RFC 2833 branch of auto); info and inband do not advertise it. The negotiated RTP DTMF property (AST_RTP_PROPERTY_DTMF) is set to the mode actually negotiated for the call, which can differ from the configured value.
  • info — DTMF is carried in SIP INFO. Inbound INFO bodies are parsed in both the application/dtmf-relay and numeric forms, including the numeric Signal= values (10*, 11#, 12-15A-D) and a Flash signal.
  • inband — in-band tone detection through the GABPBX DSP.
  • auto — resolved at SDP commit: if the far end offered telephone-event the call uses RFC 2833, otherwise it falls back to in-band DSP detection. The DSP is reconfigured in place when the mode is resolved, without being freed under a live call, so a fax tone (CNG) already being detected is preserved.

The SIPDtmfMode() dialplan application re-syncs the DTMF mode on a live channel, and AST_OPTION_DIGIT_DETECT (used by applications that toggle digit detection at runtime) is honoured.

SRTP And Encryption

SRTP support uses the GABPBX SRTP layer and helper code under channels/sofia.

Important options:

encryption=no
srtpcipher=
srtp_per_suite_keys=no

Operational notes:

  • res_srtp.so must be loaded for SRTP.
  • Unset encryption fields should behave as disabled.
  • If encryption is enabled but SRTP setup fails, call setup should fail clearly.
  • Per-suite fresh key policy is available as a stronger chan_sofia option.

Identity Headers

Supported identity behavior includes:

callingpres=allowed_not_screened
sendrpid=no
trustrpid=no
fromuser=
fromdomain=
usereqphone=no

Concepts:

  • sendrpid=pai emits P-Asserted-Identity.
  • sendrpid=rpid emits Remote-Party-ID.
  • trustrpid=yes trusts inbound PAI/RPID from trusted peers.
  • usereqphone=yes can append ;user=phone for numeric SIP users.

Use trustrpid=yes only for trusted upstream systems or SBCs.

Forced Diversion on Forwarded Calls (forceddiversion)

When a call is forwarded out a trunk, some carriers validate the forwarded leg against the diverting party — a number they provision for that trunk — rather than the original caller. forceddiversion (per-peer) forces a trunk-owned DID as the diverting party in the outbound Diversion header (RFC 5806):

forceddiversion=<trunk-DID>   ; e.g. a DID the carrier provisions for this trunk
  • The Diversion is emitted only when the call carries a redirect indication — set REDIRECTING(from-num) or REDIRECTING(reason) in the dialplan before Dial(). A plain, non-forwarded call never gets a Diversion.
  • It overrides only the diverting number, with privacy off. It does not touch the caller identity: keep CALLERID(num) as the original caller and do not point fromuser= at the DID, so the From still carries the real calling party.
  • Empty (default) keeps the legacy data-driven Diversion behavior.
  • Readable from the dialplan with SIPPEER(<peer>,forceddiversion).

Transfers

REFER transfer support is controlled by:

allowtransfer=yes

Behavior:

  • Inbound SIP REFER can trigger transfer handling.
  • allowtransfer=no rejects REFER with policy response.
  • AMI TransferRejected can report rejected transfer attempts.
  • AMI ReferProgress reports transfer progress notifications.

Blind-transfer dialog teardown (chan_sip parity):

  • After the terminal NOTIFY 200 OK is delivered to the transferer, RFC 5589 §6.1 expects the transferer's UA to send BYE to release its own dialog. chan_sofia does not race the UA with its own nua_bye; doing so would cause sofia-sip to drop the pending terminal NOTIFY and leave the UA stuck with a connected-but-silent dialog. The transferer-side pvt is marked defer_bye, the channel is detached, and the SIP dialog stays alive waiting for the UA-initiated BYE.
  • A safety-net timer (32 s, mirroring chan_sip DEFAULT_TRANS_TIMEOUT) fires nua_bye itself if the transferer's UA never BYEs. This bounds the leak window for misbehaving UAs without disturbing well-behaved ones.
  • The failure path (no bridged channel found at REFER time) still queues an immediate hangup; only the success path defers.

Originating transfers (outbound REFER)

chan_sofia can also originate a SIP REFER to push a connected call away — the outbound half of transfer, used when the PBX (not the remote UA) decides to transfer. Two entry points, both routed through the same REFER engine:

  • SofiaTransfer(<refer-to>[,<replaces>]) — a dialplan application. <refer-to> is a configured peer name (routed to its registered contact), a sip:/sips: URI, or a bare extension (resolved against the local domain). With no <replaces> it is a blind transfer; with <replaces> ("callid;to-tag=X;from-tag=Y") it is an attended transfer — the Replaces is percent-encoded into the Refer-To so the referee replaces an existing dialog.
  • Transfer(<dest>) — the standard blind-transfer app (requires app_transfer loaded). chan_sofia now implements the channel transfer callback, so ast_transfer / Transfer() / AMI blind transfer emit a SIP REFER on the call's dialog.
; blind: refer the caller to extension 200
exten = _X.,1,SofiaTransfer(200)
; blind to an explicit URI
exten = _X.,1,SofiaTransfer(sip:agent@example.com)
; attended: connect the caller to an existing dialog via Replaces
exten = _X.,1,SofiaTransfer(sip:agent@example.com,abc123@host;to-tag=t1;from-tag=f1)

The REFER is sent on the Sofia event thread; the transfer result is fed back to the dialplan as the standard transfer outcome — the implicit refer subscription's final NOTIFY (message/sipfrag) maps a 2xx to success and anything else (or a 30 s timeout, or a hangup) to failure, so Transfer() never blocks indefinitely. Only one outbound REFER is allowed in flight per dialog at a time. Outbound REFER is initiated by your own dialplan, so it is not gated by allowtransfer (that governs inbound REFER only).

Early-media on outbound legs (chan_sip parity):

  • When the dialplan signals AST_CONTROL_PROGRESS (carrier sends 183 with SDP) and prematuremedia=no, chan_sofia forwards 183 Session Progress with an SDP body that answers the inbound INVITE offer. This is the chan_sip behaviour. The body lets RFC 3262 100rel-capable UAs (which trigger reliable provisional responses on the outbound leg) settle offer/answer at the early-media stage; without SDP the offer would stay pending and a spurious empty 200 OK to the INVITE would fire after the UA's PRACK.

Recommendation:

  • Keep transfers open for trusted internal phones.
  • Disable REFER on untrusted external trunks unless required.

Orphan 2xx After Non-2xx Final (RFC 3261 §13.2.2.4 / RFC 6026)

Some forking proxies in the field violate RFC 3261 §16.7 by relaying a non-2xx final response (typically 486 Busy Here) and then a 200 OK on the same INVITE transaction. The UAC has already torn the call down when it sees the 486; the late 200 OK would otherwise be silently dropped, leaving the answering UA retransmitting its 200 OK for 64*T1 (~32 s) with a stuck media leg and a later BYE answered 481 Call/ Transaction Does Not Exist.

chan_sofia recognises this case and emits ACK followed by BYE per RFC 3261 §13.2.2.4 (a UAC MUST ACK every 2xx and, if it no longer wants the dialog, MUST send a BYE). The flag that drives the recognition is set in the non-2xx final paths of the outbound INVITE response handler so the late 2xx is correctly classified when it arrives.

What you will see when the case fires:

NOTICE  Sofia: orphan 200 OK on terminated INVITE <call-id>: ACK + BYE per RFC 3261 13.2.2.4

The LOG_NOTICE is visible at default log level so the path is auditable without enabling debug. The same fix is applied to chan_sip for parity, where the equivalent log line is:

NOTICE  Got 2xx (200) on already-terminated INVITE <call-id>: ACK + BYE per RFC 3261 13.2.2.4

The fix addresses the UAC-side hygiene gap only. The root cause of a lost call in this scenario — the proxy emitting two final responses on one transaction — must be fixed at the proxy. This handler stops the ~32 s retransmit storm, clears the ghost media leg on the answering UA, and prevents the trailing 481 from the orphan BYE.

Call Limits

Options:

callcounter=no
call-limit=0
busylevel=0
busy_on_active=no

Behavior:

  • Active, ringing and on-hold counters are tracked.
  • call-limit enforces a hard cap.
  • busylevel can create a softer busy threshold.
  • busy_on_active rejects new calls when the account is already active.

CLI:

*CLI> sip show inuse
*CLI> sip show inuse all

AMI:

  • SIPshowpeer reports call usage fields.
  • PeerStatus reports call-limit and counter transitions.

Subscribe And MWI

Options:

allowsubscribe=yes
subscribecontext=presence
subscribemwi=no
mwiexpiry=3600
mailbox=100@default

Current behavior:

  • message-summary (MWI) SUBSCRIBE is handled.
  • presence / dialog / dialog-info (BLF) SUBSCRIBE is handled — see Presence and BLF below.
  • allowsubscribe=no rejects SUBSCRIBE by policy (global if no peer allows it, or per-peer).
  • subscribecontext selects which dialplan-hint context a watcher may subscribe to (presence/BLF).
  • Unknown event packages are accepted with 202 (no NOTIFY).

MWI delivery mode (subscribemwi)

A peer with a mailbox= reflects that voicemail box's state. subscribemwi (chan_sip parity, default no) chooses how:

  • subscribemwi=no (default) — unsolicited. The phone does not need to SUBSCRIBE: a message-summary NOTIFY is pushed to its registered contact on each successful REGISTER and whenever the mailbox count changes (RFC 3842, Subscription-State: active).
  • subscribemwi=yes — SUBSCRIBE-only. MWI is delivered only on an active inbound MWI subscription dialog; nothing is pushed unsolicited. Use this for phones that subscribe for MWI themselves.

A subscribing phone keeps receiving solicited NOTIFYs on its dialog and is not also pushed unsolicited (no double notification). The unsolicited NOTIFY is sent with nua_method rather than nua_notify, because this Sofia-SIP build rejects an out-of-dialog nua_notify locally; the same mechanism backs sip notify.

Outbound MWI watcher (mwi_subscribe)

The options above are the server side — phones subscribe to this PBX. chan_sofia can also be the watcher: a per-peer

mwi_subscribe = 1234@default     ; <local-mailbox>[@context]

makes chan_sofia send a SUBSCRIBE for Event: message-summary to that trunk and inject the upstream's Voice-Message: N/M (RFC 3842 / RFC 6665) into the local MWI cache as 1234@default — so local phones watching that mailbox light up from an upstream voicemail server. chan_sip had no equivalent.

It starts after the trunk's REGISTER 200 (for register=> trunks) or at load (static-host peers), is refreshed by the stack on the mwiexpiry interval, honours outboundproxy (initial Route), and clears the lamp when the upstream terminates the subscription. A failed initial SUBSCRIBE (server momentarily unreachable) is retried with backoff; a terminal authentication failure is not retried — fix the secret and sip reload. This is distinct from the inbound subscribemwi setting.

AMI:

  • SubscribeRejected
  • SofiaPresenceState

Presence and BLF

chan_sofia serves inbound presence/BLF subscriptions so busy-lamp-field keys and presence indicators track an extension's state — a chan_sip drop-in.

A watcher sends a SUBSCRIBE with Event: dialog (BLF) or Event: presence for a watched extension. chan_sofia registers a dialplan hint watcher for that extension (so core show hints counts the watcher), accepts the subscription with 202 Accepted, and pushes a NOTIFY whenever the watched extension's state changes:

  • Event: dialogapplication/dialog-info+xml (<state> = terminated/early/confirmed for idle/ringing/in-use).
  • Event: presenceapplication/pidf+xml or application/xpidf+xml, chosen by the inbound Accept header (xpidf for older / Polycom-style endpoints).

Requirements and behavior:

  • The watched extension must have a dialplan hint, e.g. exten => 211,hint,SIP/211. With no hint the SUBSCRIBE is answered 404.
  • The hint is looked up in subscribecontext (else the peer's context, else default).
  • allowsubscribe gates whether subscriptions are accepted (global + per-peer).
  • The driver provides a device-state callback for SIP/<peer>, so a hint over SIP/<peer> reflects offline (unregistered / unreachable), call-limit busy, and on-hold; in-call (in-use/ringing) is derived from the active channel. Registration and qualify transitions trigger a hint re-evaluation so watchers are notified when an endpoint comes online or goes offline.
  • Subscription-State is sent on every NOTIFY (active;expires=N, and terminated;reason=timeout on teardown), per RFC 6665.
  • Subscriptions are torn down on an explicit unsubscribe (Expires: 0), on a re-SUBSCRIBE that replaces a prior one, and — for endpoints that simply vanish — by a periodic expiry sweep that removes subscriptions past their granted Expires.

Each pushed NOTIFY also emits an AMI SofiaPresenceState event (watcher, watched extension, previous→new state, dialog state, body format, version) for monitoring — useful for diagnosing BLF/presence in the field.

Outbound PUBLISH (publish state to a central server)

The watcher side above lets phones subscribe to this server. The publish side is the mirror image: chan_sofia can act as an Event Publication Agent (EPA) and PUBLISH an extension's dialog state out to a central presence server (an Event State Compositor, e.g. Kamailio's presence module). The central server then distributes state to watchers across a cluster or federation — so BLF works beyond a single PBX. chan_sip had no equivalent.

For each peer with publish=yes, chan_sofia publishes the peer's hint state (application/dialog-info+xml, RFC 3903) to the configured server:

[general]
publish_server  = sip:presence.example.com:60000   ; the central ESC (host:port)
publish_domain  = example.com                       ; presentity domain (default: the server host)
publish_expires = 3600                              ; refresh interval, seconds (min 30)
publish_format  = dialog-info                       ; dialog-info (RFC 4235, default) or pidf (RFC 3863 presence)
;publish_username = pbxpublisher                    ; only if the ESC challenges the PUBLISH
;publish_password = s3cr3t

[1001]
type=peer
host=dynamic
regexten=1001
subscribecontext=blf
publish=yes                                         ; publish this peer's hint state
;publish_exten=1001                                 ; override which exten(s) to publish (default: regexten/name)

The published presentity is sip:<regexten>@<publish_domain> and the state comes from the peer's subscribecontext dialplan hint, so a published peer needs a regexten, a subscribecontext, and a real hint. The full RFC 3903 lifecycle is handled: the initial PUBLISH stores the server's SIP-ETag, refreshes and state-changes carry SIP-If-Match with an incrementing version, 423 Interval Too Brief is honoured, and the publication is removed at shutdown.

The body format is selectable with publish_format: dialog-info (call state, Event: dialog, the default) or pidf (RFC 3863 presence — open/closed/busy, Event: presence, application/pidf+xml) for servers that key on a presence package. Changing it rebuilds the publications on reload. publish_exten overrides which extension(s) a peer publishes (same ext1&ext2@ctx grammar as regexten) when the published presentity should differ from the dialplan regexten.

The feature is off until publish_server is set. Initial sends are jittered and per-presentity sends are serialized, so a large fleet (thousands of peers) does not overwhelm the central server at startup. A sip reload reconciles publications live — it adds peers that gained publish=yes, removes (with an Expires:0 unpublish) peers that dropped it, and rebuilds when the server/domain/ expires settings change — so no restart is needed for PUBLISH config changes.

The published state is taken straight from gabpbx's native dialplan hints: each publication registers as an extension-state watcher (it appears as a watcher in core show hints), so the state it publishes always matches the hint — there is no parallel state system. Any hint works (SIP/<peer>, Custom:, mixed); when the hint changes, the publication re-publishes.

sip show publications lists the live publications, showing the last published state vs the current hint state, whether an ETag is held, the refresh interval, the time to the next send, and the last result code:

Peer        Presentity              LastState  Current  ETag  Expires  NextSend  Last  Target
1001        sip:1001@example.com    Idle       InUse    yes   3600     3210s     200   sip:1001@example.com:60000

The PUBLISH configuration also appears in sip show settings (the auth password is shown as <set>, never printed).

SIP Instant Messaging (SIMPLE)

chan_sofia carries SIP MESSAGE (RFC 3428, SIP SIMPLE) both in and out of a call.

In-call messages always work: a MESSAGE received on an established call is queued to the channel as a text frame (so SendText() / ReceiveText-style flows behave as on chan_sip), and SofiaSendMessage on a live channel sends one in dialog.

Out-of-dialog messages (standalone instant messages, not tied to a call) are relayed natively between registered users by default (1.4.1, message_autorelay=yes): the sender is authenticated by digest, the target extension is resolved through the auto-created hint namespace (the same one BLF uses), and the message is fanned out to every registered contact of the target peer — request-URI built per contact, Path and learned NAT source honored. The sender gets 202 Accepted when at least one contact was reached, 480 Temporarily Unavailable when none. If the target does not resolve to a local peer, the message falls back to message_context dialplan routing; with message_autorelay=no, everything routes through message_context as before. Without either, an out-of-dialog MESSAGE is answered 405 Method Not Allowed.

[general]
message_autorelay = yes         ; native user-to-user MESSAGE relay (default yes)
message_context = sipmessages   ; fallback context for inbound out-of-dialog MESSAGE

[1001]
type = friend
; message_context = sipmessages ; optional per-peer override

The launched dialplan extension can read these channel variables:

SIPMESSAGE_FROM           From URI (sip:user@host)
SIPMESSAGE_FROM_DISPLAY   From display-name
SIPMESSAGE_TO             To URI
SIPMESSAGE_RURI           Request-URI
SIPMESSAGE_PEER           matched local peer name (empty for a guest sender)
SIPMESSAGE_CONTENT_TYPE   message Content-Type
SIPMESSAGE_BODY           message text

An unknown sender is accepted only when allowguest=yes; otherwise it gets 403. A sender is matched to a peer by the From user-part. The accepted response is 202, 404 if the extension does not exist, 405 if messaging is off.

Example dialplan that logs and auto-replies:

[sipmessages]
exten = _X.,1,NoOp(SIP message from ${SIPMESSAGE_FROM}: ${SIPMESSAGE_BODY})
 same = n,SofiaSendMessage(${SIPMESSAGE_PEER},Got your message)
 same = n,Hangup()

Sending out of dialog — from the dialplan or AMI:

; dialplan
exten = _X.,1,SofiaSendMessage(1001,Hello from the PBX)
exten = _X.,1,SofiaSendMessage(sip:1001@example.com,Hello,sip:pbx@example.com)
; AMI
Action: SofiaMessageSend
To: 1001
From: sip:pbx@example.com
Body: Hello from the PBX

SofiaSendMessage(<peer-or-uri>,<body>[,<from>]) and the SofiaMessageSend AMI action take either a configured peer name (routed to its registered contact) or a sip:/sips: URI. The send is best-effort: the request is dispatched and the final response is logged.

Fax And T.38

The driver has fax and T.38 configuration/display support for migration from chan_sip.

Options include:

faxdetect=no
t38pt_udptl=no
t38pt_usertpsource=no
t38_maxdatagram=-1

Operational status should be checked against configs/sofia.conf.sample and current source before a production fax deployment. T.38/Fax support is an area with staged behavior and continuing work.

CLI Reference

Peer and channel inspection:

sip show peers
sip show peer <name>
sip show channels
sip show channel <call-id-prefix>
sip show channelstats
sip show inuse [all]
sip show settings

sip show channel <call-id-prefix> prints one channel in detail (peer, username, From, Request-URI, state, direction, audio/video RTP, SRTP, hold, T.38 and session-timer), matched by a Call-ID prefix. sip show channelstats prints a per-channel audio RTP table (tx/rx packets, tx/rx loss, jitter).

sip show peer <name>: concise summary and detail

sip show peer <name> prints a concise, friendly summary of the peer followed by its registered contacts:

  • Summary — SIP peer name, Registration state, Endpoint (user@host:port via transport), Context / source, Accountcode, Contact slots (used / allowed), Media (codecs, DTMF mode, NAT, directmedia), Calls (active / limit, ringing, on-hold), and Qualify status.
  • Contacts — the full list of contacts under the peer, each with its State, TTL, Source and User-Agent.

sip show peer <name> detail adds the complete per-field dump — session timers, identity headers, network & media, limits & features, fax / T.38, timers & RTP, routing & dialplan, security & ACL, registration, and contacts — for when every configured field needs to be inspected. The detail keyword tab-completes.

On-demand qualify and NOTIFY (chan_sip parity):

sip qualify peer <name> [load]
sip notify <type> <peer> [<peer>...]

sip qualify peer sends an OPTIONS keep-alive to a peer immediately; the result is asynchronous (watch sip show peers or the PeerStatus AMI event). load is accepted for chan_sip compatibility (realtime peers load automatically). sip notify sends a named NOTIFY built from a sip_notify.conf section to one or more peers — for example sip notify check-sync 100 to reboot a phone. The section's lines become SIP headers (Event and Content are special; a Subscription-State: terminated is added when the type omits one); the file is parsed exactly like chan_sip (Content=> lines join with CRLF, values are semicolon-unescaped). Both reuse the AMI SIPqualifypeer / SIPnotify cores.

SIP history (per-call diagnostic trace + analysis)

A compact, chronological per-call trace of SIP signalling (Tx/Rx of requests and responses, dialog state, hangup) for after-the-fact debugging — chan_sip parity with practical additions. Off by default (zero overhead); enable with recordhistory=yes in [general] or at runtime:

sip set history on                  ; record every call
sip set history on <match>          ; record only calls whose source or destination
                                    ;   contains the substring <match> (busy systems)
sip set history off
sip show history                    ; list the calls that have history (live + retained)
sip show history <call-id>          ; dump one call's history (Call-ID prefix)
sip show history <call-id> verbose  ; dump + an analysis block
sip history clear                   ; discard the retained completed-call histories

Each call keeps a bounded ring of events (sequence, a millisecond delta from call start, and a first-line summary — never message bodies or Authorization headers). When a call ends its history is snapshotted into a small global ring so it stays inspectable after hangup (the main thing plain chan_sip history can't do).

The verbose form adds a deterministic analysis: Outcome (answered / failed / not answered), Reason (the SIP reason phrase of the final pre-answer failure, or the real hangup cause for an answered call), a Timeline (post-dial delay, ring, talk time), the negotiated codecs, and Warnings — so a failed call reports, for example, 488 not acceptable here — no common codec offered. It reasons only over recorded facts (no packet captures, no heuristics).

The capture filter (sip set history on <match>) matches a case-insensitive substring against the call source (the From user / caller-id) and destination (the To user and the Request-URI user), so on a high-traffic system you can record just the calls for one number or account — e.g. sip set history on 960800120.

Tab completion of peer names. sip show peer <TAB><TAB> lists the peers currently held in RAM; typing a prefix first narrows the list by approximation (sip show peer 2<TAB> offers only the peers whose name starts with 2). This is chan_sip parity. Completion iterates the peers container and matches the typed word case-insensitively by prefix, snapshotting each peer->name under peer->lock so it is safe against a concurrent sip reload. The same per-peer completion also drives sip set debug peer <name> and sip prune realtime peer <name>.

Debug:

sip set debug
sip set debug on
sip set debug off
sip set debug peer <name>
sip set debug ip <addr>
sip set debug sdp on
sip set debug sdp off
sip set debug fork on
sip set debug fork off
sip set debug capture <host:port>
sip set debug file <path>

sip set debug sdp {on|off} is a focused SDP/ICE negotiation tracer, off by default and independent of the general debug. It dumps the generated offer SDP, the WebRTC offer-gate decision, the incoming answer SDP, and the offerer answer-apply path — showing the signalling of a WSS/TLS session already decrypted, which a packet capture on the wire cannot. It is the primary tool for diagnosing an inbound WebRTC call where GABPBX is the SDP offerer.

Every Sofia: … debug line carries a [from-user|to-user|callid] transaction tag, so each log line is attributable to a specific SIP transaction when several calls are in flight.

Live SIP capture and low-level tracers

Beyond sip set debug sdp, chan_sofia can stream a decrypted copy of every SIP message it sends or receives — over any transport (udp/tcp/tls/ ws/wss), SDP included — to an external capture server, and adds two focused low-level tracers. All are off by default.

Decrypted SIP capture (HEP / file). Point chan_sofia at a Homer / sipcapture (HEP) collector, or at a plain file, in [general]:

[general]
sip_capture_address = 127.0.0.1:9060                 ; stream every SIP message as HEP
sip_capture_file    = /var/log/gabpbx/sip-trace.txt  ; or write the same to a file

The stream carries the messages already decrypted, so a TLS/WSS session that a wire capture cannot read is visible in full; read the HEP stream with a HEP-capable viewer, e.g. sngrep -H udp:127.0.0.1:9060. Both can also be set at runtime:

sip set debug capture <host:port>   ; start / redirect the HEP stream
sip set debug file <path>           ; write decrypted SIP to a file

Low-level tracers (default off):

rtp set debug ice        ; ICE connectivity-check / candidate / DTLS trace (WebRTC media)
sip set debug fork on     ; parallel-fork per-leg lifecycle trace
sip set debug fork off
sip set debug dtmf on     ; log each received DTMF digit
sip set debug dtmf off

rtp set debug ice traces ICE connectivity checks and DTLS setup, and sip set debug fork traces the per-leg lifecycle when an outbound INVITE is forked to several contacts.

sip set debug dtmf on logs a NOTICE line for every DTMF digit chan_sofia receives on a call — whether it arrives as RFC 2833 / telephone-event, in a SIP INFO, or inband — to the CLI and the messages log, the way chan_sip did. It is off by default and is a handy way to confirm IVR keypresses and feature-code entry without a packet capture:

NOTICE ... sofia_read: Sofia: DTMF '5' received via RFC2833 on SIP/phone100-00000012

Reload:

sip reload

sip reload is a chan_sip-parity alias that re-reads /etc/gabpbx/sofia.conf and applies changes to peers, trunks and [general] settings without restarting GABPBX. It is equivalent to module reload chan_sofia.so and to the reload hook in the module registry. Active SIP dialogs and RTP streams are not paused.

The reload is executed on the Sofia-SIP NUA event thread itself (sofia_thread) via the same sofia_dispatch_to_root_thread IPC that SIPqualifypeer and SIPnotify use. Because the single consumer of sofia_cfg, the peers container and per-peer state is now blocked inside the reload worker for the duration of the critical section, there is no concurrent reader and no need for a global config lock. The previous "run on the caller's thread" model carried real use-after-free races on sofia_cfg.localha / sofia_cfg.contact_ha and peer->chanvars under concurrent SIP traffic; those races are eliminated by the dispatch model.

Reload behaviour:

  • Serialised against itself: a module-level mutex (sofia_reload_lock) makes a second concurrent reload return another reload is in progress instead of racing with the first.

  • Bounded by a 30-second timeout: if sofia_thread is busy with a long-running handler the reload waits up to 30 seconds and then returns reload timed out after 30000 ms (sofia_thread busy). The worker still completes its work safely on the next idle window (refcounted request struct, no use-after-free on the timed-out caller).

  • Listener-config changes are refused, not silently accepted. The following fields are baked into the SIP listener at module load and cannot be changed by a reload:

    bindaddr, bindport, tlsbindaddr, tlsbindport, tlscertfile,
    wsbindaddr, wsbindport, wssbindaddr, wssbindport, timert1, timerb
    

    Editing any of these and running sip reload returns listener config changed (<key1>,<key2>,...) — systemctl restart gabpbx required and leaves the live sofia_cfg and the running NUA listener untouched. Apply listener changes by editing the file and running systemctl restart gabpbx.

  • Mark-and-sweep removes peers no longer in sofia.conf. Every peer is marked at reload start; surviving peers are unmarked as they are re-parsed; remaining marked (non-realtime) peers are unlinked from the directory and their dialplan hint extension is removed. Realtime peers are exempt because their lifecycle is per-lookup, driven by sofia_find_peer_realtime, independent of sofia.conf. A removed peer produces Sofia: peer '<name>' removed by reload sweep (no longer present in sofia.conf) at LOG_NOTICE level so the operator sees what was dropped.

module reload chan_sofia.so routes through the same code path and surfaces the same errors to LOG_WARNING (so they are visible in /var/log/gabpbx/messages) and to the module manager's return code (non-zero on failure, which AMI ModuleLoad propagates back to the caller).

Memory hygiene on reload:

  • Swept peer with host=hostname.dom: the sweep callback calls ast_dnsmgr_release and drops the peer ao2 ref that sofia_dnsmgr_setup_peer bumped for callback safety, so the destructor can actually run when ao2_unlink drops the container's ref. Without that handshake the peer + its ast_dnsmgr_entry would leak permanently on every swept dynamic-DNS peer.
  • Existing peer with permit= / deny= / contactpermit / contactdeny / directmediapermit / directmediadeny: the reset-and-repopulate window frees peer->ha, peer->contactha and peer->directmediaha before the parser re-appends rules. Without the reset, the ACL chains grew linearly with the reload count and ast_apply_ha slowed accordingly.
  • Existing peer with subscribecontext + regexten: the reset window removes the old dialplan hint extension via ast_context_remove_extension before the new values are written, so sofia_create_peer_hint writes a fresh entry without risking duplicates. core show hints shows exactly one hint per reload-stable peer regardless of reload count.
  • Global domain_list: the worker drains it at the top of sofia_apply_config so removing a domain= line from sofia.conf and reloading actually removes the entry — a stale entry would otherwise be a security regression (deleted domain still accepted as local for inbound INVITE / SUBSCRIBE).
  • register => lines in [general]: parsed via find-or-alloc so re-parsing the same register => user on every reload reuses the existing peer struct instead of stacking duplicate structs under the same name in the peers container (which has hash_fn=NULL and does not natively refuse duplicates by name).
  • Existing peer with mailbox=: the reset-and-repopulate window drains peer->mailboxes (synchronous unsubscribe of each ast_event_subscribe handle, then free the struct) before the parser re-populates it. Without this drain, sofia_peer_parse_ mailboxes would AST_LIST_INSERT_TAIL fresh mailbox structs on top of the existing ones every reload, leaking memory and event subscriptions per cycle.
  • Swept peer with active outbound REGISTER (peer->nh) or pending qualify OPTIONS (peer->qualify_nh): the sweep callback destroys both nua_handles synchronously before ao2_unlink. The sweep callback runs on sofia_thread (invoked by the reload worker which is itself dispatched there), so nua_handle_destroy's same-thread- as-create constraint is satisfied without a dispatch. The peer destructor carries defensive dispatch branches for both handles to catch orphan paths (e.g. realtime cache rebuild via sip prune realtime peer <name> while a register/qualify is in flight).
  • nh->hmagic use-after-free protection: every peer-bound nua_handle_destroy is preceded by nua_handle_bind(nh, NULL). Sofia-sip's nua_handle_bind is synchronous and thread-safe (it just updates a field in the handle struct), and detaches the backpointer from the peer immediately. Without this, the destructor path — which dispatches the destroy into sofia_thread asynchronously — would leave a real UAF window: the destructor finishes, the peer struct is freed, and only THEN does the dispatched destroy execute. Between those two moments sofia-sip could deliver an inbound event (REGISTER response, OPTIONS qualify response, MWI NOTIFY ack, ...) into sofia_event_callback for the still-alive handle, and the callback would dereference nh->hmagic, which still points at the now-freed peer. By clearing hmagic before the dispatch, any event arriving in the window reads NULL and is handled by the existing if (hmagic) gates in sofia_event_callback. The sweep path is already race- free (synchronous destroy on sofia_thread), but the bind-NULL idiom is applied there too as belt-and-braces.
  • Realtime peer cache (sippeers family): sofia_find_peer now holds ao2_lock(peers) across the cache iteration AND the realtime fallback (sofia_find_peer_realtime), so two concurrent cache-miss lookups for the same name cannot both build and link duplicate peers. The lock is held through the realtime DB query (which is brief for the typical sippeers schema); cache hits do not invoke the realtime path so concurrency on hot peers is unaffected. The alternative (optimistic build outside the lock + post-lock duplicate check) was rejected: by the time the duplicate check runs, the loser thread has already registered a dialplan hint, an ast_dnsmgr_entry with a peer ao2 ref-bump, and possibly appended a dynamic_exclude_static entry to sofia_cfg.contact_ha — none of which can be cleanly unwound.

The sipregs overlay (when extconfig.conf wires a separate sipregs family) must carry ONLY registration-state columns (ipaddr, port, regseconds, fullcontact, ...). Never permit/deny, never contactpermit/contactdeny, never directmedia- permit/directmediadeny, never setvar=, never header=. Those columns belong in sippeers exclusively. The second sofia_apply_peer_variables call (sipregs overlay) APPENDS those particular fields onto chains already populated by the sippeers parse, so putting them in sipregs leaks across cache rebuilds. Schema-design responsibility; not enforced in code.

On a 1000-reload stress test on an idle PBX the resident set size grows by ~312 bytes per reload, a constant attributable to gabpbx-core ast_config_load metadata churn and not to the chan_sofia per-reload paths. A 200-reload stress test exercising register => + a peer with regexten + subscribecontext + permit/deny ends with exactly one hint entry, exactly one register-line peer struct, and exactly two ACL rules on the test peer.

Realtime cache:

sip prune realtime peer <name>
sip prune realtime peer all
sip prune realtime peer like <pattern>
sip prune realtime all

Blacklist:

sip show blacklist
sip blacklist search <IP>
sip blacklist delete <IP>
sip blacklist clear

AMI Actions

chan_sofia registers these chan_sip compatible AMI actions:

SIPpeers
SIPshowpeer
SIPqualifypeer
SIPshowregistry
SIPnotify

Action summary:

  • SIPpeers lists configured peers and reports PeerEntry events.
  • SIPshowpeer reports detailed peer state.
  • SIPqualifypeer triggers an immediate qualify probe.
  • SIPshowregistry lists outbound registration peers.
  • SIPnotify sends NOTIFY to a peer/contact.

AMI Events

Events emitted by current chan_sofia paths include:

PeerStatus
Registry
Hold
Transfer
ReferProgress
TransferRejected
AuthFailure
SubscribeRejected
LockUserAgentReject
RegisterIntervalRejected
RegextenOnQualifyTransition
DnsManagerUpdate
HintCreated
InsecureInviteBypass
SessionTimerRefresh
T38FaxNegotiation

LockUserAgentReject carries Peer, MatchPolicy (strict-anchor or prefix-list), LockedUserAgent, Prefixes, AttemptedUserAgent, RemoteAddr, and ChannelType. The empty Prefixes field signals strict-anchor mode; a non-empty value reproduces the configured allowlist.

InsecureInviteBypass carries Peer, RemoteAddr, and ChannelType and is the auditable surface for the insecure=invite auth-bypass path used by IP-validated SBC trunks and PSTN-DDI peers. The companion per-call verbose trace (Sofia: INVITE auth bypassed per insecure=invite for peer '<name>' from <addr>) is gated behind sip set debug so production runs silent; surface it on demand with sip set debug on, scope to a peer with sip set debug peer <name>, or to a source IP with sip set debug ip <addr>. The companion LOG_NOTICE for the rare force_invite_auth=yes override case (where global auth policy overrules a per-peer insecure=invite) is left at default log level — that's a security-relevant configuration transition that operators should see without enabling debug.

Use AMI events for monitoring registration, transfer behavior, authentication failures, call limits, DNS changes and session timer refreshes.

Dialplan Applications

chan_sofia registers SIP-compatible applications:

SIPDtmfMode
SIPAddHeader
SIPRemoveHeader

These preserve the common dialplan integration points used by existing SIP deployments.

Dialplan Functions

Registered SIP-compatible functions:

SIP_HEADER()
CHECKSIPDOMAIN()
SIPPEER()
SIPCHANINFO()

These functions keep existing SIP dialplans compatible with chan_sofia.

Operational Recipes

Switch from chan_sip to chan_sofia:

1. Disable chan_sip.so in modules.conf.
2. Enable chan_sofia.so in modules.conf.
3. Move or create peer configuration in sofia.conf or realtime sippeers.
4. Restart GABPBX.
5. Verify with sip show settings and sip show peers.
6. Place test calls and verify RTP in both directions.

Debug a peer:

*CLI> sip set debug peer phone100
*CLI> sip show peer phone100
*CLI> sip show channels

Debug an IP:

*CLI> sip set debug ip 203.0.113.20

Clear a stale realtime peer:

*CLI> sip prune realtime peer phone100

Remove a test IP from blacklist:

*CLI> sip blacklist delete 127.0.0.1

Reading the log: the [caller|called] prefix

The main GABPBX log (/var/log/gabpbx/messages and the CLI console) prefixes each line with [caller|called] — the CDR caller-source and dialed destination — so you can follow who is calling whom without correlating channel names by hand. With core set verbose 5 a call reads cleanly top to bottom:

NOTICE  chan_sofia.c: Sofia REGISTER OK: user='alice' ip=203.0.113.10:5060 useragent='Example SIP Phone'
VERBOSE [alice|1000] Executing [1000@internal:1] Dial("SIP/alice-00000003", "SIP/1000") in new stack
VERBOSE [alice|1000] Called SIP/1000
VERBOSE [alice|1000] SIP/1000-00000004 answered SIP/alice-00000003
VERBOSE [alice|1000] Spawn extension (internal, 1000, 1) exited non-zero on 'SIP/alice-00000003'

The same [caller|called] prefix appears on Goto/Spawn/DTMF and application log lines, which makes grepping a busy log for one caller or one destination trivial.

Troubleshooting

Module does not load:

  • Check that chan_sip.so is not loaded.
  • Check Sofia-SIP library paths.
  • Run ldconfig.
  • Check module show like sofia.

Peer cannot register:

  • Check realm, secret, defaultuser and username.
  • Check digest algorithm support.
  • Check blacklist state.
  • Check source IP ACLs.
  • Check Contact ACLs.
  • Check User-Agent lock state.

No audio:

  • Check nat=force_rport,comedia.
  • Check externaddr and localnet.
  • Check codec negotiation with sip set debug.
  • Check RTP firewall/NAT.
  • Disable direct media while troubleshooting.
  • Verify both legs negotiated compatible codecs or that transcoding is available.

Transfers fail:

  • Check allowtransfer.
  • Check REFER signaling.
  • Check bridge state.
  • Check AMI TransferRejected and ReferProgress.

SRTP fails:

  • Check res_srtp.so.
  • Check encryption.
  • Check crypto suite/cipher.
  • Verify endpoint supports the offered crypto attributes.

Known Staged Areas And Roadmap

The current driver is already broad, but the project keeps a clear distinction between completed runtime behavior and compatibility fields that are parsed, stored or displayed for migration.

Areas to continue:

  • Presence and dialog event packages.
  • Runtime subscribecontext dispatch for general SUBSCRIBE routing.
  • Unsolicited MWI NOTIFY if required by deployments.
  • Full outbound hold re-INVITE generation.
  • Dynamic outbound Allow header from disallowed_methods.
  • Compact SIP header emission if Sofia-SIP exposes a safe native control.
  • 3xx redirect behavior for promiscredir.
  • autoframing ptime/framesize behavior.
  • Exact progressinband=no state-machine parity.
  • Peer cache eviction behavior for rtcachefriends=no and rtautoclear.
  • Per-peer dynamic T1 from qualify RTT.
  • Text RTP QoS behavior.
  • Video direct-media after audio paths remain stable.
  • Fax/T.38 completion where staged behavior remains.

Quick Peer Template

[phone100]
type=peer
host=dynamic
defaultuser=phone100
secret=change-this-secret
context=internal
transport=udp
disallow=all
allow=opus
allow=alaw
allow=ulaw
dtmfmode=auto
nat=force_rport,comedia
directmedia=no
qualify=yes
qualifyfreq=60
qualifytimeout=3
maxcontacts=3
call-limit=3
allowtransfer=yes
allowsubscribe=yes
encryption=no

Production Checklist

  • Use strong secrets.
  • Disable guest calls unless required.
  • Set correct externaddr and localnet.
  • Use nat=force_rport,comedia for NATed phones.
  • Keep directmedia=no until RTP paths are proven.
  • Configure call limits for trunks and shared accounts.
  • Disable transfers on untrusted trunks.
  • Enable TLS/SRTP only after certificates and endpoint support are verified.
  • Monitor AMI security and registration events.
  • Use sip show peer to confirm registrations, contacts, codecs, NAT and call counters.
  • Restart GABPBX after changing active SIP channel driver.

Clone this wiki locally