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..9e0209e1 100644 --- a/libs/backend-api7/test/transformer.spec.ts +++ b/libs/backend-api7/test/transformer.spec.ts @@ -41,6 +41,63 @@ 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); + }); + + // 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', () => { 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..1b3215f5 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,69 @@ 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); + } + + /// 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 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..b4a5fad2 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), 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 8832e7a1..3c660ad3 100644 --- a/rust/schema.json +++ b/rust/schema.json @@ -1037,11 +1037,29 @@ "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", + "minLength": 1 + }, + "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": [