Transports & networking¶
SIPhon's transport layer is Rust-only — Python scripts never touch a socket.
You declare what to listen on in siphon.yaml; the framework terminates the
transport, frames SIP messages, and hands your handlers a parsed request. This
page covers the parts of networking that bite in the real world: WebSocket/WebRTC
access, running behind NAT or a load balancer (advertised_address), bridging a
call from one transport to another, and IPv4 ↔ IPv6.
| Transport | Spec | Notes |
|---|---|---|
| UDP | RFC 3261 §18 | The default for PSTN gateways and legacy UEs. |
| TCP | RFC 3261 §18 | Persistent connections, pooled outbound. |
| TLS | RFC 5246 (TLS 1.3 default) | Cert/key in the top-level tls: block; supports mTLS. |
| WS | RFC 7118 | SIP over WebSocket — browser / WebRTC UEs. |
| WSS | RFC 7118 + TLS | Secure WebSocket — shares the tls: cert. |
| SCTP | RFC 4168 | Off by default — opt-in sctp Cargo feature (Linux, libsctp). |
Configuring listeners¶
Every transport under listen: takes a list of bind addresses, so you can
serve several addresses and address families on the same transport:
listen:
dscp: CS3 # DiffServ marking for all SIP packets (RFC 4594);
# name (CS3, EF, AF41…) or 0–63; "BE"/0 disables.
udp:
- "0.0.0.0:5060" # all IPv4
- "[::]:5060" # all IPv6
tcp:
- "0.0.0.0:5060"
tls:
- "0.0.0.0:5061" # requires the tls: block below
ws:
- "0.0.0.0:8080" # SIP over WebSocket
wss:
- "0.0.0.0:8443" # SIP over Secure WebSocket (shares tls: cert)
tls:
certificate: "/etc/siphon/tls/example.com.crt"
private_key: "/etc/siphon/tls/example.com.key"
method: "TLSv1_3" # TLSv1_2 | TLSv1_3
# mTLS for trunks with mutual auth (applies to listen.tls AND listen.wss):
verify_client: false
client_ca: "/etc/siphon/tls/client-ca.pem"
A listener can be a plain string ("10.0.0.1:5060") or the extended form
with a per-socket advertised host and DSCP override (like OpenSIPS
socket … as …):
listen:
tls:
- address: "10.0.0.1:5061"
advertise: "sip.example.com" # what peers should see in Via/Record-Route
dscp: EF # overrides the global listen.dscp
SCTP is opt-in
SIP-over-SCTP links the libsctp system library and is Linux-only, so it's
behind the sctp Cargo feature and absent from the default build. See the
README for the --features sctp install.
For TLS/mTLS hardening (cipher policy, client-cert trunks, who terminates), see the Hardening & security recipe.
WebSocket & WebRTC (ws / wss)¶
listen.ws / listen.wss implement SIP over WebSocket (RFC 7118): siphon
performs the HTTP Upgrade handshake, confirms the Sec-WebSocket-Protocol: sip
subprotocol, and then exchanges SIP messages as WebSocket text frames (binary
frames are also accepted — some WebRTC stacks send them). WSS reuses the
top-level tls: certificate — it's a separate listener only because the
handshake differs (HTTP WebSocket upgrade vs. a raw TLS record stream).
Browsers are a one-way street¶
A browser can't accept an inbound TCP connection, so per RFC 5626 (Outbound) the connection the UE opened is the only path back to it. SIPhon registers every accepted WS/WSS connection in a flow registry keyed by the UE's source address, and:
- Responses travel back down the same connection automatically.
- Terminating requests (an INVITE to a registered browser) reuse that stored connection — there is no dial-back. If the UE's connection is gone, it's unreachable until it re-REGISTERs.
To make that terminating path explicit and robust, capture the connection as a flow at REGISTER and route back over it — see Flow tokens & connection reuse.
Don't lose the flow
Because the inbound connection is the only return path, a browser-facing
deployment should run RFC 5626 keepalives (see below)
and enable registrar.liveness so a dropped socket clears the binding instead
of black-holing terminating calls.
Signaling vs. media¶
The SIP/WebSocket layer above is only signaling. WebRTC media —
DTLS-SRTP, ICE, AVPF — is terminated by RTPEngine, not siphon, using the
ws_to_rtp / wss_to_rtp profiles (browser DTLS-SRTP+ICE on one side, plain RTP
toward your core on the other). That pairing is what makes a working WebRTC
gateway; the SIP side stays pure RFC 7118. See
Media & RTP profiles.
# WebRTC access edge: browser on WSS, core on UDP/TCP. Signaling is ordinary
# proxy routing; the media transform is one profile argument.
@proxy.on_request("INVITE")
async def route(request):
if request.body:
await rtpengine.offer(request, profile="wss_to_rtp") # DTLS-SRTP ↔ RTP
request.record_route()
request.relay()
Flow tokens & connection reuse¶
WebSocket is the sharp case, but the problem is general: connection-oriented clients (WS, WSS, and also plain TCP/TLS behind NAT) can only be reached over the connection they opened. The R-URI in their Contact is frequently a private, NATed, or IPsec-protected address that nothing on the public network can dial. This is exactly what RFC 5626 (SIP Outbound) addresses, and SIPhon gives you two layers for it.
Layer 1 — automatic connection reuse (zero config)¶
SIPhon registers every accepted stream connection in a process-global registry keyed by the client's source address (with an IP-only fallback for NAT). Responses always go back over the originating connection, and a terminating request whose target address matches a live connection reuses it. For a single-node proxy where the same box holds the registration and routes the call, browser delivery often just works with no extra code.
Layer 2 — flow tokens (explicit, robust, multi-node)¶
When address-matching isn't enough — multiple flows from one NAT, an IPsec port
pair that must be preserved, or a multi-instance / P-CSCF deployment where the
terminating request enters a different process — capture the connection as an
opaque flow at REGISTER and carry a token that names it. This is the
SIPhon realization of RFC 5626 flow tokens (it also ships the standardized
<addr>~<transport> Route-token codec as a primitive in
src/transport/flow.rs).
The pattern is three steps:
from siphon import proxy, registrar
# (1) REGISTER — stash the live flow under an opaque token, and advertise that
# token in a Path (RFC 3327) so terminating requests come back through us.
@proxy.on_request("REGISTER")
def register(request):
token = request.call_id # any stable opaque string
request.add_path(f"sip:{token}@edge.example.com;lr")
registrar.save(request, flow_token=token) # binding remembers the flow
# IMS P-CSCF convenience (uses ipsec.path_host): request.add_pcscf_path(token)
# (2) TERMINATING INVITE — our Path comes back as the topmost Route. Consume it,
# recover the token, resolve the binding, and relay back over the captured flow.
@proxy.on_request("INVITE")
def terminate(request):
if request.loose_route():
token = request.consumed_route_user # the token off the consumed Route
binding = registrar.lookup_by_token(token)
if binding and binding.is_local and binding.flow.is_alive:
request.relay(flow=binding.flow) # bypass DNS; back down the wire
return
request.reply(404, "Not Found")
request.relay(flow=...) bypasses DNS resolution of the Contact URI entirely
and writes straight to the captured connection; the egress Via host/port is taken
from flow.local_addr, so the exact listener (and, for IMS, the IPsec protected
port pair) is preserved.
Forking works the same way: fork the Contact objects from
registrar.lookup() (not bare URI strings) and SIPhon automatically attaches each
locally-accepted binding's flow to its branch — the only way a parallel fork can
ring a WebSocket UE.
The Flow object¶
Contact.flow (and request.flow for the inbound side) is an opaque view —
treat it as a handle to pass to relay(flow=...), and read these to defend
against a dead path:
| Field | Meaning |
|---|---|
flow.transport |
"udp" / "tcp" / "tls" / "ws" / "wss". |
flow.remote_addr |
The UE's source address (where the REGISTER came from). |
flow.local_addr |
The listener address the REGISTER landed on (the egress socket). |
flow.is_alive |
UDP: always True. Stream: True only while the exact accepted connection is still open on this process; a reconnected or closed UE reports False. |
Multi-node: gate on is_local first
A flow is only usable on the process that accepted the REGISTER. With a shared
Redis registrar, a lookup on another node returns the binding but its flow
points at a connection that node doesn't hold. Check Contact.is_local before
trusting flow.is_alive / relay(flow=...); otherwise route the call to the
owning instance (subscriber affinity — see
Deployment). This is also why
enabling registrar.liveness matters: a stale stream binding that no live
connection backs should be cleared, not dialed.
Behind NAT or a load balancer¶
When the address siphon binds isn't the address peers should reach it on
— a cloud instance with a private NIC and an elastic public IP, or a node behind
a SIP-aware load balancer — set the advertised address. It's the host siphon
writes into the headers it generates: Via sent-by, Record-Route,
Contact (B2BUA), and the SDP o=/connection line it rewrites for topology
hiding. (Actual media addresses are RTPEngine's job, not siphon's.)
Two levels, per-listener wins:
# Global: one public identity for every transport (the common cloud case).
advertised_address: "203.0.113.10" # or an IPv6 literal: "2001:db8::1"
# Per-listener: override per socket — e.g. behind a load balancer that presents
# a different public address per transport. Falls back to advertised_address
# for any transport without its own advertise.
listen:
udp:
- address: "10.0.0.1:5060"
advertise: "sip-udp.example.com"
tls:
- address: "10.0.0.1:5061"
advertise: "sip-tls.example.com"
Binding 0.0.0.0 / [::] requires an advertised address
With a wildcard bind and no advertised address, siphon can't know which
local IP to put in Via/Contact, so it falls back to 127.0.0.1 and logs a
warning — remote peers won't be able to route back to it. Always pair a
wildcard bind with advertised_address (or a per-listener advertise).
A few properties worth knowing:
- Outbound-only. The advertised address is purely for headers siphon emits.
Inbound routing — “is this R-URI one of mine?”, loop detection — uses the actual
bind addresses and the
domain.locallist, never the advertised address. Put your real served domains and local IPs indomain.local. - Per-transport. A bridged call (in on TLS, out on UDP) gets the outbound transport's advertised address in its Via, and a Record-Route per side (see Inter-transport routing).
- Load balancers: put the LB/health-check sources in
security.trusted_cidrsso probes aren't rate-limited, and prefer a topology that preserves the client source IP:port (externalTrafficPolicy: Local/hostNetworkon Kubernetes — see Deployment).
NAT traversal for clients¶
Subscribers behind home NAT advertise unroutable private addresses in their Via and Contact. SIPhon handles the return path and gives scripts the tools to fix the bindings.
Responses route symmetrically, always. Every response is sent back to the
source IP:port the request actually arrived from — not the Via sent-by host (RFC
6314 / the rport model, applied unconditionally). This is the safe default for
all UACs, so there's no toggle to turn it on.
Contact fixups. A private Contact still has to be rewritten to the observed source so in-dialog and terminating requests are routable:
nat:
fix_contact: true # auto-rewrite Contact on responses to the source addr
keepalive: # OPTIONS pings to registered contacts (NAT pinholes)
enabled: true
interval_secs: 30
failure_threshold: 10 # deregister a contact after N failed pings
crlf_keepalive: # RFC 5626 §4.4.1 CRLF ping on TCP/TLS/WS/WSS
enabled: true
interval_secs: 30
failure_threshold: 3 # close the connection after N missed pongs
fix_contact: true auto-rewrites the Contact on responses. For REGISTER,
do it in the script before saving the binding — siphon stores the observed source
alongside the contact so terminating calls reach the NATed UE:
@proxy.on_request("REGISTER")
def register(request):
request.fix_nated_register() # write received=/rport= on the top Via
request.fix_nated_contact() # rewrite Contact host:port to the source addr
# request.add_contact_alias() # ...or the OpenSIPS-style ;alias form
registrar.save(request) # binding remembers the observed source
Keepalives feed registration liveness. OPTIONS keepalives deregister a contact
after failure_threshold failures; CRLF keepalives close a dead TCP/TLS/WS
connection, which — with registrar.liveness.enabled — clears its binding (RFC
5626 §4.2.2 flow failure) instead of waiting hours for Expires. This is what
keeps terminating delivery honest for connection-oriented clients (browsers
especially).
force a specific egress Via
For multi-homed or IPsec-protected routes, request.force_send_via(transport, "host:port")
overrides both the outbound transport and the Via sent-by for that relay.
Inter-transport routing¶
The inbound and outbound transports are independent. A request can arrive on WSS and leave on UDP; siphon remembers the inbound transport + connection on the session and routes the response (and later in-dialog requests) back the way they came. Common shapes: a WebRTC browser (WSS) calling a SIP trunk (UDP), a TLS access edge fronting a UDP core, a TCP UE reaching a UDP gateway.
How the outbound transport is chosen, in order:
- An explicit
;transport=parameter on the relay target or the R-URI. - DNS, RFC 3263 — NAPTR then SRV (
_sips._tcp,_sip._tcp,_sip._udp), then A/AAAA. siphon resolves these natively for its own routing. - Otherwise the inbound transport (same transport in and out).
The return path is remembered, not re-derived. The session stores the inbound
transport, source address, and (for stream transports) the exact connection, and
responses go back over it — reusing the live TCP/TLS/WS connection where one
exists. When the two legs use different transports, siphon inserts two
Record-Route headers (one per side, each carrying its own ;transport=), so
in-dialog requests come back to the proxy on the correct transport for their
direction. Record-routing is therefore required for any call you want to bridge:
# WebRTC browser (WSS) ↔ SIP trunk (UDP): one record_route(), siphon double-RRs
# across the transport boundary so the in-dialog BYE finds its way back on each leg.
@proxy.on_request("INVITE")
async def route(request):
if request.body:
await rtpengine.offer(request, profile="wss_to_rtp")
request.record_route() # ← required to bridge transports
request.relay("sip:trunk.example.com:5060;transport=udp")
IPv4 / IPv6¶
SIPhon is dual-stack. Run a listener per family you want to serve — typically a wildcard pair:
listen:
udp:
- "0.0.0.0:5060" # IPv4
- "[::]:5060" # IPv6
tcp:
- "0.0.0.0:5060"
- "[::]:5060"
advertised_address: "2001:db8::1" # advertised host may itself be a v6 literal
- Egress family follows the destination. Relaying to an IPv6 next hop uses an IPv6 outbound socket, IPv4 uses IPv4. To originate toward a given family you must have a listener of that family configured, so list both wildcard addresses if you route to both.
- v6 literals are bracketed automatically in the headers siphon writes
(
[2001:db8::1]:5060); theadvertised_addressmay be a v6 literal too. - v4 ↔ v6 bridging is implicit. Because each leg owns its own transport and socket family, a v6 UE calling a v4 trunk (or vice-versa) just works — the inbound leg stays v6, the outbound leg is v4, and the remembered return path keeps responses and in-dialog requests on the right family. No special config.
See also¶
- Hardening & security — TLS/mTLS, trusted CIDRs, scanner/auth bans.
- Media & RTP profiles — WebRTC
ws_to_rtp/wss_to_rtp, SRTP↔RTP. - Deployment & operations — load balancers, Kubernetes networking, drain.
- Scaling & redundancy — DNS SRV failover across nodes.