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 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:
Need a posture none of them quite match? Define your own — see Custom policies below.
Custom policies¶
When your posture is "that preset, except for these headers", define your own in
siphon.yaml rather than repeating copy=[…] on every dial() call site. Custom
policies live in the same namespace as the built-ins, so scripts and
default_header_policy select them the same way.
header_policies:
"trunk-edge-plus@1":
extends: "sip-trunk-edge@2026"
request:
copy: ["X-Account-Ref"] # crosses despite the base's X-* strip
strip: ["Alert-Info"]
rewrite:
P-Asserted-Identity: host-to-advertised
translate:
Diversion: diversion-to-history-info
response:
strip: ["Server"]
b2bua:
default_header_policy: "trunk-edge-plus@1"
The base supplies each direction's default and its rules; the rules you write are
matched first, so they win. A direction you leave out is inherited verbatim, and an
extends: with no rules at all is just a stable local alias for a built-in.
Drop extends: to declare a policy in full, in which case each direction needs its
own default: (copy or strip):
header_policies:
"locked-down@1":
request:
default: strip
copy: ["Allow", "Supported", "Content-Type"]
response:
default: copy
strip: ["P-*", "Server", "User-Agent"]
Header names are exact and case-insensitive; a trailing * is a prefix match
("X-*"), not a glob. Within one direction an exact name beats a prefix and a longer
prefix beats a shorter one, so strip: ["X-*"] alongside copy: ["X-Account-Ref"]
does what it looks like. rewrite: ops are host-to-advertised,
replace-with-server-header and replace-with-user-agent-header; translate: ops
are diversion-to-history-info (alias rfc7044).
The map key is the name scripts pin and must carry an @version — same rule as the
built-ins — and it may not take a built-in's name. Policies are resolved and
validated at startup, so an unknown op, a rule aimed at a framework-managed header,
or a default_header_policy naming a policy nothing defines stops the node at boot
instead of surfacing mid-call.
Per-call deltas¶
On top of the preset, copy / strip / translate apply per call — for emergency
calls, aggregator quirks, etc. that a policy can't express. translate ops in v1 are
rfc7044 and diversion-to-history-info. Per-call deltas match exact header names
only; prefix patterns are a config-side feature.
Precedence (highest wins)¶
- Script
call.set_header()/call.remove_header()/call.remove_headers_matching()on the A-leg INVITE. The header goes out on the B-leg as the script left it: no preset or per-call delta strips, rewrites or translates it. copy=/strip=/translate=per-call deltas- The named preset's overrides
- The named preset's default copy/strip set
Outside the list, the framework-managed headers are SIPhon's whatever the script
or the policy says: Via, Call-ID, CSeq, Max-Forwards, Content-Length,
From, To, Contact, Record-Route, Route. Proxy-Authorization and
Proxy-Authenticate are not among them: every preset strips them (RFC 3261 §22.3),
but copy= or a script can carry them.
A few steps run after the policy and still apply on top of a script's value: the
number policy and a carrier's caller ID and CLIR on the identity headers, LCR route
headers, SIPhon's Session-Expires / Min-SE when it runs the session timer, and
replaces merged into Supported.
Responses work the same way. What a script does to a B-leg response in
@b2bua.on_answer or @b2bua.on_early_media with reply.set_header() /
reply.remove_header() / reply.remove_headers_matching() reaches the caller as the
script left it, over the response policy and SIPhon's own Supported / Allow.
SIPhon still sets the caller's Contact, drops the callee's Record-Route, decides a
provisional's reliability itself (RSeq and the 100rel tag in Require, see
below), adds its Supported: timer / Session-Expires when absent while it runs the
session timer, merges replaces into Supported, and rewrites the SDP origin.
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.
Reliable provisionals toward the caller¶
SIPhon PRACKs the callee's reliable provisionals itself on the B-leg, and it is the caller's UAS on the A-leg, so RFC 3262 on each leg is SIPhon's, under every preset:
- A provisional reaches the caller reliably when the caller sent
Require: 100rel, or sentSupported: 100reland either the callee sent that provisional reliably or it carries SDP (an 18x without SDP stays as the callee sent it). It carriesRequire: 100reland SIPhon's ownRSeq, one more per provisional on the caller's dialog. The callee'sRSeqnever reaches the caller. - It is retransmitted (T1 doubling) until the caller's PRACK, and the next reliable provisional waits for that PRACK.
- SIPhon answers the PRACK:
200when itsRAcknames a provisional SIPhon sent on that dialog (again for a retransmission, and after the final response),481otherwise. A PRACK is never relayed and its body is not bridged; an early offer goes in anUPDATE, which is. - A 2xx waits for the PRACK of a reliable provisional that carried SDP, and follows
that PRACK's
200. Only then is it retransmitted until the caller's ACK, with the 64×T1 no-ACK teardown and the BYE held for the ACK that every 2xx gets. A call that ends while the 2xx waits (a BYE from the callee, a CANCEL from the caller, a timer) gives the caller487 Request Terminated, never a BYE, and the callee a BYE. A final response stops the retransmissions. - A caller that leaves a reliable provisional unacknowledged for 32 s (64×T1) is
refused
500. The callee is CANCELled, or BYEd if its 2xx was the one waiting.@b2bua.on_failuredoes not run: routing elsewhere cannot make the caller PRACK. A call already being torn down some other way is left to that teardown.
call.progress() and call.answer() follow the same rules. RSeq and the 100rel
tag are SIPhon's alone: a reply.set_header() of either in @b2bua.on_early_media
does not reach the caller.
Supported and Allow on both legs¶
SIPhon is the UAC of the B-leg and the UAS of the A-leg, so the B-leg INVITE's
Supported and Allow, and those of every response it relays back to the caller,
say what SIPhon supports (RFC 3261 §20.37, §20.5). They are not a copy of the other
party's lists, whatever the preset does with the rest of the headers.
A relayed response follows the same rule as the INVITE, mirrored: the callee's
100rel/timer and the end-to-end tags below survive, plus replaces, and Allow
is SIPhon's. transparent-b2bua@2026 strips the callee's Supported on responses
before that, so its responses carry just Supported: replaces.
On the B-leg INVITE:
Allowis SIPhon's method set, the same one its responses advertise.Supportedisreplaces, plustimerwhen SIPhon runs the session timer, plus the caller's100relandtimerif it offered them. On top of that go the tags of extensions the two endpoints negotiate with each other through SIPhon, if the caller offered them and the policy copies what they negotiate with on requests and on responses:
| Tag | Needs the policy to copy |
|---|---|
precondition (RFC 3312) |
Supported and Require, both ways |
histinfo (RFC 7044) |
History-Info, both ways |
resource-priority (RFC 4412) |
Resource-Priority on the request, Accept-Resource-Priority on the response |
Half a negotiation is no negotiation: a callee's Require: precondition that cannot
reach the caller leaves preconditions stuck. A caller offering
Supported: 100rel, timer, precondition, histinfo, resource-priority, outbound, path:
| Preset | B-leg INVITE carries |
|---|---|
transparent-b2bua@2026 |
Supported: 100rel,timer,histinfo,resource-priority,replaces |
ims-intra-trust-domain@2026 |
Supported: 100rel,timer,precondition,histinfo,resource-priority,replaces |
ims-trust-domain-boundary@2026 |
Supported: 100rel,timer,precondition,replaces |
sip-trunk-edge@2026 |
Supported: 100rel,timer,precondition,resource-priority,replaces |
Any other tag the caller lists is dropped. Per-call deltas feed the same test:
copy=["Supported", "Require"] lets precondition cross under the default preset,
strip=["History-Info"] keeps histinfo off anywhere, and
copy=["Resource-Priority", "Accept-Resource-Priority"] opens resource-priority at
the trust boundary. copy=["Supported"] on its own does not relay the caller's list,
since every preset already copies Supported on requests. When a far end really needs
the caller's list, set it from the script, which is precedence 1 and goes out as
written even past a strip=:
replaces, and timer when SIPhon runs the session timer, are still merged into a
Supported the script set. call.remove_header("Allow") sends no Allow at all.
A caller's Require¶
The same rule decides whether SIPhon can take a call whose INVITE requires an
extension. SIPhon is the caller's UAS, so a Require the call cannot honour is
refused 420 Bad Extension with the tags listed in Unsupported (RFC 3261 §8.2.2.3),
before any B-leg goes out. A tag is honoured when SIPhon implements it (100rel,
timer, replaces, and sec-agree once the caller's IPsec agreement is verified),
or when the policy passes it end to end per the table above and copies Require
to the callee. Require: precondition under
transparent-b2bua@2026 is refused; under ims-intra-trust-domain@2026 it is dialled.
The check runs when call.dial() / fork() / route() is carried out, since that
is when the policy is known. The 420 then goes through @b2bua.on_failure like any
call that could not be connected, so the handler can route again under a relaying
policy:
@b2bua.on_failure
def on_failure(call, code, reason):
if code == 420:
call.dial(str(call.ruri), header_policy="ims-intra-trust-domain@2026")
The paths that answer the caller or dial for it without a script's routing action
refuse the same 420 and end the call without @b2bua.on_failure. When SIPhon answers
the call itself (call.answer(), the control plane's answer,
call.handover(answer=True)), it is the only UAS the caller has, so any tag it does
not implement is refused whatever the policy, before an answer-first handover anchors
media. The control plane's dial and route are checked under the call's policy
when they dial; a refused dial reports DialFailed with code 420. An INVITE with
Replaces is refused on its own transaction before the call it names is touched.
call.progress() is not checked, since a provisional answers nothing.
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")
async def on_register(request):
if await 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
async def on_invite(call):
if not await 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.