Skip to content

Route TCP connections by SNI in TLS passthrough - #125

Merged
Shine-neko merged 1 commit into
mainfrom
feat/tcp-sni-routing
Jul 20, 2026
Merged

Route TCP connections by SNI in TLS passthrough#125
Shine-neko merged 1 commit into
mainfrom
feat/tcp-sni-routing

Conversation

@Shine-neko

Copy link
Copy Markdown
Contributor

Routes TLS connections to different backends on a single TCP port, based on the server name in the ClientHello, without terminating TLS. Closes the TCP routing with SNI board item, unblocked by the Sōzu 2.2.0 upgrade (#124).

services:
  app-a:
    labels:
      - "sozune.tcp.appa.entrypoint=tlsgw"
      - "sozune.tcp.appa.sni=a.example.com"
  app-b:
    labels:
      - "sozune.tcp.appb.entrypoint=tlsgw"
      - "sozune.tcp.appb.sni=*.b.example.com"

Both share one public port. The client's handshake completes with the backend, against the backend's own certificate: Sōzune reads the name from the ClientHello (which travels in the clear) and forwards the encrypted bytes untouched.

Design

sni is its own field, not a reuse of hostnames. Traefik, HAProxy, Nginx and Caddy all keep the HTTP Host match and the TLS SNI match separate (Host vs HostSNI, hdr(host) vs req.ssl_sni, $host vs $ssl_preread_server_name). They are different layers read at different times, and conflating them in one field would have been a false economy.

Validation happens up front, not in the worker. Sōzu refuses a bad pattern over the command socket, where Sōzune can only log it at debug! — invisible to the operator, who just sees a route that never matches. So parse_sni mirrors Sōzu's validate_sni_pattern and emits W028 at parse time, with sozune explain W028 describing the accepted shapes.

A listener is either SNI-routed or catch-all. Sōzu forbids mixing the two shapes on one listener, unlike the four proxies above, where a catch-all coexists with SNI routes. That constraint is upstream, not a choice here, and it is documented as such.

The interesting failure this avoids

Detecting the clash and only warning about it is not enough. Sōzu rejects the incompatible frontend at debug! level, but the reload still records the whole desired config as applied — so every later reload skips that entrypoint as "unchanged" and it stays dead even after the operator resolves the clash. Permanently poisoned state, with nothing in the logs to explain it.

Clashing entrypoints are therefore excluded from the reload and from the snapshot, so they are retried once whatever blocks them is fixed. The rest of the listener keeps serving. A regression test covers the full clash → skip → fix → apply cycle.

Found by a Codex review, which also confirmed the two invariants I was most worried about: add/remove symmetry on Sōzu's (address, sni, alpn) frontend key (including when an SNI changes value, where a mismatched removal would silently leak the old route), and acceptance-equivalence between parse_sni and Sōzu's validator.

Note on the e2e harness

compose.func-test.yaml and config.func-test.yaml are generated by run-all.sh on every run and deleted on cleanup — editing them has no effect. The new SNI backends and listener are declared in the generator itself. The two backends terminate TLS with self-signed certs built at container boot, so no key material is committed.

@Shine-neko
Shine-neko merged commit 29d2346 into main Jul 20, 2026
3 checks passed
@Shine-neko
Shine-neko deleted the feat/tcp-sni-routing branch July 20, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant