├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── echoip │ ├── main.go │ └── main_test.go ├── go.mod ├── go.sum ├── html ├── index.html ├── leafcloud-logo.svg ├── script.html └── styles.html ├── http ├── cache.go ├── cache_test.go ├── error.go ├── http.go ├── http_test.go └── router.go ├── iputil ├── geo │ └── geo.go ├── iputil.go └── iputil_test.go └── useragent ├── useragent.go └── useragent_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.geoip -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | env: 13 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 14 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: install go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.16 21 | - name: build and test 22 | run: make 23 | - name: enable experimental docker features 24 | if: ${{ github.ref == 'refs/heads/master' }} 25 | run: | 26 | echo '{"experimental":true}' | sudo tee /etc/docker/daemon.json 27 | sudo service docker restart 28 | - name: publish multi-arch docker image 29 | if: ${{ github.ref == 'refs/heads/master' }} 30 | run: make docker-pushx 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /data/ 2 | /custom.html 3 | /vendor/ 4 | .vscode/ 5 | /bin/ 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.15-buster AS build 3 | WORKDIR /go/src/github.com/mpolden/echoip 4 | COPY . . 5 | 6 | # Must build without cgo because libc is unavailable in runtime image 7 | ENV GO111MODULE=on CGO_ENABLED=0 8 | RUN make 9 | 10 | # Run 11 | FROM scratch 12 | EXPOSE 8080 13 | 14 | COPY --from=build /go/bin/echoip /opt/echoip/ 15 | COPY html /opt/echoip/html 16 | 17 | WORKDIR /opt/echoip 18 | ENTRYPOINT ["/opt/echoip/echoip"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2020, Martin Polden 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of the copyright holder nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 23 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER ?= docker 2 | DOCKER_IMAGE ?= mpolden/echoip 3 | OS := $(shell uname) 4 | ifeq ($(OS),Linux) 5 | TAR_OPTS := --wildcards 6 | endif 7 | XGOARCH := amd64 8 | XGOOS := linux 9 | XBIN := $(XGOOS)_$(XGOARCH)/echoip 10 | 11 | all: lint test install 12 | 13 | test: 14 | go test ./... 15 | 16 | vet: 17 | go vet ./... 18 | 19 | check-fmt: 20 | bash -c "diff --line-format='%L' <(echo -n) <(gofmt -d -s .)" 21 | 22 | lint: check-fmt vet 23 | 24 | install: 25 | go install ./... 26 | 27 | databases := GeoLite2-City GeoLite2-Country GeoLite2-ASN 28 | 29 | $(databases): 30 | ifndef GEOIP_LICENSE_KEY 31 | $(error GEOIP_LICENSE_KEY must be set. Please see https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/) 32 | endif 33 | mkdir -p data 34 | @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' 35 | test ! -f data/GeoLite2-City.mmdb || mv data/GeoLite2-City.mmdb data/city.mmdb 36 | test ! -f data/GeoLite2-Country.mmdb || mv data/GeoLite2-Country.mmdb data/country.mmdb 37 | test ! -f data/GeoLite2-ASN.mmdb || mv data/GeoLite2-ASN.mmdb data/asn.mmdb 38 | 39 | geoip-download: $(databases) 40 | 41 | # Create an environment to build multiarch containers (https://github.com/docker/buildx/) 42 | docker-multiarch-builder: 43 | DOCKER_BUILDKIT=1 $(DOCKER) build -o . git://github.com/docker/buildx 44 | mkdir -p ~/.docker/cli-plugins 45 | mv buildx ~/.docker/cli-plugins/docker-buildx 46 | $(DOCKER) buildx create --name multiarch-builder --node multiarch-builder --driver docker-container --use 47 | $(DOCKER) run --rm --privileged multiarch/qemu-user-static --reset -p yes 48 | 49 | docker-build: 50 | $(DOCKER) build -t $(DOCKER_IMAGE) . 51 | 52 | docker-login: 53 | @echo "$(DOCKER_PASSWORD)" | $(DOCKER) login -u "$(DOCKER_USERNAME)" --password-stdin 54 | 55 | docker-test: 56 | $(eval CONTAINER=$(shell $(DOCKER) run --rm --detach --publish-all $(DOCKER_IMAGE))) 57 | $(eval DOCKER_PORT=$(shell $(DOCKER) port $(CONTAINER) | cut -d ":" -f 2)) 58 | curl -fsS -m 5 localhost:$(DOCKER_PORT) > /dev/null; $(DOCKER) stop $(CONTAINER) 59 | 60 | docker-push: docker-test docker-login 61 | $(DOCKER) push $(DOCKER_IMAGE) 62 | 63 | docker-pushx: docker-multiarch-builder docker-test docker-login 64 | $(DOCKER) buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t $(DOCKER_IMAGE) --push . 65 | 66 | xinstall: 67 | env GOOS=$(XGOOS) GOARCH=$(XGOARCH) go install ./... 68 | 69 | publish: 70 | ifndef DEST_PATH 71 | $(error DEST_PATH must be set when publishing) 72 | endif 73 | rsync -a $(GOPATH)/bin/$(XBIN) $(DEST_PATH)/$(XBIN) 74 | @sha256sum $(GOPATH)/bin/$(XBIN) 75 | 76 | run: 77 | 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 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # echoip 2 | 3 | ![Build Status](https://github.com/mpolden/echoip/workflows/ci/badge.svg) 4 | 5 | A simple service for looking up your IP address. This is the code that powers 6 | https://ifconfig.co. 7 | 8 | ## Usage 9 | 10 | Just the business, please: 11 | 12 | ``` 13 | $ curl ifconfig.co 14 | 127.0.0.1 15 | 16 | $ http ifconfig.co 17 | 127.0.0.1 18 | 19 | $ wget -qO- ifconfig.co 20 | 127.0.0.1 21 | 22 | $ fetch -qo- https://ifconfig.co 23 | 127.0.0.1 24 | 25 | $ bat -print=b ifconfig.co/ip 26 | 127.0.0.1 27 | ``` 28 | 29 | Country and city lookup: 30 | 31 | ``` 32 | $ curl ifconfig.co/country 33 | Elbonia 34 | 35 | $ curl ifconfig.co/country-iso 36 | EB 37 | 38 | $ curl ifconfig.co/city 39 | Bornyasherk 40 | 41 | $ curl ifconfig.co/asn 42 | AS59795 43 | 44 | $ curl ifconfig.co/asn-org 45 | Hosting4Real 46 | ``` 47 | 48 | As JSON: 49 | 50 | ``` 51 | $ curl -H 'Accept: application/json' ifconfig.co # or curl ifconfig.co/json 52 | { 53 | "city": "Bornyasherk", 54 | "country": "Elbonia", 55 | "country_iso": "EB", 56 | "ip": "127.0.0.1", 57 | "ip_decimal": 2130706433, 58 | "asn": "AS59795", 59 | "asn_org": "Hosting4Real" 60 | } 61 | ``` 62 | 63 | Port testing: 64 | 65 | ``` 66 | $ curl ifconfig.co/port/80 67 | { 68 | "ip": "127.0.0.1", 69 | "port": 80, 70 | "reachable": false 71 | } 72 | ``` 73 | 74 | Pass the appropriate flag (usually `-4` and `-6`) to your client to switch 75 | between IPv4 and IPv6 lookup. 76 | 77 | ## Features 78 | 79 | * Easy to remember domain name 80 | * Fast 81 | * Supports IPv6 82 | * Supports HTTPS 83 | * Supports common command-line clients (e.g. `curl`, `httpie`, `ht`, `wget` and `fetch`) 84 | * JSON output 85 | * ASN, country and city lookup using the MaxMind GeoIP database 86 | * Port testing 87 | * All endpoints (except `/port`) can return information about a custom IP address specified via `?ip=` query parameter 88 | * Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause) 89 | 90 | ## Why? 91 | 92 | * To scratch an itch 93 | * An excuse to use Go for something 94 | * Faster than ifconfig.me and has IPv6 support 95 | 96 | ## Building 97 | 98 | Compiling requires the [Golang compiler](https://golang.org/) to be installed. 99 | This package can be installed with: 100 | 101 | `go install github.com/mpolden/echoip/...@latest` 102 | 103 | For more information on building a Go project, see the [official Go 104 | documentation](https://golang.org/doc/code.html). 105 | 106 | ## Docker image 107 | 108 | A Docker image is available on [Docker 109 | Hub](https://hub.docker.com/r/mpolden/echoip), which can be downloaded with: 110 | 111 | `docker pull mpolden/echoip` 112 | 113 | ## [GeoIP](https://www.maxmind.com/en/geoip2-databases)/[GeoLite](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data?) Database (MaxMind) 114 | To utilise MaxMind [GeoIP](https://www.maxmind.com/en/geoip2-databases)/[GeoLite](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data?) database to enhance the information provided to end users, you can download the relevant **binary** databases (`.mmdb` format) directly from MaxMind using the above links. 115 | 116 | **Please Note**: This has only been tested using the free, GeoLite database. 117 | 118 | ### Usage 119 | 120 | ``` 121 | $ echoip -h 122 | Usage of echoip: 123 | -C int 124 | Size of response cache. Set to 0 to disable 125 | -H value 126 | Header to trust for remote IP, if present (e.g. X-Real-IP) 127 | -a string 128 | Path to GeoIP ASN database 129 | -c string 130 | Path to GeoIP city database 131 | -f string 132 | Path to GeoIP country database 133 | -l string 134 | Listening address (default ":8080") 135 | -p Enable port lookup 136 | -r Perform reverse hostname lookups 137 | -t string 138 | Path to template directory (default "html") 139 | ``` 140 | -------------------------------------------------------------------------------- /cmd/echoip/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "strings" 7 | 8 | "os" 9 | 10 | "github.com/mpolden/echoip/http" 11 | "github.com/mpolden/echoip/iputil" 12 | "github.com/mpolden/echoip/iputil/geo" 13 | ) 14 | 15 | type multiValueFlag []string 16 | 17 | func (f *multiValueFlag) String() string { 18 | return strings.Join([]string(*f), ", ") 19 | } 20 | 21 | func (f *multiValueFlag) Set(v string) error { 22 | *f = append(*f, v) 23 | return nil 24 | } 25 | 26 | func init() { 27 | log.SetPrefix("echoip: ") 28 | log.SetFlags(log.Lshortfile) 29 | } 30 | 31 | func main() { 32 | countryFile := flag.String("f", "", "Path to GeoIP country database") 33 | cityFile := flag.String("c", "", "Path to GeoIP city database") 34 | asnFile := flag.String("a", "", "Path to GeoIP ASN database") 35 | listen := flag.String("l", ":8080", "Listening address") 36 | reverseLookup := flag.Bool("r", false, "Perform reverse hostname lookups") 37 | portLookup := flag.Bool("p", false, "Enable port lookup") 38 | template := flag.String("t", "html", "Path to template dir") 39 | cacheSize := flag.Int("C", 0, "Size of response cache. Set to 0 to disable") 40 | profile := flag.Bool("P", false, "Enables profiling handlers") 41 | sponsor := flag.Bool("s", false, "Show sponsor logo") 42 | var headers multiValueFlag 43 | flag.Var(&headers, "H", "Header to trust for remote IP, if present (e.g. X-Real-IP)") 44 | flag.Parse() 45 | if len(flag.Args()) != 0 { 46 | flag.Usage() 47 | return 48 | } 49 | 50 | r, err := geo.Open(*countryFile, *cityFile, *asnFile) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | cache := http.NewCache(*cacheSize) 55 | server := http.New(r, cache, *profile) 56 | server.IPHeaders = headers 57 | if _, err := os.Stat(*template); err == nil { 58 | server.Template = *template 59 | } else { 60 | log.Printf("Not configuring default handler: Template not found: %s", *template) 61 | } 62 | if *reverseLookup { 63 | log.Println("Enabling reverse lookup") 64 | server.LookupAddr = iputil.LookupAddr 65 | } 66 | if *portLookup { 67 | log.Println("Enabling port lookup") 68 | server.LookupPort = iputil.LookupPort 69 | } 70 | if *sponsor { 71 | log.Println("Enabling sponsor logo") 72 | server.Sponsor = *sponsor 73 | } 74 | if len(headers) > 0 { 75 | log.Printf("Trusting remote IP from header(s): %s", headers.String()) 76 | } 77 | if *cacheSize > 0 { 78 | log.Printf("Cache capacity set to %d", *cacheSize) 79 | } 80 | if *profile { 81 | log.Printf("Enabling profiling handlers") 82 | } 83 | log.Printf("Listening on http://%s", *listen) 84 | if err := server.ListenAndServe(*listen); err != nil { 85 | log.Fatal(err) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/echoip/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestMultiValueFlagString(t *testing.T) { 6 | var xmvf = []struct { 7 | values multiValueFlag 8 | expect string 9 | }{ 10 | { 11 | values: multiValueFlag{ 12 | "test", 13 | "with multiples", 14 | "flags", 15 | }, 16 | expect: `test, with multiples, flags`, 17 | }, 18 | { 19 | values: multiValueFlag{ 20 | "test", 21 | }, 22 | expect: `test`, 23 | }, 24 | { 25 | values: multiValueFlag{ 26 | "", 27 | }, 28 | expect: ``, 29 | }, 30 | { 31 | values: nil, 32 | expect: ``, 33 | }, 34 | } 35 | 36 | for _, mvf := range xmvf { 37 | got := mvf.values.String() 38 | if got != mvf.expect { 39 | t.Errorf("\nFor: %#v\nExpected: %v\nGot: %v", mvf.values, mvf.expect, got) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mpolden/echoip 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/oschwald/geoip2-golang v1.5.0 7 | golang.org/x/sys v0.0.0-20210223212115-eede4237b368 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lVJ1ONZzw= 4 | github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s= 5 | github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk= 6 | github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 12 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g= 14 | golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/sys v0.0.0-20210223212115-eede4237b368 h1:fDE3p0qf2V1co1vfj3/o87Ps8Hq6QTGNxJ5Xe7xSp80= 16 | golang.org/x/sys v0.0.0-20210223212115-eede4237b368/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 20 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | What is my IP address? — {{ .Host }} 6 | 7 | 11 | 12 | 13 | 17 | 23 | 29 | 30 | {{ template "script.html" . }} {{ template "styles.html" . }} 31 | 32 | 33 |
34 |
35 |
36 |
37 |

