TLS SNI Routing
Fan a single TCP listen port out to different upstreams based on the TLS hostname. Pure L4 byte passthrough — never decrypts.
Available since v0.9.0. Routes by the SNI extension in the TLS ClientHello while staying a pure L4 byte passthrough.
How it works
A single TCP listen port (typically 443) can fan out to different upstream targets based on the TLS hostname. The client:
- Peeks the ClientHello.
- Parses the SNI extension.
- Forwards the connection to the matching rule's upstream.
- Replays the peek buffer to the upstream byte-for-byte.
Portunus never decrypts, terminates, or re-encrypts TLS — even in SNI mode.
Push an SNI rule
# Exact host
portunus-server push-rule edge-01 443 api.example.com:8443 \
--sni api.example.com
# Single-label wildcard
portunus-server push-rule edge-01 443 web.example.com:8080 \
--sni '*.example.com'Multiple rules can share the same listen port with distinct SNI selectors. The client's SNI dispatcher picks the rule that matches the ClientHello's SNI value.
TLS-only fallback
A rule with sni_pattern = NULL on a port that already runs in SNI
mode catches valid TLS connections whose SNI is missing or
unmatched:
portunus-server push-rule edge-01 443 fallback.example.com:8080
# (no --sni → fallback)Without a fallback, unmatched connections are dropped without ever reaching a backend.
Mode-locked listener
A (client, listen_port) group's mode (legacy plain-TCP vs SNI
dispatch) is fixed for its lifetime. Cross-mode pushes are refused
with HTTP 409 conflict.legacy_to_sni_unsupported. Remove the
existing rule first to switch modes.
Constraints
- TCP only — UDP rules cannot carry an SNI selector
(
validation.sni_on_unsupported_rule). - No port-range rules with SNI (same error code).
- Wildcards are single-label only —
*.example.commatchesapi.example.combut notapi.v2.example.com.
Capability gate
Pushing a rule with sni_pattern to a v0.8-or-earlier client returns
HTTP 422 sni_unsupported_by_client before any rule activates.
Metrics
portunus_tls_sni_route_total{client,rule,owner,result}
portunus_tls_sni_listener_miss_total{client,port}
portunus_tls_sni_listener_parse_failures_total{client,port}
portunus_tls_sni_routes_activeportunus_tls_sni_route_total— per-rule successful dispatches.result∈{exact, wildcard, fallback}(one row per result kind).portunus_tls_sni_listener_miss_total— per-listener connections whose SNI matched no rule and had no fallback (i.e. dropped).portunus_tls_sni_listener_parse_failures_total— per-listener peeks that failed to parse as a ClientHello, timed out, or hit the size cap.portunus_tls_sni_routes_active— global gauge of active rules with an SNI selector.
Legacy plain-TCP listeners never emit any of these series.
Tracing events
Structured events with target = "tls_sni":
event | When |
|---|---|
tls.sni_routed | Connection dispatched to a matching rule |
tls.no_sni | ClientHello had no SNI extension |
tls.sni_no_match | SNI present but matched no rule (and no fallback) |
tls.client_hello_timeout | Peek timed out before a full ClientHello arrived |
tls.parse_failed | Peeked bytes failed to parse as a ClientHello |
tls.sni_listener.started / .stopped / .accept_error | Listener lifecycle |
Per the audit-isolation invariant (D13), data-plane SNI events do not enter the SQLite operator audit ring.