Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ALPN for TCP + TLS routers #8913

Merged
merged 10 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/content/routing/routers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -839,11 +839,12 @@ If the rule is verified, the router becomes active, calls middlewares, and then

The table below lists all the available matchers:

| Rule | Description |
|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. |
| ```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. |
| Rule | Description |
|---------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| ```HostSNI(`domain-1`, ...)``` | Checks if the Server Name Indication corresponds to the given `domains`. |
| ```HostSNIRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Checks if the Server Name Indication matches the given regular expressions. See "Regexp Syntax" below. |
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Checks if the connection client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. |
| ```ALPN(`mqtt`, `h2c`)``` | Checks if any of the connection ALPN protocols is one of the given protocols. |

!!! important "Non-ASCII Domain Names"

Expand Down Expand Up @@ -879,6 +880,13 @@ The table below lists all the available matchers:

The rule is evaluated "before" any middleware has the opportunity to work, and "before" the request is forwarded to the service.

!!! important "ALPN ACME-TLS/1"

It would be a security issue to let a user-defined router catch the response to
an ACME TLS challenge previously initiated by Traefik.
For this reason, the `ALPN` matcher is not allowed to match the `ACME-TLS/1`
protocol, and Traefik returns an error if this is attempted.

### Priority

To avoid path overlap, routes are sorted, by default, in descending order using rules length.
Expand Down
33 changes: 32 additions & 1 deletion pkg/muxer/tcp/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/traefik/traefik/v2/pkg/ip"
"github.com/traefik/traefik/v2/pkg/log"
"github.com/traefik/traefik/v2/pkg/rules"
Expand All @@ -22,6 +23,7 @@ var tcpFuncs = map[string]func(*matchersTree, ...string) error{
"HostSNI": hostSNI,
"HostSNIRegexp": hostSNIRegexp,
"ClientIP": clientIP,
"ALPN": alpn,
}

// ParseHostSNI extracts the HostSNIs declared in a rule.
Expand Down Expand Up @@ -54,10 +56,11 @@ func ParseHostSNI(rule string) ([]string, error) {
type ConnData struct {
serverName string
remoteIP string
alpnProtos []string
}

// NewConnData builds a connData struct from the given parameters.
func NewConnData(serverName string, conn tcp.WriteCloser) (ConnData, error) {
func NewConnData(serverName string, conn tcp.WriteCloser, alpnProtos []string) (ConnData, error) {
remoteIP, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return ConnData{}, fmt.Errorf("error while parsing remote address %q: %w", conn.RemoteAddr().String(), err)
Expand All @@ -71,6 +74,7 @@ func NewConnData(serverName string, conn tcp.WriteCloser) (ConnData, error) {
return ConnData{
serverName: types.CanonicalDomain(serverName),
remoteIP: remoteIP,
alpnProtos: alpnProtos,
}, nil
}

Expand Down Expand Up @@ -284,6 +288,33 @@ func clientIP(tree *matchersTree, clientIPs ...string) error {
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.
Expand Down
134 changes: 133 additions & 1 deletion pkg/muxer/tcp/mux_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package tcp

import (
"fmt"
"net"
"testing"
"time"

"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v2/pkg/tcp"
Expand Down Expand Up @@ -58,6 +60,7 @@ func Test_addTCPRoute(t *testing.T) {
rule string
serverName string
remoteAddr string
protos []string
routeErr bool
matchErr bool
}{
Expand Down Expand Up @@ -436,6 +439,66 @@ func Test_addTCPRoute(t *testing.T) {
serverName: "bar",
remoteAddr: "10.0.0.1:80",
},
{
desc: "Invalid ALPN rule matching ACME-TLS/1",
rule: fmt.Sprintf("ALPN(`%s`)", tlsalpn01.ACMETLS1Protocol),
protos: []string{"foo"},
routeErr: true,
},
{
desc: "Valid ALPN rule matching single protocol",
rule: "ALPN(`foo`)",
protos: []string{"foo"},
},
{
desc: "Valid ALPN rule matching ACME-TLS/1 protocol",
rule: "ALPN(`foo`)",
protos: []string{tlsalpn01.ACMETLS1Protocol},
matchErr: true,
},
{
desc: "Valid ALPN rule not matching single protocol",
rule: "ALPN(`foo`)",
protos: []string{"bar"},
matchErr: true,
},
{
desc: "Valid alternative case ALPN rule matching single protocol without another being supported",
rule: "ALPN(`foo`) && !alpn(`h2`)",
protos: []string{"foo", "bar"},
},
{
desc: "Valid alternative case ALPN rule not matching single protocol because of another being supported",
rule: "ALPN(`foo`) && !alpn(`h2`)",
protos: []string{"foo", "h2", "bar"},
matchErr: true,
},
{
desc: "Valid complex alternative case ALPN and HostSNI rule",
rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))",
protos: []string{"foo", "bar"},
serverName: "foo",
},
{
desc: "Valid complex alternative case ALPN and HostSNI rule not matching by SNI",
rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))",
protos: []string{"foo", "bar", "h2"},
serverName: "bar",
matchErr: true,
},
{
desc: "Valid complex alternative case ALPN and HostSNI rule matching by ALPN",
rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))",
protos: []string{"foo", "bar"},
serverName: "bar",
},
{
desc: "Valid complex alternative case ALPN and HostSNI rule not matching by protos",
rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))",
protos: []string{"h2", "bar"},
serverName: "bar",
matchErr: true,
},
}

for _, test := range testCases {
Expand Down Expand Up @@ -471,7 +534,7 @@ func Test_addTCPRoute(t *testing.T) {
remoteAddr: fakeAddr{addr: addr},
}

connData, err := NewConnData(test.serverName, conn)
connData, err := NewConnData(test.serverName, conn, test.protos)
require.NoError(t, err)

matchingHandler, _ := router.Match(connData)
Expand Down Expand Up @@ -918,6 +981,75 @@ func Test_ClientIP(t *testing.T) {
}
}

func Test_ALPN(t *testing.T) {
testCases := []struct {
desc string
ruleALPNProtos []string
connProto string
buildErr bool
matchErr bool
}{
{
desc: "Empty",
buildErr: true,
},
{
desc: "ACME TLS proto",
ruleALPNProtos: []string{tlsalpn01.ACMETLS1Protocol},
buildErr: true,
},
{
desc: "Not matching empty proto",
ruleALPNProtos: []string{"h2"},
matchErr: true,
},
{
desc: "Not matching ALPN",
ruleALPNProtos: []string{"h2"},
connProto: "mqtt",
matchErr: true,
},
{
desc: "Matching ALPN",
ruleALPNProtos: []string{"h2"},
connProto: "h2",
},
{
desc: "Not matching multiple ALPNs",
ruleALPNProtos: []string{"h2", "mqtt"},
connProto: "h2c",
matchErr: true,
},
{
desc: "Matching multiple ALPNs",
ruleALPNProtos: []string{"h2", "h2c", "mqtt"},
connProto: "h2c",
},
}

for _, test := range testCases {
test := test

t.Run(test.desc, func(t *testing.T) {
t.Parallel()

matchersTree := &matchersTree{}
err := alpn(matchersTree, test.ruleALPNProtos...)
if test.buildErr {
require.Error(t, err)
return
}
require.NoError(t, err)

meta := ConnData{
alpnProtos: []string{test.connProto},
}

assert.Equal(t, test.matchErr, !matchersTree.match(meta))
})
}
}

func Test_Priority(t *testing.T) {
testCases := []struct {
desc string
Expand Down