mirror of
https://github.com/traefik/traefik.git
synced 2026-05-05 04:16:25 +02:00
Add wildcard host in Host and HostSNI matchers
This commit is contained in:
parent
9a8ff969ac
commit
ea92a3e150
@ -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.
|
||||
|
||||
@ -24,7 +24,7 @@ The table below lists all the available matchers:
|
||||
|-----------------------------------------------------------------|:-------------------------------------------------------------------------------|
|
||||
| <a id="opt-Headerkey-value" href="#opt-Headerkey-value" title="#opt-Headerkey-value">[```Header(`key`, `value`)```](#header-and-headerregexp)</a> | Matches requests containing a header named `key` set to `value`. |
|
||||
| <a id="opt-HeaderRegexpkey-regexp" href="#opt-HeaderRegexpkey-regexp" title="#opt-HeaderRegexpkey-regexp">[```HeaderRegexp(`key`, `regexp`)```](#header-and-headerregexp)</a> | Matches requests containing a header named `key` matching `regexp`. |
|
||||
| <a id="opt-Hostdomain" href="#opt-Hostdomain" title="#opt-Hostdomain">[```Host(`domain`)```](#host-and-hostregexp)</a> | Matches requests host set to `domain`. |
|
||||
| <a id="opt-Hostdomain" href="#opt-Hostdomain" title="#opt-Hostdomain">[```Host(`domain`)```](#host-and-hostregexp)</a> | Matches requests host set to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`). |
|
||||
| <a id="opt-HostRegexpregexp" href="#opt-HostRegexpregexp" title="#opt-HostRegexpregexp">[```HostRegexp(`regexp`)```](#host-and-hostregexp)</a> | Matches requests host matching `regexp`. |
|
||||
| <a id="opt-Methodmethod" href="#opt-Methodmethod" title="#opt-Methodmethod">[```Method(`method`)```](#method)</a> | Matches requests method set to `method`. |
|
||||
| <a id="opt-Pathpath" href="#opt-Pathpath" title="#opt-Pathpath">[```Path(`path`)```](#path-pathprefix-and-pathregexp)</a> | 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 |
|
||||
|-----------------------------------------------------------------|:------------------------------------------------------------------------|
|
||||
| <a id="opt-Match-requests-with-Host-set-to-example-com" href="#opt-Match-requests-with-Host-set-to-example-com" title="#opt-Match-requests-with-Host-set-to-example-com">Match requests with `Host` set to `example.com`.</a> | ```Host(`example.com`)``` |
|
||||
|
||||
@ -18,7 +18,7 @@ The table below lists all the available matchers:
|
||||
|
||||
| Rule | Description |
|
||||
|-------------------------------------------------------------|:-------------------------------------------------------------------------------------------------|
|
||||
| <a id="opt-HostSNIdomain" href="#opt-HostSNIdomain" title="#opt-HostSNIdomain">[```HostSNI(`domain`)```](#hostsni-and-hostsniregexp)</a> | Checks if the connection's Server Name Indication is equal to `domain`.<br /> More information [here](#hostsni-and-hostsniregexp). |
|
||||
| <a id="opt-HostSNIdomain" href="#opt-HostSNIdomain" title="#opt-HostSNIdomain">[```HostSNI(`domain`)```](#hostsni-and-hostsniregexp)</a> | Checks if the connection's Server Name Indication is equal to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`).<br /> More information [here](#hostsni-and-hostsniregexp). |
|
||||
| <a id="opt-HostSNIRegexpregexp" href="#opt-HostSNIRegexpregexp" title="#opt-HostSNIRegexpregexp">[```HostSNIRegexp(`regexp`)```](#hostsni-and-hostsniregexp)</a> | Checks if the connection's Server Name Indication matches `regexp`.<br />Use a [Go](https://golang.org/pkg/regexp/) flavored syntax.<br /> More information [here](#hostsni-and-hostsniregexp). |
|
||||
| <a id="opt-ClientIPip" href="#opt-ClientIPip" title="#opt-ClientIPip">[```ClientIP(`ip`)```](#clientip)</a> | Checks if the connection's client IP correspond to `ip`. It accepts IPv4, IPv6 and CIDR formats.<br /> More information [here](#clientip). |
|
||||
| <a id="opt-ALPNprotocol" href="#opt-ALPNprotocol" title="#opt-ALPNprotocol">[```ALPN(`protocol`)```](#alpn)</a> | Checks if the connection's ALPN protocol equals `protocol`.<br /> 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$`)
|
||||
|
||||
36
integration/fixtures/https/https_wildcard_host.toml
Normal file
36
integration/fixtures/https/https_wildcard_host.toml
Normal file
@ -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"
|
||||
64
integration/fixtures/https/https_wildcard_tls_options.toml
Normal file
64
integration/fixtures/https/https_wildcard_tls_options.toml
Normal file
@ -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"
|
||||
65
integration/fixtures/tcp/wildcard-hostsni-tls-options.toml
Normal file
65
integration/fixtures/tcp/wildcard-hostsni-tls-options.toml
Normal file
@ -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"
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
31
pkg/muxer/muxer.go
Normal file
31
pkg/muxer/muxer.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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, ".")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user