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

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