commit 964ab574eab00e606886d045587008794d1cc1c9 Author: Jason Date: Mon Feb 14 16:45:10 2022 -0500 🦈🏠🐜❗ Initial Commit ❗🐜🦈🏠 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30ecf57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +Dockerfile.geoip \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1a1e2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/data/ +/custom.html +/vendor/ +.vscode/ +/bin/ +# ignore commit message +.gitcommit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..41a8ca5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Build +FROM golang:1.15-buster AS build +WORKDIR /go/src/github.com/mpolden/echoip +COPY . . + +# Must build without cgo because libc is unavailable in runtime image +ENV GO111MODULE=on CGO_ENABLED=0 +RUN make + +# Run +FROM casjaysdev/alpine:latest as run +COPY --from=build /go/bin/echoip /opt/echoip/ +COPY ./html /opt/echoip/html +COPY ./bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +FROM run +ARG BUILD_DATE="$(date +'%Y-%m-%d %H:%M')" + +LABEL \ + org.label-schema.name="ifconfig" \ + org.label-schema.description="Sow ip information" \ + org.label-schema.url="https://github.com/casjaysdev/ifconfig" \ + org.label-schema.vcs-url="https://github.com/casjaysdev/ifconfig" \ + org.label-schema.build-date=$BUILD_DATE \ + org.label-schema.version=$BUILD_DATE \ + org.label-schema.vcs-ref=$BUILD_DATE \ + org.label-schema.license="MIT" \ + org.label-schema.vcs-type="Git" \ + org.label-schema.schema-version="1.0" \ + org.label-schema.vendor="CasjaysDev" \ + maintainer="CasjaysDev " + +EXPOSE 8080 +WORKDIR /opt/echoip +VOLUME /opt/echoip/html + +HEALTHCHECK CMD ["/usr/local/bin/docker-entrypoint.sh", "healthcheck"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c211aee --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2012-2020, Martin Polden +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8b799c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +DOCKER ?= docker +DOCKER_IMAGE ?= mpolden/echoip +OS := $(shell uname) +ifeq ($(OS),Linux) + TAR_OPTS := --wildcards +endif +XGOARCH := $(shell uname -m) +XGOOS := linux +XBIN := $(XGOOS)_$(XGOARCH)/echoip + +all: lint test install + +test: + go test ./... + +vet: + go vet ./... + +check-fmt: + bash -c "diff --line-format='%L' <(echo -n) <(gofmt -d -s .)" + +lint: check-fmt vet + +install: + go install ./... + +databases := GeoLite2-City GeoLite2-Country GeoLite2-ASN + +$(databases): +ifndef GEOIP_LICENSE_KEY + $(error GEOIP_LICENSE_KEY must be set. Please see https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/) +endif + mkdir -p data + @curl -fsSL -m 30 "https://download.maxmind.com/app/geoip_download?edition_id=$@&license_key=$(GEOIP_LICENSE_KEY)&suffix=tar.gz" | tar $(TAR_OPTS) --strip-components=1 -C $(CURDIR)/data -xzf - '*.mmdb' + test ! -f data/GeoLite2-City.mmdb || mv data/GeoLite2-City.mmdb data/city.mmdb + test ! -f data/GeoLite2-Country.mmdb || mv data/GeoLite2-Country.mmdb data/country.mmdb + test ! -f data/GeoLite2-ASN.mmdb || mv data/GeoLite2-ASN.mmdb data/asn.mmdb + +geoip-download: $(databases) + +# Create an environment to build multiarch containers (https://github.com/docker/buildx/) +docker-multiarch-builder: + DOCKER_BUILDKIT=1 $(DOCKER) build -o . git://github.com/docker/buildx + mkdir -p ~/.docker/cli-plugins + mv buildx ~/.docker/cli-plugins/docker-buildx + $(DOCKER) buildx create --name multiarch-builder --node multiarch-builder --driver docker-container --use + $(DOCKER) run --rm --privileged multiarch/qemu-user-static --reset -p yes + +docker-build: + $(DOCKER) build -t $(DOCKER_IMAGE) . + +docker-login: + @echo "$(DOCKER_PASSWORD)" | $(DOCKER) login -u "$(DOCKER_USERNAME)" --password-stdin + +docker-test: + $(eval CONTAINER=$(shell $(DOCKER) run --rm --detach --publish-all $(DOCKER_IMAGE))) + $(eval DOCKER_PORT=$(shell $(DOCKER) port $(CONTAINER) | cut -d ":" -f 2)) + curl -fsS -m 5 localhost:$(DOCKER_PORT) > /dev/null; $(DOCKER) stop $(CONTAINER) + +docker-push: docker-test docker-login + $(DOCKER) push $(DOCKER_IMAGE) + +docker-pushx: docker-multiarch-builder docker-test docker-login + $(DOCKER) buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t $(DOCKER_IMAGE) --push . + +xinstall: + env GOOS=$(XGOOS) GOARCH=$(XGOARCH) go install ./... + +publish: +ifndef DEST_PATH + $(error DEST_PATH must be set when publishing) +endif + rsync -a $(GOPATH)/bin/$(XBIN) $(DEST_PATH)/$(XBIN) + @sha256sum $(GOPATH)/bin/$(XBIN) + +run: + go run cmd/echoip/main.go -a data/asn.mmdb -c data/city.mmdb -f data/country.mmdb -H x-forwarded-for -r -s -p diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4ecd7b --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# echoip + +![Build Status](https://github.com/mpolden/echoip/workflows/ci/badge.svg) + +A simple service for looking up your IP address. This is the code that powers +. + +## Usage + +Just the business, please: + +```shell +$ curl ifconfig.co +127.0.0.1 + +$ http ifconfig.co +127.0.0.1 + +$ wget -qO- ifconfig.co +127.0.0.1 + +$ fetch -qo- https://ifconfig.co +127.0.0.1 + +$ bat -print=b ifconfig.co/ip +127.0.0.1 +``` + +Country and city lookup: + +```shell +$ curl ifconfig.co/country +Elbonia + +$ curl ifconfig.co/country-iso +EB + +$ curl ifconfig.co/city +Bornyasherk + +$ curl ifconfig.co/asn +AS59795 +``` + +As JSON: + +```shell +$ curl -H 'Accept: application/json' ifconfig.co # or curl ifconfig.co/json +{ + "city": "Bornyasherk", + "country": "Elbonia", + "country_iso": "EB", + "ip": "127.0.0.1", + "ip_decimal": 2130706433, + "asn": "AS59795", + "asn_org": "Hosting4Real" +} +``` + +Port testing: + +```shell +$ curl ifconfig.co/port/80 +{ + "ip": "127.0.0.1", + "port": 80, + "reachable": false +} +``` + +Pass the appropriate flag (usually `-4` and `-6`) to your client to switch +between IPv4 and IPv6 lookup. + +## Features + +* Easy to remember domain name +* Fast +* Supports IPv6 +* Supports HTTPS +* Supports common command-line clients (e.g. `curl`, `httpie`, `ht`, `wget` and `fetch`) +* JSON output +* ASN, country and city lookup using the MaxMind GeoIP database +* Port testing +* All endpoints (except `/port`) can return information about a custom IP address specified via `?ip=` query parameter +* Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause) + +## Why? + +* To scratch an itch +* An excuse to use Go for something +* Faster than ifconfig.me and has IPv6 support + +## Building + +Compiling requires the [Golang compiler](https://golang.org/) to be installed. +This package can be installed with: + +`go install github.com/mpolden/echoip/...@latest` + +For more information on building a Go project, see the [official Go +documentation](https://golang.org/doc/code.html). + +## Docker image + +A Docker image is available on [Docker +Hub](https://hub.docker.com/r/casjay/ifconfig), which can be downloaded with: + +`docker pull casjay/ifconfig` + +### Server Options + +```shell +$ echoip -h +Usage of echoip: + -C int + Size of response cache. Set to 0 to disable + -H value + Header to trust for remote IP, if present (e.g. X-Real-IP) + -a string + Path to GeoIP ASN database + -c string + Path to GeoIP city database + -f string + Path to GeoIP country database + -l string + Listening address (default ":8080") + -p Enable port lookup + -r Perform reverse hostname lookups + -t string + Path to template directory (default "html") +``` diff --git a/cmd/echoip/main.go b/cmd/echoip/main.go new file mode 100644 index 0000000..737a4f4 --- /dev/null +++ b/cmd/echoip/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "flag" + "log" + + "os" + + "github.com/mpolden/echoip/http" + "github.com/mpolden/echoip/iputil" + "github.com/mpolden/echoip/iputil/geo" +) + +type multiValueFlag []string + +func (f *multiValueFlag) String() string { + vs := "" + for i, v := range *f { + vs += v + if i < len(*f)-1 { + vs += ", " + } + } + return vs +} + +func (f *multiValueFlag) Set(v string) error { + *f = append(*f, v) + return nil +} + +func init() { + log.SetPrefix("echoip: ") + log.SetFlags(log.Lshortfile) +} + +func main() { + countryFile := flag.String("f", "", "Path to GeoIP country database") + cityFile := flag.String("c", "", "Path to GeoIP city database") + asnFile := flag.String("a", "", "Path to GeoIP ASN database") + listen := flag.String("l", ":8080", "Listening address") + reverseLookup := flag.Bool("r", false, "Perform reverse hostname lookups") + portLookup := flag.Bool("p", false, "Enable port lookup") + template := flag.String("t", "html", "Path to template dir") + cacheSize := flag.Int("C", 0, "Size of response cache. Set to 0 to disable") + profile := flag.Bool("P", false, "Enables profiling handlers") + sponsor := flag.Bool("s", false, "Show sponsor logo") + var headers multiValueFlag + flag.Var(&headers, "H", "Header to trust for remote IP, if present (e.g. X-Real-IP)") + flag.Parse() + if len(flag.Args()) != 0 { + flag.Usage() + return + } + + r, err := geo.Open(*countryFile, *cityFile, *asnFile) + if err != nil { + log.Fatal(err) + } + cache := http.NewCache(*cacheSize) + server := http.New(r, cache, *profile) + server.IPHeaders = headers + if _, err := os.Stat(*template); err == nil { + server.Template = *template + } else { + log.Printf("Not configuring default handler: Template not found: %s", *template) + } + if *reverseLookup { + log.Println("Enabling reverse lookup") + server.LookupAddr = iputil.LookupAddr + } + if *portLookup { + log.Println("Enabling port lookup") + server.LookupPort = iputil.LookupPort + } + if *sponsor { + log.Println("Enabling sponsor logo") + server.Sponsor = *sponsor + } + if len(headers) > 0 { + log.Printf("Trusting remote IP from header(s): %s", headers.String()) + } + if *cacheSize > 0 { + log.Printf("Cache capacity set to %d", *cacheSize) + } + if *profile { + log.Printf("Enabling profiling handlers") + } + log.Printf("Listening on http://%s", *listen) + if err := server.ListenAndServe(*listen); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..040fac8 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/mpolden/echoip + +go 1.13 + +require ( + github.com/oschwald/geoip2-golang v1.5.0 + golang.org/x/sys v0.0.0-20210223212115-eede4237b368 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f00e6ee --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lVJ1ONZzw= +github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s= +github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk= +github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g= +golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210223212115-eede4237b368 h1:fDE3p0qf2V1co1vfj3/o87Ps8Hq6QTGNxJ5Xe7xSp80= +golang.org/x/sys v0.0.0-20210223212115-eede4237b368/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/html/index.html b/html/index.html new file mode 100644 index 0000000..37686fb --- /dev/null +++ b/html/index.html @@ -0,0 +1,349 @@ + + + + + What is my IP address? — {{ .Host }} + + + + + + + + + {{ template "script.html" . }} {{ template "styles.html" . }} + + + +
+
+
+
+

