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.
SNI peek duration histogram
What it is for
In SNI dispatch mode the listener must peek at the inbound TLS ClientHello — without consuming it — to extract the SNI hostname before it can decide which backend the connection belongs to. That peek sits synchronously on the connection-setup path: it has to wait for enough of the ClientHello to arrive (possibly across several TCP segments) and then parse the TLS record. Slow clients, network jitter, or stalled handshakes make this step slow and drag out connection-establishment tail latency.
The histogram measures exactly that step, so you can:
- Observe the cost of SNI routing — watch p50/p99 peek time to tell whether SNI dispatch is becoming a bottleneck.
- Spot slow clients / abuse — a mass of long peeks can signal a slowloris-style slow-handshake attack or a network problem.
- Tune capacity and timeouts — set peek timeouts from the real distribution rather than guessing.
Metrics
When a listener runs in SNI dispatch mode, Portunus records how long the ClientHello peek+parse takes:
portunus_tls_client_hello_peek_duration_seconds_bucket{...}
portunus_tls_client_hello_peek_duration_seconds_sum
portunus_tls_client_hello_peek_duration_seconds_countBuckets follow Prometheus convention with finite boundaries up to
3 seconds; observations above the 3 s boundary increment _count
and le="+Inf" without bumping le="3".
The histogram does not exist for legacy plain-TCP listeners — no peek happens there.