Registrar¶
A SIP registrar accepts REGISTER, authenticates the subscriber, and stores their
Contact so calls can be routed to them later. This recipe is a registrar that also
proxies calls to the registered contacts.
Config¶
# siphon.yaml
listen:
udp: ["0.0.0.0:5060"]
tcp: ["0.0.0.0:5060"]
domain:
local: ["example.com"]
script:
path: "/etc/siphon/registrar.py"
registrar:
backend: redis # memory | redis | postgres | python
redis:
url: "redis://127.0.0.1:6379"
default_expires: 3600
max_expires: 7200
auth:
realm: "example.com"
backend: static # static | http | database | diameter_cx
# static credentials for a quick start (use http/database in production):
# credentials:
# alice: "secret"
backend: redis makes the registrar survive a restart — see
Scaling & redundancy for exactly what that does
(durability + a boot snapshot, not live cross-node sync).
Script¶
from siphon import proxy, registrar, auth, log
DOMAIN = "example.com"
@proxy.on_request
def route(request):
# In-dialog requests follow the established route set.
if request.in_dialog:
if request.loose_route():
request.relay()
else:
request.reply(404, "Not Here")
return
# REGISTER: challenge, then store the contact. registrar.save() also sends
# the 200 OK with the granted Expires.
if request.method == "REGISTER":
if not auth.require_digest(request, realm=DOMAIN):
return # 401 challenge already sent
request.fix_nated_register() # rewrite Contact with the observed source
registrar.save(request)
return
# Anything else (INVITE, MESSAGE, …): look up the AoR and route to it.
contacts = registrar.lookup(request.ruri)
if not contacts:
request.reply(404, "Not Found")
return
request.record_route()
request.fork(contacts) # ring all bindings; first 2xx wins
A few things worth knowing:
registrar.save(request)sends the 200 OK for you (with the granted Expires, clamped bymax_expires). You don't reply yourself.request.fork(contacts)passes theContactobjects (not just.uri). That matters for two reasons. A binding this node accepted routes over the captured inbound flow — the only way to reach a WebSocket UE (RFC 5626 §5.3 connection reuse). And a binding registered through an edge proxy gets its own Route header set built from its RFC 3327 Path, so each branch goes out through the proxy chain (and the per-registration Path token) that binding was registered with. Without that, every branch would carry the first binding's route set and a Path-token edge proxy would deliver them all back to the same contact, which is the difference between real failover and retrying one dead binding N times. Pass[c.uri for c in contacts]to opt out and route purely by Request-URI.- Bindings come back in the order to try them: highest q first (RFC 3261
§20.10), then most recently registered. Most UEs send no q, so recency is
usually what orders them — the right default when a SIM has moved to a new
handset and the previous binding is still inside its granted expiry. Use
Contact.age_secsif you need your own rule. request.fix_nated_register()rewrites the Contact with the source the packet actually came from, so NAT'd clients are reachable. Pair it withnat:config.
Look up by Contact, not just by AoR¶
registrar.lookup(uri) and registrar.is_registered(uri) key on the AoR
(user@domain, taken from the REGISTER's To). That is what you want when the
terminating Request-URI is the AoR. But if an upstream registrar of record (a
PBX in front of siphon) has already resolved the user and retargets the INVITE
straight at the cached contact, then loose-routes it back through siphon,
the Request-URI carries the contact (sip:1001@203.0.113.7:17514), not the
registration domain (sip:1001@pbx.example). An AoR-keyed lookup misses even
though the binding is present (and visible in /admin/registrations).
registrar.lookup_contact(uri) / registrar.is_registered_contact(uri) match on
the stored Contact instead (user + host + port; URI parameters and default
ports ignored), so the guard works on that edge:
@b2bua.on_invite
def route(call):
if not registrar.lookup_contact(str(call.ruri)):
call.reject(404, "No extension Found")
return
call.dial(str(call.ruri)) # the R-URI is already the reachable contact
AS-side capability records are excluded, same as lookup(). The scan is over
all bindings, so use it as a per-call guard on edge / PBX-front deployments, not
as a per-packet framework path.
React to registration changes¶
@registrar.on_change
def on_reg_change(aor, event_type, contacts):
# event_type: "registered" | "refreshed" | "deregistered" | "expired"
log.info(f"{aor} {event_type}: {len(contacts)} contact(s)")
Use this to push presence, notify an external system, or emit charging events.
Test it¶
Register and look up with any SIP client, or with the in-repo
sipcli.py:
If you enabled the admin API (admin.listen), confirm the binding over HTTP:
See also¶
- Real example:
examples/registrar_proxy.py(adds external-API contact notifications) andscripts/proxy_default.py. - Stateful proxy — routing and forking in more depth.
- Hardening & security — auth backends, rate limiting, bans.