Skip to content

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 by max_expires). You don't reply yourself.
  • request.fork(contacts) passes the Contact objects (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_secs if 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 with nat: 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:

python3 deploy/ha-demo/sipcli.py register 127.0.0.1 5060 alice 127.0.0.1 5080
# -> 200

If you enabled the admin API (admin.listen), confirm the binding over HTTP:

curl http://127.0.0.1:9091/admin/registrations/sip:alice@example.com

See also