Add routing IP rule matcher

Co-authored-by: Jean-Baptiste Doumenjou <925513+jbdoumenjou@users.noreply.github.com>
Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
Tom Moulard 2021-06-07 18:14:09 +02:00 committed by GitHub
parent e5024d5d0a
commit 679def0151
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 146 additions and 6 deletions

View file

@ -239,6 +239,7 @@ The table below lists all the available matchers:
| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. It accepts a sequence of literal and regular expression paths. | | ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. It accepts a sequence of literal and regular expression paths. |
| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. It accepts a sequence of literal and regular expression prefix paths. | | ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. It accepts a sequence of literal and regular expression prefix paths. |
| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | | ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. |
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Match 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"
@ -272,6 +273,10 @@ The table below lists all the available matchers:
For instance, `PathPrefix: /products` would match `/products` but also `/products/shoes` and `/products/shirts`. For instance, `PathPrefix: /products` would match `/products` but also `/products/shoes` and `/products/shirts`.
Since the path is forwarded as-is, your service is expected to listen on `/products`. Since the path is forwarded as-is, your service is expected to listen on `/products`.
!!! info "ClientIP matcher"
The `ClientIP` matcher will only match the request client IP and does not use the `X-Forwarded-For` header for matching.
### Priority ### Priority
To avoid path overlap, routes are sorted, by default, in descending order using rules length. The priority is directly equal to the length of the rule, and so the longest length has the highest priority. To avoid path overlap, routes are sorted, by default, in descending order using rules length. The priority is directly equal to the length of the rule, and so the longest length has the highest priority.

View file

@ -24,14 +24,15 @@ func NewChecker(trustedIPs []string) (*Checker, error) {
for _, ipMask := range trustedIPs { for _, ipMask := range trustedIPs {
if ipAddr := net.ParseIP(ipMask); ipAddr != nil { if ipAddr := net.ParseIP(ipMask); ipAddr != nil {
checker.authorizedIPs = append(checker.authorizedIPs, &ipAddr) checker.authorizedIPs = append(checker.authorizedIPs, &ipAddr)
} else { continue
}
_, ipAddr, err := net.ParseCIDR(ipMask) _, ipAddr, err := net.ParseCIDR(ipMask)
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing CIDR trusted IPs %s: %w", ipAddr, err) return nil, fmt.Errorf("parsing CIDR trusted IPs %s: %w", ipAddr, err)
} }
checker.authorizedIPsNet = append(checker.authorizedIPsNet, ipAddr) checker.authorizedIPsNet = append(checker.authorizedIPsNet, ipAddr)
} }
}
return checker, nil return checker, nil
} }

View file

@ -7,6 +7,7 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/traefik/traefik/v2/pkg/ip"
"github.com/traefik/traefik/v2/pkg/log" "github.com/traefik/traefik/v2/pkg/log"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
"github.com/vulcand/predicate" "github.com/vulcand/predicate"
@ -16,6 +17,7 @@ var funcs = map[string]func(*mux.Route, ...string) error{
"Host": host, "Host": host,
"HostHeader": host, "HostHeader": host,
"HostRegexp": hostRegexp, "HostRegexp": hostRegexp,
"ClientIP": clientIP,
"Path": path, "Path": path,
"PathPrefix": pathPrefix, "PathPrefix": pathPrefix,
"Method": methods, "Method": methods,
@ -155,6 +157,27 @@ func host(route *mux.Route, hosts ...string) error {
return nil 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.FromContext(req.Context()).Warnf("\"ClientIP\" matcher: could not match remote address : %w", err)
return false
}
return ok
})
return nil
}
func hostRegexp(route *mux.Route, hosts ...string) error { func hostRegexp(route *mux.Route, hosts ...string) error {
router := route.Subrouter() router := route.Subrouter()
for _, host := range hosts { for _, host := range hosts {

View file

@ -17,6 +17,7 @@ func Test_addRoute(t *testing.T) {
desc string desc string
rule string rule string
headers map[string]string headers map[string]string
remoteAddr string
expected map[string]int expected map[string]int
expectedError bool expectedError bool
}{ }{
@ -519,6 +520,112 @@ func Test_addRoute(t *testing.T) {
"http://plopi/a": 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,
},
},
} }
for _, test := range testCases { for _, test := range testCases {
@ -545,6 +652,10 @@ func Test_addRoute(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil) req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil)
// Useful for the ClientIP matcher
req.RemoteAddr = test.remoteAddr
for key, value := range test.headers { for key, value := range test.headers {
req.Header.Set(key, value) req.Header.Set(key, value)
} }