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:
|-----------------------------------------------------------------|:-------------------------------------------------------------------------------|
| | Matches requests containing a header named `key` set to `value`. |
| | 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, ".")
}