Skip to content

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_2"             # minimum TLS version: TLSv1_2 | TLSv1_3
  # INBOUND mTLS — siphon verifies clients that connect INTO it (applies to
  # listen.tls AND listen.wss):
  verify_client: false
  client_ca: "/etc/siphon/tls/client-ca.pem"
  # OUTBOUND mTLS — the cert siphon PRESENTS when it dials OUT to an upstream
  # trunk that requires a client certificate:
  client_certificate: "/etc/siphon/tls/client.crt"
  client_private_key: "/etc/siphon/tls/client.key"

method is the minimum TLS version, not an exact one: TLSv1_2 (the default) negotiates TLS 1.2 or 1.3, TLSv1_3 negotiates 1.3 only and refuses a TLS 1.2 peer. It applies in both directions — the listen.tls / listen.wss listeners this block serves, and the outbound TLS connections siphon dials — so raising the floor also stops siphon connecting to a trunk that has not moved off TLS 1.2. TLS 1.0/1.1 and SSL are rejected at config load (RFC 8996 deprecates them; the rustls stack does not implement them), as is any unrecognised value.

The two mTLS directions are independent. verify_client / client_ca govern inbound mutual TLS — siphon verifying the certificate of a peer connecting into it. client_certificate / client_private_key govern outbound mutual TLS — the certificate siphon presents when it dials out to an upstream SIP trunk that requires a client certificate (for example Microsoft Teams Direct Routing). Both outbound fields must be set together, or neither; a one-sided setting or an unreadable file is a hard startup error. Outbound TLS also now sends the resolved target hostname as SNI (RFC 6066) instead of the destination IP, so a hostname-vhost front-end can route the handshake; bare-IP next hops send no SNI, as before.

One port for SIP and WebSocket

Give the same address to both tls: and wss: (or to both tcp: and ws:) and siphon serves both protocols from a single socket:

listen:
  tls:
    - "0.0.0.0:5061"
  wss:
    - "0.0.0.0:5061"            # same address — one socket, both protocols

One port, one firewall pinhole, one certificate for a browser UE on WSS and a SIP trunk on TLS. Useful where the port is not yours to choose: 443 is often the only outbound port a guest or corporate network allows, and carriers routinely expect 5061 to be raw SIP over TLS.

Each connection is classified from its first line and then handled exactly as it would have been on a dedicated listener:

First line Treated as Transport stamped
REGISTER sip:… SIP/2.0 raw SIP tls / tcp
SIP/2.0 200 OK raw SIP tls / tcp
GET / HTTP/1.1 WebSocket upgrade wss / ws
PROXY TCP4 … / the v2 signature a PROXY header — consumed, then the line after it is classified by the rows above as above
anything else dropped (and counted as a malformed message) —

The PROXY row only applies on a listener that sets proxy_protocol. Without it the header is still recognised, but the connection is dropped with a log naming the listener — not counted as a malformed message, because the sender is almost always your own front rather than a prober.

The two grammars are disjoint — a SIP start line ends with SIP/2.0 (RFC 3261 §7.1) and an upgrade request ends with HTTP/1.1 (RFC 6455 §4.1), and no SIP method is an HTTP method — so this is an exact classification, not a guess. Via and Contact generation, flow capture, MT routing and the outbound path all see the transport the connection turned out to speak, so a UE registering over the shared port is reached over WSS and a trunk over TLS, exactly as with separate ports. The cost is one classification per connection; nothing changes on the per-message path.

A peer that connects and sends nothing (holding a connection open for reuse, RFC 5923) is taken to be raw SIP after two seconds, which is what such a peer is. A WebSocket client always sends its upgrade immediately.

Only these two pairings can share a socket. Plaintext and TLS cannot (a ClientHello is not a SIP message), so tcp + tls, tcp + wss, tls + ws and ws + wss on one address are rejected at startup rather than silently half-working:

listen.tcp and listen.tls are both configured on 0.0.0.0:5060, which cannot
share one socket. Only tcp+ws and tls+wss can be multiplexed on the same port.