What is my IP address?

+

{{ .IP }}

+

+ The best tool to find your own IP address, and information about + it. +

+
+
+
+ {{ if .Sponsor }} +
+
+
+ +
+
+

+ This site is hosted by
+ + Casjays Developments + +

+
+
+
+ {{ end }} +
+
+ +
+ +
+
+

What do we know about this IP address?

+ + + + + + + + + + {{ if .Country }} + + + + + {{ end }} {{ if .CountryISO }} + + + + + {{ end }} {{ if .CountryEU }} + + + + + {{ end }} {{ if .RegionName }} + + + + + {{ end }} {{ if .RegionCode }} + + + + + {{ end }} {{ if .MetroCode }} + + + + + {{ end }} {{ if .PostalCode }} + + + + + {{ end }} {{ if .City }} + + + + + {{ end }} {{ if .Latitude }} + + + + + {{ end }} {{ if .Longitude }} + + + + + {{ end }} {{ if .Timezone }} + + + + + {{ end }} {{ if .ASN }} + + + + + {{ end }} {{ if .ASNOrg }} + + + + + {{ end }} {{ if .Hostname }} + + + + + {{ end }} {{ if .UserAgent }} {{ if .UserAgent.Comment }} + + + + + {{ end }} {{ if .UserAgent.Comment }} + + + + + {{ end }} {{ if .UserAgent.RawValue }} + + + + + {{ end }} {{ end }} +
IP address{{ .IP }}
IP address (decimal){{ .IPDecimal }}
Country{{ .Country }}
Country (ISO code){{ .CountryISO }}
In EU?{{ .CountryEU }}
Region{{ .RegionName }}
Region code{{ .RegionCode }}
Metro code{{ .MetroCode }}
Postal code{{ .PostalCode }}
City{{ .City }}
Latitude{{ .Latitude }}
Longitude{{ .Longitude }}
Timezone{{ .Timezone }}
ASN{{ .ASN }}
ASN (organization){{ .ASNOrg }}
Hostname{{ .Hostname }}
User agent{{ .UserAgent.Product }}/{{ .UserAgent.Version }}
User agent: Comment{{ .UserAgent.Comment }}
User agent: Raw{{ .UserAgent.RawValue }}
+ {{ if .Country }} +

