Add HostSNIRegexp rule matcher for TCP
This commit is contained in:
parent
0d58e8d1ad
commit
2da7fa0397
3 changed files with 311 additions and 7 deletions
|
@ -819,16 +819,25 @@ If the rule is verified, the router becomes active, calls middlewares, and then
|
||||||
|
|
||||||
The table below lists all the available matchers:
|
The table below lists all the available matchers:
|
||||||
|
|
||||||
| Rule | Description |
|
| Rule | Description |
|
||||||
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------|
|
|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||||
| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. |
|
| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. |
|
||||||
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Check if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. |
|
| ```HostSNIRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Check if the Server Name Indication matches the given regular expressions. See "Regexp Syntax" below. |
|
||||||
|
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Check if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. |
|
||||||
|
|
||||||
!!! important "Non-ASCII Domain Names"
|
!!! important "Non-ASCII Domain Names"
|
||||||
|
|
||||||
Non-ASCII characters are not supported in the `HostSNI` expression, and by doing so the associated TCP router will be invalid.
|
Non-ASCII characters are not supported in the `HostSNI` and `HostSNIRegexp` expressions, and so using them would invalidate the associated TCP router.
|
||||||
Domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)).
|
Domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)).
|
||||||
|
|
||||||
|
!!! important "Regexp Syntax"
|
||||||
|
|
||||||
|
`HostSNIRegexp` accepts an expression with zero or more groups enclosed by curly braces, which are called named regexps.
|
||||||
|
Named regexps, of the form `{name:regexp}`, are the only expressions considered for regexp matching.
|
||||||
|
The regexp name (`name` in the above example) is an arbitrary value, that exists only for historical reasons.
|
||||||
|
|
||||||
|
Any `regexp` supported by [Go's regexp package](https://golang.org/pkg/regexp/) may be used.
|
||||||
|
|
||||||
!!! important "HostSNI & TLS"
|
!!! important "HostSNI & TLS"
|
||||||
|
|
||||||
It is important to note that the Server Name Indication is an extension of the TLS protocol.
|
It is important to note that the Server Name Indication is an extension of the TLS protocol.
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
package tcp
|
package tcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/traefik/traefik/v2/pkg/ip"
|
"github.com/traefik/traefik/v2/pkg/ip"
|
||||||
|
@ -17,8 +19,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var tcpFuncs = map[string]func(*matchersTree, ...string) error{
|
var tcpFuncs = map[string]func(*matchersTree, ...string) error{
|
||||||
"HostSNI": hostSNI,
|
"HostSNI": hostSNI,
|
||||||
"ClientIP": clientIP,
|
"HostSNIRegexp": hostSNIRegexp,
|
||||||
|
"ClientIP": clientIP,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseHostSNI extracts the HostSNIs declared in a rule.
|
// ParseHostSNI extracts the HostSNIs declared in a rule.
|
||||||
|
@ -326,3 +329,124 @@ func hostSNI(tree *matchersTree, hosts ...string) error {
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -108,12 +108,66 @@ func Test_addTCPRoute(t *testing.T) {
|
||||||
serverName: "bar",
|
serverName: "bar",
|
||||||
matchErr: true,
|
matchErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "Empty HostSNIRegexp rule",
|
||||||
|
rule: "HostSNIRegexp()",
|
||||||
|
serverName: "foobar",
|
||||||
|
routeErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Empty HostSNIRegexp rule",
|
||||||
|
rule: "HostSNIRegexp(``)",
|
||||||
|
serverName: "foobar",
|
||||||
|
routeErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule matching",
|
||||||
|
rule: "HostSNIRegexp(`{subdomain:[a-z]+}.foobar`)",
|
||||||
|
serverName: "sub.foobar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid negative HostSNIRegexp rule matching",
|
||||||
|
rule: "!HostSNIRegexp(`bar`)",
|
||||||
|
serverName: "foobar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule matching with alternative case",
|
||||||
|
rule: "hostsniregexp(`foobar`)",
|
||||||
|
serverName: "foobar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule matching with alternative case",
|
||||||
|
rule: "HOSTSNIREGEXP(`foobar`)",
|
||||||
|
serverName: "foobar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule not matching",
|
||||||
|
rule: "HostSNIRegexp(`foobar`)",
|
||||||
|
serverName: "bar",
|
||||||
|
matchErr: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: "Valid negative HostSNI rule not matching",
|
desc: "Valid negative HostSNI rule not matching",
|
||||||
rule: "!HostSNI(`bar`)",
|
rule: "!HostSNI(`bar`)",
|
||||||
serverName: "bar",
|
serverName: "bar",
|
||||||
matchErr: true,
|
matchErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule matching empty servername",
|
||||||
|
rule: "HostSNIRegexp(`{subdomain:[a-z]*}`)",
|
||||||
|
serverName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule with one name",
|
||||||
|
rule: "HostSNIRegexp(`{dummy}`)",
|
||||||
|
serverName: "toto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid HostSNIRegexp rule with one name 2",
|
||||||
|
rule: "HostSNIRegexp(`{dummy}`)",
|
||||||
|
serverName: "toto.com",
|
||||||
|
matchErr: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: "Empty ClientIP rule",
|
desc: "Empty ClientIP rule",
|
||||||
rule: "ClientIP()",
|
rule: "ClientIP()",
|
||||||
|
@ -608,6 +662,123 @@ func Test_HostSNI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_HostSNIRegexp(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
pattern string
|
||||||
|
serverNames map[string]bool
|
||||||
|
buildErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "unbalanced braces",
|
||||||
|
pattern: "subdomain:(foo\\.)?bar\\.com}",
|
||||||
|
buildErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "empty group name",
|
||||||
|
pattern: "{:(foo\\.)?bar\\.com}",
|
||||||
|
buildErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "empty capturing group",
|
||||||
|
pattern: "{subdomain:}",
|
||||||
|
buildErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "malformed capturing group",
|
||||||
|
pattern: "{subdomain:(foo\\.?bar\\.com}",
|
||||||
|
buildErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "not interpreted as a regexp",
|
||||||
|
pattern: "bar.com",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"bar.com": true,
|
||||||
|
"barucom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "capturing group",
|
||||||
|
pattern: "{subdomain:(foo\\.)?bar\\.com}",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"foo.bar.com": true,
|
||||||
|
"bar.com": true,
|
||||||
|
"fooubar.com": false,
|
||||||
|
"barucom": false,
|
||||||
|
"barcom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "non capturing group",
|
||||||
|
pattern: "{subdomain:(?:foo\\.)?bar\\.com}",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"foo.bar.com": true,
|
||||||
|
"bar.com": true,
|
||||||
|
"fooubar.com": false,
|
||||||
|
"barucom": false,
|
||||||
|
"barcom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "regex insensitive",
|
||||||
|
pattern: "{dummy:[A-Za-z-]+\\.bar\\.com}",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"FOO.bar.com": true,
|
||||||
|
"foo.bar.com": true,
|
||||||
|
"fooubar.com": false,
|
||||||
|
"barucom": false,
|
||||||
|
"barcom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "insensitive host",
|
||||||
|
pattern: "{dummy:[a-z-]+\\.bar\\.com}",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"FOO.bar.com": true,
|
||||||
|
"foo.bar.com": true,
|
||||||
|
"fooubar.com": false,
|
||||||
|
"barucom": false,
|
||||||
|
"barcom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "insensitive host simple",
|
||||||
|
pattern: "foo.bar.com",
|
||||||
|
serverNames: map[string]bool{
|
||||||
|
"FOO.bar.com": true,
|
||||||
|
"foo.bar.com": true,
|
||||||
|
"fooubar.com": false,
|
||||||
|
"barucom": false,
|
||||||
|
"barcom": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
test := test
|
||||||
|
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
matchersTree := &matchersTree{}
|
||||||
|
err := hostSNIRegexp(matchersTree, test.pattern)
|
||||||
|
if test.buildErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for serverName, match := range test.serverNames {
|
||||||
|
meta := ConnData{
|
||||||
|
serverName: serverName,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, match, matchersTree.match(meta))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_ClientIP(t *testing.T) {
|
func Test_ClientIP(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
desc string
|
desc string
|
||||||
|
|
Loading…
Reference in a new issue