UDP is a separate socket type and never conflicts — udp and tcp on 0.0.0.0:5060 is the normal SIP setup, not a shared socket.

Listeners that speak only SIP

A dedicated listen.tcp or listen.tls socket applies the same first-line check, with one difference: there is no WebSocket half to hand an HTTP request to, so an upgrade line is treated exactly like random bytes. Either way the connection is closed before a byte reaches the SIP framer, and the source is counted as a strong security.failed_auth_ban signal.

One first line is exempt: a PROXY header on a listener without proxy_protocol is recognised for what it is and the connection dropped with a log naming the listener, crediting the source nothing. It used to score as non-SIP bytes, which meant a front pointed at the wrong listener banned itself in four connections — see Behind a connection-terminating front.

This is what stops a vulnerability scanner walking /phpinfo.php, /info.php and friends against a TLS SIP port. Framing on length alone cannot: an HTTP header block ends in \r\n\r\n and carries no Content-Length, so it looks like a complete message and would be rejected only by the parser — after the probe has been queued, with the connection still open and nothing recorded against the source. Deciding from the start line closes the connection on the first probe and, at the default weights, bans the source on the fourth.

The check runs in two places, and both are load-bearing:

  • At accept, on the connection's first line, before a connection id is even allocated — so a probe never enters the connection map or the accept log.
  • In the framer, on the start line of every message, because the accept-time sniff assumes SIP for a peer that sends nothing inside its 2-second window. That assumption is right (a connection held open for reuse must not be dropped) but it is also a way past the first check: connect, wait, then send the probe. Judging every message closes that, and the framer is where the connection can actually be dropped and the source recorded.

The connection is never answered. A SIP port that replies to a probe tells the prober what it found, which is also why a blocked request is dropped rather than rejected.

Connection ceilings

Independently of any of the above, security.connection_limits bounds how many connections and how many concurrent handshakes one source — and the box as a whole — can hold. It is always on, and it covers what the ban counter cannot: a source that opens dozens of TLS connections and completes none of them never produces a completed failure to count, while each one costs a real handshake and a task held for the whole handshake timeout. See Hardening & security, including the note on carrier NAT and max_connections_per_source.

Per-domain certificates (inbound SNI)

One listener can serve a different certificate per server name, selected from the SNI extension the client sends in its ClientHello (RFC 6066). Without it, several domains on one socket need a single SAN certificate covering all of them, which couples every domain to one renewal — a failed validation for one blocks the certificate for all of them, and every peer sees the whole list.

tls:
  certificate: "/etc/siphon/tls/default.crt"   # served when nothing matches
  private_key: "/etc/siphon/tls/default.key"
  certificates:
    - server_names: ["sip.tenant-a.example", "sip.tenant-a.net"]
      certificate: "/etc/siphon/tls/tenant-a.crt"
      private_key: "/etc/siphon/tls/tenant-a.key"
    - server_names: ["*.tenant-b.example"]
      certificate: "/etc/siphon/tls/tenant-b.crt"
      private_key: "/etc/siphon/tls/tenant-b.key"

Matching rules:

  • Names are matched case-insensitively (RFC 4343).
  • A wildcard matches exactly one leading label (RFC 6125 §6.4.3): *.tenant-b.example matches ue.tenant-b.example, but not tenant-b.example and not a.b.tenant-b.example.
  • An exact entry wins over a wildcard that would also cover the name.
  • Anything unmatched — including every client that sends no SNI at all, which is any peer addressing siphon by IP literal — gets the top-level certificate/private_key. Selection never aborts a handshake, so a config without certificates: behaves exactly as it did before.

The block is shared by listen.tls and listen.wss, and every pair is watched independently, so each domain hot-reloads on its own ACME schedule without touching the others. A server name configured twice, an entry with an empty server_names, a malformed wildcard, or a certificate that does not match its key are all hard startup errors naming the offending path — none of them degrade into "some peers silently get the wrong certificate".

