mirror of
https://github.com/casjaysdevdocker/ifconfig
synced 2025-01-18 06:34:22 -05:00
🦈🏠🐜❗ Initial Commit ❗🐜🦈🏠
This commit is contained in:
commit
964ab574ea
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
Dockerfile
|
||||
Dockerfile.geoip
|
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/data/
|
||||
/custom.html
|
||||
/vendor/
|
||||
.vscode/
|
||||
/bin/
|
||||
# ignore commit message
|
||||
.gitcommit
|
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@ -0,0 +1,38 @@
|
||||
# Build
|
||||
FROM golang:1.15-buster AS build
|
||||
WORKDIR /go/src/github.com/mpolden/echoip
|
||||
COPY . .
|
||||
|
||||
# Must build without cgo because libc is unavailable in runtime image
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
RUN make
|
||||
|
||||
# Run
|
||||
FROM casjaysdev/alpine:latest as run
|
||||
COPY --from=build /go/bin/echoip /opt/echoip/
|
||||
COPY ./html /opt/echoip/html
|
||||
COPY ./bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
FROM run
|
||||
ARG BUILD_DATE="$(date +'%Y-%m-%d %H:%M')"
|
||||
|
||||
LABEL \
|
||||
org.label-schema.name="ifconfig" \
|
||||
org.label-schema.description="Sow ip information" \
|
||||
org.label-schema.url="https://github.com/casjaysdev/ifconfig" \
|
||||
org.label-schema.vcs-url="https://github.com/casjaysdev/ifconfig" \
|
||||
org.label-schema.build-date=$BUILD_DATE \
|
||||
org.label-schema.version=$BUILD_DATE \
|
||||
org.label-schema.vcs-ref=$BUILD_DATE \
|
||||
org.label-schema.license="MIT" \
|
||||
org.label-schema.vcs-type="Git" \
|
||||
org.label-schema.schema-version="1.0" \
|
||||
org.label-schema.vendor="CasjaysDev" \
|
||||
maintainer="CasjaysDev <docker-admin@casjaysdev.com>"
|
||||
|
||||
EXPOSE 8080
|
||||
WORKDIR /opt/echoip
|
||||
VOLUME /opt/echoip/html
|
||||
|
||||
HEALTHCHECK CMD ["/usr/local/bin/docker-entrypoint.sh", "healthcheck"]
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
25
LICENSE
Normal file
25
LICENSE
Normal file
@ -0,0 +1,25 @@
|
||||
Copyright (c) 2012-2020, Martin Polden
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of the copyright holder nor the
|
||||
names of its contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
77
Makefile
Normal file
77
Makefile
Normal file
@ -0,0 +1,77 @@
|
||||
DOCKER ?= docker
|
||||
DOCKER_IMAGE ?= mpolden/echoip
|
||||
OS := $(shell uname)
|
||||
ifeq ($(OS),Linux)
|
||||
TAR_OPTS := --wildcards
|
||||
endif
|
||||
XGOARCH := $(shell uname -m)
|
||||
XGOOS := linux
|
||||
XBIN := $(XGOOS)_$(XGOARCH)/echoip
|
||||
|
||||
all: lint test install
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
check-fmt:
|
||||
bash -c "diff --line-format='%L' <(echo -n) <(gofmt -d -s .)"
|
||||
|
||||
lint: check-fmt vet
|
||||
|
||||
install:
|
||||
go install ./...
|
||||
|
||||
databases := GeoLite2-City GeoLite2-Country GeoLite2-ASN
|
||||
|
||||
$(databases):
|
||||
ifndef GEOIP_LICENSE_KEY
|
||||
$(error GEOIP_LICENSE_KEY must be set. Please see https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/)
|
||||
endif
|
||||
mkdir -p data
|
||||
@curl -fsSL -m 30 "https://download.maxmind.com/app/geoip_download?edition_id=$@&license_key=$(GEOIP_LICENSE_KEY)&suffix=tar.gz" | tar $(TAR_OPTS) --strip-components=1 -C $(CURDIR)/data -xzf - '*.mmdb'
|
||||
test ! -f data/GeoLite2-City.mmdb || mv data/GeoLite2-City.mmdb data/city.mmdb
|
||||
test ! -f data/GeoLite2-Country.mmdb || mv data/GeoLite2-Country.mmdb data/country.mmdb
|
||||
test ! -f data/GeoLite2-ASN.mmdb || mv data/GeoLite2-ASN.mmdb data/asn.mmdb
|
||||
|
||||
geoip-download: $(databases)
|
||||
|
||||
# Create an environment to build multiarch containers (https://github.com/docker/buildx/)
|
||||
docker-multiarch-builder:
|
||||
DOCKER_BUILDKIT=1 $(DOCKER) build -o . git://github.com/docker/buildx
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
mv buildx ~/.docker/cli-plugins/docker-buildx
|
||||
$(DOCKER) buildx create --name multiarch-builder --node multiarch-builder --driver docker-container --use
|
||||
$(DOCKER) run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
|
||||
docker-build:
|
||||
$(DOCKER) build -t $(DOCKER_IMAGE) .
|
||||
|
||||
docker-login:
|
||||
@echo "$(DOCKER_PASSWORD)" | $(DOCKER) login -u "$(DOCKER_USERNAME)" --password-stdin
|
||||
|
||||
docker-test:
|
||||
$(eval CONTAINER=$(shell $(DOCKER) run --rm --detach --publish-all $(DOCKER_IMAGE)))
|
||||
$(eval DOCKER_PORT=$(shell $(DOCKER) port $(CONTAINER) | cut -d ":" -f 2))
|
||||
curl -fsS -m 5 localhost:$(DOCKER_PORT) > /dev/null; $(DOCKER) stop $(CONTAINER)
|
||||
|
||||
docker-push: docker-test docker-login
|
||||
$(DOCKER) push $(DOCKER_IMAGE)
|
||||
|
||||
docker-pushx: docker-multiarch-builder docker-test docker-login
|
||||
$(DOCKER) buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t $(DOCKER_IMAGE) --push .
|
||||
|
||||
xinstall:
|
||||
env GOOS=$(XGOOS) GOARCH=$(XGOARCH) go install ./...
|
||||
|
||||
publish:
|
||||
ifndef DEST_PATH
|
||||
$(error DEST_PATH must be set when publishing)
|
||||
endif
|
||||
rsync -a $(GOPATH)/bin/$(XBIN) $(DEST_PATH)/$(XBIN)
|
||||
@sha256sum $(GOPATH)/bin/$(XBIN)
|
||||
|
||||
run:
|
||||
go run cmd/echoip/main.go -a data/asn.mmdb -c data/city.mmdb -f data/country.mmdb -H x-forwarded-for -r -s -p
|
131
README.md
Normal file
131
README.md
Normal file
@ -0,0 +1,131 @@
|
||||
# echoip
|
||||
|
||||
![Build Status](https://github.com/mpolden/echoip/workflows/ci/badge.svg)
|
||||
|
||||
A simple service for looking up your IP address. This is the code that powers
|
||||
<https://ifconfig.co>.
|
||||
|
||||
## Usage
|
||||
|
||||
Just the business, please:
|
||||
|
||||
```shell
|
||||
$ curl ifconfig.co
|
||||
127.0.0.1
|
||||
|
||||
$ http ifconfig.co
|
||||
127.0.0.1
|
||||
|
||||
$ wget -qO- ifconfig.co
|
||||
127.0.0.1
|
||||
|
||||
$ fetch -qo- https://ifconfig.co
|
||||
127.0.0.1
|
||||
|
||||
$ bat -print=b ifconfig.co/ip
|
||||
127.0.0.1
|
||||
```
|
||||
|
||||
Country and city lookup:
|
||||
|
||||
```shell
|
||||
$ curl ifconfig.co/country
|
||||
Elbonia
|
||||
|
||||
$ curl ifconfig.co/country-iso
|
||||
EB
|
||||
|
||||
$ curl ifconfig.co/city
|
||||
Bornyasherk
|
||||
|
||||
$ curl ifconfig.co/asn
|
||||
AS59795
|
||||
```
|
||||
|
||||
As JSON:
|
||||
|
||||
```shell
|
||||
$ curl -H 'Accept: application/json' ifconfig.co # or curl ifconfig.co/json
|
||||
{
|
||||
"city": "Bornyasherk",
|
||||
"country": "Elbonia",
|
||||
"country_iso": "EB",
|
||||
"ip": "127.0.0.1",
|
||||
"ip_decimal": 2130706433,
|
||||
"asn": "AS59795",
|
||||
"asn_org": "Hosting4Real"
|
||||
}
|
||||
```
|
||||
|
||||
Port testing:
|
||||
|
||||
```shell
|
||||
$ curl ifconfig.co/port/80
|
||||
{
|
||||
"ip": "127.0.0.1",
|
||||
"port": 80,
|
||||
"reachable": false
|
||||
}
|
||||
```
|
||||
|
||||
Pass the appropriate flag (usually `-4` and `-6`) to your client to switch
|
||||
between IPv4 and IPv6 lookup.
|
||||
|
||||
## Features
|
||||
|
||||
* Easy to remember domain name
|
||||
* Fast
|
||||
* Supports IPv6
|
||||
* Supports HTTPS
|
||||
* Supports common command-line clients (e.g. `curl`, `httpie`, `ht`, `wget` and `fetch`)
|
||||
* JSON output
|
||||
* ASN, country and city lookup using the MaxMind GeoIP database
|
||||
* Port testing
|
||||
* All endpoints (except `/port`) can return information about a custom IP address specified via `?ip=` query parameter
|
||||
* Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause)
|
||||
|
||||
## Why?
|
||||
|
||||
* To scratch an itch
|
||||
* An excuse to use Go for something
|
||||
* Faster than ifconfig.me and has IPv6 support
|
||||
|
||||
## Building
|
||||
|
||||
Compiling requires the [Golang compiler](https://golang.org/) to be installed.
|
||||
This package can be installed with:
|
||||
|
||||
`go install github.com/mpolden/echoip/...@latest`
|
||||
|
||||
For more information on building a Go project, see the [official Go
|
||||
documentation](https://golang.org/doc/code.html).
|
||||
|
||||
## Docker image
|
||||
|
||||
A Docker image is available on [Docker
|
||||
Hub](https://hub.docker.com/r/casjay/ifconfig), which can be downloaded with:
|
||||
|
||||
`docker pull casjay/ifconfig`
|
||||
|
||||
### Server Options
|
||||
|
||||
```shell
|
||||
$ echoip -h
|
||||
Usage of echoip:
|
||||
-C int
|
||||
Size of response cache. Set to 0 to disable
|
||||
-H value
|
||||
Header to trust for remote IP, if present (e.g. X-Real-IP)
|
||||
-a string
|
||||
Path to GeoIP ASN database
|
||||
-c string
|
||||
Path to GeoIP city database
|
||||
-f string
|
||||
Path to GeoIP country database
|
||||
-l string
|
||||
Listening address (default ":8080")
|
||||
-p Enable port lookup
|
||||
-r Perform reverse hostname lookups
|
||||
-t string
|
||||
Path to template directory (default "html")
|
||||
```
|
93
cmd/echoip/main.go
Normal file
93
cmd/echoip/main.go
Normal file
@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"os"
|
||||
|
||||
"github.com/mpolden/echoip/http"
|
||||
"github.com/mpolden/echoip/iputil"
|
||||
"github.com/mpolden/echoip/iputil/geo"
|
||||
)
|
||||
|
||||
type multiValueFlag []string
|
||||
|
||||
func (f *multiValueFlag) String() string {
|
||||
vs := ""
|
||||
for i, v := range *f {
|
||||
vs += v
|
||||
if i < len(*f)-1 {
|
||||
vs += ", "
|
||||
}
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
func (f *multiValueFlag) Set(v string) error {
|
||||
*f = append(*f, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
log.SetPrefix("echoip: ")
|
||||
log.SetFlags(log.Lshortfile)
|
||||
}
|
||||
|
||||
func main() {
|
||||
countryFile := flag.String("f", "", "Path to GeoIP country database")
|
||||
cityFile := flag.String("c", "", "Path to GeoIP city database")
|
||||
asnFile := flag.String("a", "", "Path to GeoIP ASN database")
|
||||
listen := flag.String("l", ":8080", "Listening address")
|
||||
reverseLookup := flag.Bool("r", false, "Perform reverse hostname lookups")
|
||||
portLookup := flag.Bool("p", false, "Enable port lookup")
|
||||
template := flag.String("t", "html", "Path to template dir")
|
||||
cacheSize := flag.Int("C", 0, "Size of response cache. Set to 0 to disable")
|
||||
profile := flag.Bool("P", false, "Enables profiling handlers")
|
||||
sponsor := flag.Bool("s", false, "Show sponsor logo")
|
||||
var headers multiValueFlag
|
||||
flag.Var(&headers, "H", "Header to trust for remote IP, if present (e.g. X-Real-IP)")
|
||||
flag.Parse()
|
||||
if len(flag.Args()) != 0 {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
r, err := geo.Open(*countryFile, *cityFile, *asnFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cache := http.NewCache(*cacheSize)
|
||||
server := http.New(r, cache, *profile)
|
||||
server.IPHeaders = headers
|
||||
if _, err := os.Stat(*template); err == nil {
|
||||
server.Template = *template
|
||||
} else {
|
||||
log.Printf("Not configuring default handler: Template not found: %s", *template)
|
||||
}
|
||||
if *reverseLookup {
|
||||
log.Println("Enabling reverse lookup")
|
||||
server.LookupAddr = iputil.LookupAddr
|
||||
}
|
||||
if *portLookup {
|
||||
log.Println("Enabling port lookup")
|
||||
server.LookupPort = iputil.LookupPort
|
||||
}
|
||||
if *sponsor {
|
||||
log.Println("Enabling sponsor logo")
|
||||
server.Sponsor = *sponsor
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
log.Printf("Trusting remote IP from header(s): %s", headers.String())
|
||||
}
|
||||
if *cacheSize > 0 {
|
||||
log.Printf("Cache capacity set to %d", *cacheSize)
|
||||
}
|
||||
if *profile {
|
||||
log.Printf("Enabling profiling handlers")
|
||||
}
|
||||
log.Printf("Listening on http://%s", *listen)
|
||||
if err := server.ListenAndServe(*listen); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module github.com/mpolden/echoip
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/oschwald/geoip2-golang v1.5.0
|
||||
golang.org/x/sys v0.0.0-20210223212115-eede4237b368 // indirect
|
||||
)
|
20
go.sum
Normal file
20
go.sum
Normal file
@ -0,0 +1,20 @@
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lVJ1ONZzw=
|
||||
github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s=
|
||||
github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk=
|
||||
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g=
|
||||
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210223212115-eede4237b368 h1:fDE3p0qf2V1co1vfj3/o87Ps8Hq6QTGNxJ5Xe7xSp80=
|
||||
golang.org/x/sys v0.0.0-20210223212115-eede4237b368/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
349
html/index.html
Normal file
349
html/index.html
Normal file
@ -0,0 +1,349 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>What is my IP address? — {{ .Host }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta1.15-buster
|
||||
name="description"
|
||||
content="{{ .Host }} • What is my IP address? — The best tool to find your own IP address, and information about it."
|
||||
/>
|
||||
<link rel="canonical" href="https://ifconfig.co/" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/pure/1.0.0/pure-min.css"
|
||||
integrity="sha384-nn4HPE8lTHyVtfCBi5yW9d20FjT8BJwUXyWZT9InLYax14RDjBj46LmSztkmNP9w"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/pure/1.0.0/grids-responsive-min.css"
|
||||
integrity="sha384-b92sF+wDNTHrfEtRaYo+EpcA8FUyHOSXrdxKc9XB9kaaX1rSQAgMevW6cYeE5Bdv"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
{{ template "script.html" . }} {{ template "styles.html" . }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<div class="pure-g gutters center">
|
||||
<div class="pure-u-1 pure-u-md-2-3">
|
||||
<div class="l-box">
|
||||
<h1>What is my IP address?</h1>
|
||||
<p><code class="ip">{{ .IP }}</code></p>
|
||||
<p>
|
||||
The best tool to find your own IP address, and information about
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
{{ if .Sponsor }}
|
||||
<div class="l-box leafcloud-placement">
|
||||
<div class="pure-g">
|
||||
<div class="pure-u pure-u-md-1">
|
||||
<div class="leafcloud-logo">
|
||||
<a href="https://jason.malaks.us" target="_blank">
|
||||
<img
|
||||
src="https://avatars.githubusercontent.com/u/126880?v=4"
|
||||
width="72"
|
||||
height="72"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u pure-u-md-1">
|
||||
<p>
|
||||
This site is hosted by<br />
|
||||
<a href="https://casjaysdev.com" target="_blank">
|
||||
Casjays Developments
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-g gutters center">
|
||||
<!-- COLUMN 1 -->
|
||||
<div class="pure-u-1 pure-u-md-1-2 col">
|
||||
<div class="l-box">
|
||||
<h2>What do we know about this IP address?</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th scope="row">IP address</th>
|
||||
<td>{{ .IP }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IP address (decimal)</th>
|
||||
<td>{{ .IPDecimal }}</td>
|
||||
</tr>
|
||||
{{ if .Country }}
|
||||
<tr>
|
||||
<th scope="row">Country</th>
|
||||
<td>{{ .Country }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .CountryISO }}
|
||||
<tr>
|
||||
<th scope="row">Country (ISO code)</th>
|
||||
<td>{{ .CountryISO }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .CountryEU }}
|
||||
<tr>
|
||||
<th scope="row">In EU?</th>
|
||||
<td>{{ .CountryEU }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .RegionName }}
|
||||
<tr>
|
||||
<th scope="row">Region</th>
|
||||
<td>{{ .RegionName }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .RegionCode }}
|
||||
<tr>
|
||||
<th scope="row">Region code</th>
|
||||
<td>{{ .RegionCode }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .MetroCode }}
|
||||
<tr>
|
||||
<th scope="row">Metro code</th>
|
||||
<td>{{ .MetroCode }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .PostalCode }}
|
||||
<tr>
|
||||
<th scope="row">Postal code</th>
|
||||
<td>{{ .PostalCode }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .City }}
|
||||
<tr>
|
||||
<th scope="row">City</th>
|
||||
<td>{{ .City }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .Latitude }}
|
||||
<tr>
|
||||
<th scope="row">Latitude</th>
|
||||
<td>{{ .Latitude }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .Longitude }}
|
||||
<tr>
|
||||
<th scope="row">Longitude</th>
|
||||
<td>{{ .Longitude }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .Timezone }}
|
||||
<tr>
|
||||
<th scope="row">Timezone</th>
|
||||
<td>{{ .Timezone }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .ASN }}
|
||||
<tr>
|
||||
<th scope="row">ASN</th>
|
||||
<td>{{ .ASN }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .ASNOrg }}
|
||||
<tr>
|
||||
<th scope="row">ASN (organization)</th>
|
||||
<td>{{ .ASNOrg }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .Hostname }}
|
||||
<tr>
|
||||
<th scope="row">Hostname</th>
|
||||
<td>{{ .Hostname }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .UserAgent }} {{ if .UserAgent.Comment }}
|
||||
<tr>
|
||||
<th scope="row">User agent</th>
|
||||
<td>{{ .UserAgent.Product }}/{{ .UserAgent.Version }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .UserAgent.Comment }}
|
||||
<tr>
|
||||
<th scope="row">User agent: Comment</th>
|
||||
<td>{{ .UserAgent.Comment }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ if .UserAgent.RawValue }}
|
||||
<tr>
|
||||
<th scope="row">User agent: Raw</th>
|
||||
<td>{{ .UserAgent.RawValue }}</td>
|
||||
</tr>
|
||||
{{ end }} {{ end }}
|
||||
</table>
|
||||
{{ if .Country }}
|
||||
<p>
|
||||
This information is provided from the GeoLite2 database created by
|
||||
MaxMind, available from
|
||||
<a href="https://www.maxmind.com" target="_blank"
|
||||
>www.maxmind.com</a
|
||||
>
|
||||
</p>
|
||||
{{ end }} {{ if .Latitude }}
|
||||
<div class="pure-u-1 pure-u-md-1-1">
|
||||
<h2>Map</h2>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="350"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
marginheight="0"
|
||||
marginwidth="0"
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox={{ .BoxLonLeft }}%2C{{ .BoxLatBottom }}%2C{{ .BoxLonRight }}%2C{{ .BoxLatTop }}&layer=mapnik&marker={{ .Latitude }}%2C{{ .Longitude }}"
|
||||
></iframe>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- COLUMN 2 -->
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<div class="l-box">
|
||||
<h2>How do I get this programmatically?</h2>
|
||||
<p>
|
||||
With the widget below you can build your query, and see what the
|
||||
result will look like.
|
||||
</p>
|
||||
<div class="pure-form">
|
||||
<!-- COMMAND WIDGET -->
|
||||
<div class="input-buttons">
|
||||
<button
|
||||
name="ip"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
ip
|
||||
</button>
|
||||
<button
|
||||
name="country"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
country
|
||||
</button>
|
||||
<button
|
||||
name="country-iso"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
country-iso
|
||||
</button>
|
||||
<button
|
||||
name="city"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
city
|
||||
</button>
|
||||
<button
|
||||
name="asn"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
asn
|
||||
</button>
|
||||
<button
|
||||
name="json"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
json
|
||||
</button>
|
||||
<button
|
||||
name="port"
|
||||
class="pure-button widget-select"
|
||||
onclick="changeInput(this.name, this)"
|
||||
>
|
||||
port
|
||||
</button>
|
||||
<input
|
||||
id="portInput"
|
||||
type="number"
|
||||
min="1"
|
||||
max="40000"
|
||||
value="8080"
|
||||
class="narrow-input pure-input"
|
||||
placeholder="8080"
|
||||
onchange="updatePort(this.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="widgetbox input">
|
||||
<code id="command"></code>
|
||||
</div>
|
||||
<div id="output" class="widgetbox output"></div>
|
||||
|
||||
<form class="pure-form input-buttons">
|
||||
<fieldset>
|
||||
<label for="ipInput">
|
||||
Check another IP (optional)
|
||||
<input
|
||||
id="ipInput"
|
||||
class=""
|
||||
type="text"
|
||||
placeholder="IP to query"
|
||||
onkeyup="updateIP(this.value)"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pure-button"
|
||||
onclick="navigate()"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- FAQ -->
|
||||
<div class="FAQ">
|
||||
<h2>FAQ</h2>
|
||||
<h3>How do I force IPv4 or IPv6 lookup?</h3>
|
||||
<p>
|
||||
As of 2018-07-25 it's no longer possible to<br />
|
||||
force protocol using the <i>v4</i> and
|
||||
<i>v6</i> subdomains.<br />
|
||||
IPv4 or IPv6 still can be forced by passing the<br />
|
||||
appropiate flag to your client,<br />
|
||||
e.g <code>curl -4</code> or <code>curl -6</code>.
|
||||
</p>
|
||||
<h3>Can I force getting JSON?</h3>
|
||||
<p>
|
||||
Setting the <code>Accept: application/json</code><br />
|
||||
header works as expected.
|
||||
</p>
|
||||
|
||||
<h3>Is automated use of this service permitted?</h3>
|
||||
<p>
|
||||
Yes, as long as the rate limit is respected.<br />
|
||||
The rate limit is in place to ensure a fair service for all.
|
||||
</p>
|
||||
<p>
|
||||
<em
|
||||
>Please limit automated requests to 1 <br />
|
||||
request per minute</em
|
||||
>. No guarantee is made <br />
|
||||
for requests that exceed this limit. They may be <br />
|
||||
rate-limited, with a 429 status code, or dropped entirely.
|
||||
</p>
|
||||
<h3>Can I run my own service?</h3>
|
||||
<p>
|
||||
Yes, the official source code <br />
|
||||
and documentation is available at <br />
|
||||
<a href="https://github.com/mpolden/echoip" target="_blank"
|
||||
>https://github.com/mpolden/echoip</a
|
||||
>.<br />
|
||||
My modified code can be found at<br />
|
||||
<a href="https://github.com/dockermgr/ifconfig" target="_blank"
|
||||
>https://github.com/dockermgr/ifconfig</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br /><br /><br /><br /><br />
|
||||
</body>
|
||||
</html>
|
88
html/script.html
Normal file
88
html/script.html
Normal file
@ -0,0 +1,88 @@
|
||||
<script lang="text/javascript">
|
||||
let host = "{{ .Host }}";
|
||||
let jsonObj = "{{ .JSON }}";
|
||||
let data = JSON.parse(jsonObj);
|
||||
let tool = "curl";
|
||||
let commandBox, widgetBox, compositePath, commandStr;
|
||||
let path
|
||||
let ipQuery, portQuery
|
||||
let ipCheckBox, portCheckBox, portInput
|
||||
let ip = ''
|
||||
|
||||
window.onload = (event) => {
|
||||
commandBox = document.getElementById('command');
|
||||
widgetBox = document.getElementById('output');
|
||||
ipCheckBox = document.getElementById('ipCheckBox')
|
||||
portCheckBox = document.getElementById('portCheckBox')
|
||||
portInput = document.getElementById('portInput')
|
||||
reset()
|
||||
setcommdStr()
|
||||
changeInput("ip")
|
||||
}
|
||||
|
||||
function reset() {
|
||||
path = '';
|
||||
ipQuery = '';
|
||||
portQuery = '';
|
||||
}
|
||||
|
||||
function setcommdStr() {
|
||||
compositePath = `${path}${portQuery}${ipQuery}`;
|
||||
commandStr = `${tool} ${host}/${compositePath}`;
|
||||
commandBox.innerText = commandStr;
|
||||
}
|
||||
|
||||
function changeInput(input, button) {
|
||||
path = input
|
||||
portQuery = ""
|
||||
portInput.classList.add("hidden");
|
||||
switch (path) {
|
||||
case "json":
|
||||
output.innerText = jsonObj
|
||||
break
|
||||
case "country-iso":
|
||||
output.innerText = data["country_iso"]
|
||||
break
|
||||
case "port":
|
||||
portInput.classList.remove("hidden");
|
||||
path = "port";
|
||||
output.innerText = "{}";
|
||||
let currentPort = document.querySelector("#portInput").value;
|
||||
updatePort(currentPort);
|
||||
break
|
||||
case "ip":
|
||||
output.innerText = data["ip"]
|
||||
path = ""
|
||||
break
|
||||
default:
|
||||
output.innerText = data[path]
|
||||
}
|
||||
setcommdStr();
|
||||
|
||||
// set button selected
|
||||
if (button) {
|
||||
allButtons = document.querySelectorAll(('button.selected'));
|
||||
allButtons.forEach((btn) => { btn.classList.remove("selected") })
|
||||
|
||||
button.classList.add("selected");
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(event) {
|
||||
console.log("navigate", compositePath)
|
||||
window.location = compositePath
|
||||
}
|
||||
|
||||
function updatePort(value) {
|
||||
port = value
|
||||
portQuery = `/${port}`
|
||||
setcommdStr()
|
||||
}
|
||||
|
||||
function updateIP(value) {
|
||||
ip = value
|
||||
ipQuery = `?ip=${ip}`;
|
||||
setcommdStr()
|
||||
changeInput("ip", null)
|
||||
}
|
||||
</script>
|
201
html/styles.html
Normal file
201
html/styles.html
Normal file
@ -0,0 +1,201 @@
|
||||
<style>
|
||||
html,
|
||||
.pure-g [class*="pure-u"] {
|
||||
background-color: white;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "Monaco", "Menlo", "Consolas", "Courier New", monospace;
|
||||
white-space: pre-wrap;
|
||||
/* Since CSS 2.1 */
|
||||
white-space: -moz-pre-wrap;
|
||||
/* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap;
|
||||
/* Opera 4-6 */
|
||||
white-space: -o-pre-wrap;
|
||||
/* Opera 7 */
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
/* background: #e3e3e3; */
|
||||
text-decoration: underline;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
active {
|
||||
background: #d7d7d7;
|
||||
}
|
||||
|
||||
.ip {
|
||||
border: 1px solid #cbcbcb;
|
||||
background: #f2f2f2;
|
||||
font-size: 36px;
|
||||
padding: 6px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
svg.github-corner {
|
||||
fill: #151513;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 34px;
|
||||
border-top: 1px solid #cbcbcb;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-table td,
|
||||
.info-table th {
|
||||
padding: 5px;
|
||||
border: 2px solid #ababab;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.info-table th[scope="row"] {
|
||||
background-color: #d5d5d5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widgetbox {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid grey;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
margin-top: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.widgetbox.input :first-child::before {
|
||||
content: "$ ";
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.widgetbox.output {
|
||||
min-height: 4em;
|
||||
white-space: pre;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.l-box {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.medium-input {
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
button.selected {
|
||||
background-color: rgb(208 208 208);
|
||||
}
|
||||
|
||||
.input-buttons {
|
||||
line-height: 2.6em;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
/* POST CORRECTION */
|
||||
.leafcloud-logo .letters {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
/* DARK MODE OVERRIDES */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
html,
|
||||
.pure-g [class*="pure-u"],
|
||||
a {
|
||||
background-color: #161719;
|
||||
color: #d8d9da;
|
||||
}
|
||||
|
||||
.ip {
|
||||
border: 1px solid #313233;
|
||||
background: #212223;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: #8e8e8e !important;
|
||||
border-top: 1px solid #313233;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
active {
|
||||
background: #3d3e3f;
|
||||
}
|
||||
|
||||
svg.github-corner {
|
||||
fill: #f8f9fa;
|
||||
color: #161719;
|
||||
}
|
||||
|
||||
.info-table th[scope="row"] {
|
||||
background-color: #2e2e2e;
|
||||
color: rgb(220, 220, 220);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pure-button {
|
||||
background-color: #2e2e2e;
|
||||
color: rgb(220, 220, 220);
|
||||
}
|
||||
|
||||
.pure-button.selected {
|
||||
background-color: rgb(125 125 125);
|
||||
}
|
||||
|
||||
.pure-input {
|
||||
background-color: #e6e6e6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pure-input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leafcloud-logo .letters {
|
||||
fill: white;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.leafcloud-placement {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leafcloud-logo {
|
||||
height: 80px;
|
||||
margin: 2em 0 -0.5em 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.leafcloud-logo {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.debug {
|
||||
outline: 1px dotted pink;
|
||||
}
|
||||
|
||||
</style>
|
99
http/cache.go
Normal file
99
http/cache.go
Normal file
@ -0,0 +1,99 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
capacity int
|
||||
mu sync.RWMutex
|
||||
entries map[uint64]*list.Element
|
||||
values *list.List
|
||||
evictions uint64
|
||||
}
|
||||
|
||||
type CacheStats struct {
|
||||
Capacity int
|
||||
Size int
|
||||
Evictions uint64
|
||||
}
|
||||
|
||||
func NewCache(capacity int) *Cache {
|
||||
if capacity < 0 {
|
||||
capacity = 0
|
||||
}
|
||||
return &Cache{
|
||||
capacity: capacity,
|
||||
entries: make(map[uint64]*list.Element),
|
||||
values: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func key(ip net.IP) uint64 {
|
||||
h := fnv.New64a()
|
||||
h.Write(ip)
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
func (c *Cache) Set(ip net.IP, resp Response) {
|
||||
if c.capacity == 0 {
|
||||
return
|
||||
}
|
||||
k := key(ip)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
minEvictions := len(c.entries) - c.capacity + 1
|
||||
if minEvictions > 0 { // At or above capacity. Shrink the cache
|
||||
evicted := 0
|
||||
for el := c.values.Front(); el != nil && evicted < minEvictions; {
|
||||
value := el.Value.(Response)
|
||||
delete(c.entries, key(value.IP))
|
||||
next := el.Next()
|
||||
c.values.Remove(el)
|
||||
el = next
|
||||
evicted++
|
||||
}
|
||||
c.evictions += uint64(evicted)
|
||||
}
|
||||
current, ok := c.entries[k]
|
||||
if ok {
|
||||
c.values.Remove(current)
|
||||
}
|
||||
c.entries[k] = c.values.PushBack(resp)
|
||||
}
|
||||
|
||||
func (c *Cache) Get(ip net.IP) (Response, bool) {
|
||||
k := key(ip)
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
r, ok := c.entries[k]
|
||||
if !ok {
|
||||
return Response{}, false
|
||||
}
|
||||
return r.Value.(Response), true
|
||||
}
|
||||
|
||||
func (c *Cache) Resize(capacity int) error {
|
||||
if capacity < 0 {
|
||||
return fmt.Errorf("invalid capacity: %d\n", capacity)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.capacity = capacity
|
||||
c.evictions = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Stats() CacheStats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return CacheStats{
|
||||
Size: len(c.entries),
|
||||
Capacity: c.capacity,
|
||||
Evictions: c.evictions,
|
||||
}
|
||||
}
|
87
http/cache_test.go
Normal file
87
http/cache_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCacheCapacity(t *testing.T) {
|
||||
var tests = []struct {
|
||||
addCount, capacity, size int
|
||||
evictions uint64
|
||||
}{
|
||||
{1, 0, 0, 0},
|
||||
{1, 2, 1, 0},
|
||||
{2, 2, 2, 0},
|
||||
{3, 2, 2, 1},
|
||||
{10, 5, 5, 5},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
c := NewCache(tt.capacity)
|
||||
var responses []Response
|
||||
for i := 0; i < tt.addCount; i++ {
|
||||
ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i))
|
||||
r := Response{IP: ip}
|
||||
responses = append(responses, r)
|
||||
c.Set(ip, r)
|
||||
}
|
||||
if got := len(c.entries); got != tt.size {
|
||||
t.Errorf("#%d: len(entries) = %d, want %d", i, got, tt.size)
|
||||
}
|
||||
if got := c.evictions; got != tt.evictions {
|
||||
t.Errorf("#%d: evictions = %d, want %d", i, got, tt.evictions)
|
||||
}
|
||||
if tt.capacity > 0 && tt.addCount > tt.capacity && tt.capacity == tt.size {
|
||||
lastAdded := responses[tt.addCount-1]
|
||||
if _, ok := c.Get(lastAdded.IP); !ok {
|
||||
t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, lastAdded.IP.String(), ok, !ok)
|
||||
}
|
||||
firstAdded := responses[0]
|
||||
if _, ok := c.Get(firstAdded.IP); ok {
|
||||
t.Errorf("#%d: Get(%s) = (_, %t), want (_, %t)", i, firstAdded.IP.String(), ok, !ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDuplicate(t *testing.T) {
|
||||
c := NewCache(10)
|
||||
ip := net.ParseIP("192.0.2.1")
|
||||
response := Response{IP: ip}
|
||||
c.Set(ip, response)
|
||||
c.Set(ip, response)
|
||||
want := 1
|
||||
if got := len(c.entries); got != want {
|
||||
t.Errorf("want %d entries, got %d", want, got)
|
||||
}
|
||||
if got := c.values.Len(); got != want {
|
||||
t.Errorf("want %d values, got %d", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheResize(t *testing.T) {
|
||||
c := NewCache(10)
|
||||
for i := 1; i <= 20; i++ {
|
||||
ip := net.ParseIP(fmt.Sprintf("192.0.2.%d", i))
|
||||
r := Response{IP: ip}
|
||||
c.Set(ip, r)
|
||||
}
|
||||
if got, want := len(c.entries), 10; got != want {
|
||||
t.Errorf("want %d entries, got %d", want, got)
|
||||
}
|
||||
if got, want := c.evictions, uint64(10); got != want {
|
||||
t.Errorf("want %d evictions, got %d", want, got)
|
||||
}
|
||||
if err := c.Resize(5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := c.evictions, uint64(0); got != want {
|
||||
t.Errorf("want %d evictions, got %d", want, got)
|
||||
}
|
||||
r := Response{IP: net.ParseIP("192.0.2.42")}
|
||||
c.Set(r.IP, r)
|
||||
if got, want := len(c.entries), 5; got != want {
|
||||
t.Errorf("want %d entries, got %d", want, got)
|
||||
}
|
||||
}
|
40
http/error.go
Normal file
40
http/error.go
Normal file
@ -0,0 +1,40 @@
|
||||
package http
|
||||
|
||||
import "net/http"
|
||||
|
||||
type appError struct {
|
||||
Error error
|
||||
Message string
|
||||
Code int
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func internalServerError(err error) *appError {
|
||||
return &appError{
|
||||
Error: err,
|
||||
Message: "Internal server error",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
func notFound(err error) *appError {
|
||||
return &appError{Error: err, Code: http.StatusNotFound}
|
||||
}
|
||||
|
||||
func badRequest(err error) *appError {
|
||||
return &appError{Error: err, Code: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
func (e *appError) AsJSON() *appError {
|
||||
e.ContentType = jsonMediaType
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *appError) WithMessage(message string) *appError {
|
||||
e.Message = message
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *appError) IsJSON() bool {
|
||||
return e.ContentType == jsonMediaType
|
||||
}
|
466
http/http.go
Normal file
466
http/http.go
Normal file
@ -0,0 +1,466 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"net/http/pprof"
|
||||
|
||||
"github.com/mpolden/echoip/iputil"
|
||||
"github.com/mpolden/echoip/iputil/geo"
|
||||
"github.com/mpolden/echoip/useragent"
|
||||
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
jsonMediaType = "application/json"
|
||||
textMediaType = "text/plain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Template string
|
||||
IPHeaders []string
|
||||
LookupAddr func(net.IP) (string, error)
|
||||
LookupPort func(net.IP, uint64) error
|
||||
cache *Cache
|
||||
gr geo.Reader
|
||||
profile bool
|
||||
Sponsor bool
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
IP net.IP `json:"ip"`
|
||||
IPDecimal *big.Int `json:"ip_decimal"`
|
||||
Country string `json:"country,omitempty"`
|
||||
CountryISO string `json:"country_iso,omitempty"`
|
||||
CountryEU *bool `json:"country_eu,omitempty"`
|
||||
RegionName string `json:"region_name,omitempty"`
|
||||
RegionCode string `json:"region_code,omitempty"`
|
||||
MetroCode uint `json:"metro_code,omitempty"`
|
||||
PostalCode string `json:"zip_code,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
Timezone string `json:"time_zone,omitempty"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
ASNOrg string `json:"asn_org,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
UserAgent *useragent.UserAgent `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
type PortResponse struct {
|
||||
IP net.IP `json:"ip"`
|
||||
Port uint64 `json:"port"`
|
||||
Reachable bool `json:"reachable"`
|
||||
}
|
||||
|
||||
func New(db geo.Reader, cache *Cache, profile bool) *Server {
|
||||
return &Server{cache: cache, gr: db, profile: profile}
|
||||
}
|
||||
|
||||
func ipFromForwardedForHeader(v string) string {
|
||||
sep := strings.Index(v, ",")
|
||||
if sep == -1 {
|
||||
return v
|
||||
}
|
||||
return v[:sep]
|
||||
}
|
||||
|
||||
// ipFromRequest detects the IP address for this transaction.
|
||||
//
|
||||
// * `headers` - the specific HTTP headers to trust
|
||||
// * `r` - the incoming HTTP request
|
||||
// * `customIP` - whether to allow the IP to be pulled from query parameters
|
||||
func ipFromRequest(headers []string, r *http.Request, customIP bool) (net.IP, error) {
|
||||
remoteIP := ""
|
||||
if customIP && r.URL != nil {
|
||||
if v, ok := r.URL.Query()["ip"]; ok {
|
||||
remoteIP = v[0]
|
||||
}
|
||||
}
|
||||
if remoteIP == "" {
|
||||
for _, header := range headers {
|
||||
remoteIP = r.Header.Get(header)
|
||||
if http.CanonicalHeaderKey(header) == "X-Forwarded-For" {
|
||||
remoteIP = ipFromForwardedForHeader(remoteIP)
|
||||
}
|
||||
if remoteIP != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if remoteIP == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteIP = host
|
||||
}
|
||||
ip := net.ParseIP(remoteIP)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("could not parse IP: %s", remoteIP)
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func userAgentFromRequest(r *http.Request) *useragent.UserAgent {
|
||||
var userAgent *useragent.UserAgent
|
||||
userAgentRaw := r.UserAgent()
|
||||
if userAgentRaw != "" {
|
||||
parsed := useragent.Parse(userAgentRaw)
|
||||
userAgent = &parsed
|
||||
}
|
||||
return userAgent
|
||||
}
|
||||
|
||||
func (s *Server) newResponse(r *http.Request) (Response, error) {
|
||||
ip, err := ipFromRequest(s.IPHeaders, r, true)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
response, ok := s.cache.Get(ip)
|
||||
if ok {
|
||||
// Do not cache user agent
|
||||
response.UserAgent = userAgentFromRequest(r)
|
||||
return response, nil
|
||||
}
|
||||
ipDecimal := iputil.ToDecimal(ip)
|
||||
country, _ := s.gr.Country(ip)
|
||||
city, _ := s.gr.City(ip)
|
||||
asn, _ := s.gr.ASN(ip)
|
||||
var hostname string
|
||||
if s.LookupAddr != nil {
|
||||
hostname, _ = s.LookupAddr(ip)
|
||||
}
|
||||
var autonomousSystemNumber string
|
||||
if asn.AutonomousSystemNumber > 0 {
|
||||
autonomousSystemNumber = fmt.Sprintf("AS%d", asn.AutonomousSystemNumber)
|
||||
}
|
||||
response = Response{
|
||||
IP: ip,
|
||||
IPDecimal: ipDecimal,
|
||||
Country: country.Name,
|
||||
CountryISO: country.ISO,
|
||||
CountryEU: country.IsEU,
|
||||
RegionName: city.RegionName,
|
||||
RegionCode: city.RegionCode,
|
||||
MetroCode: city.MetroCode,
|
||||
PostalCode: city.PostalCode,
|
||||
City: city.Name,
|
||||
Latitude: city.Latitude,
|
||||
Longitude: city.Longitude,
|
||||
Timezone: city.Timezone,
|
||||
ASN: autonomousSystemNumber,
|
||||
ASNOrg: asn.AutonomousSystemOrganization,
|
||||
Hostname: hostname,
|
||||
}
|
||||
s.cache.Set(ip, response)
|
||||
response.UserAgent = userAgentFromRequest(r)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Server) newPortResponse(r *http.Request) (PortResponse, error) {
|
||||
lastElement := filepath.Base(r.URL.Path)
|
||||
port, err := strconv.ParseUint(lastElement, 10, 16)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return PortResponse{Port: port}, fmt.Errorf("invalid port: %s", lastElement)
|
||||
}
|
||||
ip, err := ipFromRequest(s.IPHeaders, r, false)
|
||||
if err != nil {
|
||||
return PortResponse{Port: port}, err
|
||||
}
|
||||
err = s.LookupPort(ip, port)
|
||||
return PortResponse{
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Reachable: err == nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CLIHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
ip, err := ipFromRequest(s.IPHeaders, r, true)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintln(w, ip.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CLICountryHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintln(w, response.Country)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CLICountryISOHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintln(w, response.CountryISO)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CLICityHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintln(w, response.City)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CLICoordinatesHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintf(w, "%s,%s\n", formatCoordinate(response.Latitude), formatCoordinate(response.Longitude))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CLIASNHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
fmt.Fprintf(w, "%s\n", response.ASN)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) JSONHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
if err != nil {
|
||||
return internalServerError(err).AsJSON()
|
||||
}
|
||||
w.Header().Set("Content-Type", jsonMediaType)
|
||||
w.Write(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) HealthHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
w.Header().Set("Content-Type", jsonMediaType)
|
||||
w.Write([]byte(`{"status":"OK"}`))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) PortHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newPortResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
if err != nil {
|
||||
return internalServerError(err).AsJSON()
|
||||
}
|
||||
w.Header().Set("Content-Type", jsonMediaType)
|
||||
w.Write(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cacheResizeHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
capacity, err := strconv.Atoi(string(body))
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
if err := s.cache.Resize(capacity); err != nil {
|
||||
return badRequest(err).WithMessage(err.Error()).AsJSON()
|
||||
}
|
||||
data := struct {
|
||||
Message string `json:"message"`
|
||||
}{fmt.Sprintf("Changed cache capacity to %d.", capacity)}
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return internalServerError(err).AsJSON()
|
||||
}
|
||||
w.Header().Set("Content-Type", jsonMediaType)
|
||||
w.Write(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cacheHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
cacheStats := s.cache.Stats()
|
||||
var data = struct {
|
||||
Size int `json:"size"`
|
||||
Capacity int `json:"capacity"`
|
||||
Evictions uint64 `json:"evictions"`
|
||||
}{
|
||||
cacheStats.Size,
|
||||
cacheStats.Capacity,
|
||||
cacheStats.Evictions,
|
||||
}
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return internalServerError(err).AsJSON()
|
||||
}
|
||||
w.Header().Set("Content-Type", jsonMediaType)
|
||||
w.Write(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
response, err := s.newResponse(r)
|
||||
if err != nil {
|
||||
return badRequest(err).WithMessage(err.Error())
|
||||
}
|
||||
t, err := template.ParseGlob(s.Template + "/*")
|
||||
if err != nil {
|
||||
return internalServerError(err)
|
||||
}
|
||||
json, err := json.MarshalIndent(response, "", " ")
|
||||
if err != nil {
|
||||
return internalServerError(err)
|
||||
}
|
||||
|
||||
var data = struct {
|
||||
Response
|
||||
Host string
|
||||
BoxLatTop float64
|
||||
BoxLatBottom float64
|
||||
BoxLonLeft float64
|
||||
BoxLonRight float64
|
||||
JSON string
|
||||
Port bool
|
||||
Sponsor bool
|
||||
}{
|
||||
response,
|
||||
r.Host,
|
||||
response.Latitude + 0.05,
|
||||
response.Latitude - 0.05,
|
||||
response.Longitude - 0.05,
|
||||
response.Longitude + 0.05,
|
||||
string(json),
|
||||
s.LookupPort != nil,
|
||||
s.Sponsor,
|
||||
}
|
||||
if err := t.Execute(w, &data); err != nil {
|
||||
return internalServerError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) *appError {
|
||||
err := notFound(nil).WithMessage("404 page not found")
|
||||
if r.Header.Get("accept") == jsonMediaType {
|
||||
err = err.AsJSON()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func cliMatcher(r *http.Request) bool {
|
||||
ua := useragent.Parse(r.UserAgent())
|
||||
switch ua.Product {
|
||||
case "curl", "HTTPie", "httpie-go", "Wget", "fetch libfetch", "Go", "Go-http-client", "ddclient", "Mikrotik", "xh":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type appHandler func(http.ResponseWriter, *http.Request) *appError
|
||||
|
||||
func wrapHandlerFunc(f http.HandlerFunc) appHandler {
|
||||
return func(w http.ResponseWriter, r *http.Request) *appError {
|
||||
f.ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if e := fn(w, r); e != nil { // e is *appError
|
||||
if e.Code/100 == 5 {
|
||||
log.Println(e.Error)
|
||||
}
|
||||
// When Content-Type for error is JSON, we need to marshal the response into JSON
|
||||
if e.IsJSON() {
|
||||
var data = struct {
|
||||
Code int `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}{e.Code, e.Message}
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
e.Message = string(b)
|
||||
}
|
||||
// Set Content-Type of response if set in error
|
||||
if e.ContentType != "" {
|
||||
w.Header().Set("Content-Type", e.ContentType)
|
||||
}
|
||||
w.WriteHeader(e.Code)
|
||||
fmt.Fprint(w, e.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
r := NewRouter()
|
||||
|
||||
// Health
|
||||
r.Route("GET", "/health", s.HealthHandler)
|
||||
|
||||
// JSON
|
||||
r.Route("GET", "/", s.JSONHandler).Header("Accept", jsonMediaType)
|
||||
r.Route("GET", "/json", s.JSONHandler)
|
||||
|
||||
// CLI
|
||||
r.Route("GET", "/", s.CLIHandler).MatcherFunc(cliMatcher)
|
||||
r.Route("GET", "/", s.CLIHandler).Header("Accept", textMediaType)
|
||||
r.Route("GET", "/ip", s.CLIHandler)
|
||||
if !s.gr.IsEmpty() {
|
||||
r.Route("GET", "/country", s.CLICountryHandler)
|
||||
r.Route("GET", "/country-iso", s.CLICountryISOHandler)
|
||||
r.Route("GET", "/city", s.CLICityHandler)
|
||||
r.Route("GET", "/coordinates", s.CLICoordinatesHandler)
|
||||
r.Route("GET", "/asn", s.CLIASNHandler)
|
||||
}
|
||||
|
||||
// Browser
|
||||
if s.Template != "" {
|
||||
r.Route("GET", "/", s.DefaultHandler)
|
||||
}
|
||||
|
||||
// Port testing
|
||||
if s.LookupPort != nil {
|
||||
r.RoutePrefix("GET", "/port/", s.PortHandler)
|
||||
}
|
||||
|
||||
// Profiling
|
||||
if s.profile {
|
||||
r.Route("POST", "/debug/cache/resize", s.cacheResizeHandler)
|
||||
r.Route("GET", "/debug/cache/", s.cacheHandler)
|
||||
r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline))
|
||||
r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile))
|
||||
r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol))
|
||||
r.Route("GET", "/debug/pprof/trace", wrapHandlerFunc(pprof.Trace))
|
||||
r.RoutePrefix("GET", "/debug/pprof/", wrapHandlerFunc(pprof.Index))
|
||||
}
|
||||
|
||||
return r.Handler()
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
return http.ListenAndServe(addr, s.Handler())
|
||||
}
|
||||
|
||||
func formatCoordinate(c float64) string {
|
||||
return strconv.FormatFloat(c, 'f', 6, 64)
|
||||
}
|
279
http/http_test.go
Normal file
279
http/http_test.go
Normal file
@ -0,0 +1,279 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mpolden/echoip/iputil/geo"
|
||||
)
|
||||
|
||||
func lookupAddr(net.IP) (string, error) { return "localhost", nil }
|
||||
func lookupPort(net.IP, uint64) error { return nil }
|
||||
|
||||
type testDb struct{}
|
||||
|
||||
func (t *testDb) Country(net.IP) (geo.Country, error) {
|
||||
return geo.Country{Name: "Elbonia", ISO: "EB", IsEU: new(bool)}, nil
|
||||
}
|
||||
|
||||
func (t *testDb) City(net.IP) (geo.City, error) {
|
||||
return geo.City{Name: "Bornyasherk", RegionName: "North Elbonia", RegionCode: "1234", MetroCode: 1234, PostalCode: "1234", Latitude: 63.416667, Longitude: 10.416667, Timezone: "Europe/Bornyasherk"}, nil
|
||||
}
|
||||
|
||||
func (t *testDb) ASN(net.IP) (geo.ASN, error) {
|
||||
return geo.ASN{AutonomousSystemNumber: 59795, AutonomousSystemOrganization: "Hosting4Real"}, nil
|
||||
}
|
||||
|
||||
func (t *testDb) IsEmpty() bool { return false }
|
||||
|
||||
func testServer() *Server {
|
||||
return &Server{cache: NewCache(100), gr: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort}
|
||||
}
|
||||
|
||||
func httpGet(url string, acceptMediaType string, userAgent string) (string, int, error) {
|
||||
r, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if acceptMediaType != "" {
|
||||
r.Header.Set("Accept", acceptMediaType)
|
||||
}
|
||||
r.Header.Set("User-Agent", userAgent)
|
||||
res, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return string(data), res.StatusCode, nil
|
||||
}
|
||||
|
||||
func httpPost(url, body string) (*http.Response, string, error) {
|
||||
r, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return res, string(data), nil
|
||||
}
|
||||
|
||||
func TestCLIHandlers(t *testing.T) {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
s := httptest.NewServer(testServer().Handler())
|
||||
|
||||
var tests = []struct {
|
||||
url string
|
||||
out string
|
||||
status int
|
||||
userAgent string
|
||||
acceptMediaType string
|
||||
}{
|
||||
{s.URL, "127.0.0.1\n", 200, "curl/7.43.0", ""},
|
||||
{s.URL, "127.0.0.1\n", 200, "foo/bar", textMediaType},
|
||||
{s.URL + "/ip", "127.0.0.1\n", 200, "", ""},
|
||||
{s.URL + "/country", "Elbonia\n", 200, "", ""},
|
||||
{s.URL + "/country-iso", "EB\n", 200, "", ""},
|
||||
{s.URL + "/coordinates", "63.416667,10.416667\n", 200, "", ""},
|
||||
{s.URL + "/city", "Bornyasherk\n", 200, "", ""},
|
||||
{s.URL + "/foo", "404 page not found", 404, "", ""},
|
||||
{s.URL + "/asn", "AS59795\n", 200, "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
out, status, err := httpGet(tt.url, tt.acceptMediaType, tt.userAgent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != tt.status {
|
||||
t.Errorf("Expected %d, got %d", tt.status, status)
|
||||
}
|
||||
if out != tt.out {
|
||||
t.Errorf("Expected %q, got %q", tt.out, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledHandlers(t *testing.T) {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
server := testServer()
|
||||
server.LookupPort = nil
|
||||
server.LookupAddr = nil
|
||||
server.gr, _ = geo.Open("", "", "")
|
||||
s := httptest.NewServer(server.Handler())
|
||||
|
||||
var tests = []struct {
|
||||
url string
|
||||
out string
|
||||
status int
|
||||
}{
|
||||
{s.URL + "/port/1337", "404 page not found", 404},
|
||||
{s.URL + "/country", "404 page not found", 404},
|
||||
{s.URL + "/country-iso", "404 page not found", 404},
|
||||
{s.URL + "/city", "404 page not found", 404},
|
||||
{s.URL + "/json", "{\n \"ip\": \"127.0.0.1\",\n \"ip_decimal\": 2130706433\n}", 200},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
out, status, err := httpGet(tt.url, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != tt.status {
|
||||
t.Errorf("Expected %d, got %d", tt.status, status)
|
||||
}
|
||||
if out != tt.out {
|
||||
t.Errorf("Expected %q, got %q", tt.out, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONHandlers(t *testing.T) {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
s := httptest.NewServer(testServer().Handler())
|
||||
|
||||
var tests = []struct {
|
||||
url string
|
||||
out string
|
||||
status int
|
||||
}{
|
||||
{s.URL, "{\n \"ip\": \"127.0.0.1\",\n \"ip_decimal\": 2130706433,\n \"country\": \"Elbonia\",\n \"country_iso\": \"EB\",\n \"country_eu\": false,\n \"region_name\": \"North Elbonia\",\n \"region_code\": \"1234\",\n \"metro_code\": 1234,\n \"zip_code\": \"1234\",\n \"city\": \"Bornyasherk\",\n \"latitude\": 63.416667,\n \"longitude\": 10.416667,\n \"time_zone\": \"Europe/Bornyasherk\",\n \"asn\": \"AS59795\",\n \"asn_org\": \"Hosting4Real\",\n \"hostname\": \"localhost\",\n \"user_agent\": {\n \"product\": \"curl\",\n \"version\": \"7.2.6.0\",\n \"raw_value\": \"curl/7.2.6.0\"\n }\n}", 200},
|
||||
{s.URL + "/port/foo", "{\n \"status\": 400,\n \"error\": \"invalid port: foo\"\n}", 400},
|
||||
{s.URL + "/port/0", "{\n \"status\": 400,\n \"error\": \"invalid port: 0\"\n}", 400},
|
||||
{s.URL + "/port/65537", "{\n \"status\": 400,\n \"error\": \"invalid port: 65537\"\n}", 400},
|
||||
{s.URL + "/port/31337", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 31337,\n \"reachable\": true\n}", 200},
|
||||
{s.URL + "/port/80", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 80,\n \"reachable\": true\n}", 200}, // checking that our test server is reachable on port 80
|
||||
{s.URL + "/port/80?ip=1.3.3.7", "{\n \"ip\": \"127.0.0.1\",\n \"port\": 80,\n \"reachable\": true\n}", 200}, // ensuring that the "ip" parameter is not usable to check remote host ports
|
||||
{s.URL + "/foo", "{\n \"status\": 404,\n \"error\": \"404 page not found\"\n}", 404},
|
||||
{s.URL + "/health", `{"status":"OK"}`, 200},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
out, status, err := httpGet(tt.url, jsonMediaType, "curl/7.2.6.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != tt.status {
|
||||
t.Errorf("Expected %d for %s, got %d", tt.status, tt.url, status)
|
||||
}
|
||||
if out != tt.out {
|
||||
t.Errorf("Expected %q for %s, got %q", tt.out, tt.url, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHandler(t *testing.T) {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
srv := testServer()
|
||||
srv.profile = true
|
||||
s := httptest.NewServer(srv.Handler())
|
||||
got, _, err := httpGet(s.URL+"/debug/cache/", jsonMediaType, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "{\n \"size\": 0,\n \"capacity\": 100,\n \"evictions\": 0\n}"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheResizeHandler(t *testing.T) {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
srv := testServer()
|
||||
srv.profile = true
|
||||
s := httptest.NewServer(srv.Handler())
|
||||
_, got, err := httpPost(s.URL+"/debug/cache/resize", "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "{\n \"message\": \"Changed cache capacity to 10.\"\n}"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPFromRequest(t *testing.T) {
|
||||
var tests = []struct {
|
||||
remoteAddr string
|
||||
headerKey string
|
||||
headerValue string
|
||||
trustedHeaders []string
|
||||
out string
|
||||
}{
|
||||
{"127.0.0.1:9999", "", "", nil, "127.0.0.1"}, // No header given
|
||||
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", nil, "127.0.0.1"}, // Trusted header is empty
|
||||
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", []string{"X-Foo-Bar"}, "127.0.0.1"}, // Trusted header does not match
|
||||
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", []string{"X-Real-IP", "X-Forwarded-For"}, "1.3.3.7"}, // Trusted header matches
|
||||
{"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7", []string{"X-Real-IP", "X-Forwarded-For"}, "1.3.3.7"}, // Second trusted header matches
|
||||
{"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7,4.2.4.2", []string{"X-Forwarded-For"}, "1.3.3.7"}, // X-Forwarded-For with multiple entries (commas separator)
|
||||
{"127.0.0.1:9999", "X-Forwarded-For", "1.3.3.7, 4.2.4.2", []string{"X-Forwarded-For"}, "1.3.3.7"}, // X-Forwarded-For with multiple entries (space+comma separator)
|
||||
{"127.0.0.1:9999", "X-Forwarded-For", "", []string{"X-Forwarded-For"}, "127.0.0.1"}, // Empty header
|
||||
{"127.0.0.1:9999?ip=1.2.3.4", "", "", nil, "1.2.3.4"}, // passed in "ip" parameter
|
||||
{"127.0.0.1:9999?ip=1.2.3.4", "X-Forwarded-For", "1.3.3.7,4.2.4.2", []string{"X-Forwarded-For"}, "1.2.3.4"}, // ip parameter wins over X-Forwarded-For with multiple entries
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u, err := url.Parse("http://" + tt.remoteAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &http.Request{
|
||||
RemoteAddr: u.Host,
|
||||
Header: http.Header{},
|
||||
URL: u,
|
||||
}
|
||||
r.Header.Add(tt.headerKey, tt.headerValue)
|
||||
ip, err := ipFromRequest(tt.trustedHeaders, r, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := net.ParseIP(tt.out)
|
||||
if !ip.Equal(out) {
|
||||
t.Errorf("Expected %s, got %s", out, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIMatcher(t *testing.T) {
|
||||
browserUserAgent := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " +
|
||||
"Safari/537.36"
|
||||
var tests = []struct {
|
||||
in string
|
||||
out bool
|
||||
}{
|
||||
{"curl/7.26.0", true},
|
||||
{"Wget/1.13.4 (linux-gnu)", true},
|
||||
{"Wget", true},
|
||||
{"fetch libfetch/2.0", true},
|
||||
{"HTTPie/0.9.3", true},
|
||||
{"httpie-go/0.6.0", true},
|
||||
{"Go 1.1 package http", true},
|
||||
{"Go-http-client/1.1", true},
|
||||
{"Go-http-client/2.0", true},
|
||||
{"ddclient/3.8.3", true},
|
||||
{"Mikrotik/6.x Fetch", true},
|
||||
{browserUserAgent, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r := &http.Request{Header: http.Header{"User-Agent": []string{tt.in}}}
|
||||
if got := cliMatcher(r); got != tt.out {
|
||||
t.Errorf("Expected %t, got %t for %q", tt.out, got, tt.in)
|
||||
}
|
||||
}
|
||||
}
|
73
http/router.go
Normal file
73
http/router.go
Normal file
@ -0,0 +1,73 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type router struct {
|
||||
routes []*route
|
||||
}
|
||||
|
||||
type route struct {
|
||||
method string
|
||||
path string
|
||||
prefix bool
|
||||
handler appHandler
|
||||
matcherFunc func(*http.Request) bool
|
||||
}
|
||||
|
||||
func NewRouter() *router {
|
||||
return &router{}
|
||||
}
|
||||
|
||||
func (r *router) Route(method, path string, handler appHandler) *route {
|
||||
route := route{
|
||||
method: method,
|
||||
path: path,
|
||||
handler: handler,
|
||||
}
|
||||
r.routes = append(r.routes, &route)
|
||||
return &route
|
||||
}
|
||||
|
||||
func (r *router) RoutePrefix(method, path string, handler appHandler) *route {
|
||||
route := r.Route(method, path, handler)
|
||||
route.prefix = true
|
||||
return route
|
||||
}
|
||||
|
||||
func (r *router) Handler() http.Handler {
|
||||
return appHandler(func(w http.ResponseWriter, req *http.Request) *appError {
|
||||
for _, route := range r.routes {
|
||||
if route.match(req) {
|
||||
return route.handler(w, req)
|
||||
}
|
||||
}
|
||||
return NotFoundHandler(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *route) Header(header, value string) {
|
||||
r.MatcherFunc(func(req *http.Request) bool {
|
||||
return req.Header.Get(header) == value
|
||||
})
|
||||
}
|
||||
|
||||
func (r *route) MatcherFunc(f func(*http.Request) bool) {
|
||||
r.matcherFunc = f
|
||||
}
|
||||
|
||||
func (r *route) match(req *http.Request) bool {
|
||||
if req.Method != r.method {
|
||||
return false
|
||||
}
|
||||
if r.prefix {
|
||||
if !strings.HasPrefix(req.URL.Path, r.path) {
|
||||
return false
|
||||
}
|
||||
} else if r.path != req.URL.Path {
|
||||
return false
|
||||
}
|
||||
return r.matcherFunc == nil || r.matcherFunc(req)
|
||||
}
|
157
iputil/geo/geo.go
Normal file
157
iputil/geo/geo.go
Normal file
@ -0,0 +1,157 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
|
||||
geoip2 "github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
type Reader interface {
|
||||
Country(net.IP) (Country, error)
|
||||
City(net.IP) (City, error)
|
||||
ASN(net.IP) (ASN, error)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
type Country struct {
|
||||
Name string
|
||||
ISO string
|
||||
IsEU *bool
|
||||
}
|
||||
|
||||
type City struct {
|
||||
Name string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
PostalCode string
|
||||
Timezone string
|
||||
MetroCode uint
|
||||
RegionName string
|
||||
RegionCode string
|
||||
}
|
||||
|
||||
type ASN struct {
|
||||
AutonomousSystemNumber uint
|
||||
AutonomousSystemOrganization string
|
||||
}
|
||||
|
||||
type geoip struct {
|
||||
country *geoip2.Reader
|
||||
city *geoip2.Reader
|
||||
asn *geoip2.Reader
|
||||
}
|
||||
|
||||
func Open(countryDB, cityDB string, asnDB string) (Reader, error) {
|
||||
var country, city, asn *geoip2.Reader
|
||||
if countryDB != "" {
|
||||
r, err := geoip2.Open(countryDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
country = r
|
||||
}
|
||||
if cityDB != "" {
|
||||
r, err := geoip2.Open(cityDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
city = r
|
||||
}
|
||||
if asnDB != "" {
|
||||
r, err := geoip2.Open(asnDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asn = r
|
||||
}
|
||||
return &geoip{country: country, city: city, asn: asn}, nil
|
||||
}
|
||||
|
||||
func (g *geoip) Country(ip net.IP) (Country, error) {
|
||||
country := Country{}
|
||||
if g.country == nil {
|
||||
return country, nil
|
||||
}
|
||||
record, err := g.country.Country(ip)
|
||||
if err != nil {
|
||||
return country, err
|
||||
}
|
||||
if c, exists := record.Country.Names["en"]; exists {
|
||||
country.Name = c
|
||||
}
|
||||
if c, exists := record.RegisteredCountry.Names["en"]; exists && country.Name == "" {
|
||||
country.Name = c
|
||||
}
|
||||
if record.Country.IsoCode != "" {
|
||||
country.ISO = record.Country.IsoCode
|
||||
}
|
||||
if record.RegisteredCountry.IsoCode != "" && country.ISO == "" {
|
||||
country.ISO = record.RegisteredCountry.IsoCode
|
||||
}
|
||||
isEU := record.Country.IsInEuropeanUnion || record.RegisteredCountry.IsInEuropeanUnion
|
||||
country.IsEU = &isEU
|
||||
return country, nil
|
||||
}
|
||||
|
||||
func (g *geoip) City(ip net.IP) (City, error) {
|
||||
city := City{}
|
||||
if g.city == nil {
|
||||
return city, nil
|
||||
}
|
||||
record, err := g.city.City(ip)
|
||||
if err != nil {
|
||||
return city, err
|
||||
}
|
||||
if c, exists := record.City.Names["en"]; exists {
|
||||
city.Name = c
|
||||
}
|
||||
if len(record.Subdivisions) > 0 {
|
||||
if c, exists := record.Subdivisions[0].Names["en"]; exists {
|
||||
city.RegionName = c
|
||||
}
|
||||
if record.Subdivisions[0].IsoCode != "" {
|
||||
city.RegionCode = record.Subdivisions[0].IsoCode
|
||||
}
|
||||
}
|
||||
if !math.IsNaN(record.Location.Latitude) {
|
||||
city.Latitude = record.Location.Latitude
|
||||
}
|
||||
if !math.IsNaN(record.Location.Longitude) {
|
||||
city.Longitude = record.Location.Longitude
|
||||
}
|
||||
// Metro code is US Only https://maxmind.github.io/GeoIP2-dotnet/doc/v2.7.1/html/P_MaxMind_GeoIP2_Model_Location_MetroCode.htm
|
||||
if record.Location.MetroCode > 0 && record.Country.IsoCode == "US" {
|
||||
city.MetroCode = record.Location.MetroCode
|
||||
}
|
||||
if record.Postal.Code != "" {
|
||||
city.PostalCode = record.Postal.Code
|
||||
}
|
||||
if record.Location.TimeZone != "" {
|
||||
city.Timezone = record.Location.TimeZone
|
||||
}
|
||||
|
||||
return city, nil
|
||||
}
|
||||
|
||||
func (g *geoip) ASN(ip net.IP) (ASN, error) {
|
||||
asn := ASN{}
|
||||
if g.asn == nil {
|
||||
return asn, nil
|
||||
}
|
||||
record, err := g.asn.ASN(ip)
|
||||
if err != nil {
|
||||
return asn, err
|
||||
}
|
||||
if record.AutonomousSystemNumber > 0 {
|
||||
asn.AutonomousSystemNumber = record.AutonomousSystemNumber
|
||||
}
|
||||
if record.AutonomousSystemOrganization != "" {
|
||||
asn.AutonomousSystemOrganization = record.AutonomousSystemOrganization
|
||||
}
|
||||
return asn, nil
|
||||
}
|
||||
|
||||
func (g *geoip) IsEmpty() bool {
|
||||
return g.country == nil && g.city == nil
|
||||
}
|
38
iputil/iputil.go
Normal file
38
iputil/iputil.go
Normal file
@ -0,0 +1,38 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func LookupAddr(ip net.IP) (string, error) {
|
||||
names, err := net.LookupAddr(ip.String())
|
||||
if err != nil || len(names) == 0 {
|
||||
return "", err
|
||||
}
|
||||
// Always return unrooted name
|
||||
return strings.TrimRight(names[0], "."), nil
|
||||
}
|
||||
|
||||
func LookupPort(ip net.IP, port uint64) error {
|
||||
address := fmt.Sprintf("[%s]:%d", ip, port)
|
||||
conn, err := net.DialTimeout("tcp", address, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func ToDecimal(ip net.IP) *big.Int {
|
||||
i := big.NewInt(0)
|
||||
if to4 := ip.To4(); to4 != nil {
|
||||
i.SetBytes(to4)
|
||||
} else {
|
||||
i.SetBytes(ip)
|
||||
}
|
||||
return i
|
||||
}
|
27
iputil/iputil_test.go
Normal file
27
iputil/iputil_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToDecimal(t *testing.T) {
|
||||
var msb = new(big.Int)
|
||||
msb, _ = msb.SetString("80000000000000000000000000000000", 16)
|
||||
|
||||
var tests = []struct {
|
||||
in string
|
||||
out *big.Int
|
||||
}{
|
||||
{"127.0.0.1", big.NewInt(2130706433)},
|
||||
{"::1", big.NewInt(1)},
|
||||
{"8000::", msb},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
i := ToDecimal(net.ParseIP(tt.in))
|
||||
if tt.out.Cmp(i) != 0 {
|
||||
t.Errorf("Expected %d, got %d for IP %s", tt.out, i, tt.in)
|
||||
}
|
||||
}
|
||||
}
|
40
useragent/useragent.go
Normal file
40
useragent/useragent.go
Normal file
@ -0,0 +1,40 @@
|
||||
package useragent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserAgent struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
RawValue string `json:"raw_value,omitempty"`
|
||||
}
|
||||
|
||||
func Parse(s string) UserAgent {
|
||||
parts := strings.SplitN(s, "/", 2)
|
||||
var version, comment string
|
||||
if len(parts) > 1 {
|
||||
// If first character is a number, treat it as version
|
||||
if len(parts[1]) > 0 && parts[1][0] >= 48 && parts[1][0] <= 57 {
|
||||
rest := strings.SplitN(parts[1], " ", 2)
|
||||
version = rest[0]
|
||||
if len(rest) > 1 {
|
||||
comment = rest[1]
|
||||
}
|
||||
} else {
|
||||
comment = parts[1]
|
||||
}
|
||||
} else {
|
||||
parts = strings.SplitN(s, " ", 2)
|
||||
if len(parts) > 1 {
|
||||
comment = parts[1]
|
||||
}
|
||||
}
|
||||
return UserAgent{
|
||||
Product: parts[0],
|
||||
Version: version,
|
||||
Comment: comment,
|
||||
RawValue: s,
|
||||
}
|
||||
}
|
39
useragent/useragent_test.go
Normal file
39
useragent/useragent_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package useragent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
var tests = []struct {
|
||||
in string
|
||||
out UserAgent
|
||||
}{
|
||||
{"", UserAgent{}},
|
||||
{"curl/", UserAgent{Product: "curl"}},
|
||||
{"curl/foo", UserAgent{Product: "curl", Comment: "foo"}},
|
||||
{"curl/7.26.0", UserAgent{Product: "curl", Version: "7.26.0"}},
|
||||
{"Wget/1.13.4 (linux-gnu)", UserAgent{Product: "Wget", Version: "1.13.4", Comment: "(linux-gnu)"}},
|
||||
{"Wget", UserAgent{Product: "Wget"}},
|
||||
{"fetch libfetch/2.0", UserAgent{Product: "fetch libfetch", Version: "2.0"}},
|
||||
{"Go 1.1 package http", UserAgent{Product: "Go", Comment: "1.1 package http"}},
|
||||
{"Mikrotik/6.x Fetch", UserAgent{Product: "Mikrotik", Version: "6.x", Comment: "Fetch"}},
|
||||
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " +
|
||||
"Safari/537.36", UserAgent{Product: "Mozilla", Version: "5.0", Comment: "(Macintosh; Intel Mac OS X 10_8_4) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.28 " +
|
||||
"Safari/537.36"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ua := Parse(tt.in)
|
||||
if got := ua.Product; got != tt.out.Product {
|
||||
t.Errorf("got Product=%q for %q, want %q", got, tt.in, tt.out.Product)
|
||||
}
|
||||
if got := ua.Version; got != tt.out.Version {
|
||||
t.Errorf("got Version=%q for %q, want %q", got, tt.in, tt.out.Version)
|
||||
}
|
||||
if got := ua.Comment; got != tt.out.Comment {
|
||||
t.Errorf("got Comment=%q for %q, want %q", got, tt.in, tt.out.Comment)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user