Portunus
Features

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:

  1. Peeks the ClientHello.
  2. Parses the SNI extension.
  3. Forwards the connection to the matching rule's upstream.
  4. 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.com matches api.example.com but not api.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_active
  • portunus_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":

eventWhen
tls.sni_routedConnection dispatched to a matching rule
tls.no_sniClientHello had no SNI extension
tls.sni_no_matchSNI present but matched no rule (and no fallback)
tls.client_hello_timeoutPeek timed out before a full ClientHello arrived
tls.parse_failedPeeked bytes failed to parse as a ClientHello
tls.sni_listener.started / .stopped / .accept_errorListener lifecycle

Per the audit-isolation invariant (D13), data-plane SNI events do not enter the SQLite operator audit ring.

On this page