{{ .Host }} — What is my IP address?

38 |

{{ .IP }}

39 |

40 | The best tool to find your own IP address, and information about 41 | it. 42 |

43 |
44 |
45 |
46 | {{ if .Sponsor }} 47 |
48 |
49 | 56 |
57 |

58 | This site is graciously hosted by
59 | 60 | Leafcloud – The Truly Sustainable Cloud 61 | 62 |

63 |
64 |
65 |
66 | {{ end }} 67 |
68 |
69 | 70 |
71 | 72 |
73 |
74 |

What do we know about this IP address?

75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {{ if .Country }} 85 | 86 | 87 | 88 | 89 | {{ end }} {{ if .CountryISO }} 90 | 91 | 92 | 93 | 94 | {{ end }} {{ if .CountryEU }} 95 | 96 | 97 | 98 | 99 | {{ end }} {{ if .RegionName }} 100 | 101 | 102 | 103 | 104 | {{ end }} {{ if .RegionCode }} 105 | 106 | 107 | 108 | 109 | {{ end }} {{ if .MetroCode }} 110 | 111 | 112 | 113 | 114 | {{ end }} {{ if .PostalCode }} 115 | 116 | 117 | 118 | 119 | {{ end }} {{ if .City }} 120 | 121 | 122 | 123 | 124 | {{ end }} {{ if .Latitude }} 125 | 126 | 127 | 128 | 129 | {{ end }} {{ if .Longitude }} 130 | 131 | 132 | 133 | 134 | {{ end }} {{ if .Timezone }} 135 | 136 | 137 | 138 | 139 | {{ end }} {{ if .ASN }} 140 | 141 | 142 | 143 | 144 | {{ end }} {{ if .ASNOrg }} 145 | 146 | 147 | 148 | 149 | {{ end }} {{ if .Hostname }} 150 | 151 | 152 | 153 | 154 | {{ end }} {{ if .UserAgent }} {{ if .UserAgent.Comment }} 155 | 156 | 157 | 158 | 159 | {{ end }} {{ if .UserAgent.Comment }} 160 | 161 | 162 | 163 | 164 | {{ end }} {{ if .UserAgent.RawValue }} 165 | 166 | 167 | 168 | 169 | {{ end }} {{ end }} 170 |
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 }}
171 | {{ if .Country }} 172 |