verify_client / client_ca stay listener-wide: rustls can only vary client verification per name with a distinct config per handshake, and a per-domain trust anchor is a different feature from a per-domain server certificate.

SNI is not an authentication signal

The server name is client-supplied plaintext chosen by whoever dialled in. Use it to pick a certificate, never to decide who someone is — the SIP domain in the request, or inbound mTLS, is what identifies a peer.

A listener can be a plain string ("10.0.0.1:5060") or the extended form with a per-socket advertised host and optional port, a DSCP override (like OpenSIPS socket … as …), and — on stream transports — a PROXY-protocol allowlist:

listen:
  tls:
    - address: "10.0.0.1:5061"
      advertise: "sip.example.com"   # host (and optional port) peers should see
                                     # in Via / Record-Route / Contact
      dscp: EF                       # overrides the global listen.dscp
      proxy_protocol:                # stream listeners only — see below
        from: ["198.51.100.7/32"]    # the front(s) allowed to name the client

proxy_protocol is covered in Behind a connection-terminating front; the other two fields are covered under Behind NAT or a load balancer.

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 (TCP, TLS, WS, WSS) in a process-global registry keyed by the client's source address and transport (with an IP-only fallback for NAT). A TCP entry is never handed out for a TLS send, or the reverse. Responses always go back over the originating connection, and on TLS, WS and WSS 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.

Plain TCP is the exception on purpose: a request relayed to a URI still dials the peer through the outbound connection pool, so a TCP trunk that did not ask for reuse is routed exactly as before. To reach a TCP peer over the connection it opened (behind NAT, or behind a front that terminates the connection), route over its flow as in Layer 2 below. flow.is_alive and the subscribe_state received-flow NOTIFY read the same registry, so they track TCP connections too.

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"

Advertising a port other than the bound one

advertise takes an optional port. Without one, siphon advertises the port the socket binds, as it always has. With one, that port goes in every header that tells a peer where to reach this listener: the Via sent-by, Record-Route, the B2BUA Contact on both legs, the Contact of an answered OPTIONS, and the Via and Contact of requests siphon originates (OPTIONS keepalives and probes, proxy.send_request, SUBSCRIBE and in-dialog NOTIFY). Use it when a front translates the port: it owns the public port and forwards to a different inner one, so a header naming the inner port points at a port nothing serves.

listen:
  tls:
    - address: "10.0.0.1:15061"             # what siphon binds
      advertise: "sip.example.com:5061"     # what peers are told
  udp:
    - address: "[2001:db8::10]:15060"
      advertise: "[2001:db8::1]:5060"       # an IPv6 literal with a port needs brackets

Accepted forms: host, host:port, an IP literal (192.0.2.10, 2001:db8::1, [2001:db8::1]) and [v6]:port. The value is parsed at startup and a malformed one (a port outside 1-65535, an unbracketed IPv6 literal with a port, a host with characters no SIP URI allows) stops siphon with an error naming the listener. Only headers change: siphon still binds and sends from the listen address, and it recognises both the bound and the advertised port as its own on an in-dialog Route.

Top-level advertised_address takes a host only. It is the fallback for every transport at once, and one port cannot be right for UDP on 5060 and TLS on 5061, so a value with a port is refused at startup; put the port on the listener's advertise instead.

TLS and WSS listeners should advertise a DNS name

A peer that opens a new TLS connection to siphon dials the host siphon put in its Contact, Record-Route or Via, and validates the certificate against it. It does this to send the ACK or a later in-dialog request when the original connection is gone or not reused. Certificates name DNS names, so an IP literal there fails validation, and nothing shows on siphon's side: the request just never arrives. Set advertise: on each listen.tls / listen.wss entry (or advertised_address) to a DNS name tls.certificate carries.

