From ea92a3e1501270e9883cf9923dfdf7bea60dc595 Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Tue, 31 Mar 2026 16:14:06 +0200 Subject: [PATCH] Add wildcard host in Host and HostSNI matchers --- docs/content/migrate/v3.md | 17 ++ .../http/routing/rules-and-priority.md | 11 +- .../tcp/routing/rules-and-priority.md | 19 ++- .../fixtures/https/https_wildcard_host.toml | 36 ++++ .../https/https_wildcard_tls_options.toml | 64 ++++++++ .../tcp/wildcard-hostsni-tls-options.toml | 65 ++++++++ integration/https_test.go | 125 +++++++++++--- integration/tcp_test.go | 89 ++++++++++ .../requestdecorator/request_decorator.go | 4 +- .../request_decorator_test.go | 2 +- pkg/middlewares/snicheck/snicheck.go | 2 +- pkg/muxer/http/matcher.go | 39 ++--- pkg/muxer/http/matcher_test.go | 10 ++ pkg/muxer/http/matcher_v2.go | 7 +- pkg/muxer/muxer.go | 31 ++++ pkg/muxer/tcp/matcher.go | 36 ++-- pkg/muxer/tcp/matcher_test.go | 23 +-- pkg/muxer/tcp/matcher_v2.go | 4 +- pkg/muxer/tcp/mux.go | 8 +- .../ingress-with-wildcard-host-tls.yml | 23 +++ .../ingresses/ingress-with-wildcard-host.yml | 19 +++ .../kubernetes/ingress-nginx/kubernetes.go | 15 +- .../ingress-nginx/kubernetes_test.go | 154 ++++++++++++++++++ pkg/provider/kubernetes/ingress/kubernetes.go | 12 +- .../kubernetes/ingress/kubernetes_test.go | 6 +- pkg/server/router/tcp/manager.go | 7 +- pkg/tls/certificate_store.go | 10 +- 27 files changed, 705 insertions(+), 133 deletions(-) create mode 100644 integration/fixtures/https/https_wildcard_host.toml create mode 100644 integration/fixtures/https/https_wildcard_tls_options.toml create mode 100644 integration/fixtures/tcp/wildcard-hostsni-tls-options.toml create mode 100644 pkg/muxer/muxer.go create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml diff --git a/docs/content/migrate/v3.md b/docs/content/migrate/v3.md index 7d6a917bf6..266200802c 100644 --- a/docs/content/migrate/v3.md +++ b/docs/content/migrate/v3.md @@ -682,3 +682,20 @@ To use the new options of the `retry` middleware with the Kubernetes CRD provide ```shell kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.7/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml ``` + +### Wildcard Host and HostSNI + +Since `v3.7.0`, the `Host` and `HostSNI` matchers support wildcard subdomain matching (e.g., `*.example.com`). +This allows matching any direct subdomain of a domain with a single-level wildcard prefix. +For example, `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + +This feature is only available with the v3 rule syntax (the default). + +#### TLSOptions with Wildcard Domains + +Since `v3.7.0`, TLSOptions can now be associated with routers using wildcard `Host` and `HostSNI` matchers (e.g., `Host(`*.example.com`)`). +This enables configuring different TLS options for wildcard domains. + +Previously, TLSOptions selection was limited to exact `Host` matches, and using `HostRegexp` or wildcards would fall back to the default TLS options with a warning message like: `No domain found in rule HostRegexp(...) the TLS option foo cannot be applied`. + +Note: TLSOptions for `HostRegexp` matchers remains unsupported. Use wildcard `Host` matchers as an alternative. diff --git a/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md b/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md index 69712a373b..2c04e70c63 100644 --- a/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md +++ b/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md @@ -24,7 +24,7 @@ The table below lists all the available matchers: |-----------------------------------------------------------------|:-------------------------------------------------------------------------------| | [```Header(`key`, `value`)```](#header-and-headerregexp) | Matches requests containing a header named `key` set to `value`. | | [```HeaderRegexp(`key`, `regexp`)```](#header-and-headerregexp) | Matches requests containing a header named `key` matching `regexp`. | -| [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. | +| [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`). | | [```HostRegexp(`regexp`)```](#host-and-hostregexp) | Matches requests host matching `regexp`. | | [```Method(`method`)```](#method) | Matches requests method set to `method`. | | [```Path(`path`)```](#path-pathprefix-and-pathregexp) | Matches requests path set to `path`. | @@ -54,6 +54,15 @@ If no `Host` is set in the request URL (for example, it's an IP address), these These matchers will match the request's host in lowercase. +!!! info "Wildcard subdomain matching" + + The `Host` matcher supports a single-level wildcard prefix (`*.example.com`) to match any direct subdomain of `example.com`. + It should be preferred over the `HostRegexp` matcher as it allows attaching a TLS option and is more efficient. + + A wildcard matches exactly one subdomain label: `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + + This is only available with the **v3 rule syntax** (the default). + | Behavior | Rule | |-----------------------------------------------------------------|:------------------------------------------------------------------------| | Match requests with `Host` set to `example.com`. | ```Host(`example.com`)``` | diff --git a/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md b/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md index 2e6f7b9661..271e37faf2 100644 --- a/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md +++ b/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md @@ -18,7 +18,7 @@ The table below lists all the available matchers: | Rule | Description | |-------------------------------------------------------------|:-------------------------------------------------------------------------------------------------| -| [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`.
More information [here](#hostsni-and-hostsniregexp). | +| [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`).
More information [here](#hostsni-and-hostsniregexp). | | [```HostSNIRegexp(`regexp`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication matches `regexp`.
Use a [Go](https://golang.org/pkg/regexp/) flavored syntax.
More information [here](#hostsni-and-hostsniregexp). | | [```ClientIP(`ip`)```](#clientip) | Checks if the connection's client IP correspond to `ip`. It accepts IPv4, IPv6 and CIDR formats.
More information [here](#clientip). | | [```ALPN(`protocol`)```](#alpn) | Checks if the connection's ALPN protocol equals `protocol`.
More information [here](#alpn). | @@ -59,6 +59,15 @@ These matchers do not support non-ASCII characters, use punycode encoded values when one wants a non-TLS router that matches all (non-TLS) requests, one should use the specific ```HostSNI(`*`)``` syntax. +!!! info "Wildcard subdomain matching" + + The `HostSNI` matcher supports a single-level wildcard prefix (`*.example.com`) to match any direct subdomain of `example.com`. + It should be preferred over the `HostSNIRegexp` matcher as it allows attaching a TLS option and is more efficient. + + A wildcard matches exactly one subdomain label: `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + + This is only available with the **v3 rule syntax** (the default). + #### Examples Match all connections: @@ -77,7 +86,13 @@ Match TCP connections sent to `example.com`: HostSNI(`example.com`) ``` -Match TCP connections opened on any subdomain of `example.com`: +Match TCP connections opened on any direct subdomain of `example.com` (e.g. `foo.example.com`): + +```yaml +HostSNI(`*.example.com`) +``` + +Match TCP connections opened on any subdomain of `example.com` (including nested subdomains), using a regular expression: ```yaml HostSNIRegexp(`^.+\.example\.com$`) diff --git a/integration/fixtures/https/https_wildcard_host.toml b/integration/fixtures/https/https_wildcard_host.toml new file mode 100644 index 0000000000..a39891406c --- /dev/null +++ b/integration/fixtures/https/https_wildcard_host.toml @@ -0,0 +1,36 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.websecure] + address = ":4443" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + # Wildcard router: routes any *.snitest.com subdomain to service1. + [http.routers.wildcard] + service = "service1" + rule = "Host(`*.snitest.com`)" + [http.routers.wildcard.tls] + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "http://127.0.0.1:9040" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" diff --git a/integration/fixtures/https/https_wildcard_tls_options.toml b/integration/fixtures/https/https_wildcard_tls_options.toml new file mode 100644 index 0000000000..eea893174d --- /dev/null +++ b/integration/fixtures/https/https_wildcard_tls_options.toml @@ -0,0 +1,64 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.websecure] + address = ":4443" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + # Wildcard router covering all *.snitest.com subdomains with TLS option "foo" (minTLS12). + [http.routers.wildcard] + service = "service1" + rule = "Host(`*.snitest.com`)" + [http.routers.wildcard.tls] + options = "foo" + + # foo.snitest.com uses TLS option "bar" (minTLS13) + [http.routers.bar] + service = "service1" + rule = "Host(`foo.snitest.com`)" + [http.routers.bar.tls] + options = "bar" + +# minTLS11 +[http.routers.other] + service = "service1" + rule = "Host(`other.snitest.com`)" + [http.routers.other.tls] + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "{{ .BackendURL }}" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" + +[tls.options] + [tls.options.foo] + minVersion = "VersionTLS12" + maxVersion = "VersionTLS12" + + + [tls.options.bar] + minVersion = "VersionTLS13" + maxVersion = "VersionTLS13" + + [tls.options.default] + minVersion = "VersionTLS11" + maxVersion = "VersionTLS11" diff --git a/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml b/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml new file mode 100644 index 0000000000..11c0cfca03 --- /dev/null +++ b/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml @@ -0,0 +1,65 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.tcp] + address = ":8093" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[tcp] + [tcp.routers] + # Wildcard router covering *.snitest.com with TLS option "foo" (minTLS12). + [tcp.routers.wildcard] + rule = "HostSNI(`*.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.wildcard.tls] + options = "foo" + + # Override: bar.snitest.com uses TLS option "bar" (minTLS13), stricter than the wildcard. + [tcp.routers.bar] + rule = "HostSNI(`bar.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.bar.tls] + options = "bar" + + [tcp.routers.default] + rule = "HostSNI(`other.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.default.tls] + + [tcp.services] + [tcp.services.backend.loadBalancer] + [[tcp.services.backend.loadBalancer.servers]] + address = "{{ .Backend }}" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" + +[tls.options] + [tls.options.default] + minVersion = "VersionTLS11" + maxVersion = "VersionTLS11" + + [tls.options.foo] + minVersion = "VersionTLS12" + maxVersion = "VersionTLS12" + + [tls.options.bar] + minVersion = "VersionTLS13" + maxVersion = "VersionTLS13" diff --git a/integration/https_test.go b/integration/https_test.go index 68319fea1e..b9b64e8d67 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -30,6 +30,41 @@ func TestHTTPSSuite(t *testing.T) { suite.Run(t, &HTTPSSuite{}) } +// TestWithWildcardHost verifies that a wildcard Host rule Host(`*.snitest.com`) +// routes HTTPS requests for any matching subdomain to the configured service. +func (s *SimpleSuite) TestWithWildcardHost() { + backend := startTestServer("9040", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/https/https_wildcard_host.toml", struct{}{}) + s.traefikCmd(withConfigFile(file)) + + // Wait for Traefik to load the wildcard router. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("Host(`*.snitest.com`)")) + require.NoError(s.T(), err) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + // foo.snitest.com matches the wildcard and must be routed to the backend. + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = "foo.snitest.com" + err = try.RequestWithTransport(req, 5*time.Second, tr, try.StatusCodeIs(http.StatusOK)) + require.NoError(s.T(), err) + + // bar.snitest.com also matches the wildcard and must be routed to the backend. + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = "bar.snitest.com" + err = try.RequestWithTransport(req, 3*time.Second, tr, try.StatusCodeIs(http.StatusOK)) + require.NoError(s.T(), err) +} + // TestWithSNIConfigHandshake involves a client sending a SNI hostname of // "snitest.com", which happens to match the CN of 'snitest.com.crt'. The test // verifies that traefik presents the correct certificate. @@ -115,7 +150,6 @@ func (s *HTTPSSuite) TestWithSNIConfigRoute() { } // TestWithTLSOptions verifies that traefik routes the requests with the associated tls options. - func (s *HTTPSSuite) TestWithTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -196,8 +230,71 @@ func (s *HTTPSSuite) TestWithTLSOptions() { require.NoError(s.T(), err) } -// TestWithConflictingTLSOptions checks that routers with same SNI but different TLS options get fallbacked to the default TLS options. +func (s *HTTPSSuite) TestWithTLSOptionsAndWildcard() { + backend := startTestServer("0", http.StatusNoContent, "") + defer backend.Close() + err := try.GetRequest(backend.URL, 1*time.Second, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + file := s.adaptFile("fixtures/https/https_wildcard_tls_options.toml", struct{ BackendURL string }{backend.URL}) + s.traefikCmd(withConfigFile(file)) + + // wait for Traefik + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`*.snitest.com`)")) + require.NoError(s.T(), err) + + tr1 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS12, + MinVersion: tls.VersionTLS12, + ServerName: "bar.snitest.com", + }, + } + + tr2 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS13, + MinVersion: tls.VersionTLS13, + ServerName: "foo.snitest.com", + }, + } + + tr3 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS11, + MinVersion: tls.VersionTLS11, + ServerName: "other.snitest.com", + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr1.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 30*time.Second, tr1, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr2.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 3*time.Second, tr2, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr3.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 3*time.Second, tr3, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) +} + +// TestWithConflictingTLSOptions checks that routers with same SNI but different TLS options get fallbacked to the +// default TLS options. func (s *HTTPSSuite) TestWithConflictingTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -262,7 +359,6 @@ func (s *HTTPSSuite) TestWithConflictingTLSOptions() { // TestWithSNIStrictNotMatchedRequest involves a client sending a SNI hostname of // "snitest.org", which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik closes the connection. - func (s *HTTPSSuite) TestWithSNIStrictNotMatchedRequest() { file := s.adaptFile("fixtures/https/https_sni_strict.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -284,7 +380,6 @@ func (s *HTTPSSuite) TestWithSNIStrictNotMatchedRequest() { // TestWithDefaultCertificate involves a client sending a SNI hostname of // "snitest.org", which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik returns the default certificate. - func (s *HTTPSSuite) TestWithDefaultCertificate() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -316,7 +411,6 @@ func (s *HTTPSSuite) TestWithDefaultCertificate() { // TestWithDefaultCertificateNoSNI involves a client sending a request with no ServerName // which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik returns the default certificate. - func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -348,7 +442,6 @@ func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI() { // "www.snitest.com", which matches the CN of two static certificates: // 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test // verifies that traefik returns the non-wildcard certificate. - func (s *HTTPSSuite) TestWithOverlappingStaticCertificate() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -381,7 +474,6 @@ func (s *HTTPSSuite) TestWithOverlappingStaticCertificate() { // "www.snitest.com", which matches the CN of two dynamic certificates: // 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test // verifies that traefik returns the non-wildcard certificate. - func (s *HTTPSSuite) TestWithOverlappingDynamicCertificate() { file := s.adaptFile("fixtures/https/dynamic_https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -410,9 +502,8 @@ func (s *HTTPSSuite) TestWithOverlappingDynamicCertificate() { assert.Equal(s.T(), "h2", proto) } -// TestWithClientCertificateAuthentication -// The client can send a certificate signed by a CA trusted by the server but it's optional. - +// TestWithClientCertificateAuthentication tests that a client can send a certificate signed by a CA trusted by the server +// but it's optional. func (s *HTTPSSuite) TestWithClientCertificateAuthentication() { file := s.adaptFile("fixtures/https/clientca/https_1ca1config.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -464,9 +555,8 @@ func (s *HTTPSSuite) TestWithClientCertificateAuthentication() { assert.NoError(s.T(), err, "should be allowed to connect to server") } -// TestWithClientCertificateAuthentication -// Use two CA:s and test that clients with client signed by either of them can connect. - +// TestWithClientCertificateAuthenticationMultipleCAs uses two CA:s +// and test that clients with client signed by either of them can connect. func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAs() { server1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server1")) })) server2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server2")) })) @@ -557,9 +647,8 @@ func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAs() { assert.Error(s.T(), err) } -// TestWithClientCertificateAuthentication -// Use two CA:s in two different files and test that clients with client signed by either of them can connect. - +// TestWithClientCertificateAuthenticationMultipleCAsMultipleFiles uses two CA:s in two different files +// and test that clients with client signed by either of them can connect. func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAsMultipleFiles() { server1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server1")) })) server2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server2")) })) @@ -768,7 +857,6 @@ func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithNoChange() { // that traefik updates its configuration when the HTTPS configuration is modified and // it routes the requests to the expected backends thanks to given certificate if possible // otherwise thanks to the default one. - func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithChange() { dynamicConfFileName := s.adaptFile("fixtures/https/dynamic_https.toml", struct{}{}) confFileName := s.adaptFile("fixtures/https/dynamic_https_sni.toml", struct { @@ -833,7 +921,6 @@ func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithChange() { // that traefik updates its configuration when the HTTPS configuration is modified, even if it totally deleted, and // it routes the requests to the expected backends thanks to given certificate if possible // otherwise thanks to the default one. - func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithTlsConfigurationDeletion() { dynamicConfFileName := s.adaptFile("fixtures/https/dynamic_https.toml", struct{}{}) confFileName := s.adaptFile("fixtures/https/dynamic_https_sni.toml", struct { @@ -1143,7 +1230,7 @@ func (s *HTTPSSuite) TestWithInvalidTLSOption() { } } -// modifyCertificateConfFileContent replaces the content of a HTTPS configuration file. +// modifyCertificateConfFileContent replaces the content of an HTTPS configuration file. func (s *HTTPSSuite) modifyCertificateConfFileContent(certFileName, confFileName string) { file, err := os.OpenFile("./"+confFileName, os.O_WRONLY, os.ModeExclusive) require.NoError(s.T(), err) diff --git a/integration/tcp_test.go b/integration/tcp_test.go index 0b4b36e57a..f66c6b9385 100644 --- a/integration/tcp_test.go +++ b/integration/tcp_test.go @@ -438,6 +438,95 @@ func (s *TCPSuite) TestPostgresSTARTTLSPassthrough() { assert.Equal(s.T(), byte('R'), header[0]) } +// TestTCPWildcardHostSNI verifies that a wildcard HostSNI rule HostSNI(`*.snitest.com`) +// routes TLS connections for any matching subdomain to the configured backend. +func (s *SimpleSuite) TestTCPWildcardHostSNI() { + backend := startTestServer("9041", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/tcp/wildcard-hostsni-tls-options.toml", struct { + Backend string + }{ + Backend: "127.0.0.1:9041", + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for the wildcard TCP router to be loaded. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("HostSNI(`*.snitest.com`)")) + require.NoError(s.T(), err) + + // foo.snitest.com matches the wildcard: TLS connection must succeed. + conn, err := tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "foo.snitest.com", + InsecureSkipVerify: true, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com also matches the wildcard: TLS connection must succeed. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "bar.snitest.com", + InsecureSkipVerify: true, + }) + require.NoError(s.T(), err) + conn.Close() +} + +// TestTCPWildcardHostSNITLSOptions verifies that: +// - a wildcard HostSNI rule HostSNI(`*.snitest.com`) with TLS option "foo" (minTLS12) +// routes and accepts TLS 1.2 connections for any matching subdomain; +// - a more specific rule HostSNI(`bar.snitest.com`) with TLS option "bar" (minTLS13) +// takes priority for that subdomain and rejects TLS 1.2-only connections. +func (s *SimpleSuite) TestTCPWildcardHostSNITLSOptions() { + backend := startTestServer("9041", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/tcp/wildcard-hostsni-tls-options.toml", struct { + Backend string + }{ + Backend: "127.0.0.1:9041", + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for both TCP routers to be loaded. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("HostSNI(`*.snitest.com`)")) + require.NoError(s.T(), err) + + // foo.snitest.com matches the wildcard (TLS option "foo", minTLS12). + // A TLS 1.2 connection must succeed. + conn, err := tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "foo.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com has a specific rule with TLS option "bar" (minTLS13). + // A TLS 1.2-only connection must be rejected. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "bar.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com without a version cap: connection must succeed. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "other.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS11, + MaxVersion: tls.VersionTLS11, + }) + require.NoError(s.T(), err) + conn.Close() +} + func welcome(addr string) (string, error) { tcpAddr, err := net.ResolveTCPAddr("tcp", addr) if err != nil { diff --git a/pkg/middlewares/requestdecorator/request_decorator.go b/pkg/middlewares/requestdecorator/request_decorator.go index e2a82395ca..b7ef6d065c 100644 --- a/pkg/middlewares/requestdecorator/request_decorator.go +++ b/pkg/middlewares/requestdecorator/request_decorator.go @@ -64,8 +64,8 @@ func parseHost(addr string) string { return host } -// GetCanonizedHost retrieves the canonized host from the given context (previously stored in the request context by the middleware). -func GetCanonizedHost(ctx context.Context) string { +// GetCanonicalHost retrieves the canonical host from the given context (previously stored in the request context by the middleware). +func GetCanonicalHost(ctx context.Context) string { if val, ok := ctx.Value(canonicalKey).(string); ok { return val } diff --git a/pkg/middlewares/requestdecorator/request_decorator_test.go b/pkg/middlewares/requestdecorator/request_decorator_test.go index c959808269..687b3a8fba 100644 --- a/pkg/middlewares/requestdecorator/request_decorator_test.go +++ b/pkg/middlewares/requestdecorator/request_decorator_test.go @@ -42,7 +42,7 @@ func TestRequestHost(t *testing.T) { t.Parallel() next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - host := GetCanonizedHost(r.Context()) + host := GetCanonicalHost(r.Context()) assert.Equal(t, test.expected, host) }) diff --git a/pkg/middlewares/snicheck/snicheck.go b/pkg/middlewares/snicheck/snicheck.go index 89f817fcbc..de474997f5 100644 --- a/pkg/middlewares/snicheck/snicheck.go +++ b/pkg/middlewares/snicheck/snicheck.go @@ -55,7 +55,7 @@ func getHost(req *http.Request) string { return h } - h = requestdecorator.GetCanonizedHost(req.Context()) + h = requestdecorator.GetCanonicalHost(req.Context()) if h != "" { return h } diff --git a/pkg/muxer/http/matcher.go b/pkg/muxer/http/matcher.go index d952a6bc26..506cc2c1d6 100644 --- a/pkg/muxer/http/matcher.go +++ b/pkg/muxer/http/matcher.go @@ -6,11 +6,11 @@ import ( "regexp" "slices" "strings" - "unicode" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/muxer" ) var httpFuncs = matcherBuilderFuncs{ @@ -69,33 +69,33 @@ func method(tree *matchersTree, methods ...string) error { } func host(tree *matchersTree, hosts ...string) error { - host := hosts[0] + hostExpr := hosts[0] - if !IsASCII(host) { - return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", host) + if !muxer.IsASCII(hostExpr) { + return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", hostExpr) } - host = strings.ToLower(host) + hostExpr = strings.ToLower(hostExpr) tree.matcher = func(req *http.Request) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) + reqHost := requestdecorator.GetCanonicalHost(req.Context()) if len(reqHost) == 0 { return false } - if reqHost == host { + if muxer.DomainMatchHostExpression(reqHost, hostExpr) { return true } flatH := requestdecorator.GetCNAMEFlatten(req.Context()) if len(flatH) > 0 { - return strings.EqualFold(flatH, host) + return muxer.DomainMatchHostExpression(flatH, hostExpr) } // Check for match on trailing period on host - if last := len(host) - 1; last >= 0 && host[last] == '.' { - h := host[:last] - if reqHost == h { + if last := len(hostExpr) - 1; last >= 0 && hostExpr[last] == '.' { + h := hostExpr[:last] + if muxer.DomainMatchHostExpression(reqHost, h) { return true } } @@ -103,7 +103,7 @@ func host(tree *matchersTree, hosts ...string) error { // Check for match on trailing period on request if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { h := reqHost[:last] - if h == host { + if muxer.DomainMatchHostExpression(h, hostExpr) { return true } } @@ -117,7 +117,7 @@ func host(tree *matchersTree, hosts ...string) error { func hostRegexp(tree *matchersTree, hosts ...string) error { host := hosts[0] - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) } @@ -127,7 +127,7 @@ func hostRegexp(tree *matchersTree, hosts ...string) error { } tree.matcher = func(req *http.Request) bool { - return re.MatchString(requestdecorator.GetCanonizedHost(req.Context())) || + return re.MatchString(requestdecorator.GetCanonicalHost(req.Context())) || re.MatchString(requestdecorator.GetCNAMEFlatten(req.Context())) } @@ -252,14 +252,3 @@ func queryRegexp(tree *matchersTree, queries ...string) error { return nil } - -// IsASCII checks if the given string contains only ASCII characters. -func IsASCII(s string) bool { - for i := range len(s) { - if s[i] > unicode.MaxASCII { - return false - } - } - - return true -} diff --git a/pkg/muxer/http/matcher_test.go b/pkg/muxer/http/matcher_test.go index 35550ecaf9..a8402a30ab 100644 --- a/pkg/muxer/http/matcher_test.go +++ b/pkg/muxer/http/matcher_test.go @@ -256,6 +256,16 @@ func TestHostMatcher(t *testing.T) { "https://🦭.com": http.StatusNotFound, }, }, + { + desc: "wildcard matcher", + rule: "Host(`*.example.com`)", + expected: map[string]int{ + "https://test.example.com": http.StatusOK, + "https://other.example.com": http.StatusOK, + "https://example.com": http.StatusNotFound, + "https://test.otherexample.com": http.StatusNotFound, + }, + }, } for _, test := range testCases { diff --git a/pkg/muxer/http/matcher_v2.go b/pkg/muxer/http/matcher_v2.go index 36f426f48d..e0583c26c3 100644 --- a/pkg/muxer/http/matcher_v2.go +++ b/pkg/muxer/http/matcher_v2.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/muxer" ) var httpFuncsV2 = matcherBuilderFuncs{ @@ -78,7 +79,7 @@ func pathPrefixV2(tree *matchersTree, paths ...string) error { func hostV2(tree *matchersTree, hosts ...string) error { for i, host := range hosts { - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for \"Host\" matcher, non-ASCII characters are not allowed", host) } @@ -86,7 +87,7 @@ func hostV2(tree *matchersTree, hosts ...string) error { } tree.matcher = func(req *http.Request) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) + reqHost := requestdecorator.GetCanonicalHost(req.Context()) if len(reqHost) == 0 { // If the request is an HTTP/1.0 request, then a Host may not be defined. if req.ProtoAtLeast(1, 1) { @@ -206,7 +207,7 @@ func hostRegexpV2(tree *matchersTree, hosts ...string) error { router := mux.NewRouter() for _, host := range hosts { - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) } diff --git a/pkg/muxer/muxer.go b/pkg/muxer/muxer.go new file mode 100644 index 0000000000..d7fc0ecf4b --- /dev/null +++ b/pkg/muxer/muxer.go @@ -0,0 +1,31 @@ +package muxer + +import ( + "strings" + "unicode" +) + +// IsASCII checks if the given string contains only ASCII characters. +func IsASCII(s string) bool { + for i := range len(s) { + if s[i] > unicode.MaxASCII { + return false + } + } + + return true +} + +// DomainMatchHostExpression returns true if the domain matches the host expression. +// The host expression can be a wildcard, in which case it will match any subdomain of the domain. +// For example, if the domain is "example.com" and the host expression is "*.example.com", this function will return true. +// If the host expression is "example.com", this function will also return true. +func DomainMatchHostExpression(domain string, hostExpr string) bool { + if strings.HasPrefix(hostExpr, "*") { + labels := strings.Split(domain, ".") + labels[0] = "*" + return strings.EqualFold(hostExpr, strings.Join(labels, ".")) + } + + return strings.EqualFold(domain, hostExpr) +} diff --git a/pkg/muxer/tcp/matcher.go b/pkg/muxer/tcp/matcher.go index aadefa8c10..b29caeb8b2 100644 --- a/pkg/muxer/tcp/matcher.go +++ b/pkg/muxer/tcp/matcher.go @@ -5,11 +5,11 @@ import ( "regexp" "slices" "strings" - "unicode" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" + "github.com/traefik/traefik/v3/pkg/muxer" ) var tcpFuncs = map[string]func(*matchersTree, ...string) error{ @@ -62,36 +62,31 @@ func clientIP(tree *matchersTree, clientIP ...string) error { return nil } -var hostOrIP = regexp.MustCompile(`^[[:word:]\.\-\:]+$`) +var hostOrIP = regexp.MustCompile(`^(\*\.)?[[:word:]\.\-\:]+$`) // hostSNI checks if the SNI Host of the connection match the matcher host. func hostSNI(tree *matchersTree, hosts ...string) error { - host := hosts[0] + hostExpr := hosts[0] - if host == "*" { + if hostExpr == "*" { // Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP, // it allows matching with an empty serverName. tree.matcher = func(meta ConnData) bool { return true } return nil } - if !hostOrIP.MatchString(host) { - return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", host) + if !hostOrIP.MatchString(hostExpr) { + return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", hostExpr) } + hostExpr = strings.TrimSuffix(hostExpr, ".") + tree.matcher = func(meta ConnData) bool { if meta.serverName == "" { return false } - if host == meta.serverName { - return true - } - - // trim trailing period in case of FQDN - host = strings.TrimSuffix(host, ".") - - return host == meta.serverName + return muxer.DomainMatchHostExpression(meta.serverName, hostExpr) } return nil @@ -101,7 +96,7 @@ func hostSNI(tree *matchersTree, hosts ...string) error { func hostSNIRegexp(tree *matchersTree, templates ...string) error { template := templates[0] - if !isASCII(template) { + if !muxer.IsASCII(template) { return fmt.Errorf("invalid value for HostSNIRegexp matcher, %q is not a valid hostname", template) } @@ -116,14 +111,3 @@ func hostSNIRegexp(tree *matchersTree, templates ...string) error { return nil } - -// isASCII checks if the given string contains only ASCII characters. -func isASCII(s string) bool { - for i := range len(s) { - if s[i] > unicode.MaxASCII { - return false - } - } - - return true -} diff --git a/pkg/muxer/tcp/matcher_test.go b/pkg/muxer/tcp/matcher_test.go index e5e265fe7b..f77aa37d61 100644 --- a/pkg/muxer/tcp/matcher_test.go +++ b/pkg/muxer/tcp/matcher_test.go @@ -71,11 +71,6 @@ func Test_HostSNI(t *testing.T) { rule: "HostSNI(`example.com`, `example.org`)", buildErr: true, }, - { - desc: "Invalid HostSNI matcher (globing sub domain)", - rule: "HostSNI(`*.com`)", - buildErr: true, - }, { desc: "Invalid HostSNI matcher (non ASCII host)", rule: "HostSNI(`🦭.com`)", @@ -115,12 +110,6 @@ func Test_HostSNI(t *testing.T) { serverName: "", match: true, }, - { - desc: "Matching host with trailing dot", - rule: "HostSNI(`example.com.`)", - serverName: "example.com.", - match: true, - }, { desc: "Matching host with trailing dot but not in server name", rule: "HostSNI(`example.com.`)", @@ -139,6 +128,18 @@ func Test_HostSNI(t *testing.T) { serverName: "foo_bar.example.com", match: true, }, + { + desc: "Matching hosts with subdomains with wildcard", + rule: "HostSNI(`*.example.com`)", + serverName: "foo.example.com", + match: true, + }, + { + desc: "Matching hosts with subdomains with wildcard", + rule: "HostSNI(`*.*.example.com`)", + serverName: "toto.foo.example.com", + buildErr: true, + }, } for _, test := range testCases { diff --git a/pkg/muxer/tcp/matcher_v2.go b/pkg/muxer/tcp/matcher_v2.go index 8071599940..d5fff39a9e 100644 --- a/pkg/muxer/tcp/matcher_v2.go +++ b/pkg/muxer/tcp/matcher_v2.go @@ -21,6 +21,8 @@ var tcpFuncsV2 = map[string]func(*matchersTree, ...string) error{ "HostSNIRegexp": hostSNIRegexpV2, } +var hostOrIPv2 = regexp.MustCompile(`^[[:word:]\.\-\:]+$`) + func clientIPV2(tree *matchersTree, clientIPs ...string) error { checker, err := ip.NewChecker(clientIPs) if err != nil { @@ -80,7 +82,7 @@ func hostSNIV2(tree *matchersTree, hosts ...string) error { continue } - if !hostOrIP.MatchString(host) { + if !hostOrIPv2.MatchString(host) { return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname or IP", host) } diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index db80d82892..9df58e7e21 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -27,12 +27,10 @@ func NewConnData(serverName string, conn tcp.WriteCloser, alpnProtos []string) ( return ConnData{}, fmt.Errorf("error while parsing remote address %q: %w", conn.RemoteAddr().String(), err) } - // as per https://datatracker.ietf.org/doc/html/rfc6066: - // > The hostname is represented as a byte string using ASCII encoding without a trailing dot. - // so there is no need to trim a potential trailing dot - serverName = types.CanonicalDomain(serverName) - return ConnData{ + // As per https://datatracker.ietf.org/doc/html/rfc6066: + // > The hostname is represented as a byte string using ASCII encoding without a trailing dot. + // so there is no need to trim a potential trailing dot serverName: types.CanonicalDomain(serverName), remoteIP: remoteIP, alpnProtos: alpnProtos, diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml new file mode 100644 index 0000000000..1e9b4578c3 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml @@ -0,0 +1,23 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-wildcard-host-tls + namespace: default +spec: + ingressClassName: nginx + rules: + - host: "*.localhost" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 + tls: + - hosts: + - "*.localhost" + secretName: whoami-tls diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml new file mode 100644 index 0000000000..7aa7e5d1b1 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml @@ -0,0 +1,19 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-wildcard-host + namespace: default +spec: + ingressClassName: nginx + rules: + - host: "*.localhost" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index 4fb0b08571..8f3f3d715a 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -692,7 +692,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rt := &dynamic.Router{ EntryPoints: p.NonTLSEntryPoints, - Rule: buildHostRule(rule.Host), + Rule: fmt.Sprintf("Host(%q)", rule.Host), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. RuleSyntax: "default", Service: key, @@ -706,7 +706,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS := &dynamic.Router{ EntryPoints: p.TLSEntryPoints, - Rule: buildHostRule(rule.Host), + Rule: fmt.Sprintf("Host(%q)", rule.Host), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. RuleSyntax: "default", Service: key, @@ -2156,7 +2156,7 @@ func buildRule(ctx context.Context, host string, pa netv1.HTTPIngressPath, confi var hostRules []string for _, h := range hosts { - hostRules = append(hostRules, buildHostRule(h)) + hostRules = append(hostRules, fmt.Sprintf("Host(%q)", h)) } if len(hostRules) > 1 { @@ -2189,15 +2189,6 @@ func buildRule(ctx context.Context, host string, pa netv1.HTTPIngressPath, confi return strings.Join(rules, " && ") } -func buildHostRule(host string) string { - if strings.HasPrefix(host, "*.") { - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - return fmt.Sprintf("HostRegexp(%q)", fmt.Sprintf("^%s$", host)) - } - - return fmt.Sprintf("Host(%q)", host) -} - // buildPrefixRule is a helper function to build a path prefix rule that matches path prefix split by `/`. // For example, the paths `/abc`, `/abc/`, and `/abc/def` would all match the prefix `/abc`, // but the path `/abcd` would not. See TestStrictPrefixMatchingRule() for more examples. diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index e7b64e8bf9..43dab89314 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -9730,6 +9730,160 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Wildcard host", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-wildcard-host.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-wildcard-host-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-wildcard-host-rule-0-path-0-retry"}, + Service: "default-ingress-with-wildcard-host-whoami-80", + }, + "default-ingress-with-wildcard-host-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + TLS: &dynamic.RouterTLSConfig{}, + Middlewares: []string{"default-ingress-with-wildcard-host-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-wildcard-host-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-wildcard-host-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-wildcard-host-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-wildcard-host-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-wildcard-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-wildcard-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Wildcard host with TLS", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "secrets.yml", + "ingresses/ingress-with-wildcard-host-tls.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-wildcard-host-tls-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-wildcard-host-tls-rule-0-path-0-retry"}, + Service: "default-ingress-with-wildcard-host-tls-whoami-80", + }, + "default-ingress-with-wildcard-host-tls-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + TLS: &dynamic.RouterTLSConfig{}, + Middlewares: []string{"default-ingress-with-wildcard-host-tls-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-wildcard-host-tls-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-wildcard-host-tls-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-wildcard-host-tls-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-wildcard-host-tls-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-wildcard-host-tls", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-wildcard-host-tls": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{ + CertFile: "-----BEGIN CERTIFICATE-----", + KeyFile: "-----BEGIN CERTIFICATE-----", + }, + }, + }, + }, + }, + }, { desc: "Ingress with multiple paths and one invalid path with StrictValidatePathType drops the whole ingress", paths: []string{ diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index e0303aed23..0a3bdde43b 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -8,7 +8,6 @@ import ( "math" "net" "os" - "regexp" "slices" "sort" "strconv" @@ -706,7 +705,7 @@ func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, if rt.RuleSyntax == "v2" || (rt.RuleSyntax == "" && p.DefaultRuleSyntax == "v2") { rules = append(rules, buildHostRuleV2(rule.Host)) } else { - rules = append(rules, buildHostRule(rule.Host)) + rules = append(rules, fmt.Sprintf("Host(%q)", rule.Host)) } } @@ -737,15 +736,6 @@ func buildHostRuleV2(host string) string { return fmt.Sprintf("Host(%q)", host) } -func buildHostRule(host string) string { - if strings.HasPrefix(host, "*.") { - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - return fmt.Sprintf("HostRegexp(%q)", fmt.Sprintf("^%s$", host)) - } - - return fmt.Sprintf("Host(%q)", host) -} - func getCertificates(ctx context.Context, ingress *netv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error { for _, t := range ingress.Spec.TLS { if t.SecretName == "" { diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index 924e4b4c62..2d05383c5d 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -206,8 +206,8 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Middlewares: map[string]*dynamic.Middleware{}, Routers: map[string]*dynamic.Router{ - "testing-bar-bar-97cb2ba265f7a5df4ab9": { - Rule: `HostRegexp("^[a-zA-Z0-9-]+\\.bar$") && PathPrefix("/bar")`, + "testing-bar-bar-41871576e140babe40bd": { + Rule: `Host("*.bar") && PathPrefix("/bar")`, Service: "testing-service1-80", }, "testing-bar-bar-605945111a3c9f84dc65": { @@ -1161,7 +1161,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { Middlewares: map[string]*dynamic.Middleware{}, Routers: map[string]*dynamic.Router{ "testing-foobar-com-bar": { - Rule: `HostRegexp("^[a-zA-Z0-9-]+\\.foobar\\.com$") && PathPrefix("/bar")`, + Rule: `Host("*.foobar.com") && PathPrefix("/bar")`, Service: "testing-service1-80", }, }, diff --git a/pkg/server/router/tcp/manager.go b/pkg/server/router/tcp/manager.go index c266e84d57..63e86e6cb2 100644 --- a/pkg/server/router/tcp/manager.go +++ b/pkg/server/router/tcp/manager.go @@ -12,6 +12,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/middlewares/snicheck" + "github.com/traefik/traefik/v3/pkg/muxer" httpmuxer "github.com/traefik/traefik/v3/pkg/muxer/http" tcpmuxer "github.com/traefik/traefik/v3/pkg/muxer/tcp" "github.com/traefik/traefik/v3/pkg/observability/logs" @@ -179,7 +180,9 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string // # When a request for "/foo" comes, even though it won't be routed by httpRouter2, // # if its SNI is set to foo.com, myTLSOptions will be used for the TLS connection. // # Otherwise, it will fallback to the default TLS config. - logger.Warn().Msgf("No domain found in rule %v, the TLS options applied for this router will depend on the SNI of each request", routerHTTPConfig.Rule) + if tlsOptionsName != traefiktls.DefaultTLSConfigName { + logger.Warn().Msgf("No domain found in rule %v, the TLS option %s cannot be applied", routerHTTPConfig.Rule, tlsOptionsName) + } } // Even though the error is seemingly ignored (aside from logging it), @@ -339,7 +342,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim } for _, domain := range domains { - if httpmuxer.IsASCII(domain) { + if muxer.IsASCII(domain) { continue } diff --git a/pkg/tls/certificate_store.go b/pkg/tls/certificate_store.go index 5321f98aa4..5f7478f311 100644 --- a/pkg/tls/certificate_store.go +++ b/pkg/tls/certificate_store.go @@ -281,12 +281,6 @@ func matchDomain(serverName, certDomain string) bool { } labels := strings.Split(serverName, ".") - for i := range labels { - labels[i] = "*" - candidate := strings.Join(labels, ".") - if certDomain == candidate { - return true - } - } - return false + labels[0] = "*" + return certDomain == strings.Join(labels, ".") }