173 | This information is provided from the GeoLite2 database created by 174 | MaxMind, available from 175 | www.maxmind.com 176 |

177 | {{ end }} {{ if .Latitude }} 178 |
179 |

Map

180 | 189 |
190 | {{ end }} 191 |
192 |
193 | 194 |
195 |
196 |

How do I get this programmatically?

197 |

198 | With the widget below you can build your query, and see what the 199 | result will look like. 200 |

201 |
202 | 203 |
204 | 211 | 218 | 225 | 232 | 239 | 246 | 253 | 263 |
264 |
265 | 266 |
267 |
268 | 269 |
270 |
271 | 281 | 288 |
289 |
290 |
291 | 292 | 293 |
294 |

FAQ

295 |

How do I force IPv4 or IPv6 lookup?

296 |

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

303 |

Can I force getting JSON?

304 |

305 | Setting the Accept: application/json header works 306 | as expected. 307 |

308 | 309 |

Is automated use of this service permitted?

310 |

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

314 |

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

320 | 321 |

Can I run my own service?

322 |

323 | Yes, the source code and documentation is available on 324 | GitHub. 325 |

326 |
327 |
328 |
329 |
330 |
331 | 332 | 333 | -------------------------------------------------------------------------------- /html/leafcloud-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /html/script.html: -------------------------------------------------------------------------------- 1 | 89 | -------------------------------------------------------------------------------- /html/styles.html: -------------------------------------------------------------------------------- 1 | 185 | -------------------------------------------------------------------------------- /http/cache.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "container/list" 5 | "fmt" 6 | "hash/fnv" 7 | "net" 8 | "sync" 9 | ) 10 | 11 | type Cache struct { 12 | capacity int 13 | mu sync.RWMutex 14 | entries map[uint64]*list.Element 15 | values *list.List 16 | evictions uint64 17 | } 18 | 19 | type CacheStats struct { 20 | Capacity int 21 | Size int 22 | Evictions uint64 23 | } 24 | 25 | func NewCache(capacity int) *Cache { 26 | if capacity < 0 { 27 | capacity = 0 28 | } 29 | return &Cache{ 30 | capacity: capacity, 31 | entries: make(map[uint64]*list.Element), 32 | values: list.New(), 33 | } 34 | } 35 | 36 | func key(ip net.IP) uint64 { 37 | h := fnv.New64a() 38 | h.Write(ip) 39 | return h.Sum64() 40 | } 41 | 42 | func (c *Cache) Set(ip net.IP, resp Response) { 43 | if c.capacity == 0 { 44 | return 45 | } 46 | k := key(ip) 47 | c.mu.Lock() 48 | defer c.mu.Unlock() 49 | minEvictions := len(c.entries) - c.capacity + 1 50 | if minEvictions > 0 { // At or above capacity. Shrink the cache 51 | evicted := 0 52 | for el := c.values.Front(); el != nil && evicted < minEvictions; { 53 | value := el.Value.(Response) 54 | delete(c.entries, key(value.IP)) 55 | next := el.Next() 56 | c.values.Remove(el) 57 | el = next 58 | evicted++ 59 | } 60 | c.evictions += uint64(evicted) 61 | } 62 | current, ok := c.entries[k] 63 | if ok { 64 | c.values.Remove(current) 65 | } 66 | c.entries[k] = c.values.PushBack(resp) 67 | } 68 | 69 | func (c *Cache) Get(ip net.IP) (Response, bool) { 70 | k := key(ip) 71 | c.mu.RLock() 72 | defer c.mu.RUnlock() 73 | r, ok := c.entries[k] 74 | if !ok { 75 | return Response{}, false 76 | } 77 | return r.Value.(Response), true 78 | } 79 | 80 | func (c *Cache) Resize(capacity int) error { 81 | if capacity < 0 { 82 | return fmt.Errorf("invalid capacity: %d\n", capacity) 83 | } 84 | c.mu.Lock() 85 | defer c.mu.Unlock() 86 | c.capacity = capacity 87 | c.evictions = 0 88 | return nil 89 | } 90 | 91 | func (c *Cache) Stats() CacheStats { 92 | c.mu.RLock() 93 | defer c.mu.RUnlock() 94 | return CacheStats{ 95 | Size: len(c.entries), 96 | Capacity: c.capacity, 97 | Evictions: c.evictions, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /http/cache_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "testing" 7 | ) 8 | 9 | func TestCacheCapacity(t *testing.T) { 10 | var tests = []struct { 11 | addCount, capacity, size int 12 | evictions uint64 13 | }{ 14 | {1, 0, 0, 0}, 15 | {1, 2, 1, 0}, 16 | {2, 2, 2, 0}, 17 | {3, 2, 2, 1}, 18 | {10, 5, 5, 5}, 19 | } 20 | for i, tt := range tests { 21 | c := NewCache(tt.capacity) 22 | var responses []Response 23 | for i := 0; i < tt.addCount; i++ { 24 | ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i)) 25 | r := Response{IP: ip} 26 | responses = append(responses, r) 27 | c.Set(ip, r) 28 | } 29 | if got := len(c.entries); got != tt.size { 30 | t.Errorf("#%d: len(entries) = %d, want %d", i, got, tt.size) 31 | } 32 | if got := c.evictions; got != tt.evictions { 33 | t.Errorf("#%d: evictions = %d, want %d", i, got, tt.evictions) 34 | } 35 | if tt.capacity > 0 && tt.addCount > tt.capacity && tt.capacity == tt.size { 36 | lastAdded := responses[tt.addCount-1] 37 | if _, ok := c.Get(lastAdded.IP); !ok { 38 | t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, lastAdded.IP.String(), ok, !ok) 39 | } 40 | firstAdded := responses[0] 41 | if _, ok := c.Get(firstAdded.IP); ok { 42 | t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, firstAdded.IP.String(), ok, !ok) 43 | } 44 | } 45 | } 46 | } 47 | 48 | func TestCacheDuplicate(t *testing.T) { 49 | c := NewCache(10) 50 | ip := net.ParseIP("192.0.2.1") 51 | response := Response{IP: ip} 52 | c.Set(ip, response) 53 | c.Set(ip, response) 54 | want := 1 55 | if got := len(c.entries); got != want { 56 | t.Errorf("want %d entries, got %d", want, got) 57 | } 58 | if got := c.values.Len(); got != want { 59 | t.Errorf("want %d values, got %d", want, got) 60 | } 61 | } 62 | 63 | func TestCacheResize(t *testing.T) { 64 | c := NewCache(10) 65 | for i := 1; i <= 20; i++ { 66 | ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i)) 67 | r := Response{IP: ip} 68 | c.Set(ip, r) 69 | } 70 | if got, want := len(c.entries), 10; got != want { 71 | t.Errorf("want %d entries, got %d", want, got) 72 | } 73 | if got, want := c.evictions, uint64(10); got != want { 74 | t.Errorf("want %d evictions, got %d", want, got) 75 | } 76 | if err := c.Resize(5); err != nil { 77 | t.Fatal(err) 78 | } 79 | if got, want := c.evictions, uint64(0); got != want { 80 | t.Errorf("want %d evictions, got %d", want, got) 81 | } 82 | r := Response{IP: net.ParseIP("192.0.2.42")} 83 | c.Set(r.IP, r) 84 | if got, want := len(c.entries), 5; got != want { 85 | t.Errorf("want %d entries, got %d", want, got) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /http/error.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "net/http" 4 | 5 | type appError struct { 6 | Error error 7 | Message string 8 | Code int 9 | ContentType string 10 | } 11 | 12 | func internalServerError(err error) *appError { 13 | return &appError{ 14 | Error: err, 15 | Message: "Internal server error", 16 | Code: http.StatusInternalServerError, 17 | } 18 | } 19 | 20 | func notFound(err error) *appError { 21 | return &appError{Error: err, Code: http.StatusNotFound} 22 | } 23 | 24 | func badRequest(err error) *appError { 25 | return &appError{Error: err, Code: http.StatusBadRequest} 26 | } 27 | 28 | func (e *appError) AsJSON() *appError { 29 | e.ContentType = jsonMediaType 30 | return e 31 | } 32 | 33 | func (e *appError) WithMessage(message string) *appError { 34 | e.Message = message 35 | return e 36 | } 37 | 38 | func (e *appError) IsJSON() bool { 39 | return e.ContentType == jsonMediaType 40 | } 41 | -------------------------------------------------------------------------------- /http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "io/ioutil" 8 | "log" 9 | "path/filepath" 10 | "strings" 11 | 12 | "net/http/pprof" 13 | 14 | "github.com/mpolden/echoip/iputil" 15 | "github.com/mpolden/echoip/iputil/geo" 16 | "github.com/mpolden/echoip/useragent" 17 | 18 | "math/big" 19 | "net" 20 | "net/http" 21 | "strconv" 22 | ) 23 | 24 | const ( 25 | jsonMediaType = "application/json" 26 | textMediaType = "text/plain" 27 | ) 28 | 29 | type Server struct { 30 | Template string 31 | IPHeaders []string 32 | LookupAddr func(net.IP) (string, error) 33 | LookupPort func(net.IP, uint64) error 34 | cache *Cache 35 | gr geo.Reader 36 | profile bool 37 | Sponsor bool 38 | } 39 | 40 | type Response struct { 41 | IP net.IP `json:"ip"` 42 | IPDecimal *big.Int `json:"ip_decimal"` 43 | Country string `json:"country,omitempty"` 44 | CountryISO string `json:"country_iso,omitempty"` 45 | CountryEU *bool `json:"country_eu,omitempty"` 46 | RegionName string `json:"region_name,omitempty"` 47 | RegionCode string `json:"region_code,omitempty"` 48 | MetroCode uint `json:"metro_code,omitempty"` 49 | PostalCode string `json:"zip_code,omitempty"` 50 | City string `json:"city,omitempty"` 51 | Latitude float64 `json:"latitude,omitempty"` 52 | Longitude float64 `json:"longitude,omitempty"` 53 | Timezone string `json:"time_zone,omitempty"` 54 | ASN string `json:"asn,omitempty"` 55 | ASNOrg string `json:"asn_org,omitempty"` 56 | Hostname string `json:"hostname,omitempty"` 57 | UserAgent *useragent.UserAgent `json:"user_agent,omitempty"` 58 | } 59 | 60 | type PortResponse struct { 61 | IP net.IP `json:"ip"` 62 | Port uint64 `json:"port"` 63 | Reachable bool `json:"reachable"` 64 | } 65 | 66 | func New(db geo.Reader, cache *Cache, profile bool) *Server { 67 | return &Server{cache: cache, gr: db, profile: profile} 68 | } 69 | 70 | func ipFromForwardedForHeader(v string) string { 71 | sep := strings.Index(v, ",") 72 | if sep == -1 { 73 | return v 74 | } 75 | return v[:sep] 76 | } 77 | 78 | // ipFromRequest detects the IP address for this transaction. 79 | // 80 | // * `headers` - the specific HTTP headers to trust 81 | // * `r` - the incoming HTTP request 82 | // * `customIP` - whether to allow the IP to be pulled from query parameters 83 | func ipFromRequest(headers []string, r *http.Request, customIP bool) (net.IP, error) { 84 | remoteIP := "" 85 | if customIP && r.URL != nil { 86 | if v, ok := r.URL.Query()["ip"]; ok { 87 | remoteIP = v[0] 88 | } 89 | } 90 | if remoteIP == "" { 91 | for _, header := range headers { 92 | remoteIP = r.Header.Get(header) 93 | if http.CanonicalHeaderKey(header) == "X-Forwarded-For" { 94 | remoteIP = ipFromForwardedForHeader(remoteIP) 95 | } 96 | if remoteIP != "" { 97 | break 98 | } 99 | } 100 | } 101 | if remoteIP == "" { 102 | host, _, err := net.SplitHostPort(r.RemoteAddr) 103 | if err != nil { 104 | return nil, err 105 | } 106 | remoteIP = host 107 | } 108 | ip := net.ParseIP(remoteIP) 109 | if ip == nil { 110 | return nil, fmt.Errorf("could not parse IP: %s", remoteIP) 111 | } 112 | return ip, nil 113 | } 114 | 115 | func userAgentFromRequest(r *http.Request) *useragent.UserAgent { 116 | var userAgent *useragent.UserAgent 117 | userAgentRaw := r.UserAgent() 118 | if userAgentRaw != "" { 119 | parsed := useragent.Parse(userAgentRaw) 120 | userAgent = &parsed 121 | } 122 | return userAgent 123 | } 124 | 125 | func (s *Server) newResponse(r *http.Request) (Response, error) { 126 | ip, err := ipFromRequest(s.IPHeaders, r, true) 127 | if err != nil { 128 | return Response{}, err 129 | } 130 | response, ok := s.cache.Get(ip) 131 | if ok { 132 | // Do not cache user agent 133 | response.UserAgent = userAgentFromRequest(r) 134 | return response, nil 135 | } 136 | ipDecimal := iputil.ToDecimal(ip) 137 | country, _ := s.gr.Country(ip) 138 | city, _ := s.gr.City(ip) 139 | asn, _ := s.gr.ASN(ip) 140 | var hostname string 141 | if s.LookupAddr != nil { 142 | hostname, _ = s.LookupAddr(ip) 143 | } 144 | var autonomousSystemNumber string 145 | if asn.AutonomousSystemNumber > 0 { 146 | autonomousSystemNumber = fmt.Sprintf("AS%d", asn.AutonomousSystemNumber) 147 | } 148 | response = Response{ 149 | IP: ip, 150 | IPDecimal: ipDecimal, 151 | Country: country.Name, 152 | CountryISO: country.ISO, 153 | CountryEU: country.IsEU, 154 | RegionName: city.RegionName, 155 | RegionCode: city.RegionCode, 156 | MetroCode: city.MetroCode, 157 | PostalCode: city.PostalCode, 158 | City: city.Name, 159 | Latitude: city.Latitude, 160 | Longitude: city.Longitude, 161 | Timezone: city.Timezone, 162 | ASN: autonomousSystemNumber, 163 | ASNOrg: asn.AutonomousSystemOrganization, 164 | Hostname: hostname, 165 | } 166 | s.cache.Set(ip, response) 167 | response.UserAgent = userAgentFromRequest(r) 168 | return response, nil 169 | } 170 | 171 | func (s *Server) newPortResponse(r *http.Request) (PortResponse, error) { 172 | lastElement := filepath.Base(r.URL.Path) 173 | port, err := strconv.ParseUint(lastElement, 10, 16) 174 | if err != nil || port < 1 || port > 65535 { 175 | return PortResponse{Port: port}, fmt.Errorf("invalid port: %s", lastElement) 176 | } 177 | ip, err := ipFromRequest(s.IPHeaders, r, false) 178 | if err != nil { 179 | return PortResponse{Port: port}, err 180 | } 181 | err = s.LookupPort(ip, port) 182 | return PortResponse{ 183 | IP: ip, 184 | Port: port, 185 | Reachable: err == nil, 186 | }, nil 187 | } 188 | 189 | func (s *Server) CLIHandler(w http.ResponseWriter, r *http.Request) *appError { 190 | ip, err := ipFromRequest(s.IPHeaders, r, true) 191 | if err != nil { 192 | return badRequest(err).WithMessage(err.Error()).AsJSON() 193 | } 194 | fmt.Fprintln(w, ip.String()) 195 | return nil 196 | } 197 | 198 | func (s *Server) CLICountryHandler(w http.ResponseWriter, r *http.Request) *appError { 199 | response, err := s.newResponse(r) 200 | if err != nil { 201 | return badRequest(err).WithMessage(err.Error()).AsJSON() 202 | } 203 | fmt.Fprintln(w, response.Country) 204 | return nil 205 | } 206 | 207 | func (s *Server) CLICountryISOHandler(w http.ResponseWriter, r *http.Request) *appError { 208 | response, err := s.newResponse(r) 209 | if err != nil { 210 | return badRequest(err).WithMessage(err.Error()).AsJSON() 211 | } 212 | fmt.Fprintln(w, response.CountryISO) 213 | return nil 214 | } 215 | 216 | func (s *Server) CLICityHandler(w http.ResponseWriter, r *http.Request) *appError { 217 | response, err := s.newResponse(r) 218 | if err != nil { 219 | return badRequest(err).WithMessage(err.Error()).AsJSON() 220 | } 221 | fmt.Fprintln(w, response.City) 222 | return nil 223 | } 224 | 225 | func (s *Server) CLICoordinatesHandler(w http.ResponseWriter, r *http.Request) *appError { 226 | response, err := s.newResponse(r) 227 | if err != nil { 228 | return badRequest(err).WithMessage(err.Error()).AsJSON() 229 | } 230 | fmt.Fprintf(w, "%s,%s\n", formatCoordinate(response.Latitude), formatCoordinate(response.Longitude)) 231 | return nil 232 | } 233 | 234 | func (s *Server) CLIASNHandler(w http.ResponseWriter, r *http.Request) *appError { 235 | response, err := s.newResponse(r) 236 | if err != nil { 237 | return badRequest(err).WithMessage(err.Error()).AsJSON() 238 | } 239 | fmt.Fprintf(w, "%s\n", response.ASN) 240 | return nil 241 | } 242 | 243 | func (s *Server) CLIASNOrgHandler(w http.ResponseWriter, r *http.Request) *appError { 244 | response, err := s.newResponse(r) 245 | if err != nil { 246 | return badRequest(err).WithMessage(err.Error()).AsJSON() 247 | } 248 | fmt.Fprintf(w, "%s\n", response.ASNOrg) 249 | return nil 250 | } 251 | 252 | func (s *Server) JSONHandler(w http.ResponseWriter, r *http.Request) *appError { 253 | response, err := s.newResponse(r) 254 | if err != nil { 255 | return badRequest(err).WithMessage(err.Error()).AsJSON() 256 | } 257 | b, err := json.MarshalIndent(response, "", " ") 258 | if err != nil { 259 | return internalServerError(err).AsJSON() 260 | } 261 | w.Header().Set("Content-Type", jsonMediaType) 262 | w.Write(b) 263 | return nil 264 | } 265 | 266 | func (s *Server) HealthHandler(w http.ResponseWriter, r *http.Request) *appError { 267 | w.Header().Set("Content-Type", jsonMediaType) 268 | w.Write([]byte(`{"status":"OK"}`)) 269 | return nil 270 | } 271 | 272 | func (s *Server) PortHandler(w http.ResponseWriter, r *http.Request) *appError { 273 | response, err := s.newPortResponse(r) 274 | if err != nil { 275 | return badRequest(err).WithMessage(err.Error()).AsJSON() 276 | } 277 | b, err := json.MarshalIndent(response, "", " ") 278 | if err != nil { 279 | return internalServerError(err).AsJSON() 280 | } 281 | w.Header().Set("Content-Type", jsonMediaType) 282 | w.Write(b) 283 | return nil 284 | } 285 | 286 | func (s *Server) cacheResizeHandler(w http.ResponseWriter, r *http.Request) *appError { 287 | body, err := ioutil.ReadAll(r.Body) 288 | if err != nil { 289 | return badRequest(err).WithMessage(err.Error()).AsJSON() 290 | } 291 | capacity, err := strconv.Atoi(string(body)) 292 | if err != nil { 293 | return badRequest(err).WithMessage(err.Error()).AsJSON() 294 | } 295 | if err := s.cache.Resize(capacity); err != nil { 296 | return badRequest(err).WithMessage(err.Error()).AsJSON() 297 | } 298 | data := struct { 299 | Message string `json:"message"` 300 | }{fmt.Sprintf("Changed cache capacity to %d.", capacity)} 301 | b, err := json.MarshalIndent(data, "", " ") 302 | if err != nil { 303 | return internalServerError(err).AsJSON() 304 | } 305 | w.Header().Set("Content-Type", jsonMediaType) 306 | w.Write(b) 307 | return nil 308 | } 309 | 310 | func (s *Server) cacheHandler(w http.ResponseWriter, r *http.Request) *appError { 311 | cacheStats := s.cache.Stats() 312 | var data = struct { 313 | Size int `json:"size"` 314 | Capacity int `json:"capacity"` 315 | Evictions uint64 `json:"evictions"` 316 | }{ 317 | cacheStats.Size, 318 | cacheStats.Capacity, 319 | cacheStats.Evictions, 320 | } 321 | b, err := json.MarshalIndent(data, "", " ") 322 | if err != nil { 323 | return internalServerError(err).AsJSON() 324 | } 325 | w.Header().Set("Content-Type", jsonMediaType) 326 | w.Write(b) 327 | return nil 328 | } 329 | 330 | func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError { 331 | response, err := s.newResponse(r) 332 | if err != nil { 333 | return badRequest(err).WithMessage(err.Error()) 334 | } 335 | t, err := template.ParseGlob(s.Template + "/*") 336 | if err != nil { 337 | return internalServerError(err) 338 | } 339 | json, err := json.MarshalIndent(response, "", " ") 340 | if err != nil { 341 | return internalServerError(err) 342 | } 343 | 344 | var data = struct { 345 | Response 346 | Host string 347 | BoxLatTop float64 348 | BoxLatBottom float64 349 | BoxLonLeft float64 350 | BoxLonRight float64 351 | JSON string 352 | Port bool 353 | Sponsor bool 354 | }{ 355 | response, 356 | r.Host, 357 | response.Latitude + 0.05, 358 | response.Latitude - 0.05, 359 | response.Longitude - 0.05, 360 | response.Longitude + 0.05, 361 | string(json), 362 | s.LookupPort != nil, 363 | s.Sponsor, 364 | } 365 | if err := t.Execute(w, &data); err != nil { 366 | return internalServerError(err) 367 | } 368 | return nil 369 | } 370 | 371 | func NotFoundHandler(w http.ResponseWriter, r *http.Request) *appError { 372 | err := notFound(nil).WithMessage("404 page not found") 373 | if r.Header.Get("accept") == jsonMediaType { 374 | err = err.AsJSON() 375 | } 376 | return err 377 | } 378 | 379 | func cliMatcher(r *http.Request) bool { 380 | ua := useragent.Parse(r.UserAgent()) 381 | switch ua.Product { 382 | case "curl", "HTTPie", "httpie-go", "Wget", "fetch libfetch", "Go", "Go-http-client", "ddclient", "Mikrotik", "xh": 383 | return true 384 | } 385 | return false 386 | } 387 | 388 | type appHandler func(http.ResponseWriter, *http.Request) *appError 389 | 390 | func wrapHandlerFunc(f http.HandlerFunc) appHandler { 391 | return func(w http.ResponseWriter, r *http.Request) *appError { 392 | f.ServeHTTP(w, r) 393 | return nil 394 | } 395 | } 396 | 397 | func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 398 | if e := fn(w, r); e != nil { // e is *appError 399 | if e.Code/100 == 5 { 400 | log.Println(e.Error) 401 | } 402 | // When Content-Type for error is JSON, we need to marshal the response into JSON 403 | if e.IsJSON() { 404 | var data = struct { 405 | Code int `json:"status"` 406 | Error string `json:"error"` 407 | }{e.Code, e.Message} 408 | b, err := json.MarshalIndent(data, "", " ") 409 | if err != nil { 410 | panic(err) 411 | } 412 | e.Message = string(b) 413 | } 414 | // Set Content-Type of response if set in error 415 | if e.ContentType != "" { 416 | w.Header().Set("Content-Type", e.ContentType) 417 | } 418 | w.WriteHeader(e.Code) 419 | fmt.Fprint(w, e.Message) 420 | } 421 | } 422 | 423 | func (s *Server) Handler() http.Handler { 424 | r := NewRouter() 425 | 426 | // Health 427 | r.Route("GET", "/health", s.HealthHandler) 428 | 429 | // JSON 430 | r.Route("GET", "/", s.JSONHandler).Header("Accept", jsonMediaType) 431 | r.Route("GET", "/json", s.JSONHandler) 432 | 433 | // CLI 434 | r.Route("GET", "/", s.CLIHandler).MatcherFunc(cliMatcher) 435 | r.Route("GET", "/", s.CLIHandler).Header("Accept", textMediaType) 436 | r.Route("GET", "/ip", s.CLIHandler) 437 | if !s.gr.IsEmpty() { 438 | r.Route("GET", "/country", s.CLICountryHandler) 439 | r.Route("GET", "/country-iso", s.CLICountryISOHandler) 440 | r.Route("GET", "/city", s.CLICityHandler) 441 | r.Route("GET", "/coordinates", s.CLICoordinatesHandler) 442 | r.Route("GET", "/asn", s.CLIASNHandler) 443 | r.Route("GET", "/asn-org", s.CLIASNOrgHandler) 444 | } 445 | 446 | // Browser 447 | if s.Template != "" { 448 | r.Route("GET", "/", s.DefaultHandler) 449 | } 450 | 451 | // Port testing 452 | if s.LookupPort != nil { 453 | r.RoutePrefix("GET", "/port/", s.PortHandler) 454 | } 455 | 456 | // Profiling 457 | if s.profile { 458 | r.Route("POST", "/debug/cache/resize", s.cacheResizeHandler) 459 | r.Route("GET", "/debug/cache/", s.cacheHandler) 460 | r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline)) 461 | r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile)) 462 | r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol)) 463 | r.Route("GET", "/debug/pprof/trace", wrapHandlerFunc(pprof.Trace)) 464 | r.RoutePrefix("GET", "/debug/pprof/", wrapHandlerFunc(pprof.Index)) 465 | } 466 | 467 | return r.Handler() 468 | } 469 | 470 | func (s *Server) ListenAndServe(addr string) error { 471 | return http.ListenAndServe(addr, s.Handler()) 472 | } 473 | 474 | func formatCoordinate(c float64) string { 475 | return strconv.FormatFloat(c, 'f', 6, 64) 476 | } 477 | -------------------------------------------------------------------------------- /http/http_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/mpolden/echoip/iputil/geo" 14 | ) 15 | 16 | func lookupAddr(net.IP) (string, error) { return "localhost", nil } 17 | func lookupPort(net.IP, uint64) error { return nil } 18 | 19 | type testDb struct{} 20 | 21 | func (t *testDb) Country(net.IP) (geo.Country, error) { 22 | return geo.Country{Name: "Elbonia", ISO: "EB", IsEU: new(bool)}, nil 23 | } 24 | 25 | func (t *testDb) City(net.IP) (geo.City, error) { 26 | return geo.City{Name: "Bornyasherk", RegionName: "North Elbonia", RegionCode: "1234", MetroCode: 1234, PostalCode: "1234", Latitude: 63.416667, Longitude: 10.416667, Timezone: "Europe/Bornyasherk"}, nil 27 | } 28 | 29 | func (t *testDb) ASN(net.IP) (geo.ASN, error) { 30 | return geo.ASN{AutonomousSystemNumber: 59795, AutonomousSystemOrganization: "Hosting4Real"}, nil 31 | } 32 | 33 | func (t *testDb) IsEmpty() bool { return false } 34 | 35 | func testServer() *Server { 36 | return &Server{cache: NewCache(100), gr: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort} 37 | } 38 | 39 | func httpGet(url string, acceptMediaType string, userAgent string) (string, int, error) { 40 | r, err := http.NewRequest("GET", url, nil) 41 | if err != nil { 42 | return "", 0, err 43 | } 44 | if acceptMediaType != "" { 45 | r.Header.Set("Accept", acceptMediaType) 46 | } 47 | r.Header.Set("User-Agent", userAgent) 48 | res, err := http.DefaultClient.Do(r) 49 | if err != nil { 50 | return "", 0, err 51 | } 52 | defer res.Body.Close() 53 | data, err := ioutil.ReadAll(res.Body) 54 | if err != nil { 55 | return "", 0, err 56 | } 57 | return string(data), res.StatusCode, nil 58 | } 59 | 60 | func httpPost(url, body string) (*http.Response, string, error) { 61 | r, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) 62 | if err != nil { 63 | return nil, "", err 64 | } 65 | res, err := http.DefaultClient.Do(r) 66 | if err != nil { 67 | return nil, "", err 68 | } 69 | defer res.Body.Close() 70 | data, err := ioutil.ReadAll(res.Body) 71 | if err != nil { 72 | return nil, "", err 73 | } 74 | return res, string(data), nil 75 | } 76 | 77 | func TestCLIHandlers(t *testing.T) { 78 | log.SetOutput(ioutil.Discard) 79 | s := httptest.NewServer(testServer().Handler()) 80 | 81 | var tests = []struct { 82 | url string 83 | out string 84 | status int 85 | userAgent string 86 | acceptMediaType string 87 | }{ 88 | {s.URL, "127.0.0.1\n", 200, "curl/7.43.0", ""}, 89 | {s.URL, "127.0.0.1\n", 200, "foo/bar", textMediaType}, 90 | {s.URL + "/ip", "127.0.0.1\n", 200, "", ""}, 91 | {s.URL + "/country", "Elbonia\n", 200, "", ""}, 92 | {s.URL + "/country-iso", "EB\n", 200, "", ""}, 93 | {s.URL + "/coordinates", "63.416667,10.416667\n", 200, "", ""}, 94 | {s.URL + "/city", "Bornyasherk\n", 200, "", ""}, 95 | {s.URL + "/foo", "404 page not found", 404, "", ""}, 96 | {s.URL + "/asn", "AS59795\n", 200, "", ""}, 97 | {s.URL + "/asn-org", "Hosting4Real\n", 200, "", ""}, 98 | } 99 | 100 | for _, tt := range tests { 101 | out, status, err := httpGet(tt.url, tt.acceptMediaType, tt.userAgent) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | if status != tt.status { 106 | t.Errorf("Expected %d, got %d", tt.status, status) 107 | } 108 | if out != tt.out { 109 | t.Errorf("Expected %q, got %q", tt.out, out) 110 | } 111 | } 112 | } 113 | 114 | func TestDisabledHandlers(t *testing.T) { 115 | log.SetOutput(ioutil.Discard) 116 | server := testServer() 117 | server.LookupPort = nil 118 | server.LookupAddr = nil 119 | server.gr, _ = geo.Open("", "", "") 120 | s := httptest.NewServer(server.Handler()) 121 | 122 | var tests = []struct { 123 | url string 124 | out string 125 | status int 126 | }{ 127 | {s.URL + "/port/1337", "404 page not found", 404}, 128 | {s.URL + "/country", "404 page not found", 404}, 129 | {s.URL + "/country-iso", "404 page not found", 404}, 130 | {s.URL + "/city", "404 page not found", 404}, 131 | {s.URL + "/json", "{\n \"ip\": \"127.0.0.1\",\n \"ip_decimal\": 2130706433\n}", 200}, 132 | } 133 | 134 | for _, tt := range tests { 135 | out, status, err := httpGet(tt.url, "", "") 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | if status != tt.status { 140 | t.Errorf("Expected %d, got %d", tt.status, status) 141 | } 142 | if out != tt.out { 143 | t.Errorf("Expected %q, got %q", tt.out, out) 144 | } 145 | } 146 | } 147 | 148 | func TestJSONHandlers(t *testing.T) { 149 | log.SetOutput(ioutil.Discard) 150 | s := httptest.NewServer(testServer().Handler()) 151 | 152 | var tests = []struct { 153 | url string 154 | out string 155 | status int 156 | }{ 157 | {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}, 158 | {s.URL + "/port/foo", "{\n \"status\": 400,\n \"error\": \"invalid port: foo\"\n}", 400}, 159 | {s.URL + "/port/0", "{\n \"status\": 400,\n \"error\": \"invalid port: 0\"\n}", 400}, 160 | {s.URL + "/port/65537", "{\n \"status\": 400,\n \"error\": \"invalid port: 65537\"\n}", 400}, 161 | {s.URL + "/port/31337", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 31337,\n \"reachable\": true\n}", 200}, 162 | {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 163 | {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 164 | {s.URL + "/foo", "{\n \"status\": 404,\n \"error\": \"404 page not found\"\n}", 404}, 165 | {s.URL + "/health", `{"status":"OK"}`, 200}, 166 | } 167 | 168 | for _, tt := range tests { 169 | out, status, err := httpGet(tt.url, jsonMediaType, "curl/7.2.6.0") 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | if status != tt.status { 174 | t.Errorf("Expected %d for %s, got %d", tt.status, tt.url, status) 175 | } 176 | if out != tt.out { 177 | t.Errorf("Expected %q for %s, got %q", tt.out, tt.url, out) 178 | } 179 | } 180 | } 181 | 182 | func TestCacheHandler(t *testing.T) { 183 | log.SetOutput(ioutil.Discard) 184 | srv := testServer() 185 | srv.profile = true 186 | s := httptest.NewServer(srv.Handler()) 187 | got, _, err := httpGet(s.URL+"/debug/cache/", jsonMediaType, "") 188 | if err != nil { 189 | t.Fatal(err) 190 | } 191 | want := "{\n \"size\": 0,\n \"capacity\": 100,\n \"evictions\": 0\n}" 192 | if got != want { 193 | t.Errorf("got %q, want %q", got, want) 194 | } 195 | } 196 | 197 | func TestCacheResizeHandler(t *testing.T) { 198 | log.SetOutput(ioutil.Discard) 199 | srv := testServer() 200 | srv.profile = true 201 | s := httptest.NewServer(srv.Handler()) 202 | _, got, err := httpPost(s.URL+"/debug/cache/resize", "10") 203 | if err != nil { 204 | t.Fatal(err) 205 | } 206 | want := "{\n \"message\": \"Changed cache capacity to 10.\"\n}" 207 | if got != want { 208 | t.Errorf("got %q, want %q", got, want) 209 | } 210 | } 211 | 212 | func TestIPFromRequest(t *testing.T) { 213 | var tests = []struct { 214 | remoteAddr string 215 | headerKey string 216 | headerValue string 217 | trustedHeaders []string 218 | out string 219 | }{ 220 | {"127.0.0.1:9999", "", "", nil, "127.0.0.1"}, // No header given 221 | {"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", nil, "127.0.0.1"}, // Trusted header is empty 222 | {"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 223 | {"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 224 | {"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 225 | {"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) 226 | {"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) 227 | {"127.0.0.1:9999", "X-Forwarded-For", "", []string{"X-Forwarded-For"}, "127.0.0.1"}, // Empty header 228 | {"127.0.0.1:9999?ip=1.2.3.4", "", "", nil, "1.2.3.4"}, // passed in "ip" parameter 229 | {"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 230 | } 231 | for _, tt := range tests { 232 | u, err := url.Parse("http://" + tt.remoteAddr) 233 | if err != nil { 234 | t.Fatal(err) 235 | } 236 | r := &http.Request{ 237 | RemoteAddr: u.Host, 238 | Header: http.Header{}, 239 | URL: u, 240 | } 241 | r.Header.Add(tt.headerKey, tt.headerValue) 242 | ip, err := ipFromRequest(tt.trustedHeaders, r, true) 243 | if err != nil { 244 | t.Fatal(err) 245 | } 246 | out := net.ParseIP(tt.out) 247 | if !ip.Equal(out) { 248 | t.Errorf("Expected %s, got %s", out, ip) 249 | } 250 | } 251 | } 252 | 253 | func TestCLIMatcher(t *testing.T) { 254 | browserUserAgent := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " + 255 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + 256 | "Safari/537.36" 257 | var tests = []struct { 258 | in string 259 | out bool 260 | }{ 261 | {"curl/7.26.0", true}, 262 | {"Wget/1.13.4 (linux-gnu)", true}, 263 | {"Wget", true}, 264 | {"fetch libfetch/2.0", true}, 265 | {"HTTPie/0.9.3", true}, 266 | {"httpie-go/0.6.0", true}, 267 | {"Go 1.1 package http", true}, 268 | {"Go-http-client/1.1", true}, 269 | {"Go-http-client/2.0", true}, 270 | {"ddclient/3.8.3", true}, 271 | {"Mikrotik/6.x Fetch", true}, 272 | {browserUserAgent, false}, 273 | } 274 | for _, tt := range tests { 275 | r := &http.Request{Header: http.Header{"User-Agent": []string{tt.in}}} 276 | if got := cliMatcher(r); got != tt.out { 277 | t.Errorf("Expected %t, got %t for %q", tt.out, got, tt.in) 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /http/router.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | type router struct { 9 | routes []*route 10 | } 11 | 12 | type route struct { 13 | method string 14 | path string 15 | prefix bool 16 | handler appHandler 17 | matcherFunc func(*http.Request) bool 18 | } 19 | 20 | func NewRouter() *router { 21 | return &router{} 22 | } 23 | 24 | func (r *router) Route(method, path string, handler appHandler) *route { 25 | route := route{ 26 | method: method, 27 | path: path, 28 | handler: handler, 29 | } 30 | r.routes = append(r.routes, &route) 31 | return &route 32 | } 33 | 34 | func (r *router) RoutePrefix(method, path string, handler appHandler) *route { 35 | route := r.Route(method, path, handler) 36 | route.prefix = true 37 | return route 38 | } 39 | 40 | func (r *router) Handler() http.Handler { 41 | return appHandler(func(w http.ResponseWriter, req *http.Request) *appError { 42 | for _, route := range r.routes { 43 | if route.match(req) { 44 | return route.handler(w, req) 45 | } 46 | } 47 | return NotFoundHandler(w, req) 48 | }) 49 | } 50 | 51 | func (r *route) Header(header, value string) { 52 | r.MatcherFunc(func(req *http.Request) bool { 53 | return req.Header.Get(header) == value 54 | }) 55 | } 56 | 57 | func (r *route) MatcherFunc(f func(*http.Request) bool) { 58 | r.matcherFunc = f 59 | } 60 | 61 | func (r *route) match(req *http.Request) bool { 62 | if req.Method != r.method { 63 | return false 64 | } 65 | if r.prefix { 66 | if !strings.HasPrefix(req.URL.Path, r.path) { 67 | return false 68 | } 69 | } else if r.path != req.URL.Path { 70 | return false 71 | } 72 | return r.matcherFunc == nil || r.matcherFunc(req) 73 | } 74 | -------------------------------------------------------------------------------- /iputil/geo/geo.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import ( 4 | "math" 5 | "net" 6 | 7 | geoip2 "github.com/oschwald/geoip2-golang" 8 | ) 9 | 10 | type Reader interface { 11 | Country(net.IP) (Country, error) 12 | City(net.IP) (City, error) 13 | ASN(net.IP) (ASN, error) 14 | IsEmpty() bool 15 | } 16 | 17 | type Country struct { 18 | Name string 19 | ISO string 20 | IsEU *bool 21 | } 22 | 23 | type City struct { 24 | Name string 25 | Latitude float64 26 | Longitude float64 27 | PostalCode string 28 | Timezone string 29 | MetroCode uint 30 | RegionName string 31 | RegionCode string 32 | } 33 | 34 | type ASN struct { 35 | AutonomousSystemNumber uint 36 | AutonomousSystemOrganization string 37 | } 38 | 39 | type geoip struct { 40 | country *geoip2.Reader 41 | city *geoip2.Reader 42 | asn *geoip2.Reader 43 | } 44 | 45 | func Open(countryDB, cityDB string, asnDB string) (Reader, error) { 46 | var country, city, asn *geoip2.Reader 47 | if countryDB != "" { 48 | r, err := geoip2.Open(countryDB) 49 | if err != nil { 50 | return nil, err 51 | } 52 | country = r 53 | } 54 | if cityDB != "" { 55 | r, err := geoip2.Open(cityDB) 56 | if err != nil { 57 | return nil, err 58 | } 59 | city = r 60 | } 61 | if asnDB != "" { 62 | r, err := geoip2.Open(asnDB) 63 | if err != nil { 64 | return nil, err 65 | } 66 | asn = r 67 | } 68 | return &geoip{country: country, city: city, asn: asn}, nil 69 | } 70 | 71 | func (g *geoip) Country(ip net.IP) (Country, error) { 72 | country := Country{} 73 | if g.country == nil { 74 | return country, nil 75 | } 76 | record, err := g.country.Country(ip) 77 | if err != nil { 78 | return country, err 79 | } 80 | if c, exists := record.Country.Names["en"]; exists { 81 | country.Name = c 82 | } 83 | if c, exists := record.RegisteredCountry.Names["en"]; exists && country.Name == "" { 84 | country.Name = c 85 | } 86 | if record.Country.IsoCode != "" { 87 | country.ISO = record.Country.IsoCode 88 | } 89 | if record.RegisteredCountry.IsoCode != "" && country.ISO == "" { 90 | country.ISO = record.RegisteredCountry.IsoCode 91 | } 92 | isEU := record.Country.IsInEuropeanUnion || record.RegisteredCountry.IsInEuropeanUnion 93 | country.IsEU = &isEU 94 | return country, nil 95 | } 96 | 97 | func (g *geoip) City(ip net.IP) (City, error) { 98 | city := City{} 99 | if g.city == nil { 100 | return city, nil 101 | } 102 | record, err := g.city.City(ip) 103 | if err != nil { 104 | return city, err 105 | } 106 | if c, exists := record.City.Names["en"]; exists { 107 | city.Name = c 108 | } 109 | if len(record.Subdivisions) > 0 { 110 | if c, exists := record.Subdivisions[0].Names["en"]; exists { 111 | city.RegionName = c 112 | } 113 | if record.Subdivisions[0].IsoCode != "" { 114 | city.RegionCode = record.Subdivisions[0].IsoCode 115 | } 116 | } 117 | if !math.IsNaN(record.Location.Latitude) { 118 | city.Latitude = record.Location.Latitude 119 | } 120 | if !math.IsNaN(record.Location.Longitude) { 121 | city.Longitude = record.Location.Longitude 122 | } 123 | // Metro code is US Only https://maxmind.github.io/GeoIP2-dotnet/doc/v2.7.1/html/P_MaxMind_GeoIP2_Model_Location_MetroCode.htm 124 | if record.Location.MetroCode > 0 && record.Country.IsoCode == "US" { 125 | city.MetroCode = record.Location.MetroCode 126 | } 127 | if record.Postal.Code != "" { 128 | city.PostalCode = record.Postal.Code 129 | } 130 | if record.Location.TimeZone != "" { 131 | city.Timezone = record.Location.TimeZone 132 | } 133 | 134 | return city, nil 135 | } 136 | 137 | func (g *geoip) ASN(ip net.IP) (ASN, error) { 138 | asn := ASN{} 139 | if g.asn == nil { 140 | return asn, nil 141 | } 142 | record, err := g.asn.ASN(ip) 143 | if err != nil { 144 | return asn, err 145 | } 146 | if record.AutonomousSystemNumber > 0 { 147 | asn.AutonomousSystemNumber = record.AutonomousSystemNumber 148 | } 149 | if record.AutonomousSystemOrganization != "" { 150 | asn.AutonomousSystemOrganization = record.AutonomousSystemOrganization 151 | } 152 | return asn, nil 153 | } 154 | 155 | func (g *geoip) IsEmpty() bool { 156 | return g.country == nil && g.city == nil 157 | } 158 | -------------------------------------------------------------------------------- /iputil/iputil.go: -------------------------------------------------------------------------------- 1 | package iputil 2 | 3 | import ( 4 | "fmt" 5 | "math/big" 6 | "net" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func LookupAddr(ip net.IP) (string, error) { 12 | names, err := net.LookupAddr(ip.String()) 13 | if err != nil || len(names) == 0 { 14 | return "", err 15 | } 16 | // Always return unrooted name 17 | return strings.TrimRight(names[0], "."), nil 18 | } 19 | 20 | func LookupPort(ip net.IP, port uint64) error { 21 | address := fmt.Sprintf("[%s]:%d", ip, port) 22 | conn, err := net.DialTimeout("tcp", address, 2*time.Second) 23 | if err != nil { 24 | return err 25 | } 26 | defer conn.Close() 27 | return nil 28 | } 29 | 30 | func ToDecimal(ip net.IP) *big.Int { 31 | i := big.NewInt(0) 32 | if to4 := ip.To4(); to4 != nil { 33 | i.SetBytes(to4) 34 | } else { 35 | i.SetBytes(ip) 36 | } 37 | return i 38 | } 39 | -------------------------------------------------------------------------------- /iputil/iputil_test.go: -------------------------------------------------------------------------------- 1 | package iputil 2 | 3 | import ( 4 | "math/big" 5 | "net" 6 | "testing" 7 | ) 8 | 9 | func TestToDecimal(t *testing.T) { 10 | var msb = new(big.Int) 11 | msb, _ = msb.SetString("80000000000000000000000000000000", 16) 12 | 13 | var tests = []struct { 14 | in string 15 | out *big.Int 16 | }{ 17 | {"127.0.0.1", big.NewInt(2130706433)}, 18 | {"::1", big.NewInt(1)}, 19 | {"8000::", msb}, 20 | } 21 | for _, tt := range tests { 22 | i := ToDecimal(net.ParseIP(tt.in)) 23 | if tt.out.Cmp(i) != 0 { 24 | t.Errorf("Expected %d, got %d for IP %s", tt.out, i, tt.in) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /useragent/useragent.go: -------------------------------------------------------------------------------- 1 | package useragent 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type UserAgent struct { 8 | Product string `json:"product,omitempty"` 9 | Version string `json:"version,omitempty"` 10 | Comment string `json:"comment,omitempty"` 11 | RawValue string `json:"raw_value,omitempty"` 12 | } 13 | 14 | func Parse(s string) UserAgent { 15 | parts := strings.SplitN(s, "/", 2) 16 | var version, comment string 17 | if len(parts) > 1 { 18 | // If first character is a number, treat it as version 19 | if len(parts[1]) > 0 && parts[1][0] >= 48 && parts[1][0] <= 57 { 20 | rest := strings.SplitN(parts[1], " ", 2) 21 | version = rest[0] 22 | if len(rest) > 1 { 23 | comment = rest[1] 24 | } 25 | } else { 26 | comment = parts[1] 27 | } 28 | } else { 29 | parts = strings.SplitN(s, " ", 2) 30 | if len(parts) > 1 { 31 | comment = parts[1] 32 | } 33 | } 34 | return UserAgent{ 35 | Product: parts[0], 36 | Version: version, 37 | Comment: comment, 38 | RawValue: s, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /useragent/useragent_test.go: -------------------------------------------------------------------------------- 1 | package useragent 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParse(t *testing.T) { 8 | var tests = []struct { 9 | in string 10 | out UserAgent 11 | }{ 12 | {"", UserAgent{}}, 13 | {"curl/", UserAgent{Product: "curl"}}, 14 | {"curl/foo", UserAgent{Product: "curl", Comment: "foo"}}, 15 | {"curl/7.26.0", UserAgent{Product: "curl", Version: "7.26.0"}}, 16 | {"Wget/1.13.4 (linux-gnu)", UserAgent{Product: "Wget", Version: "1.13.4", Comment: "(linux-gnu)"}}, 17 | {"Wget", UserAgent{Product: "Wget"}}, 18 | {"fetch libfetch/2.0", UserAgent{Product: "fetch libfetch", Version: "2.0"}}, 19 | {"Go 1.1 package http", UserAgent{Product: "Go", Comment: "1.1 package http"}}, 20 | {"Mikrotik/6.x Fetch", UserAgent{Product: "Mikrotik", Version: "6.x", Comment: "Fetch"}}, 21 | {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " + 22 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + 23 | "Safari/537.36", UserAgent{Product: "Mozilla", Version: "5.0", Comment: "(Macintosh; Intel Mac OS X 10_8_4) " + 24 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " + 25 | "Safari/537.36"}}, 26 | } 27 | for _, tt := range tests { 28 | ua := Parse(tt.in) 29 | if got := ua.Product; got != tt.out.Product { 30 | t.Errorf("got Product=%q for %q, want %q", got, tt.in, tt.out.Product) 31 | } 32 | if got := ua.Version; got != tt.out.Version { 33 | t.Errorf("got Version=%q for %q, want %q", got, tt.in, tt.out.Version) 34 | } 35 | if got := ua.Comment; got != tt.out.Comment { 36 | t.Errorf("got Comment=%q for %q, want %q", got, tt.in, tt.out.Comment) 37 | } 38 | } 39 | } 40 | --------------------------------------------------------------------------------