At startup siphon logs a warning for every TLS and WSS listener whose advertised host comes out as an IP literal, naming the transport, the bind address and the IP. It stays quiet when tls.certificate carries that IP as an iPAddress subjectAltName, since that validates. Only tls.certificate is checked: a peer dialling an IP sends no SNI (RFC 6066 §3), so it is never served a tls.certificates entry. If the certificate can't be read, the warning still fires and says the SAN check was not possible.

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.local list, never the advertised address. Put your real served domains and local IPs in domain.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_cidrs so probes aren't rate-limited. What to do about the client's source address depends on what the balancer does with the packets. A forwarding L4 balancer can preserve it (externalTrafficPolicy: Local / hostNetwork on Kubernetes — see Deployment). A connection-terminating front cannot, by definition; that one needs the PROXY protocol, below.

Behind a connection-terminating front

advertised_address fixes the addresses siphon writes. It does nothing for the address siphon reads, and a front that terminates the connection breaks that one.

A forwarding balancer (L4 pass-through, DSR, externalTrafficPolicy: Local) hands siphon the client's own packets, so the peer address is already right and none of this applies. A terminating front — HAProxy in tcp mode with its own TLS, stunnel, an Ingress controller, anything that re-encrypts — opens its own connection to siphon. The peer address is then the front's, and every consumer that keys on the source inherits that:

  • security.failed_auth_ban bans the front rather than the abuser — or, with the front in trusted_cidrs, bans nobody at all.
  • request.from_gateway() and request.source_ip_in() stop discriminating: every call now arrives from one address.
  • NAT return-routing writes the front into received= / rport=.
  • media.received_from gates RTP ingress to the front, so no media is accepted at all.
  • Capture and the CDR record the front for every call.

Source preservation is not something a terminating front can be configured into — it terminated the connection, which is the reason it is there. What it can do is send the client's endpoints ahead of the payload, in the PROXY protocol. Enable that per listener:

listen:
  tls:
    - address: "198.51.100.10:5061"
      advertise: "sip.example.com"
      proxy_protocol:
        from:
          - "198.51.100.7/32"     # the front, exactly

Version 1 (the text line) and version 2 (binary) are both accepted, and siphon needs no telling which — it decides from the signature. Stream listeners only: listen.tcp, listen.tls, listen.ws, listen.wss, and a shared tcp+ws / tls+wss socket. On listen.udp it is refused at config load, because a UDP reply goes to the peer address, so substituting the client's would send every answer past the front.

from is the security control

A PROXY header lets its sender claim to be any address. from names the senders allowed to do that; it is mandatory and has no default. An empty list, or an entry that is not a CIDR ("198.51.100.7" where "198.51.100.7/32" was meant), is refused at config load rather than started in a permissive state.

from does not inherit security.trusted_cidrs

That would be the obvious convenience and it is the wrong one. trusted_cidrs means "exempt from abuse controls" to all four of its consumers, and it is where monitoring boxes, health-check probes and trunks get listed. Inheriting it would let every one of them forge a source address, on the strength of a decision made months earlier for an unrelated reason. Keep from as narrow as the front really is: a /32 per front, not the subnet it happens to sit in. See Hardening & security.

What happens on the wire

The header is cleartext and arrives ahead of the TLS ClientHello, so siphon reads it before the handshake. That ordering is the whole point for a re-encrypting front: it terminates the subscriber's TLS and opens its own to siphon, and no hop carries cleartext SIP. Bytes read past the header — the ClientHello, the WebSocket GET, or the first SIP message — are replayed to whatever reads the connection next, so nothing is lost when a front packs the header and the INVITE into one segment.

On a listener with proxy_protocol set:

What arrives Result
A header from an address in from The client's address replaces the front's for every consumer
A header from an address not in from Refused at accept, logged. Not an auto-ban signal.
No header — plain SIP, a TLS record, silence Connection dropped, logged. Never attributed to the front. Not an auto-ban signal.
v2 LOCAL / v1 UNKNOWN Consumed; the socket's own peer address stands. This is what a front's health checks send.

Neither refusal credits failed_auth_ban, deliberately: the overwhelmingly likely cause is a second front that nobody added to the list, and banning your own ingress is a worse outage than the misconfiguration behind it.