+ This information is provided from the GeoLite2 database created by + MaxMind, available from + www.maxmind.com +

+ {{ end }} {{ if .Latitude }} +
+

Map

+ +
+ {{ end }} +
+
+ +
+
+

How do I get this programmatically?

+

+ With the widget below you can build your query, and see what the + result will look like. +

+
+ +
+ + + + + + + + +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+

FAQ

+

How do I force IPv4 or IPv6 lookup?

+

+ As of 2018-07-25 it's no longer possible to
+ force protocol using the v4 and + v6 subdomains.
+ IPv4 or IPv6 still can be forced by passing the
+ appropiate flag to your client,
+ e.g curl -4 or curl -6. +

+

Can I force getting JSON?

+

+ Setting the Accept: application/json
+ header works as expected. +

+ +

Is automated use of this service permitted?

+

+ Yes, as long as the rate limit is respected.
+ The rate limit is in place to ensure a fair service for all. +

+

+ Please limit automated requests to 1
+ request per minute
. No guarantee is made
+ for requests that exceed this limit. They may be
+ rate-limited, with a 429 status code, or dropped entirely. +

+

Can I run my own service?

+

+ Yes, the official source code
+ and documentation is available at
+ https://github.com/mpolden/echoip.
+ My modified code can be found at
+ https://github.com/dockermgr/ifconfig. +

+
+
+
+
+
+




