diff --git a/chart/templates/rbac.yaml b/chart/templates/rbac.yaml index 69ef4f7..661f441 100644 --- a/chart/templates/rbac.yaml +++ b/chart/templates/rbac.yaml @@ -11,7 +11,7 @@ metadata: rules: # Controller Permissions - apiGroups: ["traefik.io"] - resources: ["ingressroutes"] + resources: ["ingressroutes", "ingressroutetcps"] verbs: ["get", "list", "watch"] # Integrations {{ if .Values.integrations.certManager.enabled }} diff --git a/cmd/main.go b/cmd/main.go index deeb2d1..5f8df1e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -78,6 +78,16 @@ func main() { os.Exit(1) } + tcpController, err := controllers.NewIngressRouteTCPReconciler(manager.GetClient(), logger, config) + if err != nil { + logger.Error("unable to initialize ingress route tcp controller", "error", err) + os.Exit(1) + } + if err := tcpController.SetupWithManager(manager); err != nil { + logger.Error("unable to start ingress route tcp controller", "error", err) + os.Exit(1) + } + // Add health check endpoints if err := manager.AddReadyzCheck("readyz", healthz.Ping); err != nil { logger.Error("unable to set up ready check at /readyz", "error", err) diff --git a/go.mod b/go.mod index 9ec3c40..50ead3c 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -67,6 +68,7 @@ require ( github.com/rs/zerolog v1.34.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/traefik/paerser v0.2.2 // indirect github.com/unrolled/render v1.7.0 // indirect github.com/vulcand/predicate v1.3.0 // indirect diff --git a/go.sum b/go.sum index ac83733..4fe449e 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= @@ -138,6 +140,8 @@ github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -160,6 +164,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/internal/controllers/ingressroute.go b/internal/controllers/ingressroute.go index 565b055..dc7d06d 100644 --- a/internal/controllers/ingressroute.go +++ b/internal/controllers/ingressroute.go @@ -15,19 +15,30 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// IngressRouteReconciler reconciles an IngressRoute object. +// ingressAdapter abstracts the type-specific operations for different Traefik ingress resources. +type ingressAdapter interface { + // newResource returns a new empty instance of the ingress resource. + newResource() client.Object + // extractInfo extracts IngressInfo from a fetched ingress resource. + extractInfo(client.Object) (integrations.IngressInfo, error) + // setupWithManager configures the controller watches for this resource type. + setupWithManager(mgr ctrl.Manager, r *IngressRouteReconciler) error +} + +// IngressRouteReconciler reconciles Traefik ingress route objects, including both IngressRoute +// and IngressRouteTCP resources. type IngressRouteReconciler struct { client.Client logger *slog.Logger selector switchboard.Selector integrations []integrations.Integration + adapter ingressAdapter } -// NewIngressRouteReconciler creates a new IngressRouteReconciler. -func NewIngressRouteReconciler( - client client.Client, logger *slog.Logger, config configv1.Config, +func newReconciler( + client client.Client, logger *slog.Logger, config configv1.Config, adapter ingressAdapter, ) (IngressRouteReconciler, error) { - integrations, err := integrationsFromConfig(config, client) + itgs, err := integrationsFromConfig(config, client) if err != nil { return IngressRouteReconciler{}, fmt.Errorf("failed to initialize integrations: %s", err) } @@ -35,10 +46,25 @@ func NewIngressRouteReconciler( Client: client, logger: logger, selector: switchboard.NewSelector(config.Selector.IngressClass), - integrations: integrations, + integrations: itgs, + adapter: adapter, }, nil } +// NewIngressRouteReconciler creates a new reconciler for IngressRoute resources. +func NewIngressRouteReconciler( + client client.Client, logger *slog.Logger, config configv1.Config, +) (IngressRouteReconciler, error) { + return newReconciler(client, logger, config, ingressRouteAdapter{}) +} + +// NewIngressRouteTCPReconciler creates a new reconciler for IngressRouteTCP resources. +func NewIngressRouteTCPReconciler( + client client.Client, logger *slog.Logger, config configv1.Config, +) (IngressRouteReconciler, error) { + return newReconciler(client, logger, config, ingressRouteTCPAdapter{}) +} + // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. func (r *IngressRouteReconciler) Reconcile( @@ -47,9 +73,9 @@ func (r *IngressRouteReconciler) Reconcile( logger := r.logger.With("name", req.String()) // First, we retrieve the full resource - var ingressRoute traefik.IngressRoute + resource := r.adapter.newResource() - if err := r.Get(ctx, req.NamespacedName, &ingressRoute); err != nil { + if err := r.Get(ctx, req.NamespacedName, resource); err != nil { if !apierrs.IsNotFound(err) { logger.Error("unable to query for ingress route", "error", err) } @@ -57,7 +83,7 @@ func (r *IngressRouteReconciler) Reconcile( } // Then, we check if the resource should be processed - if !r.selector.Matches(ingressRoute.Annotations) { + if !r.selector.Matches(resource.GetAnnotations()) { logger.Debug("ignoring ingress route") return ctrl.Result{}, nil } @@ -65,28 +91,20 @@ func (r *IngressRouteReconciler) Reconcile( // Now, we have to ensure that all the dependent resources exist by calling all integrations. // For this, we first have to extract information about the ingress. - collection, err := switchboard.NewHostCollection(). - WithTLSHostsIfAvailable(ingressRoute.Spec.TLS). - WithRouteHostsIfRequired(ingressRoute.Spec.Routes) + info, err := r.adapter.extractInfo(resource) if err != nil { logger.Error("failed to parse hosts from ingress route", "error", err) return ctrl.Result{}, err } - info := integrations.IngressInfo{ - Hosts: collection.Hosts(), - TLSSecretName: ext.AndThen(ingressRoute.Spec.TLS, func(tls traefik.TLS) string { - return tls.SecretName - }), - } // Then, we can run the integrations for _, itg := range r.integrations { - if !r.selector.MatchesIntegration(ingressRoute.Annotations, itg.Name()) { + if !r.selector.MatchesIntegration(resource.GetAnnotations(), itg.Name()) { // If integration is ignored, skip it logger.Debug("ignoring integration", "integration", itg.Name()) continue } - if err := itg.UpdateResource(ctx, &ingressRoute, info); err != nil { + if err := itg.UpdateResource(ctx, resource, info); err != nil { logger.Error("failed to upsert resource", "integration", itg.Name(), "error", err, ) @@ -101,7 +119,93 @@ func (r *IngressRouteReconciler) Reconcile( // SetupWithManager sets up the controller with the Manager. func (r *IngressRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { + return r.adapter.setupWithManager(mgr, r) +} + +//------------------------------------------------------------------------------------------------- +// INGRESS ROUTE ADAPTER +//------------------------------------------------------------------------------------------------- + +type ingressRouteAdapter struct{} + +func (a ingressRouteAdapter) newResource() client.Object { + return &traefik.IngressRoute{} +} + +func (a ingressRouteAdapter) extractInfo(obj client.Object) (integrations.IngressInfo, error) { + ir, ok := obj.(*traefik.IngressRoute) + if !ok { + return integrations.IngressInfo{}, fmt.Errorf("unexpected resource type %T", obj) + } + collection, err := switchboard.NewHostCollection(). + WithTLSHostsIfAvailable(ir.Spec.TLS). + WithRouteHostsIfRequired(ir.Spec.Routes) + if err != nil { + return integrations.IngressInfo{}, err + } + return integrations.IngressInfo{ + Hosts: collection.Hosts(), + TLSSecretName: ext.AndThen(ir.Spec.TLS, func(tls traefik.TLS) string { + return tls.SecretName + }), + }, nil +} + +func (a ingressRouteAdapter) setupWithManager( + mgr ctrl.Manager, r *IngressRouteReconciler, +) error { + var list traefik.IngressRouteList builder := ctrl.NewControllerManagedBy(mgr).For(&traefik.IngressRoute{}) - builder = builderWithIntegrations(builder, r.integrations, r, r.logger) + builder = builderWithIntegrations(builder, r.integrations, r, r.logger, &list, + func(list *traefik.IngressRouteList) []client.Object { + return ext.Map(list.Items, func(v traefik.IngressRoute) client.Object { + return &v + }) + }, + ) + return builder.Complete(r) +} + +//------------------------------------------------------------------------------------------------- +// INGRESS ROUTE TCP ADAPTER +//------------------------------------------------------------------------------------------------- + +type ingressRouteTCPAdapter struct{} + +func (a ingressRouteTCPAdapter) newResource() client.Object { + return &traefik.IngressRouteTCP{} +} + +func (a ingressRouteTCPAdapter) extractInfo(obj client.Object) (integrations.IngressInfo, error) { + ir, ok := obj.(*traefik.IngressRouteTCP) + if !ok { + return integrations.IngressInfo{}, fmt.Errorf("unexpected resource type %T", obj) + } + collection, err := switchboard.NewHostCollection(). + WithTLSTCPHostsIfAvailable(ir.Spec.TLS). + WithRouteTCPHostsIfRequired(ir.Spec.Routes) + if err != nil { + return integrations.IngressInfo{}, err + } + return integrations.IngressInfo{ + Hosts: collection.Hosts(), + TLSSecretName: ext.AndThen(ir.Spec.TLS, func(tls traefik.TLSTCP) string { + return tls.SecretName + }), + }, nil +} + +func (a ingressRouteTCPAdapter) setupWithManager( + mgr ctrl.Manager, r *IngressRouteReconciler, +) error { + var list traefik.IngressRouteTCPList + builder := ctrl.NewControllerManagedBy(mgr).For(&traefik.IngressRouteTCP{}) + builder = builderWithIntegrations(builder, r.integrations, r, r.logger, &list, + func(list *traefik.IngressRouteTCPList) []client.Object { + return ext.Map(list.Items, func(v traefik.IngressRouteTCP) client.Object { + return &v + }) + }, + ) return builder.Complete(r) } diff --git a/internal/controllers/ingressroutetcp_test.go b/internal/controllers/ingressroutetcp_test.go new file mode 100644 index 0000000..33437a0 --- /dev/null +++ b/internal/controllers/ingressroutetcp_test.go @@ -0,0 +1,233 @@ +package controllers + +import ( + "context" + "fmt" + "log/slog" + "testing" + + configv1 "github.com/borchero/switchboard/internal/config/v1" + "github.com/borchero/switchboard/internal/k8tests" + certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" + traefiktypes "github.com/traefik/traefik/v3/pkg/types" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + _ "k8s.io/client-go/plugin/pkg/client/auth" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + externaldnsv1alpha1 "sigs.k8s.io/external-dns/apis/v1alpha1" +) + +func TestSimpleIngressTCP(t *testing.T) { + runTestTCP(t, testCaseTCP{ + Ingress: traefik.IngressRouteTCP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress-tcp", + }, + Spec: traefik.IngressRouteTCPSpec{ + Routes: []traefik.RouteTCP{{ + Match: "HostSNI(`www.example.com`)", + }}, + TLS: &traefik.TLSTCP{ + SecretName: "www-tls-certificate", + }, + }, + }, + DNSNames: []string{"www.example.com"}, + }) +} + +func TestIngressTCPNoTLS(t *testing.T) { + runTestTCP(t, testCaseTCP{ + Ingress: traefik.IngressRouteTCP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress-tcp", + }, + Spec: traefik.IngressRouteTCPSpec{ + Routes: []traefik.RouteTCP{{ + Match: "HostSNI(`www.example.com`)", + }}, + }, + }, + DNSNames: []string{"www.example.com"}, + }) +} + +func TestIngressTCPWildcard(t *testing.T) { + runTestTCP(t, testCaseTCP{ + Ingress: traefik.IngressRouteTCP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress-tcp", + }, + Spec: traefik.IngressRouteTCPSpec{ + Routes: []traefik.RouteTCP{{ + Match: "HostSNI(`*`)", + }}, + }, + }, + DNSNames: []string{}, + }) +} + +func TestIngressTCPCustomDNS(t *testing.T) { + runTestTCP(t, testCaseTCP{ + Ingress: traefik.IngressRouteTCP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress-tcp", + }, + Spec: traefik.IngressRouteTCPSpec{ + Routes: []traefik.RouteTCP{{ + Match: "HostSNI(`example.com`)", + }}, + TLS: &traefik.TLSTCP{ + SecretName: "www-tls-certificate", + Domains: []traefiktypes.Domain{{ + Main: "example.net", + SANs: []string{ + "*.example.net", + }, + }}, + }, + }, + }, + DNSNames: []string{"example.net", "*.example.net"}, + }) +} + +func TestIngressTCPMultipleRules(t *testing.T) { + runTestTCP(t, testCaseTCP{ + Ingress: traefik.IngressRouteTCP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress-tcp", + }, + Spec: traefik.IngressRouteTCPSpec{ + Routes: []traefik.RouteTCP{{ + Match: "HostSNI(`example.com`, `www.example.com`)", + }, { + Match: "HostSNI(`v2.example.com`)", + }}, + TLS: &traefik.TLSTCP{ + SecretName: "www-tls-certificate", + }, + }, + }, + DNSNames: []string{"example.com", "www.example.com", "v2.example.com"}, + }) +} + +//------------------------------------------------------------------------------------------------- +// TESTING UTILITIES +//------------------------------------------------------------------------------------------------- + +type testCaseTCP struct { + Ingress traefik.IngressRouteTCP + DNSNames []string +} + +func runTestTCP(t *testing.T, test testCaseTCP) { + // Setup + ctx := context.Background() + scheme := k8tests.NewScheme() + client := k8tests.NewClient(t, scheme) + namespace, shutdown := k8tests.NewNamespace(ctx, t, client) + defer shutdown() + + // Create objects and run reconciliation + service := k8tests.DummyService("traefik", namespace, 80) + err := client.Create(ctx, &service) + require.Nil(t, err) + + test.Ingress.Namespace = namespace + err = client.Create(ctx, &test.Ingress) + require.Nil(t, err) + + config := createConfigTCP(&service) + runReconciliationTCP(ctx, t, client, test.Ingress, config) + + // Check whether the outputs are valid + // 1) Certificate + certificateName := types.NamespacedName{ + Name: fmt.Sprintf("%s-tls", test.Ingress.Name), + Namespace: namespace, + } + var certificate certmanager.Certificate + err = client.Get(ctx, certificateName, &certificate) + if test.Ingress.Spec.TLS == nil { + assert.True(t, apierrors.IsNotFound(err)) + } else { + assert.Nil(t, err) + assert.ElementsMatch(t, test.DNSNames, certificate.Spec.DNSNames) + assert.Equal(t, + config.Integrations.CertManager.Template.Spec.IssuerRef.Kind, + certificate.Spec.IssuerRef.Kind, + ) + assert.Equal(t, + config.Integrations.CertManager.Template.Spec.IssuerRef.Name, + certificate.Spec.IssuerRef.Name, + ) + assert.Equal(t, test.Ingress.Spec.TLS.SecretName, certificate.Spec.SecretName) + } + + // 2) DNS records + endpointName := types.NamespacedName{Name: test.Ingress.Name, Namespace: namespace} + var dnsEndpoint externaldnsv1alpha1.DNSEndpoint + err = client.Get(ctx, endpointName, &dnsEndpoint) + if len(test.DNSNames) == 0 { + assert.True(t, apierrors.IsNotFound(err)) + } else { + assert.Nil(t, err) + assert.Len(t, dnsEndpoint.Spec.Endpoints, len(test.DNSNames)) + for _, ep := range dnsEndpoint.Spec.Endpoints { + assert.Len(t, ep.Targets, 1) + assert.Equal(t, service.Spec.ClusterIP, ep.Targets[0]) + } + } +} + +//------------------------------------------------------------------------------------------------- +// OBJECT CREATION +//------------------------------------------------------------------------------------------------- + +func runReconciliationTCP( + ctx context.Context, + t *testing.T, + client client.Client, + ingress traefik.IngressRouteTCP, + config configv1.Config, +) { + reconciler, err := NewIngressRouteTCPReconciler(client, slog.Default(), config) + require.Nil(t, err) + _, err = reconciler.Reconcile(ctx, controllerruntime.Request{ + NamespacedName: types.NamespacedName{Name: ingress.Name, Namespace: ingress.Namespace}, + }) + require.Nil(t, err) +} + +func createConfigTCP(service *v1.Service) configv1.Config { + return configv1.Config{ + Integrations: configv1.IntegrationConfigs{ + ExternalDNS: &configv1.ExternalDNSIntegrationConfig{ + TargetService: &configv1.ServiceRef{ + Name: service.Name, + Namespace: service.Namespace, + }, + }, + CertManager: &configv1.CertManagerIntegrationConfig{ + Template: certmanager.Certificate{ + Spec: certmanager.CertificateSpec{ + IssuerRef: cmmeta.IssuerReference{ + Kind: "ClusterIssuer", + Name: "my-issuer", + }, + }, + }, + }, + }, + } +} diff --git a/internal/controllers/utils.go b/internal/controllers/utils.go index d53288e..9c6a4b8 100644 --- a/internal/controllers/utils.go +++ b/internal/controllers/utils.go @@ -5,11 +5,9 @@ import ( "log/slog" configv1 "github.com/borchero/switchboard/internal/config/v1" - "github.com/borchero/switchboard/internal/ext" "github.com/borchero/switchboard/internal/integrations" "github.com/borchero/switchboard/internal/k8s" "github.com/borchero/switchboard/internal/switchboard" - traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -49,11 +47,13 @@ func integrationsFromConfig( return result, nil } -func builderWithIntegrations( +func builderWithIntegrations[L client.ObjectList]( builder *builder.Builder, integrations []integrations.Integration, ctrlClient client.Client, logger *slog.Logger, + list L, + getItems func(L) []client.Object, ) *builder.Builder { // Reconcile whenever an owned resource of one of the integrations is modified for _, itg := range integrations { @@ -63,13 +63,8 @@ func builderWithIntegrations( // Watch for dependent resources if required for _, itg := range integrations { if itg.WatchedObject() != nil { - var list traefik.IngressRouteList - enqueue := k8s.EnqueueMapFunc(ctrlClient, logger, itg.WatchedObject(), &list, - func(list *traefik.IngressRouteList) []client.Object { - return ext.Map(list.Items, func(v traefik.IngressRoute) client.Object { - return &v - }) - }, + enqueue := k8s.EnqueueMapFunc(ctrlClient, logger, itg.WatchedObject(), list, + getItems, ) builder = builder.Watches( itg.WatchedObject(), diff --git a/internal/switchboard/hosts.go b/internal/switchboard/hosts.go index 4a4159f..486ab09 100644 --- a/internal/switchboard/hosts.go +++ b/internal/switchboard/hosts.go @@ -3,7 +3,8 @@ package switchboard import ( "fmt" - muxer "github.com/traefik/traefik/v3/pkg/muxer/http" + muxerhttp "github.com/traefik/traefik/v3/pkg/muxer/http" + muxertcp "github.com/traefik/traefik/v3/pkg/muxer/tcp" traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" ) @@ -43,7 +44,7 @@ func (a *HostCollection) WithRouteHostsIfRequired( } for _, route := range routes { if route.Kind == "Rule" { - hosts, err := muxer.ParseDomains(route.Match) + hosts, err := muxerhttp.ParseDomains(route.Match) if err != nil { return nil, fmt.Errorf("failed to parse domains: %s", err) } @@ -55,6 +56,45 @@ func (a *HostCollection) WithRouteHostsIfRequired( return a, nil } +// WithTLSTCPHostsIfAvailable aggregates all hosts found in the provided TLSTCP configuration. If +// the TLS configuration is empty (i.e. `nil`), no hosts are extracted. This method should only be +// called on a freshly initialized aggregator. +func (a *HostCollection) WithTLSTCPHostsIfAvailable(config *traefik.TLSTCP) *HostCollection { + if config != nil { + for _, domain := range config.Domains { + a.hosts[domain.Main] = struct{}{} + for _, san := range domain.SANs { + a.hosts[san] = struct{}{} + } + } + } + return a +} + +// WithRouteTCPHostsIfRequired aggregates all (unique) hosts found in the provided TCP routes. If +// the aggregator already manages at least one host, this method is a noop, regardless of the routes +// passed as parameters. Host names are extracted from `HostSNI()` matchers in the route rules. +func (a *HostCollection) WithRouteTCPHostsIfRequired( + routes []traefik.RouteTCP, +) (*HostCollection, error) { + if len(a.hosts) > 0 { + return a, nil + } + for _, route := range routes { + hosts, err := muxertcp.ParseHostSNI(route.Match) + if err != nil { + return nil, fmt.Errorf("failed to parse HostSNI domains: %s", err) + } + for _, host := range hosts { + if host == "*" { + continue + } + a.hosts[host] = struct{}{} + } + } + return a, nil +} + // Len returns the number of hosts that the aggregator currently manages. func (a *HostCollection) Len() int { return len(a.hosts) diff --git a/internal/switchboard/hosts_test.go b/internal/switchboard/hosts_test.go index 109a4f4..d81737c 100644 --- a/internal/switchboard/hosts_test.go +++ b/internal/switchboard/hosts_test.go @@ -71,3 +71,58 @@ func TestParseRouteHostsNoop(t *testing.T) { assert.Nil(t, err) assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) } + +func TestParseTLSTCPHosts(t *testing.T) { + hosts := NewHostCollection().WithTLSTCPHostsIfAvailable(nil) + assert.Equal(t, hosts.Len(), 0) + + hosts.WithTLSTCPHostsIfAvailable(&traefik.TLSTCP{ + Domains: []traefiktypes.Domain{{ + Main: "example.com", + SANs: []string{"www.example.com"}, + }}, + }) + assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) +} + +func TestParseRouteTCPHosts(t *testing.T) { + hosts, err := NewHostCollection().WithRouteTCPHostsIfRequired([]traefik.RouteTCP{{ + Match: "HostSNI(`example.com`)", + }}) + assert.Nil(t, err) + assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) + + hosts, err = NewHostCollection().WithRouteTCPHostsIfRequired([]traefik.RouteTCP{{ + Match: "HostSNI(`example.com`, `www.example.com`)", + }}) + assert.Nil(t, err) + assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) + + hosts, err = NewHostCollection().WithRouteTCPHostsIfRequired([]traefik.RouteTCP{{ + Match: "HostSNI(`example.com`)", + }, { + Match: "HostSNI(`v2.example.com`)", + }}) + assert.Nil(t, err) + assert.ElementsMatch( + t, hosts.Hosts(), []string{"example.com", "v2.example.com"}, + ) +} + +func TestParseRouteTCPHostsWildcard(t *testing.T) { + hosts, err := NewHostCollection().WithRouteTCPHostsIfRequired([]traefik.RouteTCP{{ + Match: "HostSNI(`*`)", + }}) + assert.Nil(t, err) + assert.Equal(t, hosts.Len(), 0) +} + +func TestParseRouteTCPHostsNoop(t *testing.T) { + hosts := NewHostCollection() + hosts.hosts = map[string]struct{}{"example.com": {}} + _, err := hosts.WithRouteTCPHostsIfRequired([]traefik.RouteTCP{{ + Match: "HostSNI(`www.example.com`)", + }}) + assert.Nil(t, err) + assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) +} diff --git a/pixi.toml b/pixi.toml index 89df123..2709c65 100644 --- a/pixi.toml +++ b/pixi.toml @@ -39,6 +39,7 @@ run-controller = "go run cmd/main.go --config dev/config.yaml" cluster-create = "minikube start --profile switchboard" cluster-setup = """ kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml + && kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml && kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml && kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/external-dns/v0.20.0/config/crd/standard/dnsendpoints.externaldns.k8s.io.yaml && kubectl wait -n cert-manager --for=condition=Available deployment --all --timeout=120s