From 7c70abf07f2614ba8106fd186bc443d28cedbfba Mon Sep 17 00:00:00 2001 From: Daniel Carrillo Date: Sun, 1 May 2022 19:47:27 +0200 Subject: [PATCH] Add feature to get the right client port when using a trusted proxy --- Makefile | 2 +- README.md | 19 ++++--- internal/setting/app.go | 18 +++++-- internal/setting/app_test.go | 70 ++++++++++++------------ router/generic.go | 27 +++++++--- router/generic_test.go | 101 +++++++++++++++++++++++++++++++---- router/setup_test.go | 5 +- 7 files changed, 178 insertions(+), 64 deletions(-) diff --git a/Makefile b/Makefile index 1139443..712c3fb 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ integration-test: .PHONY: install-tools install-tools: @command golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin v1.45.0; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin v1.45.2; \ fi @command $(GOPATH)/shadow > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ diff --git a/README.md b/README.md index a527132..ea08038 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - [Examples](#examples) - [Run a default TCP server](#run-a-default-tcp-server) - [Run a TLS (HTTP/2) server only](#run-a-tls-http2-server-only) - - [Run a default TCP server with a custom template and trust a custom header set by an upstream proxy](#run-a-default-tcp-server-with-a-custom-template-and-trust-a-custom-header-set-by-an-upstream-proxy) + - [Run a default TCP server with a custom template and trust a pair of custom headers set by an upstream proxy](#run-a-default-tcp-server-with-a-custom-template-and-trust-a-pair-of-custom-headers-set-by-an-upstream-proxy) - [Download](#download) - [Docker](#docker) - [Run a container locally using test databases](#run-a-container-locally-using-test-databases) @@ -36,7 +36,7 @@ curl -6 ifconfig.es ## Features - TLS and HTTP/2. -- Can run behind a proxy by trusting a custom header (usually `X-Real-IP`) to figure out the source IP address. +- Can run behind a proxy by trusting a custom header (usually `X-Real-IP`) to figure out the source IP address. It also supports a custom header to resolve the client port, if the proxy can only add a header for the IP (for example a fixed header from CDNs) the client port is shown as unknown. - IPv4 and IPv6. - Geolocation info including ASN. This feature is possible thanks to [maxmind](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data?lang=en) GeoLite2 databases. In order to use these databases, a license key is needed. Please visit Maxmind site for further instructions and get a free license. - High performance. @@ -47,6 +47,7 @@ curl -6 ifconfig.es ## Endpoints - https://ifconfig.es/ +- https://ifconfig.es/client-port - https://ifconfig.es/json (this is the same as `curl -H "Accept: application/json" https://ifconfig.es/`) - https://ifconfig.es/geo - https://ifconfig.es/geo/city @@ -72,9 +73,11 @@ Golang >= 1.17 is required. Previous versions may work. ## Usage ```text -Usage of ./whatismyip: +Usage of whatismyip: -bind string Listening address (see https://pkg.go.dev/net?#Listen) (default ":8080") + -enable-secure-headers + Add sane security-related headers to every response -geoip2-asn string Path to GeoIP2 ASN database -geoip2-city string @@ -88,7 +91,9 @@ Usage of ./whatismyip: -tls-key string When using TLS, path to private key file -trusted-header string - Trusted request header for remote IP (e.g. X-Real-IP) + Trusted request header for remote IP (e.g. X-Real-IP). When using this feature if -trusted-port-header is not set the client port is shown as 'unknown' + -trusted-port-header string + Trusted request header for remote client port (e.g. X-Real-Port). When this parameter is set -trusted-header becomes mandatory -version Output version information and exit ``` @@ -108,11 +113,11 @@ Usage of ./whatismyip: -bind "" -tls-bind :8081 -tls-crt ./test/server.pem -tls-key ./test/server.key ``` -### Run a default TCP server with a custom template and trust a custom header set by an upstream proxy +### Run a default TCP server with a custom template and trust a pair of custom headers set by an upstream proxy ```bash ./whatismyip -geoip2-city ./test/GeoIP2-City-Test.mmdb -geoip2-asn ./test/GeoLite2-ASN-Test.mmdb \ - -trusted-header X-Real-IP -template mytemplate.tmpl + -trusted-header X-Real-IP -trusted-port-header X-Real-Port -template mytemplate.tmpl ``` ## Download @@ -121,7 +126,7 @@ Download latest version from https://github.com/dcarrillo/whatismyip/releases ## Docker -An ultra-light (~9MB) image is available. +An ultra-light (~10MB) image is available at [docker hub](https://hub.docker.com/r/dcarrillo/whatismyip). ### Run a container locally using test databases diff --git a/internal/setting/app.go b/internal/setting/app.go index 30869b0..1af46c1 100644 --- a/internal/setting/app.go +++ b/internal/setting/app.go @@ -27,6 +27,7 @@ type settings struct { TLSCrtPath string TLSKeyPath string TrustedHeader string + TrustedPortHeader string EnableSecureHeaders bool Server serverSettings version bool @@ -74,6 +75,11 @@ func Setup(args []string) (output string, err error) { "", "Trusted request header for remote IP (e.g. X-Real-IP)", ) + flags.StringVar(&App.TrustedPortHeader, + "trusted-port-header", + "", + "Trusted request header for remote client port (e.g. X-Real-Port)", + ) flags.BoolVar(&App.version, "version", false, "Output version information and exit") flags.BoolVar( &App.EnableSecureHeaders, @@ -91,21 +97,25 @@ func Setup(args []string) (output string, err error) { return fmt.Sprintf("whatismyip version %s", core.Version), ErrVersion } + if App.TrustedPortHeader != "" && App.TrustedHeader == "" { + return "", fmt.Errorf("truster-header is mandatory when truster-port-header is set\n") + } + if App.GeodbPath.City == "" || App.GeodbPath.ASN == "" { - return "", fmt.Errorf("geoip2-city and geoip2-asn parameters are mandatory") + return "", fmt.Errorf("geoip2-city and geoip2-asn parameters are mandatory\n") } if (App.TLSAddress != "") && (App.TLSCrtPath == "" || App.TLSKeyPath == "") { - return "", fmt.Errorf("In order to use TLS -tls-crt and -tls-key flags are mandatory") + return "", fmt.Errorf("In order to use TLS -tls-crt and -tls-key flags are mandatory\n") } if App.TemplatePath != "" { info, err := os.Stat(App.TemplatePath) if os.IsNotExist(err) { - return "", fmt.Errorf("%s no such file or directory", App.TemplatePath) + return "", fmt.Errorf("%s no such file or directory\n", App.TemplatePath) } if info.IsDir() { - return "", fmt.Errorf("%s must be a file", App.TemplatePath) + return "", fmt.Errorf("%s must be a file\n", App.TemplatePath) } } diff --git a/internal/setting/app_test.go b/internal/setting/app_test.go index d38c143..31c27dc 100644 --- a/internal/setting/app_test.go +++ b/internal/setting/app_test.go @@ -8,51 +8,51 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseMandatoryFlags(t *testing.T) { var mandatoryFlags = []struct { args []string - conf settings }{ { []string{}, - settings{}, }, { []string{"-geoip2-city", "/city-path"}, - settings{}, }, { []string{"-geoip2-asn", "/asn-path"}, - settings{}, }, { []string{ "-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path", "-tls-bind", ":9000", }, - settings{}, }, { []string{ "-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path", "-tls-bind", ":9000", "-tls-crt", "/crt-path", }, - settings{}, }, { []string{ "-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path", "-tls-bind", ":9000", "-tls-key", "/key-path", }, - settings{}, + }, + { + []string{ + "-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path", "-bind", ":8000", + "-trusted-port-header", "port-header", + }, }, } for _, tt := range mandatoryFlags { t.Run(strings.Join(tt.args, " "), func(t *testing.T) { _, err := Setup(tt.args) - assert.NotNil(t, err) + require.NotNil(t, err) assert.Contains(t, err.Error(), "mandatory") }) } @@ -70,13 +70,7 @@ func TestParseFlags(t *testing.T) { City: "/city-path", ASN: "/asn-path", }, - TemplatePath: "", - BindAddress: ":8080", - TLSAddress: "", - TLSCrtPath: "", - TLSKeyPath: "", - TrustedHeader: "", - EnableSecureHeaders: false, + BindAddress: ":8080", Server: serverSettings{ ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -90,13 +84,7 @@ func TestParseFlags(t *testing.T) { City: "/city-path", ASN: "/asn-path", }, - TemplatePath: "", - BindAddress: ":8001", - TLSAddress: "", - TLSCrtPath: "", - TLSKeyPath: "", - TrustedHeader: "", - EnableSecureHeaders: false, + BindAddress: ":8001", Server: serverSettings{ ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -113,13 +101,29 @@ func TestParseFlags(t *testing.T) { City: "/city-path", ASN: "/asn-path", }, - TemplatePath: "", - BindAddress: ":8080", - TLSAddress: ":9000", - TLSCrtPath: "/crt-path", - TLSKeyPath: "/key-path", - TrustedHeader: "", - EnableSecureHeaders: false, + BindAddress: ":8080", + TLSAddress: ":9000", + TLSCrtPath: "/crt-path", + TLSKeyPath: "/key-path", + Server: serverSettings{ + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + }, + }, + }, + { + []string{ + "-geoip2-city", "/city-path", "-geoip2-asn", "/asn-path", + "-trusted-header", "header", "-trusted-port-header", "port-header", + }, + settings{ + GeodbPath: geodbPath{ + City: "/city-path", + ASN: "/asn-path", + }, + BindAddress: ":8080", + TrustedHeader: "header", + TrustedPortHeader: "port-header", Server: serverSettings{ ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -136,11 +140,7 @@ func TestParseFlags(t *testing.T) { City: "/city-path", ASN: "/asn-path", }, - TemplatePath: "", BindAddress: ":8080", - TLSAddress: "", - TLSCrtPath: "", - TLSKeyPath: "", TrustedHeader: "header", EnableSecureHeaders: true, Server: serverSettings{ @@ -154,7 +154,7 @@ func TestParseFlags(t *testing.T) { for _, tt := range flags { t.Run(strings.Join(tt.args, " "), func(t *testing.T) { _, err := Setup(tt.args) - assert.Nil(t, err) + require.Nil(t, err) assert.True(t, reflect.DeepEqual(App, tt.conf)) }) } @@ -192,6 +192,6 @@ func TestParseFlagTemplate(t *testing.T) { "-template", "/", } _, err = Setup(flags) - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "must be a file") } diff --git a/router/generic.go b/router/generic.go index 8f912f7..7e404fd 100644 --- a/router/generic.go +++ b/router/generic.go @@ -44,15 +44,31 @@ func getRoot(ctx *gin.Context) { } } +func getClientPort(ctx *gin.Context) string { + var port string + if setting.App.TrustedPortHeader == "" { + if setting.App.TrustedHeader != "" { + port = "unknown" + } else { + _, port, _ = net.SplitHostPort(ctx.Request.RemoteAddr) + } + } else { + port = ctx.GetHeader(setting.App.TrustedPortHeader) + if port == "" { + port = "unknown" + } + } + + return port +} + func getClientPortAsString(ctx *gin.Context) { - _, port, _ := net.SplitHostPort(ctx.Request.RemoteAddr) - ctx.String(http.StatusOK, port+"\n") + ctx.String(http.StatusOK, getClientPort(ctx)+"\n") } func getAllAsString(ctx *gin.Context) { output := "IP: " + ctx.ClientIP() + "\n" - _, port, _ := net.SplitHostPort(ctx.Request.RemoteAddr) - output += "Client Port: " + port + "\n" + output += "Client Port: " + getClientPort(ctx) + "\n" r := service.Geo{IP: net.ParseIP(ctx.ClientIP())} if record := r.LookUpCity(); record != nil { @@ -83,11 +99,10 @@ func jsonOutput(ctx *gin.Context) JSONResponse { version = 6 } - _, port, _ := net.SplitHostPort(ctx.Request.RemoteAddr) return JSONResponse{ IP: ctx.ClientIP(), IPVersion: version, - ClientPort: port, + ClientPort: getClientPort(ctx), Country: cityRecord.Country.Names["en"], CountryCode: cityRecord.Country.ISOCode, City: cityRecord.City.Names["en"], diff --git a/router/generic_test.go b/router/generic_test.go index 3cd53e9..9382000 100644 --- a/router/generic_test.go +++ b/router/generic_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/dcarrillo/whatismyip/internal/setting" "github.com/stretchr/testify/assert" ) @@ -97,16 +98,78 @@ func TestHost(t *testing.T) { } func TestClientPort(t *testing.T) { - req, _ := http.NewRequest("GET", "/client-port", nil) - req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000") - req.Header.Set(trustedHeader, testIP.ipv4) + type args struct { + params []string + headers map[string][]string + } + type expected struct { + body string + } + tests := []struct { + name string + args args + expected string + }{ + { + name: "No trusted headers set", + expected: "1000\n", + }, + { + name: "Trusted header only set", + args: args{ + params: []string{ + "-geoip2-city", "city", + "-geoip2-asn", "asn", + "-trusted-header", trustedHeader, + }, + }, + expected: "unknown\n", + }, + { + name: "Trusted and port header set but not included in headers", + args: args{ + params: []string{ + "-geoip2-city", "city", + "-geoip2-asn", "asn", + "-trusted-header", trustedHeader, + "-trusted-port-header", trustedPortHeader, + }, + }, + expected: "unknown\n", + }, + { + name: "Trusted and port header set and included in headers", + args: args{ + params: []string{ + "-geoip2-city", "city", + "-geoip2-asn", "asn", + "-trusted-header", trustedHeader, + "-trusted-port-header", trustedPortHeader, + }, + headers: map[string][]string{ + trustedHeader: {testIP.ipv4}, + trustedPortHeader: {"1001"}, + }, + }, + expected: "1001\n", + }, + } - w := httptest.NewRecorder() - app.ServeHTTP(w, req) + for _, tt := range tests { + _, _ = setting.Setup(tt.args.params) + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/client-port", nil) + req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000") + req.Header = tt.args.headers - assert.Equal(t, 200, w.Code) - assert.Equal(t, contentType.text, w.Header().Get("Content-Type")) - assert.Equal(t, "1000\n", w.Body.String()) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, contentType.text, w.Header().Get("Content-Type")) + assert.Equal(t, tt.expected, w.Body.String()) + }) + } } func TestNotFound(t *testing.T) { @@ -120,6 +183,15 @@ func TestNotFound(t *testing.T) { } func TestJSON(t *testing.T) { + _, _ = setting.Setup( + []string{ + "-geoip2-city", "city", + "-geoip2-asn", "asn", + "-trusted-header", trustedHeader, + "-trusted-port-header", trustedPortHeader, + }, + ) + type args struct { ip string } @@ -149,6 +221,7 @@ func TestJSON(t *testing.T) { req.RemoteAddr = net.JoinHostPort(tt.args.ip, "1000") req.Host = "test" req.Header.Set(trustedHeader, tt.args.ip) + req.Header.Set(trustedPortHeader, "1001") w := httptest.NewRecorder() app.ServeHTTP(w, req) @@ -162,7 +235,7 @@ func TestJSON(t *testing.T) { func TestAll(t *testing.T) { expected := `IP: 81.2.69.192 -Client Port: 1000 +Client Port: 1001 City: London Country: United Kingdom Country Code: GB @@ -177,12 +250,22 @@ ASN Organization: Header1: one Host: test X-Real-Ip: 81.2.69.192 +X-Real-Port: 1001 ` + _, _ = setting.Setup( + []string{ + "-geoip2-city", "city", + "-geoip2-asn", "asn", + "-trusted-header", trustedHeader, + "-trusted-port-header", trustedPortHeader, + }, + ) req, _ := http.NewRequest("GET", "/all", nil) req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000") req.Host = "test" req.Header.Set(trustedHeader, testIP.ipv4) + req.Header.Set(trustedPortHeader, "1001") req.Header.Set("Header1", "one") w := httptest.NewRecorder() diff --git a/router/setup_test.go b/router/setup_test.go index fe9720e..712e46f 100644 --- a/router/setup_test.go +++ b/router/setup_test.go @@ -34,11 +34,12 @@ var ( text: "text/plain; charset=utf-8", json: "application/json; charset=utf-8", } - jsonIPv4 = `{"client_port":"1000","ip":"81.2.69.192","ip_version":4,"country":"United Kingdom","country_code":"GB","city":"London","latitude":51.5142,"longitude":-0.0931,"postal_code":"","time_zone":"Europe/London","asn":0,"asn_organization":"","host":"test","headers":{"X-Real-Ip":["81.2.69.192"]}}` - jsonIPv6 = `{"asn":3352, "asn_organization":"TELEFONICA DE ESPANA", "city":"", "client_port":"1000", "country":"", "country_code":"", "headers":{"X-Real-Ip":["2a02:9000::1"]}, "host":"test", "ip":"2a02:9000::1", "ip_version":6, "latitude":0, "longitude":0, "postal_code":"", "time_zone":""}` + jsonIPv4 = `{"client_port":"1001","ip":"81.2.69.192","ip_version":4,"country":"United Kingdom","country_code":"GB","city":"London","latitude":51.5142,"longitude":-0.0931,"postal_code":"","time_zone":"Europe/London","asn":0,"asn_organization":"","host":"test","headers":{"X-Real-Ip":["81.2.69.192"], "X-Real-Port":["1001"]}}` + jsonIPv6 = `{"asn":3352, "asn_organization":"TELEFONICA DE ESPANA", "city":"", "client_port":"1001", "country":"", "country_code":"", "headers":{"X-Real-Ip":["2a02:9000::1"], "X-Real-Port":["1001"]}, "host":"test", "ip":"2a02:9000::1", "ip_version":6, "latitude":0, "longitude":0, "postal_code":"", "time_zone":""}` ) const trustedHeader = "X-Real-IP" +const trustedPortHeader = "X-Real-Port" func TestMain(m *testing.M) { app = gin.Default()