The mirror case is a PROXY header arriving on a listener where the option is off. It is recognised and the connection dropped with a log naming the listener. Before that existed it was classified as non-SIP bytes, which scores strong_signal_weight — so a front aimed at the wrong listener banned itself within a few connections, with nothing in the log to say why.

Spell the key exactly

A listen entry is untagged, so a misspelled proxy_protocol key parses as a perfectly valid listener with the option off. There is no config error to see; the symptom is the header-on-a-disabled-listener log above. A misspelling inside the block (anything other than from) is a hard config error, as is omitting from.

On a shared tcp+ws or tls+wss socket, set the same proxy_protocol on both halves — one socket takes one policy. siphon warns and uses the tcp: / tls: side when the two disagree.

What the client spoke, beside what this hop speaks

A v2 header can carry PP2_TYPE_SSL, describing the TLS session the client negotiated with the front. siphon carries that beside the hop, never over it: request.transport keeps naming the transport siphon itself accepted — tcp for a front that re-encrypts into a plaintext listener — because that is what decides which connection map, pool and Via/Contact token the message belongs to.

Two properties answer for the client instead:

  • request.client_transport — the transport the front says the client used, or None when no front declared one.
  • request.client_is_secure — whether the client's effective hop was secure. A UE that connects straight to a tls listener reports True too, so if not request.client_is_secure: reject is safe to write; reading client_transport is None as "insecure" would lock out every direct-TLS UE.

Contact.client_transport persists it with the registrar binding, and the CDR records the client's transport rather than the front-facing one.


Choosing the egress socket (send_socket)

On a multi-homed host — several listeners across interfaces — routing usually picks the outbound transport, but not which local socket the request leaves from. send_socket= pins it, the way Kamailio's force_send_socket() and OpenSIPS' $fs do. It takes a "<transport>:<ip>:<port>" string naming one of siphon's own configured listeners:

# Proxy — relay this trunk call out of the carrier-facing NIC.
request.relay("sip:carrier.example.net", send_socket="udp:203.0.113.10:5060")

# Proxy — fork; the pin applies to every branch.
request.fork(contacts, send_socket="udp:10.0.0.1:5060")

# B2BUA — dial the B-leg out of a specific interface.
call.dial("sip:bob@10.0.0.2:5060", send_socket="tcp:10.0.0.1:5060")

What it does:

  • Advertises the right Via. The outgoing Via sent-by is the selected listener's advertised address (its advertise:, else its bound IP) with the listener's port (the advertised one when advertise: names a port), so the peer's response comes back to the same socket. This is the correctness reason to use send_socket instead of hand-rolling a Via rewrite — get the sent-by wrong and the response lands on the wrong listener (or nowhere).
  • UDP pins the exact (ip, port) listener socket as the egress.
  • TCP/TLS bind the source IP (interface); the source port stays ephemeral, because binding a listen port for an outbound connection collides on the 4-tuple in TIME_WAIT. Source-bound and default connections to the same peer are pooled separately, so they never reuse each other.

Rules and fall-backs:

  • A malformed spec raises ValueError at the scripting API (immediate, so you catch typos in tests).
  • A well-formed but unknown socket (no such listener) is logged and the request falls back to default routing — it is never dropped.
  • Ignored when a captured flow= is set (the flow already pins egress), and when its transport doesn't match the routed transport (logged).
  • WS/WSS callees can't be dialed (client-initiated); reach them with flow=, not send_socket.

Multi-homed UDP fast path

Per-listener UDP egress is enabled automatically once the host has more than one UDP listener (or IPsec is configured). A single-UDP-listener deployment keeps the original zero-overhead send path — send_socket only has real work to do when there's more than one socket to choose between.


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:

  1. An explicit ;transport= parameter on the relay target or the R-URI.
  2. DNS, RFC 3263 — NAPTR then SRV (_sips._tcp, _sip._tcp, _sip._udp), then A/AAAA. siphon resolves these natively for its own routing.
  3. 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); the advertised_address may 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