Route TCP connections by SNI in TLS passthrough - #125
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 SNIboard item, unblocked by the Sōzu 2.2.0 upgrade (#124).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
sniis its own field, not a reuse ofhostnames. Traefik, HAProxy, Nginx and Caddy all keep the HTTPHostmatch and the TLS SNI match separate (HostvsHostSNI,hdr(host)vsreq.ssl_sni,$hostvs$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. Soparse_snimirrors Sōzu'svalidate_sni_patternand emitsW028at parse time, withsozune explain W028describing 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 betweenparse_sniand Sōzu's validator.Note on the e2e harness
compose.func-test.yamlandconfig.func-test.yamlare generated byrun-all.shon 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.