From 5f0b215e90a65436c44100c5e0a0710a772f3afb Mon Sep 17 00:00:00 2001 From: MaZderMind Date: Sun, 30 Apr 2017 11:22:07 +0200 Subject: [PATCH] IP Whitelists for Frontend (with Docker- & Kubernetes-Provider Support) --- docs/toml.md | 16 +- glide.lock | 2 +- middlewares/ip_whitelister_test.go | 308 ++++++++++++++++++ middlewares/ip_witelister.go | 83 +++++ provider/docker/docker.go | 10 + provider/docker/docker_test.go | 62 ++++ provider/kubernetes/kubernetes.go | 16 +- provider/kubernetes/kubernetes_test.go | 59 ++++ provider/string_util.go | 19 ++ provider/string_util_test.go | 61 ++++ server/server.go | 23 ++ server/server_test.go | 53 +++ templates/docker.tmpl | 10 + templates/kubernetes.tmpl | 3 + types/types.go | 13 +- .../frontend-monitor/frontend-monitor.html | 7 +- 16 files changed, 731 insertions(+), 14 deletions(-) create mode 100644 middlewares/ip_whitelister_test.go create mode 100644 middlewares/ip_witelister.go create mode 100644 provider/string_util.go create mode 100644 provider/string_util_test.go diff --git a/docs/toml.md b/docs/toml.md index c5464e892..463188aa5 100644 --- a/docs/toml.md +++ b/docs/toml.md @@ -479,6 +479,13 @@ defaultEntryPoints = ["http", "https"] backend = "backend1" passHostHeader = true priority = 10 + + # restrict access to this frontend to the specified list of IPv4/IPv6 CIDR Nets + # an unset or empty list allows all Source-IPs to access + # if one of the Net-Specifications are invalid, the whole list is invalid + # and allows all Source-IPs to access. + whitelistSourceRange = ["10.42.0.0/16", "152.89.1.33/32", "afed:be44::/16"] + entrypoints = ["https"] # overrides defaultEntryPoints [frontends.frontend2.routes.test_1] rule = "Host:{subdomain:[a-z]+}.localhost" @@ -867,7 +874,7 @@ Labels can be used on containers to override default behaviour: - `traefik.frontend.priority=10`: override default frontend priority - `traefik.frontend.entryPoints=http,https`: assign this frontend to entry points `http` and `https`. Overrides `defaultEntryPoints`. - `traefik.frontend.auth.basic=test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/,test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP/QNo0`: Sets a Basic Auth for that frontend with the users test:test and test2:test2 -- `traefik.docker.network`: Set the docker network to use for connections to this container. If a container is linked to several networks, be sure to set the proper network name (you can check with docker inspect ) otherwise it will randomly pick one (depending on how docker is returning them). For instance when deploying docker `stack` from compose files, the compose defined networks will be prefixed with the `stack` name. +- `traefik.frontend.whitelistSourceRange: "1.2.3.0/24, fe80::/16"`: List of IP-Ranges which are allowed to access. An unset or empty list allows all Source-IPs to access. If one of the Net-Specifications are invalid, the whole list is invalid and allows all Source-IPs to access.- `traefik.docker.network`: Set the docker network to use for connections to this container. If a container is linked to several networks, be sure to set the proper network name (you can check with docker inspect ) otherwise it will randomly pick one (depending on how docker is returning them). For instance when deploying docker `stack` from compose files, the compose defined networks will be prefixed with the `stack` name. If several ports need to be exposed from a container, the services labels can be used - `traefik..port=443`: create a service binding with frontend/backend using this port. Overrides `traefik.port`. @@ -1187,6 +1194,13 @@ Additionally, an annotation can be used on Kubernetes services to set the [circu - `traefik.backend.circuitbreaker: `: set the circuit breaker expression for the backend (Default: nil). +As known from nginx when used as Kubernetes Ingress Controller, a List of IP-Ranges which are allowed to access can be configured by using an ingress annotation: + +- `ingress.kubernetes.io/whitelist-source-range: "1.2.3.0/24, fe80::/16"` + +An unset or empty list allows all Source-IPs to access. If one of the Net-Specifications are invalid, the whole list is invalid and allows all Source-IPs to access. + + ### Authentication Is possible to add additional authentication annotations in the Ingress rule. diff --git a/glide.lock b/glide.lock index 96616e8d8..91a98fc5a 100644 --- a/glide.lock +++ b/glide.lock @@ -1,4 +1,4 @@ -hash: 1aa32496b865dda72d76c7cba3458f1c2c467acf0b99aab4609323f109aa64f6 +hash: e59e8244152a823cd3633fb09cdd583c4e5be78d7b50fb7047ba6b6a9ed5e8ec updated: 2017-05-02T11:46:23.91434995-04:00 imports: - name: cloud.google.com/go diff --git a/middlewares/ip_whitelister_test.go b/middlewares/ip_whitelister_test.go new file mode 100644 index 000000000..4688d672e --- /dev/null +++ b/middlewares/ip_whitelister_test.go @@ -0,0 +1,308 @@ +package middlewares + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/codegangsta/negroni" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewIPWhitelister(t *testing.T) { + cases := []struct { + desc string + whitelistStrings []string + expectedWhitelists []*net.IPNet + errMessage string + }{ + { + desc: "nil whitelist", + whitelistStrings: nil, + expectedWhitelists: nil, + errMessage: "no whitelists provided", + }, { + desc: "empty whitelist", + whitelistStrings: []string{}, + expectedWhitelists: nil, + errMessage: "no whitelists provided", + }, { + desc: "whitelist containing empty string", + whitelistStrings: []string{ + "1.2.3.4/24", + "", + "fe80::/16", + }, + expectedWhitelists: nil, + errMessage: "parsing CIDR whitelist : invalid CIDR address: ", + }, { + desc: "whitelist containing only an empty string", + whitelistStrings: []string{ + "", + }, + expectedWhitelists: nil, + errMessage: "parsing CIDR whitelist : invalid CIDR address: ", + }, { + desc: "whitelist containing an invalid string", + whitelistStrings: []string{ + "foo", + }, + expectedWhitelists: nil, + errMessage: "parsing CIDR whitelist : invalid CIDR address: foo", + }, { + desc: "IPv4 & IPv6 whitelist", + whitelistStrings: []string{ + "1.2.3.4/24", + "fe80::/16", + }, + expectedWhitelists: []*net.IPNet{ + {IP: net.IPv4(1, 2, 3, 0).To4(), Mask: net.IPv4Mask(255, 255, 255, 0)}, + {IP: net.ParseIP("fe80::"), Mask: net.IPMask(net.ParseIP("ffff::"))}, + }, + errMessage: "", + }, { + desc: "IPv4 only", + whitelistStrings: []string{ + "127.0.0.1/8", + }, + expectedWhitelists: []*net.IPNet{ + {IP: net.IPv4(127, 0, 0, 0).To4(), Mask: net.IPv4Mask(255, 0, 0, 0)}, + }, + errMessage: "", + }, + } + + for _, test := range cases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + whitelister, err := NewIPWhitelister(test.whitelistStrings) + if test.errMessage != "" { + require.EqualError(t, err, test.errMessage) + } else { + require.NoError(t, err) + for index, actual := range whitelister.whitelists { + expected := test.expectedWhitelists[index] + assert.Equal(t, expected.IP, actual.IP) + assert.Equal(t, expected.Mask.String(), actual.Mask.String()) + } + } + }) + } +} + +func TestIPWhitelisterHandle(t *testing.T) { + cases := []struct { + desc string + whitelistStrings []string + passIPs []string + rejectIPs []string + }{ + { + desc: "IPv4", + whitelistStrings: []string{ + "1.2.3.4/24", + }, + passIPs: []string{ + "1.2.3.1", + "1.2.3.32", + "1.2.3.156", + "1.2.3.255", + }, + rejectIPs: []string{ + "1.2.16.1", + "1.2.32.1", + "127.0.0.1", + "8.8.8.8", + }, + }, + { + desc: "IPv4 single IP", + whitelistStrings: []string{ + "8.8.8.8/32", + }, + passIPs: []string{ + "8.8.8.8", + }, + rejectIPs: []string{ + "8.8.8.7", + "8.8.8.9", + "8.8.8.0", + "8.8.8.255", + "4.4.4.4", + "127.0.0.1", + }, + }, + { + desc: "multiple IPv4", + whitelistStrings: []string{ + "1.2.3.4/24", + "8.8.8.8/8", + }, + passIPs: []string{ + "1.2.3.1", + "1.2.3.32", + "1.2.3.156", + "1.2.3.255", + "8.8.4.4", + "8.0.0.1", + "8.32.42.128", + "8.255.255.255", + }, + rejectIPs: []string{ + "1.2.16.1", + "1.2.32.1", + "127.0.0.1", + "4.4.4.4", + "4.8.8.8", + }, + }, + { + desc: "IPv6", + whitelistStrings: []string{ + "2a03:4000:6:d080::/64", + }, + passIPs: []string{ + "[2a03:4000:6:d080::]", + "[2a03:4000:6:d080::1]", + "[2a03:4000:6:d080:dead:beef:ffff:ffff]", + "[2a03:4000:6:d080::42]", + }, + rejectIPs: []string{ + "[2a03:4000:7:d080::]", + "[2a03:4000:7:d080::1]", + "[fe80::]", + "[4242::1]", + }, + }, + { + desc: "IPv6 single IP", + whitelistStrings: []string{ + "2a03:4000:6:d080::42/128", + }, + passIPs: []string{ + "[2a03:4000:6:d080::42]", + }, + rejectIPs: []string{ + "[2a03:4000:6:d080::1]", + "[2a03:4000:6:d080:dead:beef:ffff:ffff]", + "[2a03:4000:6:d080::43]", + }, + }, + { + desc: "multiple IPv6", + whitelistStrings: []string{ + "2a03:4000:6:d080::/64", + "fe80::/16", + }, + passIPs: []string{ + "[2a03:4000:6:d080::]", + "[2a03:4000:6:d080::1]", + "[2a03:4000:6:d080:dead:beef:ffff:ffff]", + "[2a03:4000:6:d080::42]", + "[fe80::1]", + "[fe80:aa00:00bb:4232:ff00:eeee:00ff:1111]", + "[fe80::fe80]", + }, + rejectIPs: []string{ + "[2a03:4000:7:d080::]", + "[2a03:4000:7:d080::1]", + "[4242::1]", + }, + }, + { + desc: "multiple IPv6 & IPv4", + whitelistStrings: []string{ + "2a03:4000:6:d080::/64", + "fe80::/16", + "1.2.3.4/24", + "8.8.8.8/8", + }, + passIPs: []string{ + "[2a03:4000:6:d080::]", + "[2a03:4000:6:d080::1]", + "[2a03:4000:6:d080:dead:beef:ffff:ffff]", + "[2a03:4000:6:d080::42]", + "[fe80::1]", + "[fe80:aa00:00bb:4232:ff00:eeee:00ff:1111]", + "[fe80::fe80]", + "1.2.3.1", + "1.2.3.32", + "1.2.3.156", + "1.2.3.255", + "8.8.4.4", + "8.0.0.1", + "8.32.42.128", + "8.255.255.255", + }, + rejectIPs: []string{ + "[2a03:4000:7:d080::]", + "[2a03:4000:7:d080::1]", + "[4242::1]", + "1.2.16.1", + "1.2.32.1", + "127.0.0.1", + "4.4.4.4", + "4.8.8.8", + }, + }, + { + desc: "broken IP-addresses", + whitelistStrings: []string{ + "127.0.0.1/32", + }, + passIPs: nil, + rejectIPs: []string{ + "foo", + "10.0.0.350", + "fe:::80", + "", + "\\&$ยง&/(", + }, + }, + } + + for _, test := range cases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + whitelister, err := NewIPWhitelister(test.whitelistStrings) + + require.NoError(t, err) + require.NotNil(t, whitelister) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "traefik") + }) + n := negroni.New(whitelister) + n.UseHandler(handler) + + for _, testIP := range test.passIPs { + req, err := http.NewRequest("GET", "/", nil) + require.NoError(t, err) + + req.RemoteAddr = testIP + ":2342" + recorder := httptest.NewRecorder() + n.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code, testIP+" should have passed "+test.desc) + assert.Contains(t, recorder.Body.String(), "traefik") + } + + for _, testIP := range test.rejectIPs { + req, err := http.NewRequest("GET", "/", nil) + require.NoError(t, err) + + req.RemoteAddr = testIP + ":2342" + recorder := httptest.NewRecorder() + n.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusForbidden, recorder.Code, testIP+" should not have passed "+test.desc) + assert.NotContains(t, recorder.Body.String(), "traefik") + } + }) + } +} diff --git a/middlewares/ip_witelister.go b/middlewares/ip_witelister.go new file mode 100644 index 000000000..151e485a2 --- /dev/null +++ b/middlewares/ip_witelister.go @@ -0,0 +1,83 @@ +package middlewares + +import ( + "fmt" + "net" + "net/http" + + "github.com/codegangsta/negroni" + "github.com/containous/traefik/log" +) + +// IPWhitelister is a middleware that provides Checks of the Requesting IP against a set of Whitelists +type IPWhitelister struct { + handler negroni.Handler + whitelists []*net.IPNet +} + +// NewIPWhitelister builds a new IPWhitelister given a list of CIDR-Strings to whitelist +func NewIPWhitelister(whitelistStrings []string) (*IPWhitelister, error) { + whitelister := IPWhitelister{} + + if len(whitelistStrings) == 0 { + return nil, fmt.Errorf("no whitelists provided") + } + + for _, whitelistString := range whitelistStrings { + _, whitelist, err := net.ParseCIDR(whitelistString) + if err != nil { + return nil, fmt.Errorf("parsing CIDR whitelist %s: %v", whitelist, err) + } + whitelister.whitelists = append(whitelister.whitelists, whitelist) + } + + whitelister.handler = negroni.HandlerFunc(whitelister.handle) + log.Debugf("configured %u IP whitelists: %s", len(whitelister.whitelists), whitelister.whitelists) + + return &whitelister, nil +} + +func (whitelister *IPWhitelister) handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + remoteIP, err := ipFromRemoteAddr(r.RemoteAddr) + if err != nil { + log.Warnf("unable to parse remote-address from header: %s - rejecting", r.RemoteAddr) + reject(w) + return + } + + for _, whitelist := range whitelister.whitelists { + if whitelist.Contains(*remoteIP) { + log.Debugf("source-IP %s matched whitelist %s - passing", remoteIP, whitelist) + next.ServeHTTP(w, r) + return + } + } + + log.Debugf("source-IP %s matched none of the whitelists - rejecting", remoteIP) + reject(w) + return +} + +func reject(w http.ResponseWriter) { + statusCode := http.StatusForbidden + + w.WriteHeader(statusCode) + w.Write([]byte(http.StatusText(statusCode))) +} +func ipFromRemoteAddr(addr string) (*net.IP, error) { + ip, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("can't extract IP/Port from address %s: %s", addr, err) + } + + userIP := net.ParseIP(ip) + if userIP == nil { + return nil, fmt.Errorf("can't parse IP from address %s", ip) + } + + return &userIP, nil +} + +func (whitelister *IPWhitelister) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + whitelister.handler.ServeHTTP(rw, r, next) +} diff --git a/provider/docker/docker.go b/provider/docker/docker.go index 6ecbc775d..54630d670 100644 --- a/provider/docker/docker.go +++ b/provider/docker/docker.go @@ -272,6 +272,7 @@ func (p *Provider) loadDockerConfig(containersInspected []dockerData) *types.Con "getServicePassHostHeader": p.getServicePassHostHeader, "getServicePriority": p.getServicePriority, "getServiceBackend": p.getServiceBackend, + "getWhitelistSourceRange": p.getWhitelistSourceRange, } // filter containers filteredContainers := fun.Filter(func(container dockerData) bool { @@ -663,6 +664,15 @@ func (p *Provider) getPassHostHeader(container dockerData) string { return "true" } +func (p *Provider) getWhitelistSourceRange(container dockerData) []string { + var whitelistSourceRange []string + + if whitelistSourceRangeLabel, err := getLabel(container, "traefik.frontend.whitelistSourceRange"); err == nil { + whitelistSourceRange = provider.SplitAndTrimString(whitelistSourceRangeLabel) + } + return whitelistSourceRange +} + func (p *Provider) getPriority(container dockerData) string { if priority, err := getLabel(container, "traefik.frontend.priority"); err == nil { return priority diff --git a/provider/docker/docker_test.go b/provider/docker/docker_test.go index 5f9fa42f0..46ddfe65c 100644 --- a/provider/docker/docker_test.go +++ b/provider/docker/docker_test.go @@ -400,6 +400,68 @@ func TestDockerGetPassHostHeader(t *testing.T) { } } +func TestDockerGetWhitelistSourceRange(t *testing.T) { + containers := []struct { + desc string + container docker.ContainerJSON + expected []string + }{ + { + desc: "no whitelist-label", + container: containerJSON(), + expected: nil, + }, + { + desc: "whitelist-label with empty string", + container: containerJSON(labels(map[string]string{ + "traefik.frontend.whitelistSourceRange": "", + })), + expected: nil, + }, + { + desc: "whitelist-label with IPv4 mask", + container: containerJSON(labels(map[string]string{ + "traefik.frontend.whitelistSourceRange": "1.2.3.4/16", + })), + expected: []string{ + "1.2.3.4/16", + }, + }, + { + desc: "whitelist-label with IPv6 mask", + container: containerJSON(labels(map[string]string{ + "traefik.frontend.whitelistSourceRange": "fe80::/16", + })), + expected: []string{ + "fe80::/16", + }, + }, + { + desc: "whitelist-label with multiple masks", + container: containerJSON(labels(map[string]string{ + "traefik.frontend.whitelistSourceRange": "1.1.1.1/24, 1234:abcd::42/32", + })), + expected: []string{ + "1.1.1.1/24", + "1234:abcd::42/32", + }, + }, + } + + for _, e := range containers { + e := e + t.Run(e.desc, func(t *testing.T) { + t.Parallel() + dockerData := parseContainer(e.container) + provider := &Provider{} + actual := provider.getWhitelistSourceRange(dockerData) + if !reflect.DeepEqual(actual, e.expected) { + t.Errorf("expected %q, got %q", e.expected, actual) + } + }) + } +} + func TestDockerGetLabel(t *testing.T) { containers := []struct { container docker.ContainerJSON diff --git a/provider/kubernetes/kubernetes.go b/provider/kubernetes/kubernetes.go index 61e8bc842..8087d5af7 100644 --- a/provider/kubernetes/kubernetes.go +++ b/provider/kubernetes/kubernetes.go @@ -31,6 +31,8 @@ const ( ruleTypePathStrip = "PathStrip" ruleTypePath = "Path" ruleTypePathPrefix = "PathPrefix" + + annotationKubernetesWhitelistSourceRange = "ingress.kubernetes.io/whitelist-source-range" ) const traefikDefaultRealm = "traefik" @@ -171,17 +173,21 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) return nil, errors.New("no realm customization supported") } + witelistSourceRangeAnnotation := i.Annotations[annotationKubernetesWhitelistSourceRange] + whitelistSourceRange := provider.SplitAndTrimString(witelistSourceRangeAnnotation) + if _, exists := templateObjects.Frontends[r.Host+pa.Path]; !exists { basicAuthCreds, err := handleBasicAuthConfig(i, k8sClient) if err != nil { return nil, err } templateObjects.Frontends[r.Host+pa.Path] = &types.Frontend{ - Backend: r.Host + pa.Path, - PassHostHeader: PassHostHeader, - Routes: make(map[string]types.Route), - Priority: len(pa.Path), - BasicAuth: basicAuthCreds, + Backend: r.Host + pa.Path, + PassHostHeader: PassHostHeader, + Routes: make(map[string]types.Route), + Priority: len(pa.Path), + BasicAuth: basicAuthCreds, + WhitelistSourceRange: whitelistSourceRange, } } if len(r.Host) > 0 { diff --git a/provider/kubernetes/kubernetes_test.go b/provider/kubernetes/kubernetes_test.go index 5cb0219ae..07e843e89 100644 --- a/provider/kubernetes/kubernetes_test.go +++ b/provider/kubernetes/kubernetes_test.go @@ -1523,6 +1523,35 @@ func TestIngressAnnotations(t *testing.T) { }, }, }, + { + ObjectMeta: v1.ObjectMeta{ + Namespace: "testing", + Annotations: map[string]string{ + "kubernetes.io/ingress.class": "traefik", + "ingress.kubernetes.io/whitelist-source-range": "1.1.1.1/24, 1234:abcd::42/32", + }, + }, + Spec: v1beta1.IngressSpec{ + Rules: []v1beta1.IngressRule{ + { + Host: "test", + IngressRuleValue: v1beta1.IngressRuleValue{ + HTTP: &v1beta1.HTTPIngressRuleValue{ + Paths: []v1beta1.HTTPIngressPath{ + { + Path: "/whitelist-source-range", + Backend: v1beta1.IngressBackend{ + ServiceName: "service1", + ServicePort: intstr.FromInt(80), + }, + }, + }, + }, + }, + }, + }, + }, + }, } services := []*v1.Service{ { @@ -1613,6 +1642,19 @@ func TestIngressAnnotations(t *testing.T) { Method: "wrr", }, }, + "test/whitelist-source-range": { + Servers: map[string]types.Server{ + "http://example.com": { + URL: "http://example.com", + Weight: 1, + }, + }, + CircuitBreaker: nil, + LoadBalancer: &types.LoadBalancer{ + Sticky: false, + Method: "wrr", + }, + }, }, Frontends: map[string]*types.Frontend{ "foo/bar": { @@ -1655,6 +1697,23 @@ func TestIngressAnnotations(t *testing.T) { }, BasicAuth: []string{"myUser:myEncodedPW"}, }, + "test/whitelist-source-range": { + Backend: "test/whitelist-source-range", + PassHostHeader: true, + WhitelistSourceRange: []string{ + "1.1.1.1/24", + "1234:abcd::42/32", + }, + Priority: len("/whitelist-source-range"), + Routes: map[string]types.Route{ + "/whitelist-source-range": { + Rule: "PathPrefix:/whitelist-source-range", + }, + "test": { + Rule: "Host:test", + }, + }, + }, }, } diff --git a/provider/string_util.go b/provider/string_util.go new file mode 100644 index 000000000..a155890b2 --- /dev/null +++ b/provider/string_util.go @@ -0,0 +1,19 @@ +package provider + +import "strings" + +// SplitAndTrimString splits separatedString at the comma character and trims each +// piece, filtering out empty pieces. Returns the list of pieces or nil if the input +// did not contain a non-empty piece. +func SplitAndTrimString(separatedString string) []string { + listOfStrings := strings.Split(separatedString, ",") + var trimmedListOfStrings []string + for _, s := range listOfStrings { + s = strings.TrimSpace(s) + if len(s) > 0 { + trimmedListOfStrings = append(trimmedListOfStrings, s) + } + } + + return trimmedListOfStrings +} diff --git a/provider/string_util_test.go b/provider/string_util_test.go new file mode 100644 index 000000000..4361315a6 --- /dev/null +++ b/provider/string_util_test.go @@ -0,0 +1,61 @@ +package provider + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSplitAndTrimString(t *testing.T) { + cases := []struct { + desc string + input string + expected []string + }{ + { + desc: "empty string", + input: "", + expected: nil, + }, { + desc: "one piece", + input: "foo", + expected: []string{"foo"}, + }, { + desc: "two pieces", + input: "foo,bar", + expected: []string{"foo", "bar"}, + }, { + desc: "three pieces", + input: "foo,bar,zoo", + expected: []string{"foo", "bar", "zoo"}, + }, { + desc: "two pieces with whitespace", + input: " foo , bar ", + expected: []string{"foo", "bar"}, + }, { + desc: "consecutive commas", + input: " foo ,, bar ", + expected: []string{"foo", "bar"}, + }, { + desc: "consecutive commas with witespace", + input: " foo , , bar ", + expected: []string{"foo", "bar"}, + }, { + desc: "leading and trailing commas", + input: ",, foo , , bar,, , ", + expected: []string{"foo", "bar"}, + }, { + desc: "no valid pieces", + input: ", , , ,, ,", + expected: nil, + }, + } + + for _, test := range cases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + actual := SplitAndTrimString(test.input) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/server/server.go b/server/server.go index 3c80249a2..48de35bb3 100644 --- a/server/server.go +++ b/server/server.go @@ -716,6 +716,14 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo negroni.Use(metricsMiddlewareBackend) } } + ipWhitelistMiddleware, err := configureIPWhitelistMiddleware(frontend.WhitelistSourceRange) + if err != nil { + log.Fatalf("Error creating IP Whitelister: %s", err) + } else if ipWhitelistMiddleware != nil { + negroni.Use(ipWhitelistMiddleware) + log.Infof("Configured IP Whitelists: %s", frontend.WhitelistSourceRange) + } + if len(frontend.BasicAuth) > 0 { users := types.Users{} for _, user := range frontend.BasicAuth { @@ -770,6 +778,21 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo return serverEntryPoints, nil } +func configureIPWhitelistMiddleware(whitelistSourceRanges []string) (negroni.Handler, error) { + if len(whitelistSourceRanges) > 0 { + ipSourceRanges := whitelistSourceRanges + ipWhitelistMiddleware, err := middlewares.NewIPWhitelister(ipSourceRanges) + + if err != nil { + return nil, err + } + + return ipWhitelistMiddleware, nil + } + + return nil, nil +} + func (server *Server) wireFrontendBackend(serverRoute *serverRoute, handler http.Handler) { // path replace - This needs to always be the very last on the handler chain (first in the order in this function) // -- Replacing Path should happen at the very end of the Modifier chain, after all the Matcher+Modifiers ran diff --git a/server/server_test.go b/server/server_test.go index cb4c2d6d9..63560514a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -14,6 +14,8 @@ import ( "github.com/containous/traefik/testhelpers" "github.com/containous/traefik/types" "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/vulcand/oxy/roundrobin" ) @@ -242,6 +244,57 @@ func TestServerParseHealthCheckOptions(t *testing.T) { } } +func TestNewServerWithWhitelistSourceRange(t *testing.T) { + cases := []struct { + desc string + whitelistStrings []string + middlewareConfigured bool + errMessage string + }{ + { + desc: "no whitelists configued", + whitelistStrings: nil, + middlewareConfigured: false, + errMessage: "", + }, { + desc: "whitelists configued", + whitelistStrings: []string{ + "1.2.3.4/24", + "fe80::/16", + }, + middlewareConfigured: true, + errMessage: "", + }, { + desc: "invalid whitelists configued", + whitelistStrings: []string{ + "foo", + }, + middlewareConfigured: false, + errMessage: "parsing CIDR whitelist : invalid CIDR address: foo", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + middleware, err := configureIPWhitelistMiddleware(tc.whitelistStrings) + + if tc.errMessage != "" { + require.EqualError(t, err, tc.errMessage) + } else { + assert.NoError(t, err) + + if tc.middlewareConfigured { + require.NotNil(t, middleware, "not expected middleware to be configured") + } else { + require.Nil(t, middleware, "expected middleware to be configured") + } + } + }) + } +} + func TestServerLoadConfigEmptyBasicAuth(t *testing.T) { globalConfig := GlobalConfiguration{ EntryPoints: EntryPoints{ diff --git a/templates/docker.tmpl b/templates/docker.tmpl index ec782df8e..50dafc423 100644 --- a/templates/docker.tmpl +++ b/templates/docker.tmpl @@ -43,6 +43,11 @@ [frontends."frontend-{{getServiceBackend $container $serviceName}}"] backend = "backend-{{getServiceBackend $container $serviceName}}" passHostHeader = {{getServicePassHostHeader $container $serviceName}} + {{if getWhitelistSourceRange $container}} + whitelistSourceRange = [{{range getWhitelistSourceRange $container}} + "{{.}}", + {{end}}] + {{end}} priority = {{getServicePriority $container $serviceName}} entryPoints = [{{range getServiceEntryPoints $container $serviceName}} "{{.}}", @@ -57,6 +62,11 @@ [frontends."frontend-{{$frontend}}"] backend = "backend-{{getBackend $container}}" passHostHeader = {{getPassHostHeader $container}} + {{if getWhitelistSourceRange $container}} + whitelistSourceRange = [{{range getWhitelistSourceRange $container}} + "{{.}}", + {{end}}] + {{end}} priority = {{getPriority $container}} entryPoints = [{{range getEntryPoints $container}} "{{.}}", diff --git a/templates/kubernetes.tmpl b/templates/kubernetes.tmpl index d11a34b61..99b2a29c5 100644 --- a/templates/kubernetes.tmpl +++ b/templates/kubernetes.tmpl @@ -22,6 +22,9 @@ passHostHeader = {{$frontend.PassHostHeader}} basicAuth = [{{range $frontend.BasicAuth}} "{{.}}", + {{end}}] + whitelistSourceRange = [{{range $frontend.WhitelistSourceRange}} + "{{.}}", {{end}}] {{range $routeName, $route := $frontend.Routes}} [frontends."{{$frontendName}}".routes."{{$routeName}}"] diff --git a/types/types.go b/types/types.go index 3831847e0..955f4e0aa 100644 --- a/types/types.go +++ b/types/types.go @@ -56,12 +56,13 @@ type Route struct { // Frontend holds frontend configuration. type Frontend struct { - EntryPoints []string `json:"entryPoints,omitempty"` - Backend string `json:"backend,omitempty"` - Routes map[string]Route `json:"routes,omitempty"` - PassHostHeader bool `json:"passHostHeader,omitempty"` - Priority int `json:"priority"` - BasicAuth []string `json:"basicAuth"` + EntryPoints []string `json:"entryPoints,omitempty"` + Backend string `json:"backend,omitempty"` + Routes map[string]Route `json:"routes,omitempty"` + PassHostHeader bool `json:"passHostHeader,omitempty"` + Priority int `json:"priority"` + BasicAuth []string `json:"basicAuth"` + WhitelistSourceRange []string `json:"whitelistSourceRange,omitempty"` } // LoadBalancerMethod holds the method of load balancing to use. diff --git a/webui/src/app/sections/providers/frontend-monitor/frontend-monitor.html b/webui/src/app/sections/providers/frontend-monitor/frontend-monitor.html index 57c26628e..f46943dcf 100644 --- a/webui/src/app/sections/providers/frontend-monitor/frontend-monitor.html +++ b/webui/src/app/sections/providers/frontend-monitor/frontend-monitor.html @@ -15,9 +15,14 @@