# TLS SNI Routing (https://portunus.bybee.dev/en/docs/features/tls-sni-routing)



Available since v0.9.0. Routes by the SNI extension in the TLS
ClientHello while staying a pure L4 byte passthrough.

## How it works [#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 [#push-an-sni-rule]

```sh
# 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 [#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**:

```sh
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 [#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 [#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 [#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 [#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 [#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.
