From 9c13b6418cbf38d7618e5886d43d2dd5da51da58 Mon Sep 17 00:00:00 2001 From: zijiren <84728412+zijiren233@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:06:42 +0800 Subject: [PATCH] feat: Enhance SSL passthrough support (#3943) Signed-off-by: zijiren233 --- pkg/ingress/config/ingress_config.go | 152 ++- pkg/ingress/config/ingress_config_test.go | 443 ++++++++ pkg/ingress/kube/annotations/annotations.go | 7 + pkg/ingress/kube/annotations/downstreamtls.go | 3 + .../kube/annotations/downstreamtls_test.go | 34 + .../kube/annotations/ssl_passthrough.go | 34 + .../kube/annotations/ssl_passthrough_test.go | 112 ++ pkg/ingress/kube/common/controller.go | 60 ++ pkg/ingress/kube/common/model.go | 38 + pkg/ingress/kube/common/model_test.go | 38 + pkg/ingress/kube/ingress/controller.go | 212 +++- pkg/ingress/kube/ingress/controller_test.go | 958 ++++++++++++++++++ pkg/ingress/kube/ingressv1/controller.go | 227 ++++- pkg/ingress/kube/ingressv1/controller_test.go | 906 +++++++++++++++++ 14 files changed, 3178 insertions(+), 46 deletions(-) create mode 100644 pkg/ingress/kube/annotations/ssl_passthrough.go create mode 100644 pkg/ingress/kube/annotations/ssl_passthrough_test.go diff --git a/pkg/ingress/config/ingress_config.go b/pkg/ingress/config/ingress_config.go index cfb2a7374..6837429cd 100644 --- a/pkg/ingress/config/ingress_config.go +++ b/pkg/ingress/config/ingress_config.go @@ -46,6 +46,8 @@ import ( "istio.io/istio/pkg/log" "istio.io/istio/pkg/util/sets" v1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + networkingv1beta1 "k8s.io/api/networking/v1beta1" listersv1 "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" @@ -438,6 +440,7 @@ func (m *IngressConfig) convertGateways(configs []common.WrapperConfig) []config if err != nil { IngressLog.Errorf("Get higress https configmap err %v", err) } + m.preparePassthroughTLSHostOwners(&convertOptions, configs) for idx := range configs { cfg := configs[idx] clusterId := common.GetClusterId(cfg.Config.Annotations) @@ -504,6 +507,8 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] } } + m.preparePassthroughTLSHostOwners(&convertOptions, configs) + // convert http route for idx := range configs { cfg := configs[idx] @@ -570,13 +575,8 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] m.ingressRouteCache = convertOptions.IngressRouteCache.Extract() m.mutex.Unlock() - // Convert http route to virtual service - out := make([]config.Config, 0, len(convertOptions.HTTPRoutes)) - for host, routes := range convertOptions.HTTPRoutes { - if len(routes) == 0 { - continue - } - + out := make([]config.Config, 0, len(convertOptions.VirtualServices)) + for host, wrapperVS := range convertOptions.VirtualServices { cleanHost := common.CleanHost(host) // namespace/name, name format: (istio cluster id)-host gateways := []string{ @@ -585,13 +585,10 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] common.CreateConvertedName(constants.IstioIngressGatewayName, cleanHost), } - wrapperVS, exist := convertOptions.VirtualServices[host] - if !exist { - IngressLog.Warnf("virtual service for host %s does not exist.", host) - } vs := wrapperVS.VirtualService vs.Gateways = gateways + routes := convertOptions.HTTPRoutes[host] // Sort, exact -> prefix -> regex common.SortHTTPRoutes(routes) @@ -599,14 +596,18 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] vs.Http = append(vs.Http, route.HTTPRoute) } - firstRoute := routes[0] + if len(vs.Http) == 0 && len(vs.Tls) == 0 { + continue + } + + vsName, clusterId := virtualServiceNameAndClusterID(cleanHost, wrapperVS, routes) out = append(out, config.Config{ Meta: config.Meta{ GroupVersionKind: gvk.VirtualService, - Name: common.CreateConvertedName(constants.IstioIngressGatewayName, firstRoute.WrapperConfig.Config.Namespace, firstRoute.WrapperConfig.Config.Name, cleanHost), + Name: vsName, Namespace: m.namespace, Annotations: map[string]string{ - common.ClusterIdAnnotation: firstRoute.ClusterId.String(), + common.ClusterIdAnnotation: clusterId.String(), }, }, Spec: vs, @@ -625,6 +626,129 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] return out } +func virtualServiceNameAndClusterID(cleanHost string, wrapperVS *common.WrapperVirtualService, routes []*common.WrapperHTTPRoute) (string, cluster.ID) { + if len(routes) > 0 { + firstRoute := routes[0] + return common.CreateConvertedName(constants.IstioIngressGatewayName, firstRoute.WrapperConfig.Config.Namespace, firstRoute.WrapperConfig.Config.Name, cleanHost), firstRoute.ClusterId + } + + cfg := wrapperVS.WrapperConfig.Config + return common.CreateConvertedName(constants.IstioIngressGatewayName, cfg.Namespace, cfg.Name, cleanHost), common.GetClusterId(cfg.Annotations) +} + +func (m *IngressConfig) preparePassthroughTLSHostOwners(convertOptions *common.ConvertOptions, configs []common.WrapperConfig) { + if convertOptions.PassthroughTLSHostOwners == nil { + convertOptions.PassthroughTLSHostOwners = map[string]*config.Config{} + } + + // ingress-nginx enables SSL passthrough at host level when any ingress for the host has the + // annotation, then uses the first root path as the passthrough backend. + passthroughHosts := map[string]struct{}{} + firstRootPathHostOwners := map[string]*config.Config{} + for idx := range configs { + cfg := configs[idx] + if cfg.AnnotationsConfig.IsCanary() { + continue + } + + if cfg.AnnotationsConfig.IsSSLPassthrough() { + for _, host := range ingressRuleHosts(cfg.Config.Spec) { + passthroughHosts[host] = struct{}{} + } + } + for _, host := range ingressRootPathHosts(cfg.Config.Spec) { + if _, exist := firstRootPathHostOwners[host]; exist { + continue + } + firstRootPathHostOwners[host] = cfg.Config + } + } + + for host := range passthroughHosts { + if owner := firstRootPathHostOwners[host]; owner != nil { + convertOptions.PassthroughTLSHostOwners[host] = owner + } + } +} + +func ingressRuleHosts(spec config.Spec) []string { + switch ingressSpec := spec.(type) { + case networkingv1.IngressSpec: + return ingressV1RuleHosts(ingressSpec.Rules) + case networkingv1beta1.IngressSpec: + return ingressV1Beta1RuleHosts(ingressSpec.Rules) + default: + return nil + } +} + +func ingressRootPathHosts(spec config.Spec) []string { + switch ingressSpec := spec.(type) { + case networkingv1.IngressSpec: + return ingressV1RootPathHosts(ingressSpec.Rules) + case networkingv1beta1.IngressSpec: + return ingressV1Beta1RootPathHosts(ingressSpec.Rules) + default: + return nil + } +} + +func ingressV1RuleHosts(rules []networkingv1.IngressRule) []string { + out := make([]string, 0, len(rules)) + for _, rule := range rules { + out = append(out, rule.Host) + } + return out +} + +func ingressV1Beta1RuleHosts(rules []networkingv1beta1.IngressRule) []string { + out := make([]string, 0, len(rules)) + for _, rule := range rules { + out = append(out, rule.Host) + } + return out +} + +func ingressV1RootPathHosts(rules []networkingv1.IngressRule) []string { + out := make([]string, 0, len(rules)) + for _, rule := range rules { + if rule.HTTP == nil || !hasV1RootHTTPIngressPath(rule.HTTP.Paths) { + continue + } + out = append(out, rule.Host) + } + return out +} + +func ingressV1Beta1RootPathHosts(rules []networkingv1beta1.IngressRule) []string { + out := make([]string, 0, len(rules)) + for _, rule := range rules { + if rule.HTTP == nil || !hasV1Beta1RootHTTPIngressPath(rule.HTTP.Paths) { + continue + } + out = append(out, rule.Host) + } + return out +} + +func hasV1RootHTTPIngressPath(paths []networkingv1.HTTPIngressPath) bool { + for _, path := range paths { + if path.Path == "" || path.Path == "/" { + return true + } + } + return false +} + +func hasV1Beta1RootHTTPIngressPath(paths []networkingv1beta1.HTTPIngressPath) bool { + for _, path := range paths { + if path.Path == "" || path.Path == "/" { + return true + } + } + return false +} + func (m *IngressConfig) convertEnvoyFilter(convertOptions *common.ConvertOptions) { var envoyFilters []config.Config mappings := map[string]*common.Rule{} diff --git a/pkg/ingress/config/ingress_config_test.go b/pkg/ingress/config/ingress_config_test.go index 025809935..c5f51e32d 100644 --- a/pkg/ingress/config/ingress_config_test.go +++ b/pkg/ingress/config/ingress_config_test.go @@ -23,6 +23,7 @@ import ( networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pkg/cluster" "istio.io/istio/pkg/config" + "istio.io/istio/pkg/config/constants" "istio.io/istio/pkg/config/schema/gvk" "istio.io/istio/pkg/config/xds" ingress "k8s.io/api/networking/v1" @@ -109,6 +110,405 @@ func TestNormalizeWeightedCluster(t *testing.T) { } } +func TestVirtualServiceNameAndClusterID(t *testing.T) { + cleanHost := common.CleanHost("example.com") + wrapperVS := &common.WrapperVirtualService{ + WrapperConfig: &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "tls-ns", + Name: "tls-ingress", + Annotations: map[string]string{ + common.ClusterIdAnnotation: "tls-cluster", + }, + }, + }, + }, + } + routes := []*common.WrapperHTTPRoute{ + { + WrapperConfig: &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "http-ns", + Name: "http-ingress", + }, + }, + }, + ClusterId: "http-cluster", + }, + } + + name, clusterID := virtualServiceNameAndClusterID(cleanHost, wrapperVS, routes) + if name != common.CreateConvertedName(constants.IstioIngressGatewayName, "http-ns", "http-ingress", cleanHost) { + t.Fatalf("http-backed virtual service name mismatch: %s", name) + } + if clusterID != "http-cluster" { + t.Fatalf("http-backed cluster id mismatch: %s", clusterID) + } + + name, clusterID = virtualServiceNameAndClusterID(cleanHost, wrapperVS, nil) + if name != common.CreateConvertedName(constants.IstioIngressGatewayName, "tls-ns", "tls-ingress", cleanHost) { + t.Fatalf("tls-only virtual service name mismatch: %s", name) + } + if clusterID != "tls-cluster" { + t.Fatalf("tls-only cluster id mismatch: %s", clusterID) + } +} + +func TestPreparePassthroughTLSHostOwnersRequiresPassthroughHost(t *testing.T) { + m := &IngressConfig{} + configs := []common.WrapperConfig{ + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "plain-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "plain-root-duplicate", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + } + + options := &common.ConvertOptions{} + m.preparePassthroughTLSHostOwners(options, configs) + + if len(options.PassthroughTLSHostOwners) != 0 { + t.Fatalf("unexpected ssl passthrough owners: %+v", options.PassthroughTLSHostOwners) + } +} + +func TestPreparePassthroughTLSHostOwnersUsesFirstRootPathOwner(t *testing.T) { + m := &IngressConfig{} + configs := []common.WrapperConfig{ + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "plain-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "passthrough-non-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/api"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + }, + } + + options := &common.ConvertOptions{} + m.preparePassthroughTLSHostOwners(options, configs) + + if !common.IsPassthroughTLSHostOwner(options, configs[0].Config, "example.com") { + t.Fatal("first root ingress was not recorded as passthrough owner") + } + if !common.HasPassthroughTLSHostOwner(options, configs[0].Config) { + t.Fatal("first root ingress was not found as passthrough owner") + } +} + +func TestPreparePassthroughTLSHostOwnersIgnoresHTTPOnlyIngressForHTTPSFallback(t *testing.T) { + m := &IngressConfig{} + configs := []common.WrapperConfig{ + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "http-only", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/api"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-ingress", + }, + Spec: ingress.IngressSpec{ + TLS: []ingress.IngressTLS{ + { + Hosts: []string{"example.com"}, + SecretName: "example-com", + }, + }, + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/app"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + } + + options := &common.ConvertOptions{} + m.preparePassthroughTLSHostOwners(options, configs) + + if len(options.PassthroughTLSHostOwners) != 0 { + t.Fatalf("unexpected ssl passthrough owners: %+v", options.PassthroughTLSHostOwners) + } +} + +func TestConvertGatewaysHonorsFirstRootPathSSLPassthroughOwner(t *testing.T) { + fake := kube.NewFakeClient() + options := common.Options{ + Enable: true, + ClusterId: "ingress-v1", + RawClusterId: "ingress-v1__", + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + } + ingressController := controllerv1.NewController(fake, fake, options, nil) + m := NewIngressConfig(fake, nil, "wakanda", options) + m.remoteIngressControllers = map[cluster.ID]common.IngressController{ + "ingress-v1": ingressController, + } + + configs := []common.WrapperConfig{ + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-non-root", + Annotations: map[string]string{ + common.ClusterIdAnnotation: "ingress-v1", + }, + }, + Spec: ingress.IngressSpec{ + TLS: []ingress.IngressTLS{ + { + Hosts: []string{"example.com"}, + SecretName: "example-com", + }, + }, + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/api"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + }, + { + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "passthrough-root", + Annotations: map[string]string{ + common.ClusterIdAnnotation: "ingress-v1", + }, + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + {Path: "/"}, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + }, + } + + result := m.convertGateways(configs) + if len(result) != 1 { + t.Fatalf("gateway count mismatch, want 1, got %d", len(result)) + } + gateway := result[0].Spec.(*networking.Gateway) + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + tlsServer := gateway.Servers[1] + if tlsServer.Port.Protocol != "TLS" { + t.Fatalf("tls server protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol) + } + if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode()) + } +} + +func TestConvertGatewaysUsesFirstRootOwnerWhenLaterIngressEnablesSSLPassthrough(t *testing.T) { + fake := kube.NewFakeClient() + options := common.Options{ + Enable: true, + ClusterId: "ingress-v1", + RawClusterId: "ingress-v1__", + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + } + ingressController := controllerv1.NewController(fake, fake, options, nil) + m := NewIngressConfig(fake, nil, "wakanda", options) + m.remoteIngressControllers = map[cluster.ID]common.IngressController{ + "ingress-v1": ingressController, + } + + configs := []common.WrapperConfig{ + ingressV1Wrapper("root", "example.com", "/", false), + ingressV1Wrapper("passthrough", "example.com", "/passthrough", true), + } + + result := m.convertGateways(configs) + if len(result) != 1 { + t.Fatalf("gateway count mismatch, want 1, got %d", len(result)) + } + gateway := result[0].Spec.(*networking.Gateway) + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + tlsServer := gateway.Servers[1] + if tlsServer.Port.Protocol != "TLS" { + t.Fatalf("tls server protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol) + } + if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode()) + } +} + +func TestConvertVirtualServiceUsesFirstRootOwnerWhenLaterIngressEnablesSSLPassthrough(t *testing.T) { + fake := kube.NewFakeClient() + options := common.Options{ + Enable: true, + ClusterId: "ingress-v1", + RawClusterId: "ingress-v1__", + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + } + ingressController := controllerv1.NewController(fake, fake, options, nil) + m := NewIngressConfig(fake, nil, "wakanda", options) + m.remoteIngressControllers = map[cluster.ID]common.IngressController{ + "ingress-v1": ingressController, + } + + configs := []common.WrapperConfig{ + ingressV1Wrapper("root", "example.com", "/", false), + ingressV1Wrapper("passthrough", "example.com", "/passthrough", true), + } + + result := m.convertVirtualService(configs) + if len(result) != 1 { + t.Fatalf("virtual service count mismatch, want 1, got %d", len(result)) + } + vs := result[0].Spec.(*networking.VirtualService) + if len(vs.Tls) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls)) + } + if got := vs.Tls[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got) + } +} + func TestConvertGatewaysForIngress(t *testing.T) { fake := kube.NewFakeClient() v1Beta1Options := common.Options{ @@ -616,3 +1016,46 @@ func TestConstructBasicAuthEnvoyFilter(t *testing.T) { target := proto.Clone(pb).(*httppb.HttpFilter) t.Log(target) } + +func ingressV1Wrapper(name, host, path string, sslPassthrough bool) common.WrapperConfig { + wrapper := common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: name, + Annotations: map[string]string{ + common.ClusterIdAnnotation: "ingress-v1", + }, + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: host, + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: path, + Backend: ingress.IngressBackend{ + Service: &ingress.IngressServiceBackend{ + Name: name, + Port: ingress.ServiceBackendPort{Number: 443}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + Match: &annotations.MatchConfig{}, + }, + } + if sslPassthrough { + wrapper.AnnotationsConfig.SSLPassthrough = &annotations.SSLPassthroughConfig{Enabled: true} + } + return wrapper +} diff --git a/pkg/ingress/kube/annotations/annotations.go b/pkg/ingress/kube/annotations/annotations.go index f26ccf156..f695ab83c 100644 --- a/pkg/ingress/kube/annotations/annotations.go +++ b/pkg/ingress/kube/annotations/annotations.go @@ -57,6 +57,8 @@ type Ingress struct { DownstreamTLS *DownstreamTLSConfig + SSLPassthrough *SSLPassthroughConfig + Canary *CanaryConfig IPAccessControl *IPAccessControlConfig @@ -115,6 +117,10 @@ func (i *Ingress) IsCanary() bool { return i.Canary.Enabled } +func (i *Ingress) IsSSLPassthrough() bool { + return i.SSLPassthrough != nil && i.SSLPassthrough.Enabled +} + // CanaryKind return byHeader, byWeight func (i *Ingress) CanaryKind() (bool, bool) { if !i.IsCanary() { @@ -157,6 +163,7 @@ func NewAnnotationHandlerManager() AnnotationHandler { canary{}, cors{}, downstreamTLS{}, + sslPassthrough{}, redirect{}, rewrite{}, upstreamTLS{}, diff --git a/pkg/ingress/kube/annotations/downstreamtls.go b/pkg/ingress/kube/annotations/downstreamtls.go index 319e28a92..c218d8fd6 100644 --- a/pkg/ingress/kube/annotations/downstreamtls.go +++ b/pkg/ingress/kube/annotations/downstreamtls.go @@ -106,6 +106,9 @@ func (d downstreamTLS) ApplyGateway(gateway *networking.Gateway, config *Ingress downstreamTLSConfig := config.DownstreamTLS for _, server := range gateway.Servers { if gatewaytool.IsTLSServer(server) { + if server.Tls != nil && server.Tls.Mode == networking.ServerTLSSettings_PASSTHROUGH { + continue + } if downstreamTLSConfig.CASecretName.Name != "" { serverCert := extraSecret(server.Tls.CredentialName) if downstreamTLSConfig.CASecretName.Namespace != serverCert.Namespace || diff --git a/pkg/ingress/kube/annotations/downstreamtls_test.go b/pkg/ingress/kube/annotations/downstreamtls_test.go index 1bf36517a..886932254 100644 --- a/pkg/ingress/kube/annotations/downstreamtls_test.go +++ b/pkg/ingress/kube/annotations/downstreamtls_test.go @@ -269,6 +269,40 @@ func TestApplyGateway(t *testing.T) { }, }, }, + { + name: "skip passthrough server", + input: &networking.Gateway{ + Servers: []*networking.Server{ + { + Port: &networking.Port{ + Protocol: "TLS", + }, + Tls: &networking.ServerTLSSettings{ + Mode: networking.ServerTLSSettings_PASSTHROUGH, + }, + }, + }, + }, + config: &Ingress{ + DownstreamTLS: &DownstreamTLSConfig{ + CipherSuites: []string{"ECDHE-RSA-AES256-GCM-SHA384"}, + MinVersion: "TLSv1.2", + MaxVersion: "TLSv1.3", + }, + }, + expect: &networking.Gateway{ + Servers: []*networking.Server{ + { + Port: &networking.Port{ + Protocol: "TLS", + }, + Tls: &networking.ServerTLSSettings{ + Mode: networking.ServerTLSSettings_PASSTHROUGH, + }, + }, + }, + }, + }, } for _, tc := range testCases { diff --git a/pkg/ingress/kube/annotations/ssl_passthrough.go b/pkg/ingress/kube/annotations/ssl_passthrough.go new file mode 100644 index 000000000..90125f245 --- /dev/null +++ b/pkg/ingress/kube/annotations/ssl_passthrough.go @@ -0,0 +1,34 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package annotations + +const sslPassthroughAnnotation = "ssl-passthrough" + +var _ Parser = &sslPassthrough{} + +type SSLPassthroughConfig struct { + Enabled bool +} + +type sslPassthrough struct{} + +func (s sslPassthrough) Parse(annotations Annotations, config *Ingress, _ *GlobalContext) error { + enabled, err := annotations.ParseBoolASAP(sslPassthroughAnnotation) + if err != nil { + return nil + } + config.SSLPassthrough = &SSLPassthroughConfig{Enabled: enabled} + return nil +} diff --git a/pkg/ingress/kube/annotations/ssl_passthrough_test.go b/pkg/ingress/kube/annotations/ssl_passthrough_test.go new file mode 100644 index 000000000..64b5bab9e --- /dev/null +++ b/pkg/ingress/kube/annotations/ssl_passthrough_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package annotations + +import "testing" + +func TestSSLPassthroughParse(t *testing.T) { + testCases := []struct { + name string + input Annotations + enabled bool + exists bool + }{ + { + name: "missing", + input: Annotations{}, + }, + { + name: "enabled by nginx annotation", + input: Annotations{ + buildNginxAnnotationKey(sslPassthroughAnnotation): "true", + }, + enabled: true, + exists: true, + }, + { + name: "enabled by higress annotation", + input: Annotations{ + buildHigressAnnotationKey(sslPassthroughAnnotation): "true", + }, + enabled: true, + exists: true, + }, + { + name: "disabled by nginx annotation", + input: Annotations{ + buildNginxAnnotationKey(sslPassthroughAnnotation): "false", + }, + exists: true, + }, + { + name: "disabled by higress annotation", + input: Annotations{ + buildHigressAnnotationKey(sslPassthroughAnnotation): "false", + }, + exists: true, + }, + } + + parser := sslPassthrough{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + config := &Ingress{} + if err := parser.Parse(tc.input, config, nil); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if tc.exists && config.SSLPassthrough == nil { + t.Fatal("expected ssl passthrough config") + } + if !tc.exists && config.SSLPassthrough != nil { + t.Fatal("unexpected ssl passthrough config") + } + if tc.exists && config.SSLPassthrough.Enabled != tc.enabled { + t.Fatalf("enabled mismatch, want %v, got %v", tc.enabled, config.SSLPassthrough.Enabled) + } + }) + } +} + +func TestSSLPassthroughDoesNotSetUpstreamTLS(t *testing.T) { + parser := sslPassthrough{} + config := &Ingress{} + err := parser.Parse(Annotations{ + buildNginxAnnotationKey(sslPassthroughAnnotation): "true", + }, config, nil) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if config.UpstreamTLS != nil { + t.Fatal("unexpected upstream tls config") + } +} + +func TestSSLPassthroughKeepsExplicitBackendProtocol(t *testing.T) { + manager := NewAnnotationHandlerManager() + config := &Ingress{} + err := manager.Parse(Annotations{ + buildNginxAnnotationKey(sslPassthroughAnnotation): "true", + buildNginxAnnotationKey(backendProtocol): "HTTPS", + }, config, nil) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if config.UpstreamTLS == nil { + t.Fatal("expected upstream tls config") + } + if config.UpstreamTLS.BackendProtocol != "HTTPS" { + t.Fatalf("backend protocol mismatch, want HTTPS, got %s", config.UpstreamTLS.BackendProtocol) + } +} diff --git a/pkg/ingress/kube/common/controller.go b/pkg/ingress/kube/common/controller.go index d4fccdd17..f66723072 100644 --- a/pkg/ingress/kube/common/controller.go +++ b/pkg/ingress/kube/common/controller.go @@ -15,6 +15,7 @@ package common import ( + "strconv" "strings" "time" @@ -23,6 +24,7 @@ import ( "istio.io/istio/pkg/cluster" "istio.io/istio/pkg/config" gatewaytool "istio.io/istio/pkg/config/gateway" + "istio.io/istio/pkg/config/protocol" listerv1 "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" @@ -78,6 +80,20 @@ func (w *WrapperGateway) IsHTTPS() bool { return false } +func CreateSSLPassthroughServer(host string, port uint32, clusterId cluster.ID) *networking.Server { + return &networking.Server{ + Port: &networking.Port{ + Number: port, + Protocol: string(protocol.TLS), + Name: CreateConvertedName("tls-"+strconv.FormatUint(uint64(port), 10)+"-ingress", clusterId.String()), + }, + Hosts: []string{WildcardHost(host)}, + Tls: &networking.ServerTLSSettings{ + Mode: networking.ServerTLSSettings_PASSTHROUGH, + }, + } +} + type WrapperHTTPRoute struct { HTTPRoute *networking.HTTPRoute WrapperConfig *WrapperConfig @@ -111,6 +127,50 @@ type WrapperVirtualService struct { AppRoot string } +func (w *WrapperVirtualService) HasTLSRouteForHost(host string) bool { + if w == nil || w.VirtualService == nil { + return false + } + host = WildcardHost(host) + for _, route := range w.VirtualService.Tls { + for _, match := range route.Match { + for _, sniHost := range match.SniHosts { + if WildcardHost(sniHost) == host { + return true + } + } + } + } + return false +} + +func NewWrapperVirtualService(host string, wrapper *WrapperConfig) *WrapperVirtualService { + return &WrapperVirtualService{ + VirtualService: &networking.VirtualService{ + Hosts: []string{WildcardHost(host)}, + }, + WrapperConfig: wrapper, + } +} + +func CreateTLSRoute(host string, routeDestination []*networking.RouteDestination) *networking.TLSRoute { + return &networking.TLSRoute{ + Match: []*networking.TLSMatchAttributes{ + { + SniHosts: []string{WildcardHost(host)}, + }, + }, + Route: routeDestination, + } +} + +func WildcardHost(host string) string { + if host == "" { + return "*" + } + return host +} + type WrapperTrafficPolicy struct { TrafficPolicy *networking.TrafficPolicy PortTrafficPolicy *networking.TrafficPolicy_PortTrafficPolicy diff --git a/pkg/ingress/kube/common/model.go b/pkg/ingress/kube/common/model.go index 7a628e3b6..e60b86363 100644 --- a/pkg/ingress/kube/common/model.go +++ b/pkg/ingress/kube/common/model.go @@ -145,6 +145,41 @@ func (i *IngressDomainCache) Extract() model.IngressDomainCollection { } } +func SameConfig(left *config.Config, right *config.Config) bool { + if left == nil || right == nil { + return left == right + } + return GetClusterId(left.Annotations) == GetClusterId(right.Annotations) && + left.Namespace == right.Namespace && + left.Name == right.Name +} + +func IsPassthroughTLSHostOwner(convertOptions *ConvertOptions, cfg *config.Config, host string) bool { + if convertOptions == nil || convertOptions.PassthroughTLSHostOwners == nil { + return true + } + return SameConfig(convertOptions.PassthroughTLSHostOwners[host], cfg) +} + +func PassthroughTLSHostOwner(convertOptions *ConvertOptions, host string) *config.Config { + if convertOptions == nil || len(convertOptions.PassthroughTLSHostOwners) == 0 { + return nil + } + return convertOptions.PassthroughTLSHostOwners[host] +} + +func HasPassthroughTLSHostOwner(convertOptions *ConvertOptions, cfg *config.Config) bool { + if convertOptions == nil || len(convertOptions.PassthroughTLSHostOwners) == 0 { + return false + } + for _, owner := range convertOptions.PassthroughTLSHostOwners { + if SameConfig(owner, cfg) { + return true + } + } + return false +} + type ConvertOptions struct { HostWithRule2Ingress map[string]*config.Config @@ -167,6 +202,9 @@ type ConvertOptions struct { CanaryIngresses []*WrapperConfig + // Host to the first root-path ingress owner for hosts that have TLS passthrough enabled. + PassthroughTLSHostOwners map[string]*config.Config + Service2TrafficPolicy map[ServiceKey]*WrapperTrafficPolicy ServiceWrappers map[string]*ServiceWrapper diff --git a/pkg/ingress/kube/common/model_test.go b/pkg/ingress/kube/common/model_test.go index 68157426a..0cad672b2 100644 --- a/pkg/ingress/kube/common/model_test.go +++ b/pkg/ingress/kube/common/model_test.go @@ -18,10 +18,48 @@ import ( "testing" "github.com/stretchr/testify/assert" + networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pilot/pkg/model" "istio.io/istio/pkg/config" ) +func TestWildcardHostForSSLPassthrough(t *testing.T) { + server := CreateSSLPassthroughServer("", 443, "") + assert.Equal(t, []string{"*"}, server.Hosts) + + vs := NewWrapperVirtualService("", &WrapperConfig{}) + assert.Equal(t, []string{"*"}, vs.VirtualService.Hosts) + + route := CreateTLSRoute("", []*networking.RouteDestination{{Weight: 100}}) + assert.Equal(t, []string{"*"}, route.Match[0].SniHosts) + vs.VirtualService.Tls = append(vs.VirtualService.Tls, route) + assert.True(t, vs.HasTLSRouteForHost("")) +} + +func TestPassthroughTLSHostOwnerNilMapAllowsStandaloneConversion(t *testing.T) { + cfg := &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough", + }, + } + + // A nil owner map means the caller did not prepare ownership from the full ingress snapshot. + assert.True(t, IsPassthroughTLSHostOwner(&ConvertOptions{}, cfg, "example.com")) + assert.Nil(t, PassthroughTLSHostOwner(&ConvertOptions{}, "example.com")) + + // A non-nil owner map means ownership has been prepared and missing hosts have no owner. + options := &ConvertOptions{ + PassthroughTLSHostOwners: map[string]*config.Config{}, + } + assert.False(t, IsPassthroughTLSHostOwner(options, cfg, "example.com")) + assert.Nil(t, PassthroughTLSHostOwner(options, "example.com")) + + options.PassthroughTLSHostOwners["example.com"] = cfg + assert.True(t, IsPassthroughTLSHostOwner(options, cfg, "example.com")) + assert.Equal(t, cfg, PassthroughTLSHostOwner(options, "example.com")) +} + func TestIngressDomainCache(t *testing.T) { cache := NewIngressDomainCache() assert.NotNil(t, cache) diff --git a/pkg/ingress/kube/ingress/controller.go b/pkg/ingress/kube/ingress/controller.go index c1aa6ad23..47fb2c464 100644 --- a/pkg/ingress/kube/ingress/controller.go +++ b/pkg/ingress/kube/ingress/controller.go @@ -409,7 +409,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp Protocol: string(protocol.HTTP), Name: common.CreateConvertedName("http-80-ingress", c.options.ClusterId.String()), }, - Hosts: []string{rule.Host}, + Hosts: []string{common.WildcardHost(rule.Host)}, }) // Add new gateway, builder @@ -422,6 +422,45 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp } } + passthroughOwner := common.PassthroughTLSHostOwner(convertOptions, rule.Host) + standaloneSSLPassthrough := convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough() + if common.SameConfig(passthroughOwner, cfg) || standaloneSSLPassthrough { + if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 { + continue + } + if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); !ok { + continue + } + + domainBuilder.Protocol = common.HTTPS + if wrapperGateway.IsHTTPS() { + if common.SameConfig(preDomainBuilder.Ingress, cfg) { + continue + } + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = preDomainBuilder.Ingress + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + continue + } + wrapperGateway.Gateway.Servers = append(wrapperGateway.Gateway.Servers, + common.CreateSSLPassthroughServer(rule.Host, c.options.GatewayHttpsPort, c.options.ClusterId)) + convertOptions.IngressDomainCache.Valid[rule.Host] = domainBuilder + continue + } + if wrapper.AnnotationsConfig.IsSSLPassthrough() { + if rule.HTTP != nil { + if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); ok && passthroughOwner != nil { + domainBuilder.Protocol = common.HTTPS + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = passthroughOwner + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + } + } + continue + } + // There are no tls settings, so just skip. if len(ingressV1Beta.TLS) == 0 { continue @@ -470,6 +509,14 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp domainBuilder.SecretName = path.Join(c.options.ClusterId.String(), cfg.Namespace, secretName) + if passthroughOwner != nil { + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = passthroughOwner + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + continue + } + // There is a matching secret and the gateway has already a tls secret. // We should report the duplicated tls secret event. if wrapperGateway.IsHTTPS() { @@ -487,7 +534,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp Protocol: string(protocol.HTTPS), Name: common.CreateConvertedName("https-443-ingress", c.options.ClusterId.String()), }, - Hosts: []string{rule.Host}, + Hosts: []string{common.WildcardHost(rule.Host)}, Tls: &networking.ServerTLSSettings{ Mode: networking.ServerTLSSettings_SIMPLE, CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName), @@ -515,6 +562,19 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra return nil } + if convertOptions.Route2Ingress == nil { + convertOptions.Route2Ingress = map[string]*common.WrapperConfigWithRuleKey{} + } + if convertOptions.IngressRouteCache == nil { + convertOptions.IngressRouteCache = common.NewIngressRouteCache() + } + if convertOptions.VirtualServices == nil { + convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{} + } + if convertOptions.HTTPRoutes == nil { + convertOptions.HTTPRoutes = map[string][]*common.WrapperHTTPRoute{} + } + cfg := wrapper.Config ingressV1, ok := cfg.Spec.(ingress.IngressSpec) if !ok { @@ -546,12 +606,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra wrapperVS, exist := convertOptions.VirtualServices[rule.Host] if !exist { - wrapperVS = &common.WrapperVirtualService{ - VirtualService: &networking.VirtualService{ - Hosts: []string{rule.Host}, - }, - WrapperConfig: wrapper, - } + wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper) convertOptions.VirtualServices[rule.Host] = wrapperVS } @@ -579,7 +634,11 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra pathType = common.PrefixRegex } } else { - switch *httpPath.PathType { + ingressPathType := defaultPathType + if httpPath.PathType != nil { + ingressPathType = *httpPath.PathType + } + switch ingressPathType { case ingress.PathTypeExact: pathType = common.Exact case ingress.PathTypePrefix: @@ -661,9 +720,84 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra common.SortHTTPRoutes(routes) } + if common.HasPassthroughTLSHostOwner(convertOptions, cfg) || + (convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()) { + return c.ConvertTLSRoute(convertOptions, wrapper) + } + return nil } +func (c *controller) ConvertTLSRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error { + if convertOptions == nil { + return fmt.Errorf("convertOptions is nil") + } + if wrapper == nil { + return fmt.Errorf("wrapperConfig is nil") + } + + if convertOptions.VirtualServices == nil { + convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{} + } + + cfg := wrapper.Config + ingressV1Beta, ok := cfg.Spec.(ingress.IngressSpec) + if !ok { + common.IncrementInvalidIngress(c.options.ClusterId, common.Unknown) + return fmt.Errorf("convert type is invalid in cluster %s", c.options.ClusterId) + } + if len(ingressV1Beta.Rules) == 0 { + common.IncrementInvalidIngress(c.options.ClusterId, common.EmptyRule) + return fmt.Errorf("invalid ingress rule %s:%s in cluster %s, `rules` must be specified", cfg.Namespace, cfg.Name, c.options.ClusterId) + } + + for _, rule := range ingressV1Beta.Rules { + if !common.IsPassthroughTLSHostOwner(convertOptions, cfg, rule.Host) { + IngressLog.Warnf("ignore duplicated ssl passthrough ingress rule %s:%s for host %q in cluster %s", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 { + IngressLog.Warnf("invalid ssl passthrough ingress rule %s:%s for host %q in cluster %s, no paths defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + httpPath, ok := rootHTTPIngressPath(rule.HTTP.Paths) + if !ok { + IngressLog.Warnf("ignore ssl passthrough ingress rule %s:%s for host %q in cluster %s, root path is not defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + wrapperVS, exist := convertOptions.VirtualServices[rule.Host] + if !exist { + wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper) + convertOptions.VirtualServices[rule.Host] = wrapperVS + } else if wrapperVS.HasTLSRouteForHost(rule.Host) { + continue + } + + routeDestination, event := c.backendToTLSRouteDestination(&httpPath.Backend, cfg.Namespace, wrapper.AnnotationsConfig.Destination) + if event != common.Normal { + common.IncrementInvalidIngress(c.options.ClusterId, event) + continue + } + + wrapperVS.VirtualService.Tls = append(wrapperVS.VirtualService.Tls, + common.CreateTLSRoute(rule.Host, routeDestination)) + } + + return nil +} + +func rootHTTPIngressPath(paths []ingress.HTTPIngressPath) (*ingress.HTTPIngressPath, bool) { + for idx := range paths { + if paths[idx].Path == "" || paths[idx].Path == "/" { + return &paths[idx], true + } + } + return nil, false +} + func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error { if convertOptions == nil { return fmt.Errorf("convertOptions is nil") @@ -691,12 +825,7 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions, wirecardVS, exist := convertOptions.VirtualServices[host] if !exist || !wirecardVS.ConfiguredDefaultBackend { if !exist { - wirecardVS = &common.WrapperVirtualService{ - VirtualService: &networking.VirtualService{ - Hosts: []string{host}, - }, - WrapperConfig: wrapper, - } + wirecardVS = common.NewWrapperVirtualService(host, wrapper) } specDefaultBackend := c.createDefaultRoute(wrapper, ingressV1Beta1.Backend, "*") @@ -790,7 +919,11 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w pathType = common.PrefixRegex } } else { - switch *httpPath.PathType { + ingressPathType := defaultPathType + if httpPath.PathType != nil { + ingressPathType = *httpPath.PathType + } + switch ingressPathType { case ingress.PathTypeExact: pathType = common.Exact case ingress.PathTypePrefix: @@ -1092,6 +1225,53 @@ func (c *controller) backendToRouteDestination(backend *ingress.IngressBackend, }, common.Normal } +func (c *controller) backendToTLSRouteDestination(backend *ingress.IngressBackend, namespace string, + config *annotations.DestinationConfig, +) ([]*networking.RouteDestination, common.Event) { + if backend == nil { + return nil, common.InvalidBackendService + } + + if backend.ServiceName == "" { + if config != nil && len(config.McpDestination) > 0 { + return httpRouteDestinationToRouteDestination(config.McpDestination), common.Normal + } + return nil, common.InvalidBackendService + } + + port := &networking.PortSelector{} + if backend.ServicePort.Type == intstr.Int { + port.Number = uint32(backend.ServicePort.IntVal) + } else { + resolvedPort, err := resolveNamedPort(backend, namespace, c.serviceLister) + if err != nil { + return nil, common.PortNameResolveError + } + port.Number = uint32(resolvedPort) + } + + return []*networking.RouteDestination{ + { + Destination: &networking.Destination{ + Host: util.CreateServiceFQDN(namespace, backend.ServiceName), + Port: port, + }, + Weight: 100, + }, + }, common.Normal +} + +func httpRouteDestinationToRouteDestination(destinations []*networking.HTTPRouteDestination) []*networking.RouteDestination { + out := make([]*networking.RouteDestination, 0, len(destinations)) + for _, destination := range destinations { + out = append(out, &networking.RouteDestination{ + Destination: destination.Destination, + Weight: destination.Weight, + }) + } + return out +} + func resolveNamedPort(backend *ingress.IngressBackend, namespace string, serviceLister listerv1.ServiceLister) (int32, error) { if backend == nil { return 0, fmt.Errorf("ingressBackend is nil") diff --git a/pkg/ingress/kube/ingress/controller_test.go b/pkg/ingress/kube/ingress/controller_test.go index b0f292c3e..13632dcdf 100644 --- a/pkg/ingress/kube/ingress/controller_test.go +++ b/pkg/ingress/kube/ingress/controller_test.go @@ -16,9 +16,11 @@ package ingress import ( "context" + "strings" "testing" "time" + "github.com/alibaba/higress/v2/pkg/cert" "github.com/google/go-cmp/cmp" "istio.io/api/networking/v1alpha3" "istio.io/istio/pilot/pkg/model" @@ -68,6 +70,962 @@ func TestIngressControllerApplies(t *testing.T) { } } +func TestSSLPassthroughConvertGatewayAndTLSRoute(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "app", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + gatewayOptions := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + gateway := gatewayOptions.Gateways["example.com"].Gateway + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + tlsServer := gateway.Servers[1] + if tlsServer.Port.Protocol != "TLS" { + t.Fatalf("protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol) + } + if tlsServer.Port.Number != 443 { + t.Fatalf("port mismatch, want 443, got %d", tlsServer.Port.Number) + } + if tlsServer.Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode()) + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + httpRoutes := routeOptions.HTTPRoutes["example.com"] + if len(httpRoutes) != 1 { + t.Fatalf("http route count mismatch, want 1, got %d", len(httpRoutes)) + } + if got := httpRoutes[0].HTTPRoute.Route[0].Destination.Host; got != "app.default.svc.cluster.local" { + t.Fatalf("http destination host mismatch, got %s", got) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + route := routes[0] + if got := route.Match[0].SniHosts[0]; got != "example.com" { + t.Fatalf("sni host mismatch, want example.com, got %s", got) + } + if got := route.Route[0].Destination.Host; got != "app.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } + if got := route.Route[0].Destination.Port.Number; got != 443 { + t.Fatalf("destination port mismatch, got %d", got) + } +} + +func TestSSLPassthroughConvertTLSRouteRejectsNilInputs(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{}, + AnnotationsConfig: &annotations.Ingress{}, + } + + if err := c.ConvertTLSRoute(nil, wrapper); err == nil { + t.Fatal("ConvertTLSRoute() with nil convertOptions returned nil error") + } + if err := c.ConvertTLSRoute(&common.ConvertOptions{}, nil); err == nil { + t.Fatal("ConvertTLSRoute() with nil wrapper returned nil error") + } +} + +func TestSSLPassthroughUsesConfiguredHTTPSPort(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 8443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "app", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + gatewayOptions := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + tlsServer := gatewayOptions.Gateways["example.com"].Gateway.Servers[1] + if tlsServer.Port.Number != 8443 { + t.Fatalf("port mismatch, want 8443, got %d", tlsServer.Port.Number) + } +} + +func TestSSLPassthroughCanaryIngressKeepsCanaryHandling(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-canary", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "app-canary", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + Canary: &annotations.CanaryConfig{Enabled: true}, + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if len(routeOptions.CanaryIngresses) != 1 { + t.Fatalf("canary ingress count mismatch, want 1, got %d", len(routeOptions.CanaryIngresses)) + } + if len(routeOptions.VirtualServices) != 0 { + t.Fatalf("unexpected virtual services: %+v", routeOptions.VirtualServices) + } +} + +func TestSSLPassthroughSkipsDuplicatedTLSHost(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + primary := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-primary", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "primary", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + duplicate := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-duplicate", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "duplicate", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config}, + } + if err := c.ConvertGateway(options, primary, nil); err != nil { + t.Fatalf("ConvertGateway(primary) error = %v", err) + } + if err := c.ConvertGateway(options, duplicate, nil); err != nil { + t.Fatalf("ConvertGateway(duplicate) error = %v", err) + } + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, duplicate); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + if len(options.VirtualServices) != 0 { + t.Fatalf("unexpected virtual services: %+v", options.VirtualServices) + } +} + +func TestSSLPassthroughDuplicateTLSHostUsesExistingGatewayOwner(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + primary := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-primary", + }, + Spec: ingress.IngressSpec{ + TLS: []ingress.IngressTLS{ + {Hosts: []string{"example.com"}}, + }, + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "primary", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + } + duplicate := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-duplicate", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "duplicate", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + httpsCredentialConfig := &cert.Config{ + CredentialConfig: []cert.CredentialEntry{ + { + Domains: []string{"example.com"}, + TLSSecret: "default/example-tls", + }, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, primary, httpsCredentialConfig); err != nil { + t.Fatalf("ConvertGateway(primary) error = %v", err) + } + if err := c.ConvertGateway(options, duplicate, httpsCredentialConfig); err != nil { + t.Fatalf("ConvertGateway(duplicate) error = %v", err) + } + + if len(options.IngressDomainCache.Invalid) != 1 { + t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid)) + } + invalid := options.IngressDomainCache.Invalid[0] + if !strings.Contains(invalid.Error, "tls-primary") { + t.Fatalf("invalid domain error does not reference existing gateway owner: %s", invalid.Error) + } +} + +func TestBackendToTLSRouteDestinationRejectsEmptyMCPDestination(t *testing.T) { + c := controller{} + backend := &ingress.IngressBackend{} + config := &annotations.DestinationConfig{} + + destinations, event := c.backendToTLSRouteDestination(backend, "default", config) + if event != common.InvalidBackendService { + t.Fatalf("event mismatch, want InvalidBackendService, got %s", event) + } + if len(destinations) != 0 { + t.Fatalf("destination count mismatch, want 0, got %d", len(destinations)) + } +} + +func TestSSLPassthroughUsesFirstRootOwnerWhenLaterIngressEnablesPassthrough(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + root := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/", "root", 443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + } + passthrough := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "passthrough", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/passthrough", "passthrough", 443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config}, + } + if err := c.ConvertGateway(options, root, nil); err != nil { + t.Fatalf("ConvertGateway(root) error = %v", err) + } + if err := c.ConvertGateway(options, passthrough, nil); err != nil { + t.Fatalf("ConvertGateway(passthrough) error = %v", err) + } + gateway := options.Gateways["example.com"].Gateway + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + if gateway.Servers[1].Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", gateway.Servers[1].Tls.GetMode()) + } + + routeOptions := &common.ConvertOptions{ + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config}, + } + if err := c.ConvertHTTPRoute(routeOptions, root); err != nil { + t.Fatalf("ConvertHTTPRoute(root) error = %v", err) + } + if err := c.ConvertHTTPRoute(routeOptions, passthrough); err != nil { + t.Fatalf("ConvertHTTPRoute(passthrough) error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got) + } +} + +func TestSSLPassthroughNonRootIngressDoesNotBlockLaterRootIngress(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + nonRoot := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-non-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/api", "api", 8443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + root := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/", "root", 443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, nonRoot, nil); err != nil { + t.Fatalf("ConvertGateway(nonRoot) error = %v", err) + } + if len(options.Gateways["example.com"].Gateway.Servers) != 1 { + t.Fatalf("non-root ingress server count mismatch, want 1, got %d", len(options.Gateways["example.com"].Gateway.Servers)) + } + if err := c.ConvertGateway(options, root, nil); err != nil { + t.Fatalf("ConvertGateway(root) error = %v", err) + } + if options.Gateways["example.com"].Gateway.Servers[1].Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH { + t.Fatal("root ingress did not create a TLS passthrough server") + } + + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, root); err != nil { + t.Fatalf("ConvertTLSRoute(root) error = %v", err) + } + routes := options.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughPreservesRepeatedHostInSameIngress(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-repeated-host", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/health", + Backend: ingress.IngressBackend{ + ServiceName: "health", + ServicePort: intstr.FromInt(8443), + }, + }, + }, + }, + }, + }, + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "root", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, wrapper); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + routes := options.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughUsesFirstRootBackendForRepeatedHost(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-repeated-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "first", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "second", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "first.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughHandlesMultipleHosts(t *testing.T) { + c := controller{} + testcases := []struct { + name string + rules []ingress.IngressRule + wantHosts []string + wantRoutes map[string]string + }{ + { + name: "root path first", + rules: []ingress.IngressRule{ + ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/", "first", 443)), + ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/health", "middle", 8443)), + ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/health", "last", 8443)), + }, + wantHosts: []string{"first.example.com"}, + wantRoutes: map[string]string{ + "first.example.com": "first.default.svc.cluster.local", + }, + }, + { + name: "root path middle", + rules: []ingress.IngressRule{ + ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/health", "first", 8443)), + ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/", "middle", 443)), + ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/health", "last", 8443)), + }, + wantHosts: []string{"middle.example.com"}, + wantRoutes: map[string]string{ + "middle.example.com": "middle.default.svc.cluster.local", + }, + }, + { + name: "root path last", + rules: []ingress.IngressRule{ + ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/health", "first", 8443)), + ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/health", "middle", 8443)), + ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/", "last", 443)), + }, + wantHosts: []string{"last.example.com"}, + wantRoutes: map[string]string{ + "last.example.com": "last.default.svc.cluster.local", + }, + }, + { + name: "multiple root hosts", + rules: []ingress.IngressRule{ + ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/", "first", 443)), + ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/", "middle", 443)), + ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/", "last", 443)), + }, + wantHosts: []string{"first.example.com", "middle.example.com", "last.example.com"}, + wantRoutes: map[string]string{ + "first.example.com": "first.default.svc.cluster.local", + "middle.example.com": "middle.default.svc.cluster.local", + "last.example.com": "last.default.svc.cluster.local", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-multi-host", + }, + Spec: ingress.IngressSpec{ + Rules: tc.rules, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + for _, host := range tc.wantHosts { + routes := routeOptions.VirtualServices[host].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch for host %s, want 1, got %d", host, len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != tc.wantRoutes[host] { + t.Fatalf("destination host mismatch for host %s, want %s, got %s", host, tc.wantRoutes[host], got) + } + } + }) + } +} + +func ingressV1Beta1Path(path, service string, port int32) ingress.HTTPIngressPath { + return ingress.HTTPIngressPath{ + Path: path, + Backend: ingress.IngressBackend{ + ServiceName: service, + ServicePort: intstr.FromInt(int(port)), + }, + } +} + +func ingressV1Beta1Rule(host string, paths ...ingress.HTTPIngressPath) ingress.IngressRule { + return ingress.IngressRule{ + Host: host, + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + } +} + +func TestSSLPassthroughUsesRootPathBackend(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/api", + Backend: ingress.IngressBackend{ + ServiceName: "api", + ServicePort: intstr.FromInt(8443), + }, + }, + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "root", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughWildcardHostKeepsVirtualServiceConsistent(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-wildcard", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/", + Backend: ingress.IngressBackend{ + ServiceName: "root", + ServicePort: intstr.FromInt(443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if err := c.ConvertTLSRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + + vs := routeOptions.VirtualServices[""].VirtualService + if got := vs.Hosts; len(got) != 1 || got[0] != "*" { + t.Fatalf("virtual service hosts mismatch, got %+v", got) + } + if len(vs.Tls) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls)) + } + if got := vs.Tls[0].Match[0].SniHosts; len(got) != 1 || got[0] != "*" { + t.Fatalf("sni hosts mismatch, got %+v", got) + } +} + +func TestSSLPassthroughIgnoresNonRootPath(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-non-root", + }, + Spec: ingress.IngressSpec{ + Rules: []ingress.IngressRule{ + { + Host: "example.com", + IngressRuleValue: ingress.IngressRuleValue{ + HTTP: &ingress.HTTPIngressRuleValue{ + Paths: []ingress.HTTPIngressPath{ + { + Path: "/api", + Backend: ingress.IngressBackend{ + ServiceName: "api", + ServicePort: intstr.FromInt(8443), + }, + }, + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if len(routeOptions.HTTPRoutes["example.com"]) != 1 { + t.Fatalf("http route count mismatch, want 1, got %d", len(routeOptions.HTTPRoutes["example.com"])) + } + if routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls; len(routes) != 0 { + t.Fatalf("unexpected tls routes: %+v", routes) + } +} + func testApplyCanaryIngress(t *testing.T, c common.IngressController) { testcases := []struct { description string diff --git a/pkg/ingress/kube/ingressv1/controller.go b/pkg/ingress/kube/ingressv1/controller.go index 4c0c4ba11..30cc38fe7 100644 --- a/pkg/ingress/kube/ingressv1/controller.go +++ b/pkg/ingress/kube/ingressv1/controller.go @@ -337,6 +337,13 @@ func extractTLSSecretName(host string, tls []ingress.IngressTLS) string { } func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig, httpsCredentialConfig *cert.Config) error { + if convertOptions == nil { + return fmt.Errorf("convertOptions is nil") + } + if wrapper == nil { + return fmt.Errorf("wrapperConfig is nil") + } + // Ignore canary config. if wrapper.AnnotationsConfig.IsCanary() { return nil @@ -382,7 +389,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp Protocol: string(protocol.HTTP), Name: common.CreateConvertedName("http-"+strconv.FormatUint(uint64(c.options.GatewayHttpPort), 10)+"-ingress", string(c.options.ClusterId)), }, - Hosts: []string{rule.Host}, + Hosts: []string{common.WildcardHost(rule.Host)}, }) // Add new gateway, builder @@ -395,6 +402,45 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp } } + passthroughOwner := common.PassthroughTLSHostOwner(convertOptions, rule.Host) + standaloneSSLPassthrough := convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough() + if common.SameConfig(passthroughOwner, cfg) || standaloneSSLPassthrough { + if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 { + continue + } + if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); !ok { + continue + } + + domainBuilder.Protocol = common.HTTPS + if wrapperGateway.IsHTTPS() { + if common.SameConfig(preDomainBuilder.Ingress, cfg) { + continue + } + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = preDomainBuilder.Ingress + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + continue + } + wrapperGateway.Gateway.Servers = append(wrapperGateway.Gateway.Servers, + common.CreateSSLPassthroughServer(rule.Host, c.options.GatewayHttpsPort, c.options.ClusterId)) + convertOptions.IngressDomainCache.Valid[rule.Host] = domainBuilder + continue + } + if wrapper.AnnotationsConfig.IsSSLPassthrough() { + if rule.HTTP != nil { + if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); ok && passthroughOwner != nil { + domainBuilder.Protocol = common.HTTPS + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = passthroughOwner + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + } + } + continue + } + // There are no tls settings, so just skip. if len(ingressV1.TLS) == 0 { continue @@ -443,6 +489,14 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp domainBuilder.Protocol = common.HTTPS domainBuilder.SecretName = path.Join(c.options.ClusterId.String(), cfg.Namespace, secretName) + if passthroughOwner != nil { + domainBuilder.Event = common.DuplicatedTls + domainBuilder.PreIngress = passthroughOwner + convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid, + domainBuilder.Build()) + continue + } + // There is a matching secret and the gateway has already a tls secret. // We should report the duplicated tls secret event. if wrapperGateway.IsHTTPS() { @@ -460,7 +514,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp Protocol: string(protocol.HTTPS), Name: common.CreateConvertedName("https-"+strconv.FormatUint(uint64(c.options.GatewayHttpsPort), 10)+"-ingress", string(c.options.ClusterId)), }, - Hosts: []string{rule.Host}, + Hosts: []string{common.WildcardHost(rule.Host)}, Tls: &networking.ServerTLSSettings{ Mode: networking.ServerTLSSettings_SIMPLE, CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName), @@ -475,12 +529,32 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp } func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error { + if convertOptions == nil { + return fmt.Errorf("convertOptions is nil") + } + if wrapper == nil { + return fmt.Errorf("wrapperConfig is nil") + } + // Canary ingress will be processed in the end. if wrapper.AnnotationsConfig.IsCanary() { convertOptions.CanaryIngresses = append(convertOptions.CanaryIngresses, wrapper) return nil } + if convertOptions.Route2Ingress == nil { + convertOptions.Route2Ingress = map[string]*common.WrapperConfigWithRuleKey{} + } + if convertOptions.IngressRouteCache == nil { + convertOptions.IngressRouteCache = common.NewIngressRouteCache() + } + if convertOptions.VirtualServices == nil { + convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{} + } + if convertOptions.HTTPRoutes == nil { + convertOptions.HTTPRoutes = map[string][]*common.WrapperHTTPRoute{} + } + cfg := wrapper.Config ingressV1, ok := cfg.Spec.(ingress.IngressSpec) if !ok { @@ -515,12 +589,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra wrapperVS, exist := convertOptions.VirtualServices[rule.Host] if !exist { - wrapperVS = &common.WrapperVirtualService{ - VirtualService: &networking.VirtualService{ - Hosts: []string{rule.Host}, - }, - WrapperConfig: wrapper, - } + wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper) convertOptions.VirtualServices[rule.Host] = wrapperVS } @@ -549,7 +618,11 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra pathType = common.PrefixRegex } } else { - switch *httpPath.PathType { + ingressPathType := defaultPathType + if httpPath.PathType != nil { + ingressPathType = *httpPath.PathType + } + switch ingressPathType { case ingress.PathTypeExact: pathType = common.Exact case ingress.PathTypePrefix: @@ -626,9 +699,84 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra } } + if common.HasPassthroughTLSHostOwner(convertOptions, cfg) || + (convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()) { + return c.ConvertTLSRoute(convertOptions, wrapper) + } + return nil } +func (c *controller) ConvertTLSRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error { + if convertOptions == nil { + return fmt.Errorf("convertOptions is nil") + } + if wrapper == nil { + return fmt.Errorf("wrapperConfig is nil") + } + + if convertOptions.VirtualServices == nil { + convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{} + } + + cfg := wrapper.Config + ingressV1, ok := cfg.Spec.(ingress.IngressSpec) + if !ok { + common.IncrementInvalidIngress(c.options.ClusterId, common.Unknown) + return fmt.Errorf("convert type is invalid in cluster %s", c.options.ClusterId) + } + if len(ingressV1.Rules) == 0 { + common.IncrementInvalidIngress(c.options.ClusterId, common.EmptyRule) + return fmt.Errorf("invalid ingress rule %s:%s in cluster %s, `rules` must be specified", cfg.Namespace, cfg.Name, c.options.ClusterId) + } + + for _, rule := range ingressV1.Rules { + if !common.IsPassthroughTLSHostOwner(convertOptions, cfg, rule.Host) { + IngressLog.Warnf("ignore duplicated ssl passthrough ingress rule %s:%s for host %q in cluster %s", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 { + IngressLog.Warnf("invalid ssl passthrough ingress rule %s:%s for host %q in cluster %s, no paths defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + httpPath, ok := rootHTTPIngressPath(rule.HTTP.Paths) + if !ok { + IngressLog.Warnf("ignore ssl passthrough ingress rule %s:%s for host %q in cluster %s, root path is not defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId) + continue + } + + wrapperVS, exist := convertOptions.VirtualServices[rule.Host] + if !exist { + wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper) + convertOptions.VirtualServices[rule.Host] = wrapperVS + } else if wrapperVS.HasTLSRouteForHost(rule.Host) { + continue + } + + routeDestination, event := c.backendToTLSRouteDestination(&httpPath.Backend, cfg.Namespace, wrapper.AnnotationsConfig.Destination) + if event != common.Normal { + common.IncrementInvalidIngress(c.options.ClusterId, event) + continue + } + + wrapperVS.VirtualService.Tls = append(wrapperVS.VirtualService.Tls, + common.CreateTLSRoute(rule.Host, routeDestination)) + } + + return nil +} + +func rootHTTPIngressPath(paths []ingress.HTTPIngressPath) (*ingress.HTTPIngressPath, bool) { + for idx := range paths { + if paths[idx].Path == "" || paths[idx].Path == "/" { + return &paths[idx], true + } + } + return nil, false +} + func (c *controller) generateHttpMatches(pathType common.PathType, path string, wrapperVS *common.WrapperVirtualService) []*networking.HTTPMatchRequest { var httpMatches []*networking.HTTPMatchRequest @@ -689,12 +837,7 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions, wirecardVS, exist := convertOptions.VirtualServices[host] if !exist || !wirecardVS.ConfiguredDefaultBackend { if !exist { - wirecardVS = &common.WrapperVirtualService{ - VirtualService: &networking.VirtualService{ - Hosts: []string{host}, - }, - WrapperConfig: wrapper, - } + wirecardVS = common.NewWrapperVirtualService(host, wrapper) convertOptions.VirtualServices[host] = wirecardVS } @@ -782,7 +925,11 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w pathType = common.PrefixRegex } } else { - switch *httpPath.PathType { + ingressPathType := defaultPathType + if httpPath.PathType != nil { + ingressPathType = *httpPath.PathType + } + switch ingressPathType { case ingress.PathTypeExact: pathType = common.Exact case ingress.PathTypePrefix: @@ -1074,6 +1221,54 @@ func (c *controller) backendToRouteDestination(backend *ingress.IngressBackend, }, common.Normal } +func (c *controller) backendToTLSRouteDestination(backend *ingress.IngressBackend, namespace string, + config *annotations.DestinationConfig, +) ([]*networking.RouteDestination, common.Event) { + if backend == nil { + return nil, common.InvalidBackendService + } + + if backend.Service == nil { + if config != nil && len(config.McpDestination) > 0 { + return httpRouteDestinationToRouteDestination(config.McpDestination), common.Normal + } + return nil, common.InvalidBackendService + } + + service := backend.Service + port := &networking.PortSelector{} + if service.Port.Number > 0 { + port.Number = uint32(service.Port.Number) + } else { + resolvedPort, err := resolveNamedPort(service, namespace, c.serviceLister) + if err != nil { + return nil, common.PortNameResolveError + } + port.Number = uint32(resolvedPort) + } + + return []*networking.RouteDestination{ + { + Destination: &networking.Destination{ + Host: util.CreateServiceFQDN(namespace, service.Name), + Port: port, + }, + Weight: 100, + }, + }, common.Normal +} + +func httpRouteDestinationToRouteDestination(destinations []*networking.HTTPRouteDestination) []*networking.RouteDestination { + out := make([]*networking.RouteDestination, 0, len(destinations)) + for _, destination := range destinations { + out = append(out, &networking.RouteDestination{ + Destination: destination.Destination, + Weight: destination.Weight, + }) + } + return out +} + func resolveNamedPort(service *ingress.IngressServiceBackend, namespace string, serviceLister listerv1.ServiceLister) (int32, error) { svc, err := serviceLister.Services(namespace).Get(service.Name) if err != nil { diff --git a/pkg/ingress/kube/ingressv1/controller_test.go b/pkg/ingress/kube/ingressv1/controller_test.go index 0b082013d..8ddd3332b 100644 --- a/pkg/ingress/kube/ingressv1/controller_test.go +++ b/pkg/ingress/kube/ingressv1/controller_test.go @@ -15,12 +15,17 @@ package ingressv1 import ( + "strings" "testing" + "github.com/alibaba/higress/v2/pkg/cert" + "github.com/alibaba/higress/v2/pkg/ingress/kube/annotations" "github.com/alibaba/higress/v2/pkg/ingress/kube/common" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" networking "istio.io/api/networking/v1alpha3" + "istio.io/istio/pkg/config" + corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -118,3 +123,904 @@ func TestGenerateHttpMatches(t *testing.T) { } } } + +func TestSSLPassthroughConvertGatewayAndTLSRoute(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + gatewayOptions := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + gateway := gatewayOptions.Gateways["example.com"].Gateway + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + tlsServer := gateway.Servers[1] + if tlsServer.Port.Protocol != "TLS" { + t.Fatalf("protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol) + } + if tlsServer.Port.Number != 443 { + t.Fatalf("port mismatch, want 443, got %d", tlsServer.Port.Number) + } + if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode()) + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + httpRoutes := routeOptions.HTTPRoutes["example.com"] + if len(httpRoutes) != 1 { + t.Fatalf("http route count mismatch, want 1, got %d", len(httpRoutes)) + } + if got := httpRoutes[0].HTTPRoute.Route[0].Destination.Host; got != "app.default.svc.cluster.local" { + t.Fatalf("http destination host mismatch, got %s", got) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + route := routes[0] + if got := route.Match[0].SniHosts[0]; got != "example.com" { + t.Fatalf("sni host mismatch, want example.com, got %s", got) + } + if got := route.Route[0].Destination.Host; got != "app.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } + if got := route.Route[0].Destination.Port.Number; got != 443 { + t.Fatalf("destination port mismatch, got %d", got) + } +} + +func TestSSLPassthroughConvertGatewayRejectsNilInputs(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{}, + AnnotationsConfig: &annotations.Ingress{}, + } + + if err := c.ConvertGateway(nil, wrapper, nil); err == nil { + t.Fatal("ConvertGateway() with nil convertOptions returned nil error") + } + if err := c.ConvertGateway(&common.ConvertOptions{}, nil, nil); err == nil { + t.Fatal("ConvertGateway() with nil wrapper returned nil error") + } +} + +func TestSSLPassthroughConvertTLSRouteRejectsNilInputs(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{}, + AnnotationsConfig: &annotations.Ingress{}, + } + + if err := c.ConvertTLSRoute(nil, wrapper); err == nil { + t.Fatal("ConvertTLSRoute() with nil convertOptions returned nil error") + } + if err := c.ConvertTLSRoute(&common.ConvertOptions{}, nil); err == nil { + t.Fatal("ConvertTLSRoute() with nil wrapper returned nil error") + } +} + +func TestSSLPassthroughUsesConfiguredHTTPSPort(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 8443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + gatewayOptions := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + tlsServer := gatewayOptions.Gateways["example.com"].Gateway.Servers[1] + if tlsServer.Port.Number != 8443 { + t.Fatalf("port mismatch, want 8443, got %d", tlsServer.Port.Number) + } +} + +func TestSSLPassthroughCanaryIngressKeepsCanaryHandling(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-canary", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app-canary", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + Canary: &annotations.CanaryConfig{Enabled: true}, + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if len(routeOptions.CanaryIngresses) != 1 { + t.Fatalf("canary ingress count mismatch, want 1, got %d", len(routeOptions.CanaryIngresses)) + } + if len(routeOptions.VirtualServices) != 0 { + t.Fatalf("unexpected virtual services: %+v", routeOptions.VirtualServices) + } +} + +func TestSSLPassthroughSkipsDuplicatedTLSHost(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + primary := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-primary", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "primary", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + duplicate := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-duplicate", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config}, + } + if err := c.ConvertGateway(options, primary, nil); err != nil { + t.Fatalf("ConvertGateway(primary) error = %v", err) + } + if err := c.ConvertGateway(options, duplicate, nil); err != nil { + t.Fatalf("ConvertGateway(duplicate) error = %v", err) + } + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, duplicate); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + if len(options.VirtualServices) != 0 { + t.Fatalf("unexpected virtual services: %+v", options.VirtualServices) + } +} + +func TestSSLPassthroughDuplicateTLSHostRecordsInvalidDomain(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + primary := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-primary", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "primary", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + duplicate := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-duplicate", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config}, + } + if err := c.ConvertGateway(options, primary, nil); err != nil { + t.Fatalf("ConvertGateway(primary) error = %v", err) + } + if err := c.ConvertGateway(options, duplicate, nil); err != nil { + t.Fatalf("ConvertGateway(duplicate) error = %v", err) + } + + if len(options.IngressDomainCache.Invalid) != 1 { + t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid)) + } + invalid := options.IngressDomainCache.Invalid[0] + if invalid.Error == "" { + t.Fatal("duplicated tls invalid domain error is empty") + } + if !strings.Contains(invalid.Error, "tls-passthrough-primary") { + t.Fatalf("invalid domain error does not reference previous ingress: %s", invalid.Error) + } +} + +func TestSSLPassthroughDuplicateTLSHostUsesExistingGatewayOwner(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + primary := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-primary", + }, + Spec: v1.IngressSpec{ + TLS: []v1.IngressTLS{ + {Hosts: []string{"example.com"}}, + }, + Rules: []v1.IngressRule{ + ingressRule("example.com", ingressPath("/", "primary", 443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + } + duplicate := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-duplicate", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + httpsCredentialConfig := &cert.Config{ + CredentialConfig: []cert.CredentialEntry{ + { + Domains: []string{"example.com"}, + TLSSecret: "default/example-tls", + }, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, primary, httpsCredentialConfig); err != nil { + t.Fatalf("ConvertGateway(primary) error = %v", err) + } + if err := c.ConvertGateway(options, duplicate, httpsCredentialConfig); err != nil { + t.Fatalf("ConvertGateway(duplicate) error = %v", err) + } + + if len(options.IngressDomainCache.Invalid) != 1 { + t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid)) + } + invalid := options.IngressDomainCache.Invalid[0] + if !strings.Contains(invalid.Error, "tls-primary") { + t.Fatalf("invalid domain error does not reference existing gateway owner: %s", invalid.Error) + } +} + +func TestSSLPassthroughUsesFirstRootOwnerWhenLaterIngressEnablesPassthrough(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + root := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "root", + }, + Spec: v1.IngressSpec{ + Rules: []v1.IngressRule{ + ingressRule("example.com", ingressPath("/", "root", 443)), + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{}, + } + passthrough := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "passthrough", + }, + Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{ + ingressPath("/passthrough", "passthrough", 443), + }), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config}, + } + if err := c.ConvertGateway(options, root, nil); err != nil { + t.Fatalf("ConvertGateway(root) error = %v", err) + } + if err := c.ConvertGateway(options, passthrough, nil); err != nil { + t.Fatalf("ConvertGateway(passthrough) error = %v", err) + } + gateway := options.Gateways["example.com"].Gateway + if len(gateway.Servers) != 2 { + t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers)) + } + if gateway.Servers[1].Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH { + t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", gateway.Servers[1].Tls.GetMode()) + } + + routeOptions := &common.ConvertOptions{ + PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config}, + } + if err := c.ConvertHTTPRoute(routeOptions, root); err != nil { + t.Fatalf("ConvertHTTPRoute(root) error = %v", err) + } + if err := c.ConvertHTTPRoute(routeOptions, passthrough); err != nil { + t.Fatalf("ConvertHTTPRoute(passthrough) error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got) + } +} + +func TestSSLPassthroughNonRootIngressDoesNotBlockLaterRootIngress(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + nonRoot := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-non-root", + }, + Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{ + ingressPath("/api", "api", 8443), + }), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + root := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-root", + }, + Spec: ingressSpecWithSSLPassthroughBackend("example.com", "root", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, nonRoot, nil); err != nil { + t.Fatalf("ConvertGateway(nonRoot) error = %v", err) + } + if len(options.Gateways["example.com"].Gateway.Servers) != 1 { + t.Fatalf("non-root ingress server count mismatch, want 1, got %d", len(options.Gateways["example.com"].Gateway.Servers)) + } + if err := c.ConvertGateway(options, root, nil); err != nil { + t.Fatalf("ConvertGateway(root) error = %v", err) + } + if options.Gateways["example.com"].Gateway.Servers[1].Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH { + t.Fatal("root ingress did not create a TLS passthrough server") + } + + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, root); err != nil { + t.Fatalf("ConvertTLSRoute(root) error = %v", err) + } + routes := options.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughPreservesRepeatedHostInSameIngress(t *testing.T) { + c := controller{ + options: common.Options{ + GatewayHttpPort: 80, + GatewayHttpsPort: 443, + }, + } + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-repeated-host", + }, + Spec: v1.IngressSpec{ + Rules: []v1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: []v1.HTTPIngressPath{ + ingressPath("/health", "health", 8443), + }, + }, + }, + }, + { + Host: "example.com", + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: []v1.HTTPIngressPath{ + ingressPath("/", "root", 443), + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + options := &common.ConvertOptions{ + Gateways: map[string]*common.WrapperGateway{}, + IngressDomainCache: common.NewIngressDomainCache(), + } + if err := c.ConvertGateway(options, wrapper, nil); err != nil { + t.Fatalf("ConvertGateway() error = %v", err) + } + options.VirtualServices = map[string]*common.WrapperVirtualService{} + if err := c.ConvertTLSRoute(options, wrapper); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + routes := options.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughUsesFirstRootBackendForRepeatedHost(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-repeated-root", + }, + Spec: v1.IngressSpec{ + Rules: []v1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: []v1.HTTPIngressPath{ + ingressPath("/", "first", 443), + }, + }, + }, + }, + { + Host: "example.com", + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: []v1.HTTPIngressPath{ + ingressPath("/", "second", 443), + }, + }, + }, + }, + }, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "first.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughHandlesMultipleHosts(t *testing.T) { + c := controller{} + testcases := []struct { + name string + rules []v1.IngressRule + wantHosts []string + wantRoutes map[string]string + }{ + { + name: "root path first", + rules: []v1.IngressRule{ + ingressRule("first.example.com", ingressPath("/", "first", 443)), + ingressRule("middle.example.com", ingressPath("/health", "middle", 8443)), + ingressRule("last.example.com", ingressPath("/health", "last", 8443)), + }, + wantHosts: []string{"first.example.com"}, + wantRoutes: map[string]string{ + "first.example.com": "first.default.svc.cluster.local", + }, + }, + { + name: "root path middle", + rules: []v1.IngressRule{ + ingressRule("first.example.com", ingressPath("/health", "first", 8443)), + ingressRule("middle.example.com", ingressPath("/", "middle", 443)), + ingressRule("last.example.com", ingressPath("/health", "last", 8443)), + }, + wantHosts: []string{"middle.example.com"}, + wantRoutes: map[string]string{ + "middle.example.com": "middle.default.svc.cluster.local", + }, + }, + { + name: "root path last", + rules: []v1.IngressRule{ + ingressRule("first.example.com", ingressPath("/health", "first", 8443)), + ingressRule("middle.example.com", ingressPath("/health", "middle", 8443)), + ingressRule("last.example.com", ingressPath("/", "last", 443)), + }, + wantHosts: []string{"last.example.com"}, + wantRoutes: map[string]string{ + "last.example.com": "last.default.svc.cluster.local", + }, + }, + { + name: "multiple root hosts", + rules: []v1.IngressRule{ + ingressRule("first.example.com", ingressPath("/", "first", 443)), + ingressRule("middle.example.com", ingressPath("/", "middle", 443)), + ingressRule("last.example.com", ingressPath("/", "last", 443)), + }, + wantHosts: []string{"first.example.com", "middle.example.com", "last.example.com"}, + wantRoutes: map[string]string{ + "first.example.com": "first.default.svc.cluster.local", + "middle.example.com": "middle.default.svc.cluster.local", + "last.example.com": "last.default.svc.cluster.local", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-multi-host", + }, + Spec: v1.IngressSpec{ + Rules: tc.rules, + }, + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + for _, host := range tc.wantHosts { + routes := routeOptions.VirtualServices[host].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch for host %s, want 1, got %d", host, len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != tc.wantRoutes[host] { + t.Fatalf("destination host mismatch for host %s, want %s, got %s", host, tc.wantRoutes[host], got) + } + } + }) + } +} + +func TestSSLPassthroughUsesRootPathBackend(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-root", + }, + Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{ + ingressPath("/api", "api", 8443), + ingressPath("/", "root", 443), + }), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestSSLPassthroughWildcardHostKeepsVirtualServiceConsistent(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-wildcard", + }, + Spec: ingressSpecWithSSLPassthroughBackend("", "root", 443), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if err := c.ConvertTLSRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertTLSRoute() error = %v", err) + } + + vs := routeOptions.VirtualServices[""].VirtualService + if got := vs.Hosts; len(got) != 1 || got[0] != "*" { + t.Fatalf("virtual service hosts mismatch, got %+v", got) + } + if len(vs.Tls) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls)) + } + if got := vs.Tls[0].Match[0].SniHosts; len(got) != 1 || got[0] != "*" { + t.Fatalf("sni hosts mismatch, got %+v", got) + } +} + +func TestSSLPassthroughIgnoresNonRootPath(t *testing.T) { + c := controller{} + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-non-root", + }, + Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{ + ingressPath("/api", "api", 8443), + }), + }, + AnnotationsConfig: &annotations.Ingress{ + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + if len(routeOptions.HTTPRoutes["example.com"]) != 1 { + t.Fatalf("http route count mismatch, want 1, got %d", len(routeOptions.HTTPRoutes["example.com"])) + } + if routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls; len(routes) != 0 { + t.Fatalf("unexpected tls routes: %+v", routes) + } +} + +func TestSSLPassthroughKeepsMCPResourceBackend(t *testing.T) { + c := controller{} + apiGroup := "networking.higress.io" + wrapper := &common.WrapperConfig{ + Config: &config.Config{ + Meta: config.Meta{ + Namespace: "default", + Name: "tls-passthrough-mcp", + }, + Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{ + { + Path: "/", + PathType: pathTypePtr(v1.PathTypePrefix), + Backend: v1.IngressBackend{ + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: &apiGroup, + Kind: "McpBridge", + Name: "default", + }, + }, + }, + }), + }, + AnnotationsConfig: &annotations.Ingress{ + Destination: &annotations.DestinationConfig{ + McpDestination: []*networking.HTTPRouteDestination{ + { + Destination: &networking.Destination{ + Host: "mcp.example.internal", + Port: &networking.PortSelector{Number: 443}, + }, + Weight: 100, + }, + }, + WeightSum: 100, + }, + SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true}, + }, + } + + routeOptions := &common.ConvertOptions{} + if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil { + t.Fatalf("ConvertHTTPRoute() error = %v", err) + } + routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls + if len(routes) != 1 { + t.Fatalf("tls route count mismatch, want 1, got %d", len(routes)) + } + if got := routes[0].Route[0].Destination.Host; got != "mcp.example.internal" { + t.Fatalf("destination host mismatch, got %s", got) + } +} + +func TestBackendToTLSRouteDestinationRejectsEmptyMCPDestination(t *testing.T) { + c := controller{} + apiGroup := "networking.higress.io" + backend := &v1.IngressBackend{ + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: &apiGroup, + Kind: "McpBridge", + Name: "default", + }, + } + config := &annotations.DestinationConfig{} + + destinations, event := c.backendToTLSRouteDestination(backend, "default", config) + if event != common.InvalidBackendService { + t.Fatalf("event mismatch, want InvalidBackendService, got %s", event) + } + if len(destinations) != 0 { + t.Fatalf("destination count mismatch, want 0, got %d", len(destinations)) + } +} + +func ingressSpecWithSSLPassthroughBackend(host, service string, port int32) v1.IngressSpec { + return ingressSpecWithSSLPassthroughPaths(host, []v1.HTTPIngressPath{ + ingressPath("/", service, port), + }) +} + +func ingressSpecWithSSLPassthroughPaths(host string, paths []v1.HTTPIngressPath) v1.IngressSpec { + return v1.IngressSpec{ + Rules: []v1.IngressRule{ + { + Host: host, + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + }, + }, + } +} + +func ingressPath(path, service string, port int32) v1.HTTPIngressPath { + return v1.HTTPIngressPath{ + Path: path, + PathType: pathTypePtr(v1.PathTypePrefix), + Backend: v1.IngressBackend{ + Service: &v1.IngressServiceBackend{ + Name: service, + Port: v1.ServiceBackendPort{Number: port}, + }, + }, + } +} + +func pathTypePtr(pathType v1.PathType) *v1.PathType { + return &pathType +} + +func ingressRule(host string, paths ...v1.HTTPIngressPath) v1.IngressRule { + return v1.IngressRule{ + Host: host, + IngressRuleValue: v1.IngressRuleValue{ + HTTP: &v1.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + } +}