From aef835a7e44025eac3e990a0a07dff1780bf8fac Mon Sep 17 00:00:00 2001 From: Peerachat Ongya Date: Fri, 22 Jan 2021 11:58:46 +0700 Subject: [PATCH] first commit --- .traefik.yml | 9 +++++ README.md | 5 +++ go.mod | 3 ++ real_ip.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ real_ip_test.go | 75 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 .traefik.yml create mode 100644 README.md create mode 100644 go.mod create mode 100644 real_ip.go create mode 100644 real_ip_test.go diff --git a/.traefik.yml b/.traefik.yml new file mode 100644 index 0000000..b7b16e7 --- /dev/null +++ b/.traefik.yml @@ -0,0 +1,9 @@ +displayName: Traefik Real IP +type: middleware +import: github.com/soulbalz/traefik-real-ip + +summary: When traefik is deployed behind a load balancer, it should get the real IP from the X-Forwarded-For or Cf-Connecting-Ip (if from Cloudflare) header. + +testData: + excludednets: + - "1.1.1.1/24" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dba6de --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Traefik Real IP + +If Traefik is behind a load balancer, it won't be able to get the Real IP from the external client by checking the remote IP address. + +This plugin solves this issue by overwriting the X-Real-Ip with an IP from the X-Forwarded-For or Cf-Connecting-Ip (if from Cloudflare) header. The real IP will be the first one that is not included in any of the CIDRs passed as the ExcludedNets parameter. The evaluation of the X-Forwarded-For or Cf-Connecting-Ip (if from Cloudflare) IPs will go from the last to the first one. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4539977 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/soulbalz/traefik-real-ip + +go 1.13 diff --git a/real_ip.go b/real_ip.go new file mode 100644 index 0000000..d66d2ad --- /dev/null +++ b/real_ip.go @@ -0,0 +1,91 @@ +package traefik_real_ip + +import ( + "context" + "net" + "net/http" + "strings" +) + +const ( + xRealIP = "X-Real-Ip" + xForwardedFor = "X-Forwarded-For" + cfConnectingIp = "Cf-Connecting-Ip" +) + +// Config the plugin configuration. +type Config struct { + ExcludedNets []string `json:"excludednets,omitempty" toml:"excludednets,omitempty" yaml:"excludednets,omitempty"` +} + +// CreateConfig creates the default plugin configuration. +func CreateConfig() *Config { + return &Config{ + ExcludedNets: []string{}, + } +} + +// RealIPOverWriter is a plugin that blocks incoming requests depending on their source IP. +type RealIPOverWriter struct { + next http.Handler + name string + ExcludedNets []*net.IPNet +} + +// New created a new Demo plugin. +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + ipOverWriter := &RealIPOverWriter{ + next: next, + name: name, + } + + for _, v := range config.ExcludedNets { + _, excludedNet, err := net.ParseCIDR(v) + if err != nil { + return nil, err + } + + ipOverWriter.ExcludedNets = append(ipOverWriter.ExcludedNets, excludedNet) + } + + return ipOverWriter, nil +} + +func (r *RealIPOverWriter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + forwardedIPs := strings.Split(req.Header.Get(xForwardedFor), ",") + + // TODO - Implement a max for the iterations + var realIP string + for i := len(forwardedIPs) - 1; i >= 0; i-- { + // TODO - Check if TrimSpace is necessary + trimmedIP := strings.TrimSpace(forwardedIPs[i]) + if !r.excludedIP(trimmedIP) { + realIP = trimmedIP + break + } + } + + if realIP == "" { + realIP = req.Header.Get(cfConnectingIp) + } + + req.Header.Set(xRealIP, realIP) + + r.next.ServeHTTP(rw, req) +} + +func (r *RealIPOverWriter) excludedIP(s string) bool { + ip := net.ParseIP(s) + if ip == nil { + // log the error and fallback to the default value (check if true is ok) + return true + } + + for _, network := range r.ExcludedNets { + if network.Contains(ip) { + return true + } + } + + return false +} diff --git a/real_ip_test.go b/real_ip_test.go new file mode 100644 index 0000000..f829c44 --- /dev/null +++ b/real_ip_test.go @@ -0,0 +1,75 @@ +package traefik_real_ip_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + plugin "github.com/soulbalz/traefik-real-ip" +) + +func TestNew(t *testing.T) { + cfg := plugin.CreateConfig() + cfg.ExcludedNets = []string{"127.0.0.1/24"} + + ctx := context.Background() + next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) + + handler, err := plugin.New(ctx, next, cfg, "traefik-real-ip") + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + header string + desc string + xForwardedFor string + expected string + }{ + { + header: "X-Forwarded-For", + desc: "don't forward", + xForwardedFor: "127.0.0.2", + expected: "", + }, + { + header: "X-Forwarded-For", + desc: "forward", + xForwardedFor: "10.0.0.1", + expected: "10.0.0.1", + }, + { + header: "Cf-Connecting-Ip", + desc: "forward", + xForwardedFor: "10.0.0.1", + expected: "10.0.0.1", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + recorder := httptest.NewRecorder() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set(test.header, test.xForwardedFor) + + handler.ServeHTTP(recorder, req) + + assertHeader(t, req, "X-Real-Ip", test.expected) + }) + } +} + +func assertHeader(t *testing.T, req *http.Request, key, expected string) { + t.Helper() + + if req.Header.Get(key) != expected { + t.Errorf("invalid header value: %s", req.Header.Get(key)) + } +}