Conversation
…vice `stream_route.sni` holds a single SNI, so a route that has to serve several hostnames — a Gateway API TLSRoute with several `hostnames`, for instance — has to be duplicated once per hostname, which duplicates its plugins and its traffic-split state along with it. `service.hosts` already accepts exactly the values `sni` accepts (both use `host_def_pat`, wildcards included), it was simply never read by the stream router. Fall back to it when the stream route carries no `sni` of its own, mirroring the host precedence the HTTP router already implements in `apisix/http/router/radixtree_host_uri.lua`. Two details worth calling out: a bare `*` host means "no SNI restriction" and keeps the route in the address-matched set, since reversed into the radixtree it would only ever match a literal `*`; and the hosts are lowercased because `apisix/ssl.lua`'s `server_name()` returns a lowercased SNI. This changes the behavior of a stream route that has no `sni` and references a service that has `hosts`: it used to match every SNI on the port, and now matches only those hosts.
A stream route with a mixed-case `sni` could never match. `apisix/ssl.lua`
server_name() always returns a lowercased SNI, and nothing lowercased the
configured value, so `Mixed.SNI.com` was compared against `mixed.sni.com`.
Normalize it in the shared route filter in `apisix/router.lua`, where the
same function already lowercases `host`/`hosts` and which `router.lua` also
passes to the stream router. `service.hosts` is likewise already normalized
by the service filter, so get_snis() no longer lowercases anything itself
and is left with just the selection.
Test cases also close the cosocket on every early return and keep the bytes
that come back alongside a "closed" from receive("*a").
5 tasks
A stream route could only carry one `sni`, so serving several names from one backend meant duplicating the route once per name, and with it the plugins and any traffic-split state. Add `snis`, the plural form of the same field: a route matches if the SNI in the ClientHello equals any entry, wildcards included. A bare `*` puts no restriction at all, which is what carrying no SNI already means. The schema forbids `sni` and `snis` together, so the precedence between them never has to be guessed, and `apisix/router.lua` lowercases the list the way it already does for `sni` and for the HTTP `hosts`. This is what Gateway API needs to map a TLSRoute: its `hostnames` is a list, and one TLSRoute rule now translates to one stream route.
Replaces the service-hosts fallback the earlier revision documented.
…r-service-hosts # Conflicts: # apisix/schema_def.lua
The existing cases drive a terminating listen, where the SNI comes from a handshake the worker performed itself. On a passthrough listen it comes from the prereaded ClientHello instead — a different path through apisix/ssl.lua's server_name() — and nothing exercised it. The listen holds no certificate, so a completed handshake can only have been terminated by the backend. An SNI outside the list has no route and therefore no backend to hand the handshake to, which is why the unmatched probe expects a handshake failure rather than the empty body a terminating listen returns.
get_snis() treated a bare `*` as "no SNI restriction" and dropped the route
into other_routes. That changed what an existing `{"sni": "*"}` route matches:
_M.match only consults the TLS router when the connection carries an SNI,
while other_routes is walked unconditionally, so such a route started
matching connections with no SNI at all and could take traffic away from a
route matching on address.
A `*` path is already a match-anything prefix in the radixtree
(resty.radixtree turns a trailing `*` into an empty path with `<=`), which is
how `sni: "*"` has always behaved and how apisix/ssl/router/radixtree_sni.lua
treats it. Register it as before and let that stand for `snis` too. The
comment claiming the radixtree would match only the literal `*` was wrong.
Also guard against an empty list, which would otherwise leave a route in
neither the TLS router nor other_routes and silently never match.
Tests: a bare `*` on a passthrough listen matches any SNI but leaves a
connection without one unmatched (fails if the special case comes back); the
singular `sni` is normalized for case; two routes sharing one SNI are told
apart by their address; shrinking a route's sni list stops the removed name
from matching.
utils/fix-zh-doc-segment.py rejects a Chinese paragraph split mid-sentence, which is what the Markdown CI job reports.
AlinsRan
force-pushed
the
feat/stream-router-service-hosts
branch
from
September 11, 2026 08:28
049ed15 to
19f274b
Compare
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.
Description
A stream route can only carry one
sni, so serving several names from one backend means duplicating the route once per name — and with it the plugins and any traffic-split state attached to it.This adds
snis, the plural form of the same field. A route matches when the SNI in the ClientHello equals any entry:Wildcards keep the existing suffix semantics (
*.test.comalso matchesa.b.test.com), and a bare*puts no restriction on the SNI at all — the same as a route carrying neither field.Why
snisand nothostssni/snisis the pair thesslobject already uses, with the samehost_def_patpattern. A stream proxy never parses HTTP, so the only thing being matched is the TLS SNI; naming the fieldhostswould import HTTP vocabulary into L4, and someone who knowsstream_route.sniwill look forsnis.Why the two are mutually exclusive
The schema carries
not: required[sni, snis], so a route with both is rejected at write time rather than resolved by a precedence rule nobody can guess.sslmakes the same choice for its ownsni/snis.It is expressed as
notrather thanoneOfon purpose:oneOfwould mean exactly one, which would forbid a stream route carrying neither — that is a supported case, matching purely onremote_addr/server_addr/server_port(seet/stream-node/sni.tTEST 5 and TEST 11).Motivation: Gateway API TLSRoute
TLSRoute matches on
and its
hostnamesis a list. Withsnis, one TLSRoute rule translates to one stream route instead of one per hostname.Changes
apisix/schema_def.lua:stream_route.snis, andnot: required[sni, snis].apisix/stream/router/ip_port.lua:create_router()registers one radixtree path per SNI instead of one per route. Thesni_to_itemsdedup is preserved, so routes sharing an SNI still share one entry.apisix/router.lua: the shared route filter lowercasessnisthe way it already does forhosts. It now also lowercasessni, which fixes a pre-existing bug — a stream route with a mixed-casesninever matched, becauseapisix/ssl.lua'sserver_name()always returns a lowercased SNI.Tests
t/stream-node/stream-route-snis.t: every SNI of a route matched and a third name not; the singularsnistill working on its own;sni+snisrejected with 400; wildcard suffix matching; a bare*falling through to address matching; and a mixed-case entry matched by a lowercase SNI.54 assertions, all passing. 10 of them fail without this change, so the file tests the change rather than the framework.