+ + diff --git a/html/script.html b/html/script.html new file mode 100644 index 0000000..23b27be --- /dev/null +++ b/html/script.html @@ -0,0 +1,88 @@ + diff --git a/html/styles.html b/html/styles.html new file mode 100644 index 0000000..4af5be9 --- /dev/null +++ b/html/styles.html @@ -0,0 +1,201 @@ + diff --git a/http/cache.go b/http/cache.go new file mode 100644 index 0000000..5568d7a --- /dev/null +++ b/http/cache.go @@ -0,0 +1,99 @@ +package http + +import ( + "container/list" + "fmt" + "hash/fnv" + "net" + "sync" +) + +type Cache struct { + capacity int + mu sync.RWMutex + entries map[uint64]*list.Element + values *list.List + evictions uint64 +} + +type CacheStats struct { + Capacity int + Size int + Evictions uint64 +} + +func NewCache(capacity int) *Cache { + if capacity < 0 { + capacity = 0 + } + return &Cache{ + capacity: capacity, + entries: make(map[uint64]*list.Element), + values: list.New(), + } +} + +func key(ip net.IP) uint64 { + h := fnv.New64a() + h.Write(ip) + return h.Sum64() +} + +func (c *Cache) Set(ip net.IP, resp Response) { + if c.capacity == 0 { + return + } + k := key(ip) + c.mu.Lock() + defer c.mu.Unlock() + minEvictions := len(c.entries) - c.capacity + 1 + if minEvictions > 0 { // At or above capacity. Shrink the cache + evicted := 0 + for el := c.values.Front(); el != nil && evicted < minEvictions; { + value := el.Value.(Response) + delete(c.entries, key(value.IP)) + next := el.Next() + c.values.Remove(el) + el = next + evicted++ + } + c.evictions += uint64(evicted) + } + current, ok := c.entries[k] + if ok { + c.values.Remove(current) + } + c.entries[k] = c.values.PushBack(resp) +} + +func (c *Cache) Get(ip net.IP) (Response, bool) { + k := key(ip) + c.mu.RLock() + defer c.mu.RUnlock() + r, ok := c.entries[k] + if !ok { + return Response{}, false + } + return r.Value.(Response), true +} + +func (c *Cache) Resize(capacity int) error { + if capacity < 0 { + return fmt.Errorf("invalid capacity: %d\n", capacity) + } + c.mu.Lock() + defer c.mu.Unlock() + c.capacity = capacity + c.evictions = 0 + return nil +} + +func (c *Cache) Stats() CacheStats { + c.mu.RLock() + defer c.mu.RUnlock() + return CacheStats{ + Size: len(c.entries), + Capacity: c.capacity, + Evictions: c.evictions, + } +} diff --git a/http/cache_test.go b/http/cache_test.go new file mode 100644 index 0000000..e99d480 --- /dev/null +++ b/http/cache_test.go @@ -0,0 +1,87 @@ +package http + +import ( + "fmt" + "net" + "testing" +) + +func TestCacheCapacity(t *testing.T) { + var tests = []struct { + addCount, capacity, size int + evictions uint64 + }{ + {1, 0, 0, 0}, + {1, 2, 1, 0}, + {2, 2, 2, 0}, + {3, 2, 2, 1}, + {10, 5, 5, 5}, + } + for i, tt := range tests { + c := NewCache(tt.capacity) + var responses []Response + for i := 0; i < tt.addCount; i++ { + ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i)) + r := Response{IP: ip} + responses = append(responses, r) + c.Set(ip, r) + } + if got := len(c.entries); got != tt.size { + t.Errorf("#%d: len(entries) = %d, want %d", i, got, tt.size) + } + if got := c.evictions; got != tt.evictions { + t.Errorf("#%d: evictions = %d, want %d", i, got, tt.evictions) + } + if tt.capacity > 0 && tt.addCount > tt.capacity && tt.capacity == tt.size { + lastAdded := responses[tt.addCount-1] + if _, ok := c.Get(lastAdded.IP); !ok { + t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, lastAdded.IP.String(), ok, !ok) + } + firstAdded := responses[0] + if _, ok := c.Get(firstAdded.IP); ok { + t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, firstAdded.IP.String(), ok, !ok) + } + } + } +} + +func TestCacheDuplicate(t *testing.T) { + c := NewCache(10) + ip := net.ParseIP("192.0.2.1") + response := Response{IP: ip} + c.Set(ip, response) + c.Set(ip, response) + want := 1 + if got := len(c.entries); got != want { + t.Errorf("want %d entries, got %d", want, got) + } + if got := c.values.Len(); got != want { + t.Errorf("want %d values, got %d", want, got) + } +} + +func TestCacheResize(t *testing.T) { + c := NewCache(10) + for i := 1; i <= 20; i++ { + ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i)) + r := Response{IP: ip} + c.Set(ip, r) + } + if got, want := len(c.entries), 10; got != want { + t.Errorf("want %d entries, got %d", want, got) + } + if got, want := c.evictions, uint64(10); got != want { + t.Errorf("want %d evictions, got %d", want, got) + } + if err := c.Resize(5); err != nil { + t.Fatal(err) + } + if got, want := c.evictions, uint64(0); got != want { + t.Errorf("want %d evictions, got %d", want, got) + } + r := Response{IP: net.ParseIP("192.0.2.42")} + c.Set(r.IP, r) + if got, want := len(c.entries), 5; got != want { + t.Errorf("want %d entries, got %d", want, got) + } +} diff --git a/http/error.go b/http/error.go new file mode 100644 index 0000000..72c6fce --- /dev/null +++ b/http/error.go @@ -0,0 +1,40 @@ +package http + +import "net/http" + +type appError struct { + Error error + Message string + Code int + ContentType string +} + +func internalServerError(err error) *appError { + return &appError{ + Error: err, + Message: "Internal server error", + Code: http.StatusInternalServerError, + } +} + +func notFound(err error) *appError { + return &appError{Error: err, Code: http.StatusNotFound} +} + +func badRequest(err error) *appError { + return &appError{Error: err, Code: http.StatusBadRequest} +} + +func (e *appError) AsJSON() *appError { + e.ContentType = jsonMediaType + return e +} + +func (e *appError) WithMessage(message string) *appError { + e.Message = message + return e +} + +func (e *appError) IsJSON() bool { + return e.ContentType == jsonMediaType +} diff --git a/http/http.go b/http/http.go new file mode 100644 index 0000000..626d75b --- /dev/null +++ b/http/http.go @@ -0,0 +1,466 @@ +package http + +import ( + "encoding/json" + "fmt" + "html/template" + "io/ioutil" + "log" + "path/filepath" + "strings" + + "net/http/pprof" + + "github.com/mpolden/echoip/iputil" + "github.com/mpolden/echoip/iputil/geo" + "github.com/mpolden/echoip/useragent" + + "math/big" + "net" + "net/http" + "strconv" +) + +const ( + jsonMediaType = "application/json" + textMediaType = "text/plain" +) + +type Server struct { + Template string + IPHeaders []string + LookupAddr func(net.IP) (string, error) + LookupPort func(net.IP, uint64) error + cache *Cache + gr geo.Reader + profile bool + Sponsor bool +} + +type Response struct { + IP net.IP `json:"ip"` + IPDecimal *big.Int `json:"ip_decimal"` + Country string `json:"country,omitempty"` + CountryISO string `json:"country_iso,omitempty"` + CountryEU *bool `json:"country_eu,omitempty"` + RegionName string `json:"region_name,omitempty"` + RegionCode string `json:"region_code,omitempty"` + MetroCode uint `json:"metro_code,omitempty"` + PostalCode string `json:"zip_code,omitempty"` + City string `json:"city,omitempty"` + Latitude float64 `json:"latitude,omitempty"` + Longitude float64 `json:"longitude,omitempty"` + Timezone string `json:"time_zone,omitempty"` + ASN string `json:"asn,omitempty"` + ASNOrg string `json:"asn_org,omitempty"` + Hostname string `json:"hostname,omitempty"` + UserAgent *useragent.UserAgent `json:"user_agent,omitempty"` +} + +type PortResponse struct { + IP net.IP `json:"ip"` + Port uint64 `json:"port"` + Reachable bool `json:"reachable"` +} + +func New(db geo.Reader, cache *Cache, profile bool) *Server { + return &Server{cache: cache, gr: db, profile: profile} +} + +func ipFromForwardedForHeader(v string) string { + sep := strings.Index(v, ",") + if sep == -1 { + return v + } + return v[:sep] +} + +// ipFromRequest detects the IP address for this transaction. +// +// * `headers` - the specific HTTP headers to trust +// * `r` - the incoming HTTP request +// * `customIP` - whether to allow the IP to be pulled from query parameters +func ipFromRequest(headers []string, r *http.Request, customIP bool) (net.IP, error) { + remoteIP := "" + if customIP && r.URL != nil { + if v, ok := r.URL.Query()["ip"]; ok { + remoteIP = v[0] + } + } + if remoteIP == "" { + for _, header := range headers { + remoteIP = r.Header.Get(header) + if http.CanonicalHeaderKey(header) == "X-Forwarded-For" { + remoteIP = ipFromForwardedForHeader(remoteIP) + } + if remoteIP != "" { + break + } + } + } + if remoteIP == "" { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil, err + } + remoteIP = host + } + ip := net.ParseIP(remoteIP) + if ip == nil { + return nil, fmt.Errorf("could not parse IP: %s", remoteIP) + } + return ip, nil +} + +func userAgentFromRequest(r *http.Request) *useragent.UserAgent { + var userAgent *useragent.UserAgent + userAgentRaw := r.UserAgent() + if userAgentRaw != "" { + parsed := useragent.Parse(userAgentRaw) + userAgent = &parsed + } + return userAgent +} + +func (s *Server) newResponse(r *http.Request) (Response, error) { + ip, err := ipFromRequest(s.IPHeaders, r, true) + if err != nil { + return Response{}, err + } + response, ok := s.cache.Get(ip) + if ok { + // Do not cache user agent + response.UserAgent = userAgentFromRequest(r) + return response, nil + } + ipDecimal := iputil.ToDecimal(ip) + country, _ := s.gr.Country(ip) + city, _ := s.gr.City(ip) + asn, _ := s.gr.ASN(ip) + var hostname string + if s.LookupAddr != nil { + hostname, _ = s.LookupAddr(ip) + } + var autonomousSystemNumber string + if asn.AutonomousSystemNumber > 0 { + autonomousSystemNumber = fmt.Sprintf("AS%d", asn.AutonomousSystemNumber) + } + response = Response{ + IP: ip, + IPDecimal: ipDecimal, + Country: country.Name, + CountryISO: country.ISO, + CountryEU: country.IsEU, + RegionName: city.RegionName, + RegionCode: city.RegionCode, + MetroCode: city.MetroCode, + PostalCode: city.PostalCode, + City: city.Name, + Latitude: city.Latitude, + Longitude: city.Longitude, + Timezone: city.Timezone, + ASN: autonomousSystemNumber, + ASNOrg: asn.AutonomousSystemOrganization, + Hostname: hostname, + } + s.cache.Set(ip, response) + response.UserAgent = userAgentFromRequest(r) + return response, nil +} + +func (s *Server) newPortResponse(r *http.Request) (PortResponse, error) { + lastElement := filepath.Base(r.URL.Path) + port, err := strconv.ParseUint(lastElement, 10, 16) + if err != nil || port < 1 || port > 65535 { + return PortResponse{Port: port}, fmt.Errorf("invalid port: %s", lastElement) + } + ip, err := ipFromRequest(s.IPHeaders, r, false) + if err != nil { + return PortResponse{Port: port}, err + } + err = s.LookupPort(ip, port) + return PortResponse{ + IP: ip, + Port: port, + Reachable: err == nil, + }, nil +} + +func (s *Server) CLIHandler(w http.ResponseWriter, r *http.Request) *appError { + ip, err := ipFromRequest(s.IPHeaders, r, true) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintln(w, ip.String()) + return nil +} + +func (s *Server) CLICountryHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintln(w, response.Country) + return nil +} + +func (s *Server) CLICountryISOHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintln(w, response.CountryISO) + return nil +} + +func (s *Server) CLICityHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintln(w, response.City) + return nil +} + +func (s *Server) CLICoordinatesHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintf(w, "%s,%s\n", formatCoordinate(response.Latitude), formatCoordinate(response.Longitude)) + return nil +} + +func (s *Server) CLIASNHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + fmt.Fprintf(w, "%s\n", response.ASN) + return nil +} + +func (s *Server) JSONHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + b, err := json.MarshalIndent(response, "", " ") + if err != nil { + return internalServerError(err).AsJSON() + } + w.Header().Set("Content-Type", jsonMediaType) + w.Write(b) + return nil +} + +func (s *Server) HealthHandler(w http.ResponseWriter, r *http.Request) *appError { + w.Header().Set("Content-Type", jsonMediaType) + w.Write([]byte(`{"status":"OK"}`)) + return nil +} + +func (s *Server) PortHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newPortResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + b, err := json.MarshalIndent(response, "", " ") + if err != nil { + return internalServerError(err).AsJSON() + } + w.Header().Set("Content-Type", jsonMediaType) + w.Write(b) + return nil +} + +func (s *Server) cacheResizeHandler(w http.ResponseWriter, r *http.Request) *appError { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + capacity, err := strconv.Atoi(string(body)) + if err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + if err := s.cache.Resize(capacity); err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + data := struct { + Message string `json:"message"` + }{fmt.Sprintf("Changed cache capacity to %d.", capacity)} + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return internalServerError(err).AsJSON() + } + w.Header().Set("Content-Type", jsonMediaType) + w.Write(b) + return nil +} + +func (s *Server) cacheHandler(w http.ResponseWriter, r *http.Request) *appError { + cacheStats := s.cache.Stats() + var data = struct { + Size int `json:"size"` + Capacity int `json:"capacity"` + Evictions uint64 `json:"evictions"` + }{ + cacheStats.Size, + cacheStats.Capacity, + cacheStats.Evictions, + } + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return internalServerError(err).AsJSON() + } + w.Header().Set("Content-Type", jsonMediaType) + w.Write(b) + return nil +} + +func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := s.newResponse(r) + if err != nil { + return badRequest(err).WithMessage(err.Error()) + } + t, err := template.ParseGlob(s.Template + "/*") + if err != nil { + return internalServerError(err) + } + json, err := json.MarshalIndent(response, "", " ") + if err != nil { + return internalServerError(err) + } + + var data = struct { + Response + Host string + BoxLatTop float64 + BoxLatBottom float64 + BoxLonLeft float64 + BoxLonRight float64 + JSON string + Port bool + Sponsor bool + }{ + response, + r.Host, + response.Latitude + 0.05, + response.Latitude - 0.05, + response.Longitude - 0.05, + response.Longitude + 0.05, + string(json), + s.LookupPort != nil, + s.Sponsor, + } + if err := t.Execute(w, &data); err != nil { + return internalServerError(err) + } + return nil +} + +func NotFoundHandler(w http.ResponseWriter, r *http.Request) *appError { + err := notFound(nil).WithMessage("404 page not found") + if r.Header.Get("accept") == jsonMediaType { + err = err.AsJSON() + } + return err +} + +func cliMatcher(r *http.Request) bool { + ua := useragent.Parse(r.UserAgent()) + switch ua.Product { + case "curl", "HTTPie", "httpie-go", "Wget", "fetch libfetch", "Go", "Go-http-client", "ddclient", "Mikrotik", "xh": + return true + } + return false +} + +type appHandler func(http.ResponseWriter, *http.Request) *appError + +func wrapHandlerFunc(f http.HandlerFunc) appHandler { + return func(w http.ResponseWriter, r *http.Request) *appError { + f.ServeHTTP(w, r) + return nil + } +} + +func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if e := fn(w, r); e != nil { // e is *appError + if e.Code/100 == 5 { + log.Println(e.Error) + } + // When Content-Type for error is JSON, we need to marshal the response into JSON + if e.IsJSON() { + var data = struct { + Code int `json:"status"` + Error string `json:"error"` + }{e.Code, e.Message} + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + panic(err) + } + e.Message = string(b) + } + // Set Content-Type of response if set in error + if e.ContentType != "" { + w.Header().Set("Content-Type", e.ContentType) + } + w.WriteHeader(e.Code) + fmt.Fprint(w, e.Message) + } +} + +func (s *Server) Handler() http.Handler { + r := NewRouter() + + // Health + r.Route("GET", "/health", s.HealthHandler) + + // JSON + r.Route("GET", "/", s.JSONHandler).Header("Accept", jsonMediaType) + r.Route("GET", "/json", s.JSONHandler) + + // CLI + r.Route("GET", "/", s.CLIHandler).MatcherFunc(cliMatcher) + r.Route("GET", "/", s.CLIHandler).Header("Accept", textMediaType) + r.Route("GET", "/ip", s.CLIHandler) + if !s.gr.IsEmpty() { + r.Route("GET", "/country", s.CLICountryHandler) + r.Route("GET", "/country-iso", s.CLICountryISOHandler) + r.Route("GET", "/city", s.CLICityHandler) + r.Route("GET", "/coordinates", s.CLICoordinatesHandler) + r.Route("GET", "/asn", s.CLIASNHandler) + } + + // Browser + if s.Template != "" { + r.Route("GET", "/", s.DefaultHandler) + } + + // Port testing + if s.LookupPort != nil { + r.RoutePrefix("GET", "/port/", s.PortHandler) + } + + // Profiling + if s.profile { + r.Route("POST", "/debug/cache/resize", s.cacheResizeHandler) + r.Route("GET", "/debug/cache/", s.cacheHandler) + r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline)) + r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile)) + r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol)) + r.Route("GET", "/debug/pprof/trace", wrapHandlerFunc(pprof.Trace)) + r.RoutePrefix("GET", "/debug/pprof/", wrapHandlerFunc(pprof.Index)) + } + + return r.Handler() +} + +func (s *Server) ListenAndServe(addr string) error { + return http.ListenAndServe(addr, s.Handler()) +} + +func formatCoordinate(c float64) string { + return strconv.FormatFloat(c, 'f', 6, 64) +} diff --git a/http/http_test.go b/http/http_test.go new file mode 100644 index 0000000..29dcf1b --- /dev/null +++ b/http/http_test.go @@ -0,0 +1,279 @@ +package http + +import ( + "io/ioutil" + "log" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/mpolden/echoip/iputil/geo" +) + +func lookupAddr(net.IP) (string, error) { return "localhost", nil } +func lookupPort(net.IP, uint64) error { return nil } + +type testDb struct{} + +func (t *testDb) Country(net.IP) (geo.Country, error) { + return geo.Country{Name: "Elbonia", ISO: "EB", IsEU: new(bool)}, nil +} + +func (t *testDb) City(net.IP) (geo.City, error) { + return geo.City{Name: "Bornyasherk", RegionName: "North Elbonia", RegionCode: "1234", MetroCode: 1234, PostalCode: "1234", Latitude: 63.416667, Longitude: 10.416667, Timezone: "Europe/Bornyasherk"}, nil +} + +func (t *testDb) ASN(net.IP) (geo.ASN, error) { + return geo.ASN{AutonomousSystemNumber: 59795, AutonomousSystemOrganization: "Hosting4Real"}, nil +} + +func (t *testDb) IsEmpty() bool { return false } + +func testServer() *Server { + return &Server{cache: NewCache(100), gr: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort} +} + +func httpGet(url string, acceptMediaType string, userAgent string) (string, int, error) { + r, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", 0, err + } + if acceptMediaType != "" { + r.Header.Set("Accept", acceptMediaType) + } + r.Header.Set("User-Agent", userAgent) + res, err := http.DefaultClient.Do(r) + if err != nil { + return "", 0, err + } + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", 0, err + } + return string(data), res.StatusCode, nil +} + +func httpPost(url, body string) (*http.Response, string, error) { + r, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return nil, "", err + } + res, err := http.DefaultClient.Do(r) + if err != nil { + return nil, "", err + } + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, "", err + } + return res, string(data), nil +} + +func TestCLIHandlers(t *testing.T) { + log.SetOutput(ioutil.Discard) + s := httptest.NewServer(testServer().Handler()) + + var tests = []struct { + url string + out string + status int + userAgent string + acceptMediaType string + }{ + {s.URL, "127.0.0.1\n", 200, "curl/7.43.0", ""}, + {s.URL, "127.0.0.1\n", 200, "foo/bar", textMediaType}, + {s.URL + "/ip", "127.0.0.1\n", 200, "", ""}, + {s.URL + "/country", "Elbonia\n", 200, "", ""}, + {s.URL + "/country-iso", "EB\n", 200, "", ""}, + {s.URL + "/coordinates", "63.416667,10.416667\n", 200, "", ""}, + {s.URL + "/city", "Bornyasherk\n", 200, "", ""}, + {s.URL + "/foo", "404 page not found", 404, "", ""}, + {s.URL + "/asn", "AS59795\n", 200, "", ""}, + } + + for _, tt := range tests { + out, status, err := httpGet(tt.url, tt.acceptMediaType, tt.userAgent) + if err != nil { + t.Fatal(err) + } + if status != tt.status { + t.Errorf("Expected %d, got %d", tt.status, status) + } + if out != tt.out { + t.Errorf("Expected %q, got %q", tt.out, out) + } + } +} + +func TestDisabledHandlers(t *testing.T) { + log.SetOutput(ioutil.Discard) + server := testServer() + server.LookupPort = nil + server.LookupAddr = nil + server.gr, _ = geo.Open("", "", "") + s := httptest.NewServer(server.Handler()) + + var tests = []struct { + url string + out string + status int + }{ + {s.URL + "/port/1337", "404 page not found", 404}, + {s.URL + "/country", "404 page not found", 404}, + {s.URL + "/country-iso", "404 page not found", 404}, + {s.URL + "/city", "404 page not found", 404}, + {s.URL + "/json", "{\n \"ip\": \"127.0.0.1\",\n \"ip_decimal\": 2130706433\n}", 200}, + } + + for _, tt := range tests { + out, status, err := httpGet(tt.url, "", "") + if err != nil { + t.Fatal(err) + } + if status != tt.status { + t.Errorf("Expected %d, got %d", tt.status, status) + } + if out != tt.out { + t.Errorf("Expected %q, got %q", tt.out, out) + } + } +} + +func TestJSONHandlers(t *testing.T) { + log.SetOutput(ioutil.Discard) + s := httptest.NewServer(testServer().Handler()) + + var tests = []struct { + url string + out string + status int + }{ + {s.URL, "{\n \"ip\": \"127.0.0.1\",\n \"ip_decimal\": 2130706433,\n \"country\": \"Elbonia\",\n \"country_iso\": \"EB\",\n \"country_eu\": false,\n \"region_name\": \"North Elbonia\",\n \"region_code\": \"1234\",\n \"metro_code\": 1234,\n \"zip_code\": \"1234\",\n \"city\": \"Bornyasherk\",\n \"latitude\": 63.416667,\n \"longitude\": 10.416667,\n \"time_zone\": \"Europe/Bornyasherk\",\n \"asn\": \"AS59795\",\n \"asn_org\": \"Hosting4Real\",\n \"hostname\": \"localhost\",\n \"user_agent\": {\n \"product\": \"curl\",\n \"version\": \"7.2.6.0\",\n \"raw_value\": \"curl/7.2.6.0\"\n }\n}", 200}, + {s.URL + "/port/foo", "{\n \"status\": 400,\n \"error\": \"invalid port: foo\"\n}", 400}, + {s.URL + "/port/0", "{\n \"status\": 400,\n \"error\": \"invalid port: 0\"\n}", 400}, + {s.URL + "/port/65537", "{\n \"status\": 400,\n \"error\": \"invalid port: 65537\"\n}", 400}, + {s.URL + "/port/31337", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 31337,\n \"reachable\": true\n}", 200}, + {s.URL + "/port/80", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 80,\n \"reachable\": true\n}", 200}, // checking that our test server is reachable on port 80 + {s.URL + "/port/80?ip=1.3.3.7", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 80,\n \"reachable\": true\n}", 200}, // ensuring that the "ip" parameter is not usable to check remote host ports + {s.URL + "/foo", "{\n \"status\": 404,\n \"error\": \"404 page not found\"\n}", 404}, + {s.URL + "/health", `{"status":"OK"}`, 200}, + } + + for _, tt := range tests { + out, status, err := httpGet(tt.url, jsonMediaType, "curl/7.2.6.0") + if err != nil { + t.Fatal(err) + } + if status != tt.status { + t.Errorf("Expected %d for %s, got %d", tt.status, tt.url, status) + } + if out != tt.out { + t.Errorf("Expected %q for %s, got %q", tt.out, tt.url, out) + } + } +} + +func TestCacheHandler(t *testing.T) { + log.SetOutput(ioutil.Discard) + srv := testServer() + srv.profile = true + s := httptest.NewServer(srv.Handler()) + got, _, err := httpGet(s.URL+"/debug/cache/", jsonMediaType, "") + if err != nil { + t.Fatal(err) + } + want := "{\n \"size\": 0,\n \"capacity\": 100,\n \"evictions\": 0\n}" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestCacheResizeHandler(t *testing.T) { + log.SetOutput(ioutil.Discard) + srv := testServer() + srv.profile = true + s := httptest.NewServer(srv.Handler()) + _, got, err := httpPost(s.URL+"/debug/cache/resize", "10") + if err != nil { + t.Fatal(err) + } + want := "{\n \"message\": \"Changed cache capacity to 10.\"\n}" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestIPFromRequest(t *testing.T) { + var tests = []struct { + remoteAddr string + headerKey string + headerValue string + trustedHeaders []string + out string + }{ + {"127.0.0.1:9999", "", "", nil, "127.0.0.1"}, // No header given + {"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", nil, "127.0.0.1"}, // Trusted header is empty + {"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", []string{"X-Foo-Bar"}, "127.0.0.1"}, // Trusted header does not match + {"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", []string{"X-Real-IP", "X-Forwarded-For"}, "1.3.3.7"}, // Trusted header matches + {"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7", []string{"X-Real-IP", "X-Forwarded-For"}, "1.3.3.7"}, // Second trusted header matches + {"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7,4.2.4.2", []string{"X-Forwarded-For"}, "1.3.3.7"}, // X-Forwarded-For with multiple entries (commas separator) + {"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7, 4.2.4.2", []string{"X-Forwarded-For"}, "1.3.3.7"}, // X-Forwarded-For with multiple entries (space+comma separator) + {"127.0.0.1:9999", "X-Forwarded-For", "", []string{"X-Forwarded-For"}, "127.0.0.1"}, // Empty header + {"127.0.0.1:9999?ip=1.2.3.4", "", "", nil, "1.2.3.4"}, // passed in "ip" parameter + {"127.0.0.1:9999?ip=1.2.3.4", "X-Forwarded-For", "1.3.3.7,4.2.4.2", []string{"X-Forwarded-For"}, "1.2.3.4"}, // ip parameter wins over X-Forwarded-For with multiple entries + } + for _, tt := range tests { + u, err := url.Parse("http://" + tt.remoteAddr) + if err != nil { + t.Fatal(err) + } + r := &http.Request{ + RemoteAddr: u.Host, + Header: http.Header{}, + URL: u, + } + r.Header.Add(tt.headerKey, tt.headerValue) + ip, err := ipFromRequest(tt.trustedHeaders, r, true) + if err != nil { + t.Fatal(err) + } + out := net.ParseIP(tt.out) + if !ip.Equal(out) { + t.Errorf("Expected %s, got %s", out, ip) + } + } +} + +func TestCLIMatcher(t *testing.T) { + browserUserAgent := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + + "Safari/537.36" + var tests = []struct { + in string + out bool + }{ + {"curl/7.26.0", true}, + {"Wget/1.13.4 (linux-gnu)", true}, + {"Wget", true}, + {"fetch libfetch/2.0", true}, + {"HTTPie/0.9.3", true}, + {"httpie-go/0.6.0", true}, + {"Go 1.1 package http", true}, + {"Go-http-client/1.1", true}, + {"Go-http-client/2.0", true}, + {"ddclient/3.8.3", true}, + {"Mikrotik/6.x Fetch", true}, + {browserUserAgent, false}, + } + for _, tt := range tests { + r := &http.Request{Header: http.Header{"User-Agent": []string{tt.in}}} + if got := cliMatcher(r); got != tt.out { + t.Errorf("Expected %t, got %t for %q", tt.out, got, tt.in) + } + } +} diff --git a/http/router.go b/http/router.go new file mode 100644 index 0000000..dfff9f2 --- /dev/null +++ b/http/router.go @@ -0,0 +1,73 @@ +package http + +import ( + "net/http" + "strings" +) + +type router struct { + routes []*route +} + +type route struct { + method string + path string + prefix bool + handler appHandler + matcherFunc func(*http.Request) bool +} + +func NewRouter() *router { + return &router{} +} + +func (r *router) Route(method, path string, handler appHandler) *route { + route := route{ + method: method, + path: path, + handler: handler, + } + r.routes = append(r.routes, &route) + return &route +} + +func (r *router) RoutePrefix(method, path string, handler appHandler) *route { + route := r.Route(method, path, handler) + route.prefix = true + return route +} + +func (r *router) Handler() http.Handler { + return appHandler(func(w http.ResponseWriter, req *http.Request) *appError { + for _, route := range r.routes { + if route.match(req) { + return route.handler(w, req) + } + } + return NotFoundHandler(w, req) + }) +} + +func (r *route) Header(header, value string) { + r.MatcherFunc(func(req *http.Request) bool { + return req.Header.Get(header) == value + }) +} + +func (r *route) MatcherFunc(f func(*http.Request) bool) { + r.matcherFunc = f +} + +func (r *route) match(req *http.Request) bool { + if req.Method != r.method { + return false + } + if r.prefix { + if !strings.HasPrefix(req.URL.Path, r.path) { + return false + } + } else if r.path != req.URL.Path { + return false + } + return r.matcherFunc == nil || r.matcherFunc(req) +} diff --git a/iputil/geo/geo.go b/iputil/geo/geo.go new file mode 100644 index 0000000..4b73729 --- /dev/null +++ b/iputil/geo/geo.go @@ -0,0 +1,157 @@ +package geo + +import ( + "math" + "net" + + geoip2 "github.com/oschwald/geoip2-golang" +) + +type Reader interface { + Country(net.IP) (Country, error) + City(net.IP) (City, error) + ASN(net.IP) (ASN, error) + IsEmpty() bool +} + +type Country struct { + Name string + ISO string + IsEU *bool +} + +type City struct { + Name string + Latitude float64 + Longitude float64 + PostalCode string + Timezone string + MetroCode uint + RegionName string + RegionCode string +} + +type ASN struct { + AutonomousSystemNumber uint + AutonomousSystemOrganization string +} + +type geoip struct { + country *geoip2.Reader + city *geoip2.Reader + asn *geoip2.Reader +} + +func Open(countryDB, cityDB string, asnDB string) (Reader, error) { + var country, city, asn *geoip2.Reader + if countryDB != "" { + r, err := geoip2.Open(countryDB) + if err != nil { + return nil, err + } + country = r + } + if cityDB != "" { + r, err := geoip2.Open(cityDB) + if err != nil { + return nil, err + } + city = r + } + if asnDB != "" { + r, err := geoip2.Open(asnDB) + if err != nil { + return nil, err + } + asn = r + } + return &geoip{country: country, city: city, asn: asn}, nil +} + +func (g *geoip) Country(ip net.IP) (Country, error) { + country := Country{} + if g.country == nil { + return country, nil + } + record, err := g.country.Country(ip) + if err != nil { + return country, err + } + if c, exists := record.Country.Names["en"]; exists { + country.Name = c + } + if c, exists := record.RegisteredCountry.Names["en"]; exists && country.Name == "" { + country.Name = c + } + if record.Country.IsoCode != "" { + country.ISO = record.Country.IsoCode + } + if record.RegisteredCountry.IsoCode != "" && country.ISO == "" { + country.ISO = record.RegisteredCountry.IsoCode + } + isEU := record.Country.IsInEuropeanUnion || record.RegisteredCountry.IsInEuropeanUnion + country.IsEU = &isEU + return country, nil +} + +func (g *geoip) City(ip net.IP) (City, error) { + city := City{} + if g.city == nil { + return city, nil + } + record, err := g.city.City(ip) + if err != nil { + return city, err + } + if c, exists := record.City.Names["en"]; exists { + city.Name = c + } + if len(record.Subdivisions) > 0 { + if c, exists := record.Subdivisions[0].Names["en"]; exists { + city.RegionName = c + } + if record.Subdivisions[0].IsoCode != "" { + city.RegionCode = record.Subdivisions[0].IsoCode + } + } + if !math.IsNaN(record.Location.Latitude) { + city.Latitude = record.Location.Latitude + } + if !math.IsNaN(record.Location.Longitude) { + city.Longitude = record.Location.Longitude + } + // Metro code is US Only https://maxmind.github.io/GeoIP2-dotnet/doc/v2.7.1/html/P_MaxMind_GeoIP2_Model_Location_MetroCode.htm + if record.Location.MetroCode > 0 && record.Country.IsoCode == "US" { + city.MetroCode = record.Location.MetroCode + } + if record.Postal.Code != "" { + city.PostalCode = record.Postal.Code + } + if record.Location.TimeZone != "" { + city.Timezone = record.Location.TimeZone + } + + return city, nil +} + +func (g *geoip) ASN(ip net.IP) (ASN, error) { + asn := ASN{} + if g.asn == nil { + return asn, nil + } + record, err := g.asn.ASN(ip) + if err != nil { + return asn, err + } + if record.AutonomousSystemNumber > 0 { + asn.AutonomousSystemNumber = record.AutonomousSystemNumber + } + if record.AutonomousSystemOrganization != "" { + asn.AutonomousSystemOrganization = record.AutonomousSystemOrganization + } + return asn, nil +} + +func (g *geoip) IsEmpty() bool { + return g.country == nil && g.city == nil +} diff --git a/iputil/iputil.go b/iputil/iputil.go new file mode 100644 index 0000000..b26bb47 --- /dev/null +++ b/iputil/iputil.go @@ -0,0 +1,38 @@ +package iputil + +import ( + "fmt" + "math/big" + "net" + "strings" + "time" +) + +func LookupAddr(ip net.IP) (string, error) { + names, err := net.LookupAddr(ip.String()) + if err != nil || len(names) == 0 { + return "", err + } + // Always return unrooted name + return strings.TrimRight(names[0], "."), nil +} + +func LookupPort(ip net.IP, port uint64) error { + address := fmt.Sprintf("[%s]:%d", ip, port) + conn, err := net.DialTimeout("tcp", address, 2*time.Second) + if err != nil { + return err + } + defer conn.Close() + return nil +} + +func ToDecimal(ip net.IP) *big.Int { + i := big.NewInt(0) + if to4 := ip.To4(); to4 != nil { + i.SetBytes(to4) + } else { + i.SetBytes(ip) + } + return i +} diff --git a/iputil/iputil_test.go b/iputil/iputil_test.go new file mode 100644 index 0000000..6ad5e07 --- /dev/null +++ b/iputil/iputil_test.go @@ -0,0 +1,27 @@ +package iputil + +import ( + "math/big" + "net" + "testing" +) + +func TestToDecimal(t *testing.T) { + var msb = new(big.Int) + msb, _ = msb.SetString("80000000000000000000000000000000", 16) + + var tests = []struct { + in string + out *big.Int + }{ + {"127.0.0.1", big.NewInt(2130706433)}, + {"::1", big.NewInt(1)}, + {"8000::", msb}, + } + for _, tt := range tests { + i := ToDecimal(net.ParseIP(tt.in)) + if tt.out.Cmp(i) != 0 { + t.Errorf("Expected %d, got %d for IP %s", tt.out, i, tt.in) + } + } +} diff --git a/useragent/useragent.go b/useragent/useragent.go new file mode 100644 index 0000000..ebac6c4 --- /dev/null +++ b/useragent/useragent.go @@ -0,0 +1,40 @@ +package useragent + +import ( + "strings" +) + +type UserAgent struct { + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Comment string `json:"comment,omitempty"` + RawValue string `json:"raw_value,omitempty"` +} + +func Parse(s string) UserAgent { + parts := strings.SplitN(s, "/", 2) + var version, comment string + if len(parts) > 1 { + // If first character is a number, treat it as version + if len(parts[1]) > 0 && parts[1][0] >= 48 && parts[1][0] <= 57 { + rest := strings.SplitN(parts[1], " ", 2) + version = rest[0] + if len(rest) > 1 { + comment = rest[1] + } + } else { + comment = parts[1] + } + } else { + parts = strings.SplitN(s, " ", 2) + if len(parts) > 1 { + comment = parts[1] + } + } + return UserAgent{ + Product: parts[0], + Version: version, + Comment: comment, + RawValue: s, + } +} diff --git a/useragent/useragent_test.go b/useragent/useragent_test.go new file mode 100644 index 0000000..19b6e45 --- /dev/null +++ b/useragent/useragent_test.go @@ -0,0 +1,39 @@ +package useragent + +import ( + "testing" +) + +func TestParse(t *testing.T) { + var tests = []struct { + in string + out UserAgent + }{ + {"", UserAgent{}}, + {"curl/", UserAgent{Product: "curl"}}, + {"curl/foo", UserAgent{Product: "curl", Comment: "foo"}}, + {"curl/7.26.0", UserAgent{Product: "curl", Version: "7.26.0"}}, + {"Wget/1.13.4 (linux-gnu)", UserAgent{Product: "Wget", Version: "1.13.4", Comment: "(linux-gnu)"}}, + {"Wget", UserAgent{Product: "Wget"}}, + {"fetch libfetch/2.0", UserAgent{Product: "fetch libfetch", Version: "2.0"}}, + {"Go 1.1 package http", UserAgent{Product: "Go", Comment: "1.1 package http"}}, + {"Mikrotik/6.x Fetch", UserAgent{Product: "Mikrotik", Version: "6.x", Comment: "Fetch"}}, + {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + + "Safari/537.36", UserAgent{Product: "Mozilla", Version: "5.0", Comment: "(Macintosh; Intel Mac OS X 10_8_4) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + + "Safari/537.36"}}, + } + for _, tt := range tests { + ua := Parse(tt.in) + if got := ua.Product; got != tt.out.Product { + t.Errorf("got Product=%q for %q, want %q", got, tt.in, tt.out.Product) + } + if got := ua.Version; got != tt.out.Version { + t.Errorf("got Version=%q for %q, want %q", got, tt.in, tt.out.Version) + } + if got := ua.Comment; got != tt.out.Comment { + t.Errorf("got Comment=%q for %q, want %q", got, tt.in, tt.out.Comment) + } + } +}