SBC (B2BUA)¶
A Session Border Controller sits between two networks as a back-to-back user agent:
two fully independent dialogs, topology hiding, media anchoring, and control over
exactly which headers cross the trust boundary. In SIPhon the B2BUA is first-class —
no entity IDs, no bridge calls, just @b2bua.* handlers and a call object.
The call lifecycle¶
from siphon import b2bua, gateway, log
@b2bua.on_invite
def on_invite(call):
call.media.anchor(engine="rtpengine") # hide media topology
call.remove_headers_matching("^X-") # strip internal headers
gw = gateway.select("carriers") # pick a trunk
call.dial(gw.uri, timeout=30) # dial the B-leg
@b2bua.on_early_media
def on_early_media(call, reply):
log.info(f"[{call.id}] early media {reply.status_code}")
@b2bua.on_answer
def on_answer(call, reply):
log.info(f"[{call.id}] answered")
@b2bua.on_failure
def on_failure(call, code, reason):
call.reject(code, reason) # propagate to the A-leg
@b2bua.on_bye
def on_bye(call, initiator):
call.media.release()
log.info(f"[{call.id}] ended by {initiator.side}")
@b2bua.on_cancel
def on_cancel(call): # caller abandoned before answer
log.info(f"[{call.id}] cancelled")
Each B-leg gets its own Call-ID and From-tag by default, so the two dialogs are fully
decoupled — topology hiding out of the box. Other call methods: call.fork(targets)
(ring several B-legs), call.reject(code, reason), call.terminate(),
call.set_header / remove_header, and B-leg URI rewrites — userpart
(call.set_ruri_user / set_from_user / set_to_user) and host
(call.set_from_host / set_to_host).
Keeping a tenant domain in the From¶
Topology hiding rewrites the B-leg From host to SIPhon's advertised address and the To host to the dial target. That's the right default, but a multitenant downstream that selects the tenant from the From domain needs the original domain to survive — a domainless From lands the call in its unauthenticated/default routing context. Pin it:
@b2bua.on_invite
def on_invite(call):
call.set_from_host("tenant.example.com") # keep the tenant domain in From
call.dial(str(call.ruri), next_hop="sip:pbx.example.com:5060")
set_from_host opts that leg out of the From host-rewrite; set_to_host pins the To
host the same way (a declarative replacement for hand-building
set_header("To", "<sip:user@host>")). Only the host changes — scheme, user, port,
params, and tags are preserved — and both apply to call.dial() and call.fork().
Header policies — control what crosses the boundary¶
The whole point of an SBC is deciding which headers leak between two networks. SIPhon handles this with named, versioned header policies instead of hand-rolled strip/copy logic on every call.
call.dial(
"sip:5112@ims.example.com",
header_policy="ims-trust-domain-boundary@2026",
copy=["X-Operator-Tag"], # also let this one through
strip=["History-Info"], # also drop this one
translate=[("Diversion", "rfc7044")], # rewrite Diversion → History-Info
)
Built-in presets¶
Pin the version (@2026) so a SIPhon upgrade can't silently change which headers
cross the boundary.
| Preset | Use at | Behaviour |
|---|---|---|
transparent-b2bua@2026 |
general SBC (default) | today's strip set; behaviour-equivalent to pre-policy SIPhon |
ims-intra-trust-domain@2026 |
S-CSCF ↔ AS | passes P-* headers + end-to-end PRACK / preconditions |
ims-trust-domain-boundary@2026 |
P-CSCF / IBCF / BGCF edge | strict trust-boundary hygiene |
sip-trunk-edge@2026 |
plain SIP trunk | strips P-* / X-* |
Set a default for all calls in siphon.yaml and override per call as needed:
Per-call deltas¶
On top of the preset, copy / strip / translate apply per call — for emergency
calls, aggregator quirks, etc. that the YAML preset can't express. translate ops in
v1 are rfc7044 and diversion-to-history-info.
Precedence (highest wins)¶
- Script
call.set_header()/call.remove_header()— always wins copy=/strip=/translate=per-call deltas- The named preset's overrides
- The named preset's default copy/strip set
- Framework-auto headers —
Via,Call-ID,CSeq,Max-Forwards,Content-Length,From,To,Contact,Record-Route,Route,Proxy-Authorization,Proxy-Authenticate. Never policy-able.
One intentional change from pre-policy SIPhon
Every preset strips Proxy-Authenticate on B→A responses. RFC 3261 §22.3 makes
it hop-by-hop, so passing it through would point the A-leg's
Proxy-Authorization at the wrong realm. Opt back in with
copy=["Proxy-Authenticate"] if you really want the old transparent behaviour.
Add media anchoring¶
call.media.anchor(engine="rtpengine") hides the media path too. For SRTP↔RTP
interworking, WebRTC, transcoding, hold, or announcements, drive RTPEngine directly —
see Media & RTP profiles:
from siphon import b2bua, rtpengine
@b2bua.on_invite
async def on_invite(call):
await rtpengine.offer(call, profile="srtp_to_rtp") # SRTP UE ↔ RTP trunk
call.dial(str(call.ruri))
@b2bua.on_answer
async def on_answer(call, reply):
await rtpengine.answer(reply, profile="srtp_to_rtp", call=call)
@b2bua.on_bye
async def on_bye(call, initiator):
await rtpengine.delete(call)
Hybrid: proxy + SBC in one script¶
INVITEs go to @b2bua.on_invite; REGISTER/OPTIONS/etc. go to @proxy.on_request —
in the same script, same process. So you can B2BUA calls (topology hiding + media)
while lightly proxying registrations:
@proxy.on_request("REGISTER")
def on_register(request):
if auth.require_digest(request, realm=DOMAIN):
registrar.save(request)
@b2bua.on_invite
def on_invite(call):
call.media.anchor(engine="rtpengine")
call.dial(gateway.select("carriers").uri)
Authenticate the caller before dialling anything¶
A B2BUA facing untrusted callers should challenge them, not just route them.
Registering any @b2bua.* handler takes INVITE off the proxy path, so a
@proxy.on_request("INVITE") challenge would never run — pass the call to the
digest helpers instead:
from siphon import auth, b2bua, gateway, log
@b2bua.on_invite
def on_invite(call):
if not auth.require_proxy_digest(call, realm=DOMAIN):
return # 407 armed; siphon answers the A-leg
log.info(f"authenticated {call.auth_user}")
call.media.anchor(engine="rtpengine")
call.dial(gateway.select("carriers").uri)
An unauthenticated caller gets siphon's own 407 and no B-leg is dialled —
the challenge is armed as the call's deferred reject, so the call actor is
dropped before any trunk sees traffic. That ordering is the point: a toll-fraud
probe never reaches your carrier. The caller re-INVITEs with credentials,
require_proxy_digest returns True, and the hop-by-hop Proxy-Authorization
is stripped before the B-leg INVITE is built (RFC 3261 §22.3).
Pair it with anti-spoofing on the caller ID, as on the proxy path:
from_user = call.from_uri.user if call.from_uri else None
if call.auth_user != from_user:
call.reject(403, "Forbidden")
return
Two different auth directions
Challenging on the call is siphon authenticating its caller.
call.dial(..., auth_passthrough=True) is the opposite: a downstream PBX
or trunk challenges, and siphon relays that challenge to the caller to answer
end-to-end. A third option, call.set_credentials(user, password), has
siphon answer the downstream challenge itself.
Upstream trunk requiring mutual TLS¶
Some upstream SIP trunks require siphon to present a client certificate when
it dials out over TLS — mutual TLS (for example Microsoft Teams Direct Routing).
Without one, the peer aborts the handshake with CertificateUnknown. Attach the
client identity in the top-level tls: block:
tls:
certificate: "/etc/siphon/tls/example.com.crt" # inbound server cert
private_key: "/etc/siphon/tls/example.com.key"
# Presented on OUTBOUND TLS when the upstream trunk requests a client cert:
client_certificate: "/etc/siphon/tls/client.crt"
client_private_key: "/etc/siphon/tls/client.key"
Then dial the trunk over TLS as usual — the B2BUA presents the configured client certificate automatically:
@b2bua.on_invite
def on_invite(call):
call.media.anchor(engine="rtpengine")
call.dial("sip:+15551234567@sbc.example.com;transport=tls")
Both client_certificate and client_private_key must be set together (or
neither); a one-sided setting or an unreadable file fails startup. The outbound
handshake also sends the target hostname (sbc.example.com) as SNI, so a
hostname-vhost trunk front-end can route it. Server-certificate verification is
unchanged (permissive) — this only adds the client certificate siphon presents.
Terminate strict peers (Teams) as a B2BUA, not a plain proxy
Microsoft Teams Direct Routing rejects any Contact or Record-Route whose
host is an IP (403 Forbidden) — it must be the SBC FQDN that matches the TLS
certificate. The B2BUA rewrites Contact to siphon's advertised address, so
set advertised_address (or the per-listener advertise) to that FQDN and it
satisfies the requirement. A pure proxy (@proxy.on_request relaying
INVITEs) forwards the upstream UA's Contact verbatim — typically a PBX's
private IP — which Teams refuses; RFC 3261 §16 forbids a proxy from rewriting
another UA's Contact, so this is by design, not a bug. Front Teams-facing
signalling with @b2bua.on_invite. siphon's OPTIONS keepalive and its 200 OK
to Teams' OPTIONS already carry the advertised FQDN in Contact plus an
Allow advertising the supported methods (including REFER/NOTIFY).
See also¶
- Real examples:
scripts/b2bua_default.py,examples/b2bua_gateway.py,examples/b2bua_rtpengine.py. - Media & RTP profiles — the RTPEngine profiles in depth.
- Hardening & security — STIR/SHAKEN at the edge, TLS, IPsec.