Skip to content

feat(stream): match a stream route by several SNIs - #13911

Open
AlinsRan wants to merge 10 commits into
apache:masterfrom
AlinsRan:feat/stream-router-service-hosts
Open

AlinsRan wants to merge 10 commits into
apache:masterfrom
AlinsRan:feat/stream-router-service-hosts

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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:

curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_key" -X PUT -d '
{
    "snis": ["a.test.com", "b.test.com"],
    "upstream": {"nodes": {"127.0.0.1:5991": 1}, "type": "roundrobin"}
}'

Wildcards keep the existing suffix semantics (*.test.com also matches a.b.test.com), and a bare * puts no restriction on the SNI at all — the same as a route carrying neither field.

Why snis and not hosts

sni/snis is the pair the ssl object already uses, with the same host_def_pat pattern. A stream proxy never parses HTTP, so the only thing being matched is the TLS SNI; naming the field hosts would import HTTP vocabulary into L4, and someone who knows stream_route.sni will look for snis.

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. ssl makes the same choice for its own sni/snis.

It is expressed as not rather than oneOf on purpose: oneOf would mean exactly one, which would forbid a stream route carrying neither — that is a supported case, matching purely on remote_addr/server_addr/server_port (see t/stream-node/sni.t TEST 5 and TEST 11).

Motivation: Gateway API TLSRoute

TLSRoute matches on

the Server Name Indication (SNI) hostname provided during the TLS handshake

and its hostnames is a list. With snis, one TLSRoute rule translates to one stream route instead of one per hostname.

Changes

  • apisix/schema_def.lua: stream_route.snis, and not: required[sni, snis].
  • apisix/stream/router/ip_port.lua: create_router() registers one radixtree path per SNI instead of one per route. The sni_to_items dedup is preserved, so routes sharing an SNI still share one entry.
  • apisix/router.lua: the shared route filter lowercases snis the way it already does for hosts. It now also lowercases sni, which fixes a pre-existing bug — a stream route with a mixed-case sni never matched, because apisix/ssl.lua's server_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 singular sni still working on its own; sni + snis rejected 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.

…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").
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.
@AlinsRan AlinsRan changed the title feat(stream): match a stream route by the hosts of its referenced service feat(stream): match a stream route by several SNIs Sep 3, 2026
…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
AlinsRan force-pushed the feat/stream-router-service-hosts branch from 049ed15 to 19f274b Compare September 11, 2026 08: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