├── .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 | 
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 |
66 | {{ end }}
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
What do we know about this IP address?
75 |
76 |
77 | IP address
78 | {{ .IP }}
79 |
80 |
81 | IP address (decimal)
82 | {{ .IPDecimal }}
83 |
84 | {{ if .Country }}
85 |
86 | Country
87 | {{ .Country }}
88 |
89 | {{ end }} {{ if .CountryISO }}
90 |
91 | Country (ISO code)
92 | {{ .CountryISO }}
93 |
94 | {{ end }} {{ if .CountryEU }}
95 |
96 | In EU?
97 | {{ .CountryEU }}
98 |
99 | {{ end }} {{ if .RegionName }}
100 |
101 | Region
102 | {{ .RegionName }}
103 |
104 | {{ end }} {{ if .RegionCode }}
105 |
106 | Region code
107 | {{ .RegionCode }}
108 |
109 | {{ end }} {{ if .MetroCode }}
110 |
111 | Metro code
112 | {{ .MetroCode }}
113 |
114 | {{ end }} {{ if .PostalCode }}
115 |
116 | Postal code
117 | {{ .PostalCode }}
118 |
119 | {{ end }} {{ if .City }}
120 |
121 | City
122 | {{ .City }}
123 |
124 | {{ end }} {{ if .Latitude }}
125 |
126 | Latitude
127 | {{ .Latitude }}
128 |
129 | {{ end }} {{ if .Longitude }}
130 |
131 | Longitude
132 | {{ .Longitude }}
133 |
134 | {{ end }} {{ if .Timezone }}
135 |
136 | Timezone
137 | {{ .Timezone }}
138 |
139 | {{ end }} {{ if .ASN }}
140 |
141 | ASN
142 | {{ .ASN }}
143 |
144 | {{ end }} {{ if .ASNOrg }}
145 |
146 | ASN (organization)
147 | {{ .ASNOrg }}
148 |
149 | {{ end }} {{ if .Hostname }}
150 |
151 | Hostname
152 | {{ .Hostname }}
153 |
154 | {{ end }} {{ if .UserAgent }} {{ if .UserAgent.Comment }}
155 |
156 | User agent
157 | {{ .UserAgent.Product }}/{{ .UserAgent.Version }}
158 |
159 | {{ end }} {{ if .UserAgent.Comment }}
160 |
161 | User agent: Comment
162 | {{ .UserAgent.Comment }}
163 |
164 | {{ end }} {{ if .UserAgent.RawValue }}
165 |
166 | User agent: Raw
167 | {{ .UserAgent.RawValue }}
168 |
169 | {{ end }} {{ end }}
170 |
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 |
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 |
--------------------------------------------------------------------------------