1
0
Fork 0

Update routing syntax

Co-authored-by: Tom Moulard <tom.moulard@traefik.io>
This commit is contained in:
Antoine 2022-11-28 15:48:05 +01:00 committed by GitHub
parent b93141992e
commit 4d86668af3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 2484 additions and 2085 deletions

253
pkg/muxer/http/matcher.go Normal file
View file

@ -0,0 +1,253 @@
package http
import (
"fmt"
"net/http"
"regexp"
"strings"
"unicode/utf8"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v2/pkg/ip"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
"golang.org/x/exp/slices"
)
var httpFuncs = map[string]func(*mux.Route, ...string) error{
"ClientIP": expectNParameters(clientIP, 1),
"Method": expectNParameters(method, 1),
"Host": expectNParameters(host, 1),
"HostRegexp": expectNParameters(hostRegexp, 1),
"Path": expectNParameters(path, 1),
"PathRegexp": expectNParameters(pathRegexp, 1),
"PathPrefix": expectNParameters(pathPrefix, 1),
"Header": expectNParameters(header, 2),
"HeaderRegexp": expectNParameters(headerRegexp, 2),
"Query": expectNParameters(query, 1, 2),
"QueryRegexp": expectNParameters(queryRegexp, 1, 2),
}
func expectNParameters(fn func(*mux.Route, ...string) error, n ...int) func(*mux.Route, ...string) error {
return func(route *mux.Route, s ...string) error {
if !slices.Contains(n, len(s)) {
return fmt.Errorf("unexpected number of parameters; got %d, expected one of %v", len(s), n)
}
return fn(route, s...)
}
}
func clientIP(route *mux.Route, clientIP ...string) error {
checker, err := ip.NewChecker(clientIP)
if err != nil {
return fmt.Errorf("initializing IP checker for ClientIP matcher: %w", err)
}
strategy := ip.RemoteAddrStrategy{}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
ok, err := checker.Contains(strategy.GetIP(req))
if err != nil {
log.Ctx(req.Context()).Warn().Err(err).Msg("ClientIP matcher: could not match remote address")
return false
}
return ok
})
return nil
}
func method(route *mux.Route, methods ...string) error {
return route.Methods(methods...).GetError()
}
func host(route *mux.Route, hosts ...string) error {
host := hosts[0]
if !IsASCII(host) {
return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", host)
}
host = strings.ToLower(host)
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
reqHost := requestdecorator.GetCanonizedHost(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) {
log.Ctx(req.Context()).Warn().Str("host", req.Host).Msg("Could not retrieve CanonizedHost, rejecting")
}
return false
}
flatH := requestdecorator.GetCNAMEFlatten(req.Context())
if len(flatH) > 0 {
if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) {
return true
}
log.Ctx(req.Context()).Debug().
Str("host", reqHost).
Str("flattenHost", flatH).
Str("matcher", host).
Msg("CNAMEFlattening: resolved Host does not match")
return false
}
if reqHost == host {
return true
}
// Check for match on trailing period on host
if last := len(host) - 1; last >= 0 && host[last] == '.' {
h := host[:last]
if reqHost == h {
return true
}
}
// Check for match on trailing period on request
if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' {
h := reqHost[:last]
if h == host {
return true
}
}
return false
})
return nil
}
func hostRegexp(route *mux.Route, hosts ...string) error {
host := hosts[0]
if !IsASCII(host) {
return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host)
}
re, err := regexp.Compile(host)
if err != nil {
return fmt.Errorf("compiling HostRegexp matcher: %w", err)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
return re.MatchString(req.Host)
})
return nil
}
func path(route *mux.Route, paths ...string) error {
path := paths[0]
if !strings.HasPrefix(path, "/") {
return fmt.Errorf("path %q does not start with a '/'", path)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
return req.URL.Path == path
})
return nil
}
func pathRegexp(route *mux.Route, paths ...string) error {
path := paths[0]
re, err := regexp.Compile(path)
if err != nil {
return fmt.Errorf("compiling PathPrefix matcher: %w", err)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
return re.MatchString(req.URL.Path)
})
return nil
}
func pathPrefix(route *mux.Route, paths ...string) error {
path := paths[0]
if !strings.HasPrefix(path, "/") {
return fmt.Errorf("path %q does not start with a '/'", path)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
return strings.HasPrefix(req.URL.Path, path)
})
return nil
}
func header(route *mux.Route, headers ...string) error {
return route.Headers(headers...).GetError()
}
func headerRegexp(route *mux.Route, headers ...string) error {
return route.HeadersRegexp(headers...).GetError()
}
func query(route *mux.Route, queries ...string) error {
key := queries[0]
var value string
if len(queries) == 2 {
value = queries[1]
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
values, ok := req.URL.Query()[key]
if !ok {
return false
}
return slices.Contains(values, value)
})
return nil
}
func queryRegexp(route *mux.Route, queries ...string) error {
if len(queries) == 1 {
return query(route, queries...)
}
key, value := queries[0], queries[1]
re, err := regexp.Compile(value)
if err != nil {
return fmt.Errorf("compiling QueryRegexp matcher: %w", err)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
values, ok := req.URL.Query()[key]
if !ok {
return false
}
idx := slices.IndexFunc(values, func(value string) bool {
return re.MatchString(value)
})
return idx >= 0
})
return nil
}
// IsASCII checks if the given string contains only ASCII characters.
func IsASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}

View file

@ -0,0 +1,974 @@
package http
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
)
func TestClientIPMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid ClientIP matcher",
rule: "ClientIP(`1`)",
expectedError: true,
},
{
desc: "invalid ClientIP matcher (no parameter)",
rule: "ClientIP()",
expectedError: true,
},
{
desc: "invalid ClientIP matcher (empty parameter)",
rule: "ClientIP(``)",
expectedError: true,
},
{
desc: "invalid ClientIP matcher (too many parameters)",
rule: "ClientIP(`127.0.0.1`, `192.168.1.0/24`)",
expectedError: true,
},
{
desc: "valid ClientIP matcher",
rule: "ClientIP(`127.0.0.1`)",
expected: map[string]int{
"127.0.0.1": http.StatusOK,
"192.168.1.1": http.StatusNotFound,
},
},
{
desc: "valid ClientIP matcher but invalid remote address",
rule: "ClientIP(`127.0.0.1`)",
expected: map[string]int{
"1": http.StatusNotFound,
},
},
{
desc: "valid ClientIP matcher using CIDR",
rule: "ClientIP(`192.168.1.0/24`)",
expected: map[string]int{
"192.168.1.1": http.StatusOK,
"192.168.1.100": http.StatusOK,
"192.168.2.1": http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for remoteAddr := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody)
req.RemoteAddr = remoteAddr
muxer.ServeHTTP(w, req)
results[remoteAddr] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestMethodMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid Method matcher (no parameter)",
rule: "Method()",
expectedError: true,
},
{
desc: "invalid Method matcher (empty parameter)",
rule: "Method(``)",
expectedError: true,
},
{
desc: "invalid Method matcher (too many parameters)",
rule: "Method(`GET`, `POST`)",
expectedError: true,
},
{
desc: "valid Method matcher",
rule: "Method(`GET`)",
expected: map[string]int{
http.MethodGet: http.StatusOK,
http.MethodPost: http.StatusMethodNotAllowed,
},
},
{
desc: "valid Method matcher (lower case)",
rule: "Method(`get`)",
expected: map[string]int{
http.MethodGet: http.StatusOK,
http.MethodPost: http.StatusMethodNotAllowed,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for method := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, "https://example.com", http.NoBody)
muxer.ServeHTTP(w, req)
results[method] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestHostMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid Host matcher (no parameter)",
rule: "Host()",
expectedError: true,
},
{
desc: "invalid Host matcher (empty parameter)",
rule: "Host(``)",
expectedError: true,
},
{
desc: "invalid Host matcher (non-ASCII)",
rule: "Host(`🦭.com`)",
expectedError: true,
},
{
desc: "invalid Host matcher (too many parameters)",
rule: "Host(`example.com`, `example.org`)",
expectedError: true,
},
{
desc: "valid Host matcher",
rule: "Host(`example.com`)",
expected: map[string]int{
"https://example.com": http.StatusOK,
"https://example.com/path": http.StatusOK,
"https://example.org": http.StatusNotFound,
"https://example.org/path": http.StatusNotFound,
},
},
{
desc: "valid Host matcher - matcher ending with a dot",
rule: "Host(`example.com.`)",
expected: map[string]int{
"https://example.com": http.StatusOK,
"https://example.com/path": http.StatusOK,
"https://example.org": http.StatusNotFound,
"https://example.org/path": http.StatusNotFound,
"https://example.com.": http.StatusOK,
"https://example.com./path": http.StatusOK,
"https://example.org.": http.StatusNotFound,
"https://example.org./path": http.StatusNotFound,
},
},
{
desc: "valid Host matcher - URL ending with a dot",
rule: "Host(`example.com`)",
expected: map[string]int{
"https://example.com.": http.StatusOK,
"https://example.com./path": http.StatusOK,
"https://example.org.": http.StatusNotFound,
"https://example.org./path": http.StatusNotFound,
},
},
{
desc: "valid Host matcher - puny-coded emoji",
rule: "Host(`xn--9t9h.com`)",
expected: map[string]int{
"https://xn--9t9h.com": http.StatusOK,
"https://xn--9t9h.com/path": http.StatusOK,
"https://example.com": http.StatusNotFound,
"https://example.com/path": http.StatusNotFound,
// The request's sender must use puny-code.
"https://🦭.com": http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
// RequestDecorator is necessary for the host rule
reqHost := requestdecorator.New(nil)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
reqHost.ServeHTTP(w, req, muxer.ServeHTTP)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestHostRegexpMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid HostRegexp matcher (no parameter)",
rule: "HostRegexp()",
expectedError: true,
},
{
desc: "invalid HostRegexp matcher (empty parameter)",
rule: "HostRegexp(``)",
expectedError: true,
},
{
desc: "invalid HostRegexp matcher (non-ASCII)",
rule: "HostRegexp(`🦭.com`)",
expectedError: true,
},
{
desc: "invalid HostRegexp matcher (invalid regexp)",
rule: "HostRegexp(`(example.com`)",
expectedError: true,
},
{
desc: "invalid HostRegexp matcher (too many parameters)",
rule: "HostRegexp(`example.com`, `example.org`)",
expectedError: true,
},
{
desc: "valid HostRegexp matcher",
rule: "HostRegexp(`^[a-zA-Z-]+\\.com$`)",
expected: map[string]int{
"https://example.com": http.StatusOK,
"https://example.com/path": http.StatusOK,
"https://example.org": http.StatusNotFound,
"https://example.org/path": http.StatusNotFound,
},
},
{
desc: "valid HostRegexp matcher with Traefik v2 syntax",
rule: "HostRegexp(`{domain:[a-zA-Z-]+\\.com}`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com/path": http.StatusNotFound,
"https://example.org": http.StatusNotFound,
"https://example.org/path": http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestPathMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid Path matcher (no parameter)",
rule: "Path()",
expectedError: true,
},
{
desc: "invalid Path matcher (empty parameter)",
rule: "Path(``)",
expectedError: true,
},
{
desc: "invalid Path matcher (no leading /)",
rule: "Path(`css`)",
expectedError: true,
},
{
desc: "invalid Path matcher (too many parameters)",
rule: "Path(`/css`, `/js`)",
expectedError: true,
},
{
desc: "valid Path matcher",
rule: "Path(`/css`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com/html": http.StatusNotFound,
"https://example.org/css": http.StatusOK,
"https://example.com/css": http.StatusOK,
"https://example.com/css/": http.StatusNotFound,
"https://example.com/css/main.css": http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestPathRegexpMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid PathRegexp matcher (no parameter)",
rule: "PathRegexp()",
expectedError: true,
},
{
desc: "invalid PathRegexp matcher (empty parameter)",
rule: "PathRegexp(``)",
expectedError: true,
},
{
desc: "invalid PathRegexp matcher (invalid regexp)",
rule: "PathRegexp(`/(css`)",
expectedError: true,
},
{
desc: "invalid PathRegexp matcher (too many parameters)",
rule: "PathRegexp(`/css`, `/js`)",
expectedError: true,
},
{
desc: "valid PathRegexp matcher",
rule: "PathRegexp(`^/(css|js)`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com/html": http.StatusNotFound,
"https://example.org/css": http.StatusOK,
"https://example.com/CSS": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/css/": http.StatusOK,
"https://example.com/css/main.css": http.StatusOK,
"https://example.com/js": http.StatusOK,
"https://example.com/js/": http.StatusOK,
"https://example.com/js/main.js": http.StatusOK,
},
},
{
desc: "valid PathRegexp matcher with Traefik v2 syntax",
rule: `PathRegexp("/{path:(css|js)}")`,
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com/html": http.StatusNotFound,
"https://example.org/css": http.StatusNotFound,
"https://example.com/{path:css}": http.StatusOK,
"https://example.com/{path:css}/": http.StatusOK,
"https://example.com/%7Bpath:css%7D": http.StatusOK,
"https://example.com/%7Bpath:css%7D/": http.StatusOK,
"https://example.com/{path:js}": http.StatusOK,
"https://example.com/{path:js}/": http.StatusOK,
"https://example.com/%7Bpath:js%7D": http.StatusOK,
"https://example.com/%7Bpath:js%7D/": http.StatusOK,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestPathPrefixMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid PathPrefix matcher (no parameter)",
rule: "PathPrefix()",
expectedError: true,
},
{
desc: "invalid PathPrefix matcher (empty parameter)",
rule: "PathPrefix(``)",
expectedError: true,
},
{
desc: "invalid PathPrefix matcher (no leading /)",
rule: "PathPrefix(`css`)",
expectedError: true,
},
{
desc: "invalid PathPrefix matcher (too many parameters)",
rule: "PathPrefix(`/css`, `/js`)",
expectedError: true,
},
{
desc: "valid PathPrefix matcher",
rule: `PathPrefix("/css")`,
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com/html": http.StatusNotFound,
"https://example.org/css": http.StatusOK,
"https://example.com/css": http.StatusOK,
"https://example.com/css/": http.StatusOK,
"https://example.com/css/main.css": http.StatusOK,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestHeaderMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[*http.Header]int
expectedError bool
}{
{
desc: "invalid Header matcher (no parameter)",
rule: "Header()",
expectedError: true,
},
{
desc: "invalid Header matcher (missing value parameter)",
rule: "Header(`X-Forwarded-Host`)",
expectedError: true,
},
{
desc: "invalid Header matcher (missing value parameter)",
rule: "Header(`X-Forwarded-Host`, ``)",
expectedError: true,
},
{
desc: "invalid Header matcher (missing key parameter)",
rule: "Header(``, `example.com`)",
expectedError: true,
},
{
desc: "invalid Header matcher (too many parameters)",
rule: "Header(`X-Forwarded-Host`, `example.com`, `example.org`)",
expectedError: true,
},
{
desc: "valid Header matcher",
rule: "Header(`X-Forwarded-Proto`, `https`)",
expected: map[*http.Header]int{
{"X-Forwarded-Proto": []string{"https"}}: http.StatusOK,
{"x-forwarded-proto": []string{"https"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"http", "https"}}: http.StatusOK,
{"X-Forwarded-Proto": []string{"https", "http"}}: http.StatusOK,
{"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
for headers := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody)
req.Header = *headers
muxer.ServeHTTP(w, req)
assert.Equal(t, test.expected[headers], w.Code, headers)
}
})
}
}
func TestHeaderRegexpMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[*http.Header]int
expectedError bool
}{
{
desc: "invalid HeaderRegexp matcher (no parameter)",
rule: "HeaderRegexp()",
expectedError: true,
},
{
desc: "invalid HeaderRegexp matcher (missing value parameter)",
rule: "HeaderRegexp(`X-Forwarded-Host`)",
expectedError: true,
},
{
desc: "invalid HeaderRegexp matcher (missing value parameter)",
rule: "HeaderRegexp(`X-Forwarded-Host`, ``)",
expectedError: true,
},
{
desc: "invalid HeaderRegexp matcher (missing key parameter)",
rule: "HeaderRegexp(``, `example.com`)",
expectedError: true,
},
{
desc: "invalid HeaderRegexp matcher (invalid regexp)",
rule: "HeaderRegexp(`X-Forwarded-Host`,`(example.com`)",
expectedError: true,
},
{
desc: "invalid HeaderRegexp matcher (too many parameters)",
rule: "HeaderRegexp(`X-Forwarded-Host`, `example.com`, `example.org`)",
expectedError: true,
},
{
desc: "valid HeaderRegexp matcher",
rule: "HeaderRegexp(`X-Forwarded-Proto`, `^https?$`)",
expected: map[*http.Header]int{
{"X-Forwarded-Proto": []string{"http"}}: http.StatusOK,
{"x-forwarded-proto": []string{"http"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"https"}}: http.StatusOK,
{"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"ws", "https"}}: http.StatusOK,
{"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound,
},
},
{
desc: "valid HeaderRegexp matcher with Traefik v2 syntax",
rule: "HeaderRegexp(`X-Forwarded-Proto`, `http{secure:s?}`)",
expected: map[*http.Header]int{
{"X-Forwarded-Proto": []string{"http"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"https"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"http{secure:}"}}: http.StatusOK,
{"X-Forwarded-Proto": []string{"HTTP{secure:}"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"http{secure:s}"}}: http.StatusOK,
{"X-Forwarded-Proto": []string{"http{secure:S}"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound,
{"X-Forwarded-Proto": []string{"ws", "http{secure:s}"}}: http.StatusOK,
{"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
for headers := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody)
req.Header = *headers
muxer.ServeHTTP(w, req)
assert.Equal(t, test.expected[headers], w.Code, *headers)
}
})
}
}
func TestQueryMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid Query matcher (no parameter)",
rule: "Query()",
expectedError: true,
},
{
desc: "invalid Query matcher (empty key, one parameter)",
rule: "Query(``)",
expectedError: true,
},
{
desc: "invalid Query matcher (empty key)",
rule: "Query(``, `traefik`)",
expectedError: true,
},
{
desc: "invalid Query matcher (empty value)",
rule: "Query(`q`, ``)",
expectedError: true,
},
{
desc: "invalid Query matcher (too many parameters)",
rule: "Query(`q`, `traefik`, `proxy`)",
expectedError: true,
},
{
desc: "valid Query matcher",
rule: "Query(`q`, `traefik`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com?q=traefik": http.StatusOK,
"https://example.com?rel=ddg&q=traefik": http.StatusOK,
"https://example.com?q=traefik&q=proxy": http.StatusOK,
"https://example.com?q=awesome&q=traefik": http.StatusOK,
"https://example.com?q=nginx": http.StatusNotFound,
"https://example.com?rel=ddg": http.StatusNotFound,
"https://example.com?q=TRAEFIK": http.StatusNotFound,
"https://example.com?Q=traefik": http.StatusNotFound,
"https://example.com?rel=traefik": http.StatusNotFound,
},
},
{
desc: "valid Query matcher with empty value",
rule: "Query(`mobile`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com?mobile": http.StatusOK,
"https://example.com?mobile=true": http.StatusNotFound,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
func TestQueryRegexpMatcher(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]int
expectedError bool
}{
{
desc: "invalid QueryRegexp matcher (no parameter)",
rule: "QueryRegexp()",
expectedError: true,
},
{
desc: "invalid QueryRegexp matcher (empty parameter)",
rule: "QueryRegexp(``)",
expectedError: true,
},
{
desc: "invalid QueryRegexp matcher (invalid regexp)",
rule: "QueryRegexp(`q`, `(traefik`)",
expectedError: true,
},
{
desc: "invalid QueryRegexp matcher (too many parameters)",
rule: "QueryRegexp(`q`, `traefik`, `proxy`)",
expectedError: true,
},
{
desc: "valid QueryRegexp matcher",
rule: "QueryRegexp(`q`, `^(traefik|nginx)$`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com?q=traefik": http.StatusOK,
"https://example.com?rel=ddg&q=traefik": http.StatusOK,
"https://example.com?q=traefik&q=proxy": http.StatusOK,
"https://example.com?q=awesome&q=traefik": http.StatusOK,
"https://example.com?q=TRAEFIK": http.StatusNotFound,
"https://example.com?Q=traefik": http.StatusNotFound,
"https://example.com?rel=traefik": http.StatusNotFound,
"https://example.com?q=nginx": http.StatusOK,
"https://example.com?rel=ddg&q=nginx": http.StatusOK,
"https://example.com?q=nginx&q=proxy": http.StatusOK,
"https://example.com?q=awesome&q=nginx": http.StatusOK,
"https://example.com?q=NGINX": http.StatusNotFound,
"https://example.com?Q=nginx": http.StatusNotFound,
"https://example.com?rel=nginx": http.StatusNotFound,
"https://example.com?q=haproxy": http.StatusNotFound,
"https://example.com?rel=ddg": http.StatusNotFound,
},
},
{
desc: "valid QueryRegexp matcher",
rule: "QueryRegexp(`q`, `^.*$`)",
expected: map[string]int{
"https://example.com": http.StatusNotFound,
"https://example.com?q=traefik": http.StatusOK,
"https://example.com?rel=ddg&q=traefik": http.StatusOK,
"https://example.com?q=traefik&q=proxy": http.StatusOK,
"https://example.com?q=awesome&q=traefik": http.StatusOK,
"https://example.com?q=TRAEFIK": http.StatusOK,
"https://example.com?Q=traefik": http.StatusNotFound,
"https://example.com?rel=traefik": http.StatusNotFound,
"https://example.com?q=nginx": http.StatusOK,
"https://example.com?rel=ddg&q=nginx": http.StatusOK,
"https://example.com?q=nginx&q=proxy": http.StatusOK,
"https://example.com?q=awesome&q=nginx": http.StatusOK,
"https://example.com?q=NGINX": http.StatusOK,
"https://example.com?Q=nginx": http.StatusNotFound,
"https://example.com?rel=nginx": http.StatusNotFound,
"https://example.com?q=haproxy": http.StatusOK,
"https://example.com?rel=ddg": http.StatusNotFound,
},
},
{
desc: "valid QueryRegexp matcher with Traefik v2 syntax",
rule: "QueryRegexp(`q`, `{value:(traefik|nginx)}`)",
expected: map[string]int{
"https://example.com?q=traefik": http.StatusNotFound,
"https://example.com?q={value:traefik}": http.StatusOK,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody)
muxer.ServeHTTP(w, req)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}

View file

@ -3,32 +3,12 @@ package http
import (
"fmt"
"net/http"
"strings"
"unicode/utf8"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v2/pkg/ip"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
"github.com/traefik/traefik/v2/pkg/rules"
"github.com/vulcand/predicate"
)
const hostMatcher = "Host"
var httpFuncs = map[string]func(*mux.Route, ...string) error{
hostMatcher: host,
"HostHeader": host,
"HostRegexp": hostRegexp,
"ClientIP": clientIP,
"Path": path,
"PathPrefix": pathPrefix,
"Method": methods,
"Headers": headers,
"HeadersRegexp": headersRegexp,
"Query": query,
}
// Muxer handles routing with rules.
type Muxer struct {
*mux.Router
@ -80,171 +60,6 @@ func (r *Muxer) AddRoute(rule string, priority int, handler http.Handler) error
return nil
}
// ParseDomains extract domains from rule.
func ParseDomains(rule string) ([]string, error) {
var matchers []string
for matcher := range httpFuncs {
matchers = append(matchers, matcher)
}
parser, err := rules.NewParser(matchers)
if err != nil {
return nil, err
}
parse, err := parser.Parse(rule)
if err != nil {
return nil, err
}
buildTree, ok := parse.(rules.TreeBuilder)
if !ok {
return nil, fmt.Errorf("error while parsing rule %s", rule)
}
return buildTree().ParseMatchers([]string{hostMatcher}), nil
}
func path(route *mux.Route, paths ...string) error {
rt := route.Subrouter()
for _, path := range paths {
if err := rt.Path(path).GetError(); err != nil {
return err
}
}
return nil
}
func pathPrefix(route *mux.Route, paths ...string) error {
rt := route.Subrouter()
for _, path := range paths {
if err := rt.PathPrefix(path).GetError(); err != nil {
return err
}
}
return nil
}
func host(route *mux.Route, hosts ...string) error {
for i, host := range hosts {
if !IsASCII(host) {
return fmt.Errorf("invalid value %q for \"Host\" matcher, non-ASCII characters are not allowed", host)
}
hosts[i] = strings.ToLower(host)
}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
reqHost := requestdecorator.GetCanonizedHost(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) {
log.Ctx(req.Context()).Warn().Msgf("Could not retrieve CanonizedHost, rejecting %s", req.Host)
}
return false
}
flatH := requestdecorator.GetCNAMEFlatten(req.Context())
if len(flatH) > 0 {
for _, host := range hosts {
if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) {
return true
}
log.Ctx(req.Context()).Debug().Msgf("CNAMEFlattening: request %s which resolved to %s, is not matched to route %s", reqHost, flatH, host)
}
return false
}
for _, host := range hosts {
if reqHost == host {
return true
}
// Check for match on trailing period on host
if last := len(host) - 1; last >= 0 && host[last] == '.' {
h := host[:last]
if reqHost == h {
return true
}
}
// Check for match on trailing period on request
if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' {
h := reqHost[:last]
if h == host {
return true
}
}
}
return false
})
return nil
}
func clientIP(route *mux.Route, clientIPs ...string) error {
checker, err := ip.NewChecker(clientIPs)
if err != nil {
return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err)
}
strategy := ip.RemoteAddrStrategy{}
route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool {
ok, err := checker.Contains(strategy.GetIP(req))
if err != nil {
log.Ctx(req.Context()).Warn().Err(err).Msg("\"ClientIP\" matcher: could not match remote address")
return false
}
return ok
})
return nil
}
func hostRegexp(route *mux.Route, hosts ...string) error {
router := route.Subrouter()
for _, host := range hosts {
if !IsASCII(host) {
return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host)
}
tmpRt := router.Host(host)
if tmpRt.GetError() != nil {
return tmpRt.GetError()
}
}
return nil
}
func methods(route *mux.Route, methods ...string) error {
return route.Methods(methods...).GetError()
}
func headers(route *mux.Route, headers ...string) error {
return route.Headers(headers...).GetError()
}
func headersRegexp(route *mux.Route, headers ...string) error {
return route.HeadersRegexp(headers...).GetError()
}
func query(route *mux.Route, query ...string) error {
var queries []string
for _, elem := range query {
queries = append(queries, strings.SplitN(elem, "=", 2)...)
}
route.Queries(queries...)
// Queries can return nil so we can't chain the GetError()
return route.GetError()
}
func addRuleOnRouter(router *mux.Router, rule *rules.Tree) error {
switch rule.Matcher {
case "and":
@ -276,20 +91,6 @@ func addRuleOnRouter(router *mux.Router, rule *rules.Tree) error {
}
}
func not(m func(*mux.Route, ...string) error) func(*mux.Route, ...string) error {
return func(r *mux.Route, v ...string) error {
router := mux.NewRouter()
err := m(router.NewRoute(), v...)
if err != nil {
return err
}
r.MatcherFunc(func(req *http.Request, ma *mux.RouteMatch) bool {
return !router.Match(req, ma)
})
return nil
}
}
func addRuleOnRoute(route *mux.Route, rule *rules.Tree) error {
switch rule.Matcher {
case "and":
@ -322,13 +123,44 @@ func addRuleOnRoute(route *mux.Route, rule *rules.Tree) error {
}
}
// IsASCII checks if the given string contains only ASCII characters.
func IsASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
func not(m func(*mux.Route, ...string) error) func(*mux.Route, ...string) error {
return func(r *mux.Route, v ...string) error {
router := mux.NewRouter()
err := m(router.NewRoute(), v...)
if err != nil {
return err
}
r.MatcherFunc(func(req *http.Request, ma *mux.RouteMatch) bool {
return !router.Match(req, ma)
})
return nil
}
}
// ParseDomains extract domains from rule.
func ParseDomains(rule string) ([]string, error) {
var matchers []string
for matcher := range httpFuncs {
matchers = append(matchers, matcher)
}
return true
parser, err := rules.NewParser(matchers)
if err != nil {
return nil, err
}
parse, err := parser.Parse(rule)
if err != nil {
return nil, err
}
buildTree, ok := parse.(rules.TreeBuilder)
if !ok {
return nil, fmt.Errorf("error while parsing rule %s", rule)
}
return buildTree().ParseMatchers([]string{"Host"}), nil
}

View file

@ -7,14 +7,13 @@ import (
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
"github.com/traefik/traefik/v2/pkg/testhelpers"
)
func Test_addRoute(t *testing.T) {
func TestMuxer(t *testing.T) {
testCases := []struct {
desc string
rule string
@ -33,607 +32,177 @@ func Test_addRoute(t *testing.T) {
expectedError: true,
},
{
desc: "Host empty",
rule: "Host(``)",
desc: "Rule without quote",
rule: "Host(example.com)",
expectedError: true,
},
{
desc: "PathPrefix empty",
rule: "PathPrefix(``)",
expectedError: true,
},
{
desc: "PathPrefix",
rule: "PathPrefix(`/foo`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "wrong PathPrefix",
rule: "PathPrefix(`/bar`)",
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "Host",
rule: "Host(`localhost`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Non-ASCII Host",
rule: "Host(`locàlhost`)",
expectedError: true,
},
{
desc: "Non-ASCII HostRegexp",
rule: "HostRegexp(`locàlhost`)",
expectedError: true,
},
{
desc: "HostHeader equivalent to Host",
rule: "HostHeader(`localhost`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
"http://bar/foo": http.StatusNotFound,
},
},
{
desc: "Host with trailing period in rule",
rule: "Host(`localhost.`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Host with trailing period in domain",
rule: "Host(`localhost`)",
expected: map[string]int{
"http://localhost./foo": http.StatusOK,
},
},
{
desc: "Host with trailing period in domain and rule",
rule: "Host(`localhost.`)",
expected: map[string]int{
"http://localhost./foo": http.StatusOK,
},
},
{
desc: "wrong Host",
rule: "Host(`nope`)",
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "Host and PathPrefix",
rule: "Host(`localhost`) && PathPrefix(`/foo`)",
rule: "Host(`localhost`) && PathPrefix(`/css`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Host and PathPrefix wrong PathPrefix",
rule: "Host(`localhost`) && PathPrefix(`/bar`)",
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "Host and PathPrefix wrong Host",
rule: "Host(`nope`) && PathPrefix(`/foo`)",
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "Host and PathPrefix Host OR, first host",
rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Host and PathPrefix Host OR, second host",
rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)",
expected: map[string]int{
"http://nope/foo": http.StatusOK,
},
},
{
desc: "Host and PathPrefix Host OR, first host and wrong PathPrefix",
rule: "Host(`nope,localhost`) && PathPrefix(`/bar`)",
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "HostRegexp with capturing group",
rule: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)",
expected: map[string]int{
"http://foo.bar.com": http.StatusOK,
"http://bar.com": http.StatusOK,
"http://fooubar.com": http.StatusNotFound,
"http://barucom": http.StatusNotFound,
"http://barcom": http.StatusNotFound,
},
},
{
desc: "HostRegexp with non capturing group",
rule: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)",
expected: map[string]int{
"http://foo.bar.com": http.StatusOK,
"http://bar.com": http.StatusOK,
"http://fooubar.com": http.StatusNotFound,
"http://barucom": http.StatusNotFound,
"http://barcom": http.StatusNotFound,
},
},
{
desc: "Methods with GET",
rule: "Method(`GET`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Methods with GET and POST",
rule: "Method(`GET`,`POST`)",
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Methods with POST",
rule: "Method(`POST`)",
expected: map[string]int{
"http://localhost/foo": http.StatusMethodNotAllowed,
},
},
{
desc: "Header with matching header",
rule: "Headers(`Content-Type`,`application/json`)",
headers: map[string]string{
"Content-Type": "application/json",
},
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Header without matching header",
rule: "Headers(`Content-Type`,`application/foo`)",
headers: map[string]string{
"Content-Type": "application/json",
},
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "HeaderRegExp with matching header",
rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)",
headers: map[string]string{
"Content-Type": "application/json",
},
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "HeaderRegExp without matching header",
rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)",
headers: map[string]string{
"Content-Type": "application/foo",
},
expected: map[string]int{
"http://localhost/foo": http.StatusNotFound,
},
},
{
desc: "HeaderRegExp with matching second header",
rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)",
headers: map[string]string{
"Content-Type": "application/text",
},
expected: map[string]int{
"http://localhost/foo": http.StatusOK,
},
},
{
desc: "Query with multiple params",
rule: "Query(`foo=bar`, `bar=baz`)",
expected: map[string]int{
"http://localhost/foo?foo=bar&bar=baz": http.StatusOK,
"http://localhost/foo?bar=baz": http.StatusNotFound,
},
},
{
desc: "Query with multiple equals",
rule: "Query(`foo=b=ar`)",
expected: map[string]int{
"http://localhost/foo?foo=b=ar": http.StatusOK,
"http://localhost/foo?foo=bar": http.StatusNotFound,
},
},
{
desc: "Rule with simple path",
rule: `Path("/a")`,
expected: map[string]int{
"http://plop/a": http.StatusOK,
},
},
{
desc: `Rule with a simple host`,
rule: `Host("plop")`,
expected: map[string]int{
"http://plop": http.StatusOK,
},
},
{
desc: "Rule with Path AND Host",
rule: `Path("/a") && Host("plop")`,
expected: map[string]int{
"http://plop/a": http.StatusOK,
"http://plopi/a": http.StatusNotFound,
"https://localhost/css": http.StatusOK,
"https://localhost/js": http.StatusNotFound,
},
},
{
desc: "Rule with Host OR Host",
rule: `Host("tchouk") || Host("pouet")`,
rule: "Host(`example.com`) || Host(`example.org`)",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
"http://pouet/a": http.StatusOK,
"http://plopi/a": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.org/js": http.StatusOK,
"https://example.eu/html": http.StatusNotFound,
},
},
{
desc: "Rule with host OR (host AND path)",
rule: `Host("tchouk") || (Host("pouet") && Path("/powpow"))`,
rule: `Host("example.com") || (Host("example.org") && Path("/css"))`,
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
"http://tchouk/powpow": http.StatusOK,
"http://pouet/powpow": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.org/js": http.StatusNotFound,
"https://example.eu/css": http.StatusNotFound,
},
},
{
desc: "Rule with host OR host AND path",
rule: `Host("tchouk") || Host("pouet") && Path("/powpow")`,
rule: `Host("example.com") || Host("example.org") && Path("/css")`,
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
"http://tchouk/powpow": http.StatusOK,
"http://pouet/powpow": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.org/js": http.StatusNotFound,
"https://example.eu/css": http.StatusNotFound,
},
},
{
desc: "Rule with (host OR host) AND path",
rule: `(Host("tchouk") || Host("pouet")) && Path("/powpow")`,
rule: `(Host("example.com") || Host("example.org")) && Path("/css")`,
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
"http://tchouk/powpow": http.StatusOK,
"http://pouet/powpow": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
},
},
{
desc: "Rule with multiple host AND path",
rule: `(Host("tchouk","pouet")) && Path("/powpow")`,
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
"http://tchouk/powpow": http.StatusOK,
"http://pouet/powpow": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
},
},
{
desc: "Rule with multiple host AND multiple path",
rule: `Host("tchouk","pouet") && Path("/powpow", "/titi")`,
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
"http://tchouk/powpow": http.StatusOK,
"http://pouet/powpow": http.StatusOK,
"http://tchouk/titi": http.StatusOK,
"http://pouet/titi": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
"https://example.org/css": http.StatusOK,
"https://example.org/js": http.StatusNotFound,
"https://example.eu/css": http.StatusNotFound,
},
},
{
desc: "Rule with (host AND path) OR (host AND path)",
rule: `(Host("tchouk") && Path("/titi")) || ((Host("pouet")) && Path("/powpow"))`,
rule: `(Host("example.com") && Path("/js")) || ((Host("example.org")) && Path("/css"))`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"http://pouet/powpow": http.StatusOK,
"http://pouet/toto": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound,
"https://example.com/css": http.StatusNotFound,
"https://example.com/js": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.org/js": http.StatusNotFound,
"https://example.eu/css": http.StatusNotFound,
},
},
{
desc: "Rule without quote",
rule: `Host(tchouk)`,
expectedError: true,
},
{
desc: "Rule case UPPER",
rule: `(HOST("tchouk") && PATHPREFIX("/titi"))`,
rule: `PATHPREFIX("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule case lower",
rule: `(host("tchouk") && pathprefix("/titi"))`,
rule: `pathprefix("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule case CamelCase",
rule: `(Host("tchouk") && PathPrefix("/titi"))`,
rule: `PathPrefix("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule case Title",
rule: `(Host("tchouk") && Pathprefix("/titi"))`,
rule: `Pathprefix("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule Path with error",
rule: `Path("titi")`,
expectedError: true,
},
{
desc: "Rule PathPrefix with error",
rule: `PathPrefix("titi")`,
expectedError: true,
},
{
desc: "Rule HostRegexp with error",
rule: `HostRegexp("{test")`,
expectedError: true,
},
{
desc: "Rule Headers with error",
rule: `Headers("titi")`,
expectedError: true,
},
{
desc: "Rule HeadersRegexp with error",
rule: `HeadersRegexp("titi")`,
expectedError: true,
},
{
desc: "Rule Query",
rule: `Query("titi")`,
expectedError: true,
},
{
desc: "Rule Query with bad syntax",
rule: `Query("titi={test")`,
expectedError: true,
},
{
desc: "Rule with Path without args",
rule: `Host("tchouk") && Path()`,
expectedError: true,
},
{
desc: "Rule with an empty path",
rule: `Host("tchouk") && Path("")`,
expectedError: true,
},
{
desc: "Rule with an empty path",
rule: `Host("tchouk") && Path("", "/titi")`,
expectedError: true,
},
{
desc: "Rule with not",
rule: `!Host("tchouk")`,
rule: `!Host("example.com")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://test/powpow": http.StatusOK,
},
},
{
desc: "Rule with not on Path",
rule: `!Path("/titi")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://tchouk/powpow": http.StatusOK,
"https://example.org": http.StatusOK,
"https://example.com": http.StatusNotFound,
},
},
{
desc: "Rule with not on multiple route with or",
rule: `!(Host("tchouk") || Host("toto"))`,
rule: `!(Host("example.com") || Host("example.org"))`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://toto/powpow": http.StatusNotFound,
"http://test/powpow": http.StatusOK,
"https://example.eu/js": http.StatusOK,
"https://example.com/css": http.StatusNotFound,
"https://example.org/js": http.StatusNotFound,
},
},
{
desc: "Rule with not on multiple route with and",
rule: `!(Host("tchouk") && Path("/titi"))`,
rule: `!(Host("example.com") && Path("/css"))`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://tchouk/toto": http.StatusOK,
"http://test/titi": http.StatusOK,
"https://example.com/js": http.StatusOK,
"https://example.eu/css": http.StatusOK,
"https://example.com/css": http.StatusNotFound,
},
},
{
desc: "Rule with not on multiple route with and another not",
rule: `!(Host("tchouk") && !Path("/titi"))`,
rule: `!(Host("example.com") && !Path("/css"))`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://toto/titi": http.StatusOK,
"http://tchouk/toto": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule with not on two rule",
rule: `!Host("tchouk") || !Path("/titi")`,
rule: `!Host("example.com") || !Path("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://tchouk/toto": http.StatusOK,
"http://test/titi": http.StatusOK,
"https://example.com/js": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.com/css": http.StatusNotFound,
},
},
{
desc: "Rule case with double not",
rule: `!(!(Host("tchouk") && Pathprefix("/titi")))`,
rule: `!(!(Host("example.com") && Pathprefix("/css")))`,
expected: map[string]int{
"http://tchouk/titi": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"http://test/titi": http.StatusNotFound,
"https://example.com/css": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
"https://example.org/css": http.StatusNotFound,
},
},
{
desc: "Rule case with not domain",
rule: `!Host("tchouk") && Pathprefix("/titi")`,
rule: `!Host("example.com") && Pathprefix("/css")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://tchouk/powpow": http.StatusNotFound,
"http://toto/powpow": http.StatusNotFound,
"http://toto/titi": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.org/js": http.StatusNotFound,
"https://example.com/css": http.StatusNotFound,
"https://example.com/js": http.StatusNotFound,
},
},
{
desc: "Rule with multiple host AND multiple path AND not",
rule: `!(Host("tchouk","pouet") && Path("/powpow", "/titi"))`,
rule: `!(Host("example.com") && Path("/js"))`,
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound,
"http://pouet/powpow": http.StatusNotFound,
"http://tchouk/titi": http.StatusNotFound,
"http://pouet/titi": http.StatusNotFound,
"http://pouet/toto": http.StatusOK,
"http://plopi/a": http.StatusOK,
},
},
{
desc: "ClientIP empty",
rule: "ClientIP(``)",
expectedError: true,
},
{
desc: "Invalid ClientIP",
rule: "ClientIP(`invalid`)",
expectedError: true,
},
{
desc: "Non matching ClientIP",
rule: "ClientIP(`10.10.1.1`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
},
},
{
desc: "Non matching IPv6",
rule: "ClientIP(`10::10`)",
remoteAddr: "::1",
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
},
},
{
desc: "Matching IP",
rule: "ClientIP(`10.0.0.0`)",
remoteAddr: "10.0.0.0:8456",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IPv6",
rule: "ClientIP(`10::10`)",
remoteAddr: "10::10",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IP among several IP",
rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Non Matching IP with CIDR",
rule: "ClientIP(`11.0.0.0/24`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
},
},
{
desc: "Non Matching IPv6 with CIDR",
rule: "ClientIP(`11::/16`)",
remoteAddr: "10::",
expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound,
},
},
{
desc: "Matching IP with CIDR",
rule: "ClientIP(`10.0.0.0/16`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IPv6 with CIDR",
rule: "ClientIP(`10::/16`)",
remoteAddr: "10::10",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IP among several CIDR",
rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IP among non matching CIDR and matching IP",
rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
},
},
{
desc: "Matching IP among matching CIDR and non matching IP",
rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)",
remoteAddr: "10.0.0.0",
expected: map[string]int{
"http://tchouk/toto": http.StatusOK,
"https://example.com/js": http.StatusNotFound,
"https://example.com/html": http.StatusOK,
"https://example.org/js": http.StatusOK,
"https://example.com/css": http.StatusOK,
"https://example.org/css": http.StatusOK,
"https://example.org/html": http.StatusOK,
"https://example.eu/images": http.StatusOK,
},
},
}
@ -644,36 +213,37 @@ func Test_addRoute(t *testing.T) {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer()
require.NoError(t, err)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
// RequestDecorator is necessary for the host rule
reqHost := requestdecorator.New(nil)
results := make(map[string]int)
for calledURL := range test.expected {
w := httptest.NewRecorder()
req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil)
// Useful for the ClientIP matcher
req.RemoteAddr = test.remoteAddr
for key, value := range test.headers {
req.Header.Set(key, value)
}
reqHost.ServeHTTP(w, req, muxer.ServeHTTP)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
return
}
require.NoError(t, err)
// RequestDecorator is necessary for the host rule
reqHost := requestdecorator.New(nil)
results := make(map[string]int)
for calledURL := range test.expected {
req := testhelpers.MustNewRequest(http.MethodGet, calledURL, http.NoBody)
// Useful for the ClientIP matcher
req.RemoteAddr = test.remoteAddr
for key, value := range test.headers {
req.Header.Set(key, value)
}
w := httptest.NewRecorder()
reqHost.ServeHTTP(w, req, muxer.ServeHTTP)
results[calledURL] = w.Code
}
assert.Equal(t, test.expected, results)
})
}
}
@ -813,7 +383,7 @@ func Test_addRoutePriority(t *testing.T) {
muxer.SortRoutes()
w := httptest.NewRecorder()
req := testhelpers.MustNewRequest(http.MethodGet, test.path, nil)
req := testhelpers.MustNewRequest(http.MethodGet, test.path, http.NoBody)
muxer.ServeHTTP(w, req)
@ -822,86 +392,6 @@ func Test_addRoutePriority(t *testing.T) {
}
}
func TestHostRegexp(t *testing.T) {
testCases := []struct {
desc string
hostExp string
urls map[string]bool
}{
{
desc: "capturing group",
hostExp: "{subdomain:(foo\\.)?bar\\.com}",
urls: map[string]bool{
"http://foo.bar.com": true,
"http://bar.com": true,
"http://fooubar.com": false,
"http://barucom": false,
"http://barcom": false,
},
},
{
desc: "non capturing group",
hostExp: "{subdomain:(?:foo\\.)?bar\\.com}",
urls: map[string]bool{
"http://foo.bar.com": true,
"http://bar.com": true,
"http://fooubar.com": false,
"http://barucom": false,
"http://barcom": false,
},
},
{
desc: "regex insensitive",
hostExp: "{dummy:[A-Za-z-]+\\.bar\\.com}",
urls: map[string]bool{
"http://FOO.bar.com": true,
"http://foo.bar.com": true,
"http://fooubar.com": false,
"http://barucom": false,
"http://barcom": false,
},
},
{
desc: "insensitive host",
hostExp: "{dummy:[a-z-]+\\.bar\\.com}",
urls: map[string]bool{
"http://FOO.bar.com": true,
"http://foo.bar.com": true,
"http://fooubar.com": false,
"http://barucom": false,
"http://barcom": false,
},
},
{
desc: "insensitive host simple",
hostExp: "foo.bar.com",
urls: map[string]bool{
"http://FOO.bar.com": true,
"http://foo.bar.com": true,
"http://fooubar.com": false,
"http://barucom": false,
"http://barcom": false,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
rt := &mux.Route{}
err := hostRegexp(rt, test.hostExp)
require.NoError(t, err)
for testURL, match := range test.urls {
req := testhelpers.MustNewRequest(http.MethodGet, testURL, nil)
assert.Equal(t, match, rt.Match(req, &mux.RouteMatch{}), testURL)
}
})
}
}
func TestParseDomains(t *testing.T) {
testCases := []struct {
description string
@ -914,21 +404,6 @@ func TestParseDomains(t *testing.T) {
expression: "Foobar(`foo.bar`,`test.bar`)",
errorExpected: true,
},
{
description: "Several host rules",
expression: "Host(`foo.bar`,`test.bar`)",
domain: []string{"foo.bar", "test.bar"},
},
{
description: "Several host rules upper",
expression: "HOST(`foo.bar`,`test.bar`)",
domain: []string{"foo.bar", "test.bar"},
},
{
description: "Several host rules lower",
expression: "host(`foo.bar`,`test.bar`)",
domain: []string{"foo.bar", "test.bar"},
},
{
description: "No host rule",
expression: "Path(`/test`)",
@ -938,6 +413,11 @@ func TestParseDomains(t *testing.T) {
expression: "Host(`foo.bar`) && Path(`/test`)",
domain: []string{"foo.bar"},
},
{
description: "Host rule to trim and another rule",
expression: "Host(`Foo.Bar`) || Host(`bar.buz`) && Path(`/test`)",
domain: []string{"foo.bar", "bar.buz"},
},
{
description: "Host rule to trim and another rule",
expression: "Host(`Foo.Bar`) && Path(`/test`)",
@ -967,7 +447,9 @@ func TestParseDomains(t *testing.T) {
}
}
func TestAbsoluteFormURL(t *testing.T) {
// TestEmptyHost is a non regression test for
// https://github.com/traefik/traefik/pull/9131
func TestEmptyHost(t *testing.T) {
testCases := []struct {
desc string
request string
@ -975,41 +457,41 @@ func TestAbsoluteFormURL(t *testing.T) {
expected int
}{
{
desc: "!HostRegexp with absolute-form URL with empty host with non-matching host header",
request: "GET http://@/ HTTP/1.1\r\nHost: test.localhost\r\n\r\n",
rule: "!HostRegexp(`test.localhost`)",
expected: http.StatusNotFound,
},
{
desc: "!Host with absolute-form URL with empty host with non-matching host header",
request: "GET http://@/ HTTP/1.1\r\nHost: test.localhost\r\n\r\n",
rule: "!Host(`test.localhost`)",
expected: http.StatusNotFound,
},
{
desc: "!HostRegexp with absolute-form URL with matching host header",
request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n",
rule: "!HostRegexp(`test.localhost`)",
expected: http.StatusNotFound,
},
{
desc: "!Host with absolute-form URL with matching host header",
request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n",
rule: "!Host(`test.localhost`)",
expected: http.StatusNotFound,
},
{
desc: "!HostRegexp with absolute-form URL with non-matching host header",
request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n",
rule: "!HostRegexp(`toto.localhost`)",
desc: "HostRegexp with absolute-form URL with empty host with non-matching host header",
request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n",
rule: "HostRegexp(`example.com`)",
expected: http.StatusOK,
},
{
desc: "!Host with absolute-form URL with non-matching host header",
request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n",
rule: "!Host(`toto.localhost`)",
desc: "Host with absolute-form URL with empty host with non-matching host header",
request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n",
rule: "Host(`example.com`)",
expected: http.StatusOK,
},
{
desc: "HostRegexp with absolute-form URL with matching host header",
request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n",
rule: "HostRegexp(`example.com`)",
expected: http.StatusOK,
},
{
desc: "Host with absolute-form URL with matching host header",
request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n",
rule: "Host(`example.com`)",
expected: http.StatusOK,
},
{
desc: "HostRegexp with absolute-form URL with non-matching host header",
request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n",
rule: "HostRegexp(`example.org`)",
expected: http.StatusNotFound,
},
{
desc: "Host with absolute-form URL with non-matching host header",
request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n",
rule: "Host(`example.org`)",
expected: http.StatusNotFound,
},
}
for _, test := range testCases {

134
pkg/muxer/tcp/matcher.go Normal file
View file

@ -0,0 +1,134 @@
package tcp
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v2/pkg/ip"
)
var tcpFuncs = map[string]func(*matchersTree, ...string) error{
"ALPN": expect1Parameter(alpn),
"ClientIP": expect1Parameter(clientIP),
"HostSNI": expect1Parameter(hostSNI),
"HostSNIRegexp": expect1Parameter(hostSNIRegexp),
}
func expect1Parameter(fn func(*matchersTree, ...string) error) func(*matchersTree, ...string) error {
return func(route *matchersTree, s ...string) error {
if len(s) != 1 {
return fmt.Errorf("unexpected number of parameters; got %d, expected 1", len(s))
}
return fn(route, s...)
}
}
// alpn checks if any of the connection ALPN protocols matches one of the matcher protocols.
func alpn(tree *matchersTree, protos ...string) error {
proto := protos[0]
if proto == tlsalpn01.ACMETLS1Protocol {
return fmt.Errorf("invalid protocol value for ALPN matcher, %q is not allowed", proto)
}
tree.matcher = func(meta ConnData) bool {
for _, alpnProto := range meta.alpnProtos {
if alpnProto == proto {
return true
}
}
return false
}
return nil
}
func clientIP(tree *matchersTree, clientIP ...string) error {
checker, err := ip.NewChecker(clientIP)
if err != nil {
return fmt.Errorf("initializing IP checker for ClientIP matcher: %w", err)
}
tree.matcher = func(meta ConnData) bool {
ok, err := checker.Contains(meta.remoteIP)
if err != nil {
log.Warn().Err(err).Msg("ClientIP matcher: could not match remote address")
return false
}
return ok
}
return nil
}
var almostFQDN = regexp.MustCompile(`^[[:alnum:]\.-]+$`)
// hostSNI checks if the SNI Host of the connection match the matcher host.
func hostSNI(tree *matchersTree, hosts ...string) error {
host := hosts[0]
if host == "*" {
// 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 !almostFQDN.MatchString(host) {
return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", host)
}
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 nil
}
// hostSNIRegexp checks if the SNI Host of the connection matches the matcher host regexp.
func hostSNIRegexp(tree *matchersTree, templates ...string) error {
template := templates[0]
if !isASCII(template) {
return fmt.Errorf("invalid value for HostSNIRegexp matcher, %q is not a valid hostname", template)
}
re, err := regexp.Compile(template)
if err != nil {
return fmt.Errorf("compiling HostSNIRegexp matcher: %w", err)
}
tree.matcher = func(meta ConnData) bool {
return re.MatchString(meta.serverName)
}
return nil
}
// isASCII checks if the given string contains only ASCII characters.
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}

View file

@ -0,0 +1,383 @@
package tcp
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v2/pkg/tcp"
)
func Test_HostSNICatchAll(t *testing.T) {
testCases := []struct {
desc string
rule string
isCatchAll bool
}{
{
desc: "HostSNI(`example.com`) is not catchAll",
rule: "HostSNI(`example.com`)",
},
{
desc: "HostSNI(`*`) is catchAll",
rule: "HostSNI(`*`)",
isCatchAll: true,
},
{
desc: "HostSNIRegexp(`^.*$`) is not catchAll",
rule: "HostSNIRegexp(`.*`)",
isCatchAll: false,
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {}))
require.NoError(t, err)
handler, catchAll := muxer.Match(ConnData{
serverName: "example.com",
})
require.NotNil(t, handler)
assert.Equal(t, test.isCatchAll, catchAll)
})
}
}
func Test_HostSNI(t *testing.T) {
testCases := []struct {
desc string
rule string
serverName string
buildErr bool
match bool
}{
{
desc: "Empty",
buildErr: true,
},
{
desc: "Invalid HostSNI matcher (empty host)",
rule: "HostSNI(``)",
buildErr: true,
},
{
desc: "Invalid HostSNI matcher (too many parameters)",
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`)",
buildErr: true,
},
{
desc: "Valid HostSNI matcher - puny-coded emoji",
rule: "HostSNI(`xn--9t9h.com`)",
serverName: "xn--9t9h.com",
match: true,
},
{
desc: "Valid HostSNI matcher - puny-coded emoji but emoji in server name",
rule: "HostSNI(`xn--9t9h.com`)",
serverName: "🦭.com",
},
{
desc: "Matching hosts",
rule: "HostSNI(`example.com`)",
serverName: "example.com",
match: true,
},
{
desc: "No matching hosts",
rule: "HostSNI(`example.com`)",
serverName: "example.org",
},
{
desc: "Matching globing host `*`",
rule: "HostSNI(`*`)",
serverName: "example.com",
match: true,
},
{
desc: "Matching globing host `*` and empty server name",
rule: "HostSNI(`*`)",
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.`)",
serverName: "example.com",
match: true,
},
{
desc: "Matching hosts with subdomains",
rule: "HostSNI(`foo.example.com`)",
serverName: "foo.example.com",
match: true,
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {}))
if test.buildErr {
require.Error(t, err)
return
}
require.NoError(t, err)
meta := ConnData{
serverName: test.serverName,
}
handler, _ := muxer.Match(meta)
require.Equal(t, test.match, handler != nil)
})
}
}
func Test_HostSNIRegexp(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]bool
buildErr bool
match bool
}{
{
desc: "Empty",
buildErr: true,
},
{
desc: "Invalid HostSNIRegexp matcher (empty host)",
rule: "HostSNIRegexp(``)",
buildErr: true,
},
{
desc: "Invalid HostSNIRegexp matcher (non ASCII host)",
rule: "HostSNIRegexp(`🦭.com`)",
buildErr: true,
},
{
desc: "Invalid HostSNIRegexp matcher (invalid regexp)",
rule: "HostSNIRegexp(`(example.com`)",
buildErr: true,
},
{
desc: "Invalid HostSNIRegexp matcher (too many parameters)",
rule: "HostSNIRegexp(`example.com`, `example.org`)",
buildErr: true,
},
{
desc: "valid HostSNIRegexp matcher",
rule: "HostSNIRegexp(`^example\\.(com|org)$`)",
expected: map[string]bool{
"example.com": true,
"example.com.": false,
"EXAMPLE.com": false,
"example.org": true,
"exampleuorg": false,
"": false,
},
},
{
desc: "valid HostSNIRegexp matcher with Traefik v2 syntax",
rule: "HostSNIRegexp(`example.{tld:(com|org)}`)",
expected: map[string]bool{
"example.com": false,
"example.com.": false,
"EXAMPLE.com": false,
"example.org": false,
"exampleuorg": false,
"": false,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {}))
if test.buildErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for serverName, match := range test.expected {
meta := ConnData{
serverName: serverName,
}
handler, _ := muxer.Match(meta)
assert.Equal(t, match, handler != nil, serverName)
}
})
}
}
func Test_ClientIP(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]bool
buildErr bool
}{
{
desc: "Empty",
buildErr: true,
},
{
desc: "Invalid ClientIP matcher (empty host)",
rule: "ClientIP(``)",
buildErr: true,
},
{
desc: "Invalid ClientIP matcher (non ASCII host)",
rule: "ClientIP(`🦭/32`)",
buildErr: true,
},
{
desc: "Invalid ClientIP matcher (too many parameters)",
rule: "ClientIP(`127.0.0.1`, `127.0.0.2`)",
buildErr: true,
},
{
desc: "valid ClientIP matcher",
rule: "ClientIP(`20.20.20.20`)",
expected: map[string]bool{
"20.20.20.20": true,
"10.10.10.10": false,
},
},
{
desc: "valid ClientIP matcher with CIDR",
rule: "ClientIP(`20.20.20.20/24`)",
expected: map[string]bool{
"20.20.20.20": true,
"20.20.20.40": true,
"10.10.10.10": false,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {}))
if test.buildErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for remoteIP, match := range test.expected {
meta := ConnData{
remoteIP: remoteIP,
}
handler, _ := muxer.Match(meta)
assert.Equal(t, match, handler != nil, remoteIP)
}
})
}
}
func Test_ALPN(t *testing.T) {
testCases := []struct {
desc string
rule string
expected map[string]bool
buildErr bool
}{
{
desc: "Empty",
buildErr: true,
},
{
desc: "Invalid ALPN matcher (TLS proto)",
rule: "ALPN(`acme-tls/1`)",
buildErr: true,
},
{
desc: "Invalid ALPN matcher (empty parameters)",
rule: "ALPN(``)",
buildErr: true,
},
{
desc: "Invalid ALPN matcher (too many parameters)",
rule: "ALPN(`h2`, `mqtt`)",
buildErr: true,
},
{
desc: "Valid ALPN matcher",
rule: "ALPN(`h2`)",
expected: map[string]bool{
"h2": true,
"mqtt": false,
"": false,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
muxer, err := NewMuxer()
require.NoError(t, err)
err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {}))
if test.buildErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for proto, match := range test.expected {
meta := ConnData{
alpnProtos: []string{proto},
}
handler, _ := muxer.Match(meta)
assert.Equal(t, match, handler != nil, proto)
}
})
}
}

View file

@ -1,31 +1,18 @@
package tcp
import (
"bytes"
"errors"
"fmt"
"net"
"regexp"
"sort"
"strconv"
"strings"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v2/pkg/ip"
"github.com/traefik/traefik/v2/pkg/rules"
"github.com/traefik/traefik/v2/pkg/tcp"
"github.com/traefik/traefik/v2/pkg/types"
"github.com/vulcand/predicate"
)
var tcpFuncs = map[string]func(*matchersTree, ...string) error{
"HostSNI": hostSNI,
"HostSNIRegexp": hostSNIRegexp,
"ClientIP": clientIP,
"ALPN": alpn,
}
// ParseHostSNI extracts the HostSNIs declared in a rule.
// This is a first naive implementation used in TCP routing.
func ParseHostSNI(rule string) ([]string, error) {
@ -261,233 +248,7 @@ func (m *matchersTree) match(meta ConnData) bool {
return m.left.match(meta) && m.right.match(meta)
default:
// This should never happen as it should have been detected during parsing.
log.Warn().Msgf("Invalid rule operator %s", m.operator)
log.Warn().Str("operator", m.operator).Msg("Invalid rule operator")
return false
}
}
func clientIP(tree *matchersTree, clientIPs ...string) error {
checker, err := ip.NewChecker(clientIPs)
if err != nil {
return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err)
}
tree.matcher = func(meta ConnData) bool {
if meta.remoteIP == "" {
return false
}
ok, err := checker.Contains(meta.remoteIP)
if err != nil {
log.Warn().Err(err).Msg("\"ClientIP\" matcher: could not match remote address")
return false
}
return ok
}
return nil
}
// alpn checks if any of the connection ALPN protocols matches one of the matcher protocols.
func alpn(tree *matchersTree, protos ...string) error {
if len(protos) == 0 {
return errors.New("empty value for \"ALPN\" matcher is not allowed")
}
for _, proto := range protos {
if proto == tlsalpn01.ACMETLS1Protocol {
return fmt.Errorf("invalid protocol value for \"ALPN\" matcher, %q is not allowed", proto)
}
}
tree.matcher = func(meta ConnData) bool {
for _, proto := range meta.alpnProtos {
for _, filter := range protos {
if proto == filter {
return true
}
}
}
return false
}
return nil
}
var almostFQDN = regexp.MustCompile(`^[[:alnum:]\.-]+$`)
// hostSNI checks if the SNI Host of the connection match the matcher host.
func hostSNI(tree *matchersTree, hosts ...string) error {
if len(hosts) == 0 {
return errors.New("empty value for \"HostSNI\" matcher is not allowed")
}
for i, host := range hosts {
// Special case to allow global wildcard
if host == "*" {
continue
}
if !almostFQDN.MatchString(host) {
return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname", host)
}
hosts[i] = strings.ToLower(host)
}
tree.matcher = func(meta ConnData) bool {
// Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP,
// it allows matching with an empty serverName.
// Which is why we make sure to take that case into account before
// checking meta.serverName.
if hosts[0] == "*" {
return true
}
if meta.serverName == "" {
return false
}
for _, host := range hosts {
if host == "*" {
return true
}
if host == meta.serverName {
return true
}
// trim trailing period in case of FQDN
host = strings.TrimSuffix(host, ".")
if host == meta.serverName {
return true
}
}
return false
}
return nil
}
// hostSNIRegexp checks if the SNI Host of the connection matches the matcher host regexp.
func hostSNIRegexp(tree *matchersTree, templates ...string) error {
if len(templates) == 0 {
return fmt.Errorf("empty value for \"HostSNIRegexp\" matcher is not allowed")
}
var regexps []*regexp.Regexp
for _, template := range templates {
preparedPattern, err := preparePattern(template)
if err != nil {
return fmt.Errorf("invalid pattern value for \"HostSNIRegexp\" matcher, %q is not a valid pattern: %w", template, err)
}
regexp, err := regexp.Compile(preparedPattern)
if err != nil {
return err
}
regexps = append(regexps, regexp)
}
tree.matcher = func(meta ConnData) bool {
for _, regexp := range regexps {
if regexp.MatchString(meta.serverName) {
return true
}
}
return false
}
return nil
}
// TODO: expose more of containous/mux fork to get rid of the following copied code (https://github.com/containous/mux/blob/8ffa4f6d063c/regexp.go).
// preparePattern builds a regexp pattern from the initial user defined expression.
// This function reuses the code dedicated to host matching of the newRouteRegexp func from the gorilla/mux library.
// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618.
func preparePattern(template string) (string, error) {
// Check if it is well-formed.
idxs, errBraces := braceIndices(template)
if errBraces != nil {
return "", errBraces
}
defaultPattern := "[^.]+"
pattern := bytes.NewBufferString("")
// Host SNI matching is case-insensitive
fmt.Fprint(pattern, "(?i)")
pattern.WriteByte('^')
var end int
var err error
for i := 0; i < len(idxs); i += 2 {
// Set all values we are interested in.
raw := template[end:idxs[i]]
end = idxs[i+1]
parts := strings.SplitN(template[idxs[i]+1:end-1], ":", 2)
name := parts[0]
patt := defaultPattern
if len(parts) == 2 {
patt = parts[1]
}
// Name or pattern can't be empty.
if name == "" || patt == "" {
return "", fmt.Errorf("mux: missing name or pattern in %q",
template[idxs[i]:end])
}
// Build the regexp pattern.
fmt.Fprintf(pattern, "%s(?P<%s>%s)", regexp.QuoteMeta(raw), varGroupName(i/2), patt)
// Append variable name and compiled pattern.
if err != nil {
return "", err
}
}
// Add the remaining.
raw := template[end:]
pattern.WriteString(regexp.QuoteMeta(raw))
pattern.WriteByte('$')
return pattern.String(), nil
}
// varGroupName builds a capturing group name for the indexed variable.
// This function is a copy of varGroupName func from the gorilla/mux library.
// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618.
func varGroupName(idx int) string {
return "v" + strconv.Itoa(idx)
}
// braceIndices returns the first level curly brace indices from a string.
// This function is a copy of braceIndices func from the gorilla/mux library.
// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618.
func braceIndices(s string) ([]int, error) {
var level, idx int
var idxs []int
for i := 0; i < len(s); i++ {
switch s[i] {
case '{':
if level++; level == 1 {
idx = i
}
case '}':
if level--; level == 0 {
idxs = append(idxs, idx, i+1)
} else if level < 0 {
return nil, fmt.Errorf("mux: unbalanced braces in %q", s)
}
}
}
if level != 0 {
return nil, fmt.Errorf("mux: unbalanced braces in %q", s)
}
return idxs, nil
}

File diff suppressed because it is too large Load diff