From a549b23bed0cda244ea23c32a9537eb9bb58d31f Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 16:06:17 +0800 Subject: [PATCH 1/3] feat(stream_route): carry snis and tls_passthrough through every backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway can now route a TLS stream by the SNI it prereads from the ClientHello and forward it to the upstream untouched, instead of having to terminate the handshake first to learn the SNI (apache/apisix#13912). A stream listen opts in with `tls_passthrough`; on a mixed listen (`tls` and `tls_passthrough` both set) the matched stream route decides per connection through its own `tls_passthrough`. The gateway also accepts `snis` as the plural form of `sni`. `StreamRoute` carried neither, and every backend's wire shape maps stream route fields explicitly, so both were dropped on the way in and on the way out. Adding them to the SDK schema alone would not have been enough — and would have been rejected outright, since the Node schema is a `z.strictObject`. Both implementations, all three backends: - SDK: `snis` and `tls_passthrough` on `StreamRoute` (zod + schemars), with `schema.json` and `rust/schema.json` regenerated. - backend-apisix and backend-apisix-standalone: the two new fields on the wire shapes and both conversion directions. - backend-api7: `sni` as well. The read direction hardcoded `sni: None` and the write direction never emitted it, because the API7 control plane had no field to carry it; it does now (api7/api7ee-3-control-plane#3018), so a stream route synced to an API7 gateway group no longer loses what it matches on. `sni` and `snis` are mutually exclusive on the gateway. That is not enforced here: the SDK's `streamRouteSchema` is consumed by `readFieldMeta` for its `.shape`, which a refinement would take away, and it is the shape embedded in `serviceBaseSchema.stream_routes` — where a refinement on the standalone schema would not apply anyway. The gateway and the API7 control plane both reject the combination. --- libs/backend-api7/src/transformer.ts | 6 +++ libs/backend-api7/src/typing.ts | 3 ++ libs/backend-api7/test/transformer.spec.ts | 36 ++++++++++++++ .../backend-apisix-standalone/src/operator.ts | 2 + .../src/transformer.ts | 2 + libs/backend-apisix-standalone/src/typing.ts | 2 + libs/backend-apisix/src/transformer.ts | 4 ++ libs/backend-apisix/src/typing.ts | 2 + libs/backend-apisix/test/transformer.spec.ts | 28 +++++++++++ libs/sdk/src/core/schema.ts | 4 ++ .../adc-backend-api7/src/transformer.rs | 45 +++++++++++++++++- rust/crates/adc-backend-api7/src/typing.rs | 6 +++ .../src/transformer.rs | 6 +++ .../src/typing.rs | 4 ++ .../tests/e2e_conf_version_isolation.rs | 2 + .../tests/e2e_resource_stream_route.rs | 2 + .../adc-backend-apisix/src/transformer.rs | 4 ++ rust/crates/adc-backend-apisix/src/typing.rs | 4 ++ .../adc-backend-apisix/tests/transformer.rs | 47 +++++++++++++++++++ rust/crates/adc-sdk/src/resources/route.rs | 7 +++ rust/schema.json | 17 +++++++ schema.json | 11 +++++ 22 files changed, 243 insertions(+), 1 deletion(-) diff --git a/libs/backend-api7/src/transformer.ts b/libs/backend-api7/src/transformer.ts index 27250395..5e866105 100644 --- a/libs/backend-api7/src/transformer.ts +++ b/libs/backend-api7/src/transformer.ts @@ -41,6 +41,9 @@ export class ToADC { server_addr: route.server_addr, server_port: route.server_port, remote_addr: route.remote_addr, + sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, }); } @@ -224,6 +227,9 @@ export class FromADC { server_addr: route.server_addr, server_port: route.server_port, remote_addr: route.remote_addr, + sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, }); } diff --git a/libs/backend-api7/src/typing.ts b/libs/backend-api7/src/typing.ts index d10026fc..78181335 100644 --- a/libs/backend-api7/src/typing.ts +++ b/libs/backend-api7/src/typing.ts @@ -69,6 +69,9 @@ export interface StreamRoute { server_addr?: string; server_port?: number; remote_addr?: string; + sni?: string; + snis?: Array; + tls_passthrough?: boolean; } export interface Service { id?: string; diff --git a/libs/backend-api7/test/transformer.spec.ts b/libs/backend-api7/test/transformer.spec.ts index 996fe2b0..67d42f36 100644 --- a/libs/backend-api7/test/transformer.spec.ts +++ b/libs/backend-api7/test/transformer.spec.ts @@ -41,6 +41,42 @@ describe('Transformer', () => { }); }); + // Regression: both directions used to drop the SNI match outright (ToADC + // never read it, FromADC never wrote it), because the control plane had no + // field to carry it. It does now, together with snis and tls_passthrough, so + // a TLS passthrough stream route survives a dump/sync round trip instead of + // losing what it matches on. + describe('stream route SNI match and tls_passthrough round-trip', () => { + const snis = ['a.example.com', 'b.example.com']; + + it('FromADC.transformStreamRoute writes snis and tls_passthrough', () => { + const out = new FromADC().transformStreamRoute( + { + id: 'sr1', + name: 'sr1', + snis, + tls_passthrough: true, + } as ADCSDK.StreamRoute, + 'svc1', + ); + expect(out.snis).toEqual(snis); + expect(out.tls_passthrough).toBe(true); + }); + + it('ToADC.transformStreamRoute preserves snis and tls_passthrough on dump', () => { + const out = new ToADC().transformStreamRoute({ + id: 'sr1', + name: 'sr1', + service_id: 'svc1', + stream_route_id: 'sr1', + snis, + tls_passthrough: true, + } as typing.StreamRoute); + expect(out.snis).toEqual(snis); + expect(out.tls_passthrough).toBe(true); + }); + }); + describe('active health check req_headers round-trip', () => { it('ToADC.transformUpstream maps req_headers to ADC http_req_headers', () => { const out = new ToADC().transformUpstream({ diff --git a/libs/backend-apisix-standalone/src/operator.ts b/libs/backend-apisix-standalone/src/operator.ts index 0f3bf5d3..00083c56 100644 --- a/libs/backend-apisix-standalone/src/operator.ts +++ b/libs/backend-apisix-standalone/src/operator.ts @@ -439,6 +439,8 @@ export class Operator extends ADCSDK.backend.BackendEventSource { server_addr: res.server_addr, server_port: res.server_port, sni: res.sni, + snis: res.snis, + tls_passthrough: res.tls_passthrough, service_id: event.parentId!, } satisfies typing.StreamRoute as typing.StreamRoute; } diff --git a/libs/backend-apisix-standalone/src/transformer.ts b/libs/backend-apisix-standalone/src/transformer.ts index b7d975ce..7d2ac936 100644 --- a/libs/backend-apisix-standalone/src/transformer.ts +++ b/libs/backend-apisix-standalone/src/transformer.ts @@ -112,6 +112,8 @@ export const toADC = (input: typing.APISIXStandalone) => { server_addr: route.server_addr, server_port: route.server_port, sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, plugins: route.plugins, })) .map(ADCSDK.utils.recursiveOmitUndefined), diff --git a/libs/backend-apisix-standalone/src/typing.ts b/libs/backend-apisix-standalone/src/typing.ts index f5621d11..92ddfc17 100644 --- a/libs/backend-apisix-standalone/src/typing.ts +++ b/libs/backend-apisix-standalone/src/typing.ts @@ -253,6 +253,8 @@ const StreamRouteSchema = z.strictObject({ server_addr: z.string().optional(), server_port: Port.optional(), sni: z.string().optional(), + snis: z.array(z.string()).min(1).optional(), + tls_passthrough: z.boolean().optional(), service_id: Metadata.id, plugins: Plugins.optional(), protocol: z diff --git a/libs/backend-apisix/src/transformer.ts b/libs/backend-apisix/src/transformer.ts index 863f8e9f..4361031f 100644 --- a/libs/backend-apisix/src/transformer.ts +++ b/libs/backend-apisix/src/transformer.ts @@ -199,6 +199,8 @@ export class ToADC { server_addr: streamRoute.server_addr, server_port: streamRoute.server_port, sni: streamRoute.sni, + snis: streamRoute.snis, + tls_passthrough: streamRoute.tls_passthrough, } as ADCSDK.StreamRoute); } @@ -463,6 +465,8 @@ export class FromADC { server_addr: streamRoute.server_addr, server_port: streamRoute.server_port, sni: streamRoute.sni, + snis: streamRoute.snis, + tls_passthrough: streamRoute.tls_passthrough, service_id: parentId, } as unknown as typing.StreamRoute); } diff --git a/libs/backend-apisix/src/typing.ts b/libs/backend-apisix/src/typing.ts index 153ce8cd..8056ce8c 100644 --- a/libs/backend-apisix/src/typing.ts +++ b/libs/backend-apisix/src/typing.ts @@ -146,6 +146,8 @@ export interface StreamRoute { server_addr?: string; server_port?: number; sni?: string; + snis?: Array; + tls_passthrough?: boolean; upstream?: InlineUpstream; upstream_id?: string; service_id?: string; diff --git a/libs/backend-apisix/test/transformer.spec.ts b/libs/backend-apisix/test/transformer.spec.ts index ac40b845..bd36929b 100644 --- a/libs/backend-apisix/test/transformer.spec.ts +++ b/libs/backend-apisix/test/transformer.spec.ts @@ -76,6 +76,34 @@ describe('Transformer', () => { }); }); + // snis (the plural SNI match) and tls_passthrough are newer gateway stream + // route fields; both directions have to carry them, or a TLS passthrough + // route silently degrades into a terminating one that matches nothing. + describe('stream route snis and tls_passthrough', () => { + const snis = ['a.example.com', 'b.example.com']; + + it('should write snis and tls_passthrough to the wire', () => { + const wire = new FromADC().transformStreamRoute( + { name: 'my-stream-route', snis, tls_passthrough: true }, + 'svc1', + 'native', + ); + expect(wire.snis).toEqual(snis); + expect(wire.tls_passthrough).toBe(true); + }); + + it('should read snis and tls_passthrough back on dump', () => { + expect( + new ToADC().transformStreamRoute({ + id: 'sr1', + name: 'sr1', + snis, + tls_passthrough: true, + } as typing.StreamRoute), + ).toMatchObject({ snis, tls_passthrough: true }); + }); + }); + describe('stream route name persistence', () => { const streamRoute = { name: 'my-stream-route' } as ADCSDK.StreamRoute; diff --git a/libs/sdk/src/core/schema.ts b/libs/sdk/src/core/schema.ts index 1faffa14..0f2bbde7 100644 --- a/libs/sdk/src/core/schema.ts +++ b/libs/sdk/src/core/schema.ts @@ -249,7 +249,11 @@ const streamRouteSchema = z.strictObject({ remote_addr: z.string().optional(), server_addr: z.string().optional(), server_port: portSchema.optional(), + // `sni` and `snis` are singular/plural forms of the same match, and the + // gateway rejects a stream route carrying both. sni: hostSchema.optional(), + snis: z.array(hostSchema).min(1).optional(), + tls_passthrough: z.boolean().optional(), }); export type StreamRoute = z.infer; export { streamRouteSchema }; diff --git a/rust/crates/adc-backend-api7/src/transformer.rs b/rust/crates/adc-backend-api7/src/transformer.rs index df6b7242..721882bb 100644 --- a/rust/crates/adc-backend-api7/src/transformer.rs +++ b/rust/crates/adc-backend-api7/src/transformer.rs @@ -340,7 +340,9 @@ impl From for adc::StreamRoute { remote_addr: route.remote_addr, server_addr: route.server_addr, server_port: route.server_port, - sni: None, + sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, } } } @@ -384,6 +386,9 @@ pub fn transform_stream_route(route: adc::StreamRoute, parent_id: String) -> typ server_addr: route.server_addr, server_port: route.server_port, remote_addr: route.remote_addr, + sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, } } @@ -541,6 +546,8 @@ mod tests { server_addr: None, server_port: None, sni: None, + snis: None, + tls_passthrough: None, }; let wire = transform_stream_route(route, "svc1".to_string()); @@ -565,6 +572,9 @@ mod tests { server_addr: Some("1.1.1.1".to_string()), server_port: Some(80), remote_addr: None, + sni: None, + snis: None, + tls_passthrough: None, }; let route = adc::StreamRoute::from(wire); @@ -572,6 +582,39 @@ mod tests { assert_eq!(route.plugins, Some(ip_restriction_plugins())); } + /// Regression: both stream route conversions used to drop the SNI + /// match outright (the read direction hardcoded `sni: None`), because + /// the control plane had no field to carry it. It does now, together + /// with `snis` and `tls_passthrough`, so a TLS passthrough stream route + /// survives a dump/sync round trip instead of losing what it matches on. + #[test] + fn stream_route_round_trips_its_sni_match_and_tls_passthrough() { + let route = adc::StreamRoute { + id: Some("sr1".to_string()), + name: "sr1".to_string(), + description: None, + labels: None, + plugins: None, + remote_addr: None, + server_addr: None, + server_port: None, + sni: None, + snis: Some(vec!["a.example.com".to_string()]), + tls_passthrough: Some(true), + }; + + let wire = transform_stream_route(route.clone(), "svc1".to_string()); + assert_eq!(wire.snis, Some(vec!["a.example.com".to_string()])); + assert_eq!(wire.tls_passthrough, Some(true)); + + let back = adc::StreamRoute::from(typing::StreamRoute { + id: wire.stream_route_id.clone(), + ..wire + }); + assert_eq!(back.snis, route.snis); + assert_eq!(back.tls_passthrough, route.tls_passthrough); + } + /// `server_port` is `u16` at the wire level too (not a wider int /// narrowed later): a port outside 0-65535 is never a real port, so a /// server response containing one is rejected right at deserialization diff --git a/rust/crates/adc-backend-api7/src/typing.rs b/rust/crates/adc-backend-api7/src/typing.rs index e9a52834..aa14c74e 100644 --- a/rust/crates/adc-backend-api7/src/typing.rs +++ b/rust/crates/adc-backend-api7/src/typing.rs @@ -132,6 +132,12 @@ pub struct StreamRoute { pub server_port: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_addr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_passthrough: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] diff --git a/rust/crates/adc-backend-apisix-standalone/src/transformer.rs b/rust/crates/adc-backend-apisix-standalone/src/transformer.rs index 171f2b13..f1c74408 100644 --- a/rust/crates/adc-backend-apisix-standalone/src/transformer.rs +++ b/rust/crates/adc-backend-apisix-standalone/src/transformer.rs @@ -141,6 +141,8 @@ fn stream_route_to_adc(route: &typing::StreamRoute) -> adc::StreamRoute { server_addr: route.server_addr.clone(), server_port: route.server_port, sni: route.sni.clone(), + snis: route.snis.clone(), + tls_passthrough: route.tls_passthrough, } } @@ -464,6 +466,8 @@ fn stream_route_to_wire(route: &adc::StreamRoute, service_id: &str) -> typing::S server_addr: route.server_addr.clone(), server_port: route.server_port, sni: route.sni.clone(), + snis: route.snis.clone(), + tls_passthrough: route.tls_passthrough, service_id: service_id.to_string(), protocol: None, @@ -850,6 +854,8 @@ mod tests { server_addr: None, server_port: Some(3000 + index as u16 * 10 + k as u16), sni: None, + snis: None, + tls_passthrough: None, }) .collect(), }), diff --git a/rust/crates/adc-backend-apisix-standalone/src/typing.rs b/rust/crates/adc-backend-apisix-standalone/src/typing.rs index 02e0c411..a605f01a 100644 --- a/rust/crates/adc-backend-apisix-standalone/src/typing.rs +++ b/rust/crates/adc-backend-apisix-standalone/src/typing.rs @@ -399,6 +399,10 @@ pub struct StreamRoute { pub server_port: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sni: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_passthrough: Option, pub service_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_conf_version_isolation.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_conf_version_isolation.rs index 8b2d54fc..566fed6d 100644 --- a/rust/crates/adc-backend-apisix-standalone/tests/e2e_conf_version_isolation.rs +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_conf_version_isolation.rs @@ -68,6 +68,8 @@ fn stream_route(name: &str, server_port: u16) -> adc::StreamRoute { server_addr: None, server_port: Some(server_port), sni: None, + snis: None, + tls_passthrough: None, } } diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_stream_route.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_stream_route.rs index 8f0fcca6..0bcc1a2e 100644 --- a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_stream_route.rs +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_stream_route.rs @@ -42,6 +42,8 @@ fn stream_route(name: &str, server_port: u16) -> adc::StreamRoute { server_addr: None, server_port: Some(server_port), sni: None, + snis: None, + tls_passthrough: None, } } diff --git a/rust/crates/adc-backend-apisix/src/transformer.rs b/rust/crates/adc-backend-apisix/src/transformer.rs index 097f06fe..4e7b9976 100644 --- a/rust/crates/adc-backend-apisix/src/transformer.rs +++ b/rust/crates/adc-backend-apisix/src/transformer.rs @@ -394,6 +394,8 @@ impl From for adc::StreamRoute { server_addr: route.server_addr, server_port: route.server_port, sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, } } } @@ -679,6 +681,8 @@ pub fn transform_stream_route( server_addr: route.server_addr, server_port: route.server_port, sni: route.sni, + snis: route.snis, + tls_passthrough: route.tls_passthrough, upstream: None, upstream_id: None, service_id: Some(parent_id), diff --git a/rust/crates/adc-backend-apisix/src/typing.rs b/rust/crates/adc-backend-apisix/src/typing.rs index 0a5753de..7e093451 100644 --- a/rust/crates/adc-backend-apisix/src/typing.rs +++ b/rust/crates/adc-backend-apisix/src/typing.rs @@ -254,6 +254,10 @@ pub struct StreamRoute { #[serde(default, skip_serializing_if = "Option::is_none")] pub sni: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub snis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_passthrough: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub upstream: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub upstream_id: Option, diff --git a/rust/crates/adc-backend-apisix/tests/transformer.rs b/rust/crates/adc-backend-apisix/tests/transformer.rs index da64385f..ee576754 100644 --- a/rust/crates/adc-backend-apisix/tests/transformer.rs +++ b/rust/crates/adc-backend-apisix/tests/transformer.rs @@ -102,6 +102,8 @@ fn adc_stream_route(name: &str) -> adc::StreamRoute { server_addr: None, server_port: None, sni: None, + snis: None, + tls_passthrough: None, } } @@ -453,6 +455,8 @@ fn stream_route_recovers_its_name_from_the_magic_label_and_strips_it() { server_addr: None, server_port: Some(9000), sni: None, + snis: None, + tls_passthrough: None, upstream: None, upstream_id: None, service_id: None, @@ -467,6 +471,45 @@ fn stream_route_recovers_its_name_from_the_magic_label_and_strips_it() { assert_eq!(labels.get("env"), Some(&LabelValue::Single("prod".into()))); } +/// `snis` (the plural SNI match) and `tls_passthrough` are newer gateway +/// stream route fields; both conversions have to carry them, or a TLS +/// passthrough route silently degrades into a terminating one that matches +/// nothing. +#[test] +fn stream_route_carries_snis_and_tls_passthrough_both_ways() { + let wire = typing::StreamRoute { + id: Some("sr1".into()), + name: Some("sr1".into()), + desc: None, + labels: None, + remote_addr: None, + server_addr: None, + server_port: Some(9110), + sni: None, + snis: Some(vec!["a.example.com".into(), "b.example.com".into()]), + tls_passthrough: Some(true), + upstream: None, + upstream_id: None, + service_id: None, + plugins: None, + protocol: None, + }; + + let adc_route: adc::StreamRoute = wire.into(); + assert_eq!( + adc_route.snis, + Some(vec!["a.example.com".into(), "b.example.com".into()]) + ); + assert_eq!(adc_route.tls_passthrough, Some(true)); + + let back = transform_stream_route(adc_route, "svc1".into(), StreamRouteNameMode::Native); + assert_eq!( + back.snis, + Some(vec!["a.example.com".into(), "b.example.com".into()]) + ); + assert_eq!(back.tls_passthrough, Some(true)); +} + #[test] fn stream_route_without_the_magic_label_falls_back_to_id() { let route = typing::StreamRoute { @@ -478,6 +521,8 @@ fn stream_route_without_the_magic_label_falls_back_to_id() { server_addr: None, server_port: None, sni: None, + snis: None, + tls_passthrough: None, upstream: None, upstream_id: None, service_id: None, @@ -502,6 +547,8 @@ fn stream_route_prefers_the_native_name_over_the_magic_label() { server_addr: None, server_port: None, sni: None, + snis: None, + tls_passthrough: None, upstream: None, upstream_id: None, service_id: None, diff --git a/rust/crates/adc-sdk/src/resources/route.rs b/rust/crates/adc-sdk/src/resources/route.rs index adc2b1ef..562e7d01 100644 --- a/rust/crates/adc-sdk/src/resources/route.rs +++ b/rust/crates/adc-sdk/src/resources/route.rs @@ -149,7 +149,14 @@ pub struct StreamRoute { #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1))] pub server_port: Option, + /// `sni` and `snis` are singular/plural forms of the same match, and the + /// gateway rejects a stream route carrying both. #[serde(skip_serializing_if = "Option::is_none")] #[schemars(length(min = 1))] pub sni: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] + pub snis: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls_passthrough: Option, } diff --git a/rust/schema.json b/rust/schema.json index 8832e7a1..0dd05a2c 100644 --- a/rust/schema.json +++ b/rust/schema.json @@ -1037,11 +1037,28 @@ "maximum": 65535 }, "sni": { + "description": "`sni` and `snis` are singular/plural forms of the same match, and the\ngateway rejects a stream route carrying both.", "type": [ "string", "null" ], "minLength": 1 + }, + "snis": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "minItems": 1 + }, + "tls_passthrough": { + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false, diff --git a/schema.json b/schema.json index e442efc2..f803723a 100644 --- a/schema.json +++ b/schema.json @@ -1199,6 +1199,17 @@ "sni": { "type": "string", "minLength": 1 + }, + "snis": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tls_passthrough": { + "type": "boolean" } }, "required": [ From c334420b291aa4664e6affd3c8ae52c53a6f6835 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 16:45:05 +0800 Subject: [PATCH 2/3] fix(sdk): reject an empty SNI entry in the Rust StreamRoute.snis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[schemars(length(min = 1))]` constrains the array, not its entries, so `rust/schema.json` accepted `snis: [""]` while `schema.json` rejected it — the zod element there is `hostSchema` (`z.string().min(1)`). Add `inner(length(min = 1))`, the same pair `SSL.snis` already carries, and regenerate `rust/schema.json`. --- rust/crates/adc-sdk/src/resources/route.rs | 2 +- rust/schema.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/crates/adc-sdk/src/resources/route.rs b/rust/crates/adc-sdk/src/resources/route.rs index 562e7d01..b4a5fad2 100644 --- a/rust/crates/adc-sdk/src/resources/route.rs +++ b/rust/crates/adc-sdk/src/resources/route.rs @@ -155,7 +155,7 @@ pub struct StreamRoute { #[schemars(length(min = 1))] pub sni: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(length(min = 1))] + #[schemars(length(min = 1), inner(length(min = 1)))] pub snis: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tls_passthrough: Option, diff --git a/rust/schema.json b/rust/schema.json index 0dd05a2c..3c660ad3 100644 --- a/rust/schema.json +++ b/rust/schema.json @@ -1050,7 +1050,8 @@ "null" ], "items": { - "type": "string" + "type": "string", + "minLength": 1 }, "minItems": 1 }, From 7440cd810fd001cde7415d4a5a64c6a584443e77 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 17 Sep 2026 17:01:40 +0800 Subject: [PATCH 3/3] test(backend-api7): cover the singular sni in the stream route round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip tests set `sni` to `None`/undefined and asserted only `snis` and `tls_passthrough`, so the regression they exist for — the read direction hardcoding `sni: None`, the write direction never emitting it — would still have passed if reintroduced. Cover it with a case of its own on both sides rather than adding `sni` to the existing fixtures: the gateway rejects a stream route that carries `sni` and `snis` at once, so the two forms cannot share one. --- libs/backend-api7/test/transformer.spec.ts | 21 +++++++++++++ .../adc-backend-api7/src/transformer.rs | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/libs/backend-api7/test/transformer.spec.ts b/libs/backend-api7/test/transformer.spec.ts index 67d42f36..9e0209e1 100644 --- a/libs/backend-api7/test/transformer.spec.ts +++ b/libs/backend-api7/test/transformer.spec.ts @@ -75,6 +75,27 @@ describe('Transformer', () => { expect(out.snis).toEqual(snis); expect(out.tls_passthrough).toBe(true); }); + + // The singular form gets its own case rather than being added to the two + // above: it is the value ToADC used to drop, and the gateway rejects a + // stream route carrying `sni` and `snis` at once. + it('carries the singular sni in both directions', () => { + const wire = new FromADC().transformStreamRoute( + { + id: 'sr1', + name: 'sr1', + sni: 'a.example.com', + } as ADCSDK.StreamRoute, + 'svc1', + ); + expect(wire.sni).toEqual('a.example.com'); + + const out = new ToADC().transformStreamRoute({ + ...wire, + id: 'sr1', + } as typing.StreamRoute); + expect(out.sni).toEqual('a.example.com'); + }); }); describe('active health check req_headers round-trip', () => { diff --git a/rust/crates/adc-backend-api7/src/transformer.rs b/rust/crates/adc-backend-api7/src/transformer.rs index 721882bb..1b3215f5 100644 --- a/rust/crates/adc-backend-api7/src/transformer.rs +++ b/rust/crates/adc-backend-api7/src/transformer.rs @@ -615,6 +615,36 @@ mod tests { assert_eq!(back.tls_passthrough, route.tls_passthrough); } + /// The singular form gets its own case rather than being added to the + /// fixture above: it is the exact value the read direction used to + /// hardcode to `None`, and the gateway rejects a stream route that + /// carries `sni` and `snis` at once, so the two cannot share a fixture. + #[test] + fn stream_route_round_trips_its_singular_sni() { + let route = adc::StreamRoute { + id: Some("sr1".to_string()), + name: "sr1".to_string(), + description: None, + labels: None, + plugins: None, + remote_addr: None, + server_addr: None, + server_port: None, + sni: Some("a.example.com".to_string()), + snis: None, + tls_passthrough: None, + }; + + let wire = transform_stream_route(route.clone(), "svc1".to_string()); + assert_eq!(wire.sni, Some("a.example.com".to_string())); + + let back = adc::StreamRoute::from(typing::StreamRoute { + id: wire.stream_route_id.clone(), + ..wire + }); + assert_eq!(back.sni, route.sni); + } + /// `server_port` is `u16` at the wire level too (not a wider int /// narrowed later): a port outside 0-65535 is never a real port, so a /// server response containing one is rejected right at deserialization