Merge pull request #2 from mpolden/master

Upgrade to latest
This commit is contained in:
Johann Richard 2018-10-28 11:21:58 +01:00 committed by GitHub
commit 72f121cc9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 868 additions and 610 deletions

5
.gitignore vendored
View File

@ -1,2 +1,3 @@
/GeoLite2-Country.mmdb
/GeoLite2-City.mmdb
/data/
/custom.html
/vendor/

View File

@ -3,5 +3,15 @@ sudo: false
language: go
go:
- 1.x
- tip
- stable
# Go module behaviour is disabled by default for packages inside GOPATH.
# Turn module support on explicitly:
env:
- GO111MODULE=on
deploy:
provider: heroku
app: ifconfig-co
api_key:
secure: IQG/ls5Zu0yua5Ynn5EL9JCPjo1/WcmS0z7BSaXWdgW+JIWFm7oF5z54bUZHl/q1tTuWzAJk59zSTYJijtQqh2Ssl3fLu3uFDwyJSrOuUu1akPlETam8NpdbH4lPkFp75JSDdDXV08c0APmeLL6gqRuTrUuufu69Wigjq4gLo+o=

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# Compile
FROM golang:1.11-alpine AS build
ADD . /go/src/github.com/mpolden/echoip
WORKDIR /go/src/github.com/mpolden/echoip
RUN apk --update add git gcc musl-dev
ENV GO111MODULE=on
RUN go get -d -v .
RUN go install ./...
# Run
FROM alpine
RUN mkdir -p /opt/
COPY --from=build /go/bin/echoip /opt/
WORKDIR /opt/
ENTRYPOINT ["/opt/echoip"]

View File

@ -1,4 +1,4 @@
Copyright (c) 2012-2017, Martin Polden
Copyright (c) 2012-2018, Martin Polden
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@ -1,20 +1,33 @@
all: deps test vet install
OS := $(shell uname)
ifeq ($(OS),Linux)
TAR_OPTS := --wildcards
endif
fmt:
go fmt ./...
test:
go test ./...
vet:
go vet ./...
all: deps lint test install
deps:
go get -d -v ./...
go get ./...
install:
go install
test: deps
go test ./...
get-geoip-dbs:
curl -s http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.mmdb.gz | gunzip > GeoLite2-Country.mmdb
curl -s http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz | gunzip > GeoLite2-City.mmdb
vet: deps
go vet ./...
check-fmt:
bash -c "diff --line-format='%L' <(echo -n) <(gofmt -d -s .)"
lint: check-fmt vet
install: deps
go install ./...
databases := GeoLite2-City GeoLite2-Country
$(databases):
mkdir -p data
curl -fsSL -m 30 https://geolite.maxmind.com/download/geoip/database/$@.tar.gz | tar $(TAR_OPTS) --strip-components=1 -C $(PWD)/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
geoip-download: $(databases)

View File

@ -1,9 +1,9 @@
# ipd
# echoip
[![Build Status](https://travis-ci.org/mpolden/ipd.svg)](https://travis-ci.org/mpolden/ipd)
[![Build Status](https://travis-ci.org/mpolden/echoip.svg)](https://travis-ci.org/mpolden/echoip)
A simple service for looking up your IP address. This is the code that powers
https://ifconfig.co
https://ifconfig.co.
## Usage
@ -19,7 +19,7 @@ $ http ifconfig.co
$ wget -qO- ifconfig.co
127.0.0.1
$ fetch -qo- http://ifconfig.co
$ fetch -qo- https://ifconfig.co
127.0.0.1
$ bat -print=b ifconfig.co/ip
@ -29,41 +29,54 @@ $ bat -print=b ifconfig.co/ip
Country and city lookup:
```
$ http ifconfig.co/country
$ curl ifconfig.co/country
Elbonia
$ http ifconfig.co/city
$ curl ifconfig.co/country-iso
EB
$ curl ifconfig.co/city
Bornyasherk
```
As JSON:
```
$ http --json ifconfig.co
$ 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
}
```
Pass the appropriate flag (usually `-4` and `-6`) to your tool to switch between
IPv4 and IPv6 lookup.
Port testing:
The subdomains https://v4.ifconfig.co and https://v6.ifconfig.co can be used to
force IPv4 or IPv6 lookup.
```
$ 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
* Supports IPv4 and IPv6
* Supports HTTPS
* Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause)
* Fast
* Supports typical CLI tools (`curl`, `httpie`, `wget` and `fetch`)
* JSON output (optional)
* Country and city lookup through the MaxMind GeoIP database
* Supports IPv6
* Supports HTTPS
* Supports common command-line clients (e.g. `curl`, `httpie`, `wget` and `fetch`)
* JSON output
* Country and city lookup using the MaxMind GeoIP database
* Port testing
* Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause)
## Why?
@ -74,9 +87,9 @@ force IPv4 or IPv6 lookup.
## Building
Compiling requires the [Golang compiler](https://golang.org/) to be installed.
This application can be installed by using `go get`:
This package can be installed with `go get`:
`go get github.com/mpolden/ipd`
`go get github.com/mpolden/echoip/...`
For more information on building a Go project, see the [official Go
documentation](https://golang.org/doc/code.html).
@ -84,20 +97,19 @@ documentation](https://golang.org/doc/code.html).
### Usage
```
$ ipd -h
$ echoip -h
Usage:
ipd [OPTIONS]
echoip [OPTIONS]
Application Options:
-f, --country-db=FILE Path to GeoIP country database
-c, --city-db=FILE Path to GeoIP city database
-l, --listen=ADDR Listening address (default: :8080)
-r, --reverse-lookup Perform reverse hostname lookups
-p, --port-lookup Enable port lookup
-t, --template=FILE Path to template (default: index.html)
-H, --trusted-header=NAME Header to trust for remote IP, if present (e.g. X-Real-IP)
-L, --log-level=[debug|info|warn|error|fatal|panic] Log level to use (default: info)
-f, --country-db=FILE Path to GeoIP country database
-c, --city-db=FILE Path to GeoIP city database
-l, --listen=ADDR Listening address (default: :8080)
-r, --reverse-lookup Perform reverse hostname lookups
-p, --port-lookup Enable port lookup
-t, --template=FILE Path to template (default: index.html)
-H, --trusted-header=NAME Header to trust for remote IP, if present (e.g. X-Real-IP)
Help Options:
-h, --help Show this help message
-h, --help Show this help message
```

View File

@ -1,265 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"html/template"
"github.com/mpolden/ipd/useragent"
"github.com/sirupsen/logrus"
"math/big"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gorilla/mux"
)
const (
jsonMediaType = "application/json"
textMediaType = "text/plain"
)
type API struct {
Template string
IPHeader string
oracle Oracle
log *logrus.Logger
}
type Response struct {
IP net.IP `json:"ip"`
IPDecimal *big.Int `json:"ip_decimal"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
type PortResponse struct {
IP net.IP `json:"ip"`
Port uint64 `json:"port"`
Reachable bool `json:"reachable"`
}
func New(oracle Oracle, logger *logrus.Logger) *API {
return &API{oracle: oracle, log: logger}
}
func ipToDecimal(ip net.IP) *big.Int {
i := big.NewInt(0)
if to4 := ip.To4(); to4 != nil {
i.SetBytes(to4)
} else {
i.SetBytes(ip)
}
return i
}
func ipFromRequest(header string, r *http.Request) (net.IP, error) {
remoteIP := r.Header.Get(header)
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 (a *API) newResponse(r *http.Request) (Response, error) {
ip, err := ipFromRequest(a.IPHeader, r)
if err != nil {
return Response{}, err
}
ipDecimal := ipToDecimal(ip)
country, err := a.oracle.LookupCountry(ip)
if err != nil {
a.log.Debug(err)
}
city, err := a.oracle.LookupCity(ip)
if err != nil {
a.log.Debug(err)
}
hostnames, err := a.oracle.LookupAddr(ip)
if err != nil {
a.log.Debug(err)
}
return Response{
IP: ip,
IPDecimal: ipDecimal,
Country: country,
City: city,
Hostname: strings.Join(hostnames, " "),
}, nil
}
func (a *API) newPortResponse(r *http.Request) (PortResponse, error) {
vars := mux.Vars(r)
port, err := strconv.ParseUint(vars["port"], 10, 16)
if err != nil {
return PortResponse{Port: port}, err
}
if port < 1 || port > 65355 {
return PortResponse{Port: port}, fmt.Errorf("invalid port: %d", port)
}
ip, err := ipFromRequest(a.IPHeader, r)
if err != nil {
return PortResponse{Port: port}, err
}
err = a.oracle.LookupPort(ip, port)
return PortResponse{
IP: ip,
Port: port,
Reachable: err == nil,
}, nil
}
func (a *API) CLIHandler(w http.ResponseWriter, r *http.Request) *appError {
ip, err := ipFromRequest(a.IPHeader, r)
if err != nil {
return internalServerError(err)
}
fmt.Fprintln(w, ip.String())
return nil
}
func (a *API) CLICountryHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := a.newResponse(r)
if err != nil {
return internalServerError(err)
}
fmt.Fprintln(w, response.Country)
return nil
}
func (a *API) CLICityHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := a.newResponse(r)
if err != nil {
return internalServerError(err)
}
fmt.Fprintln(w, response.City)
return nil
}
func (a *API) JSONHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := a.newResponse(r)
if err != nil {
return internalServerError(err).AsJSON()
}
b, err := json.Marshal(response)
if err != nil {
return internalServerError(err).AsJSON()
}
w.Header().Set("Content-Type", jsonMediaType)
w.Write(b)
return nil
}
func (a *API) PortHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := a.newPortResponse(r)
if err != nil {
return badRequest(err).WithMessage(fmt.Sprintf("Invalid port: %d", response.Port)).AsJSON()
}
b, err := json.Marshal(response)
if err != nil {
return internalServerError(err).AsJSON()
}
w.Header().Set("Content-Type", jsonMediaType)
w.Write(b)
return nil
}
func (a *API) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := a.newResponse(r)
if err != nil {
return internalServerError(err)
}
t, err := template.New(filepath.Base(a.Template)).ParseFiles(a.Template)
if err != nil {
return internalServerError(err)
}
var data = struct {
Host string
Response
Oracle
}{r.Host, response, a.oracle}
if err := t.Execute(w, &data); err != nil {
return internalServerError(err)
}
return nil
}
func (a *API) 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, rm *mux.RouteMatch) bool {
ua := useragent.Parse(r.UserAgent())
switch ua.Product {
case "curl", "HTTPie", "Wget", "fetch libfetch", "Go", "Go-http-client", "ddclient":
return true
}
return false
}
type appHandler func(http.ResponseWriter, *http.Request) *appError
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError
// When Content-Type for error is JSON, we need to marshal the response into JSON
if e.IsJSON() {
var data = struct {
Error string `json:"error"`
}{e.Message}
b, err := json.Marshal(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 (a *API) Router() http.Handler {
r := mux.NewRouter()
// JSON
r.Handle("/", appHandler(a.JSONHandler)).Methods("GET").Headers("Accept", jsonMediaType)
r.Handle("/json", appHandler(a.JSONHandler)).Methods("GET")
// CLI
r.Handle("/", appHandler(a.CLIHandler)).Methods("GET").MatcherFunc(cliMatcher)
r.Handle("/", appHandler(a.CLIHandler)).Methods("GET").Headers("Accept", textMediaType)
r.Handle("/ip", appHandler(a.CLIHandler)).Methods("GET")
r.Handle("/country", appHandler(a.CLICountryHandler)).Methods("GET")
r.Handle("/city", appHandler(a.CLICityHandler)).Methods("GET")
// Browser
r.Handle("/", appHandler(a.DefaultHandler)).Methods("GET")
// Port testing
r.Handle("/port/{port:[0-9]+}", appHandler(a.PortHandler)).Methods("GET")
// Not found handler which returns JSON when appropriate
r.NotFoundHandler = appHandler(a.NotFoundHandler)
return r
}

View File

@ -1,139 +0,0 @@
package api
import (
"fmt"
"net"
"strings"
"time"
"github.com/oschwald/geoip2-golang"
)
type Oracle interface {
LookupAddr(net.IP) ([]string, error)
LookupCountry(net.IP) (string, error)
LookupCity(net.IP) (string, error)
LookupPort(net.IP, uint64) error
IsLookupAddrEnabled() bool
IsLookupCountryEnabled() bool
IsLookupCityEnabled() bool
IsLookupPortEnabled() bool
}
type DefaultOracle struct {
lookupAddr func(net.IP) ([]string, error)
lookupCountry func(net.IP) (string, error)
lookupCity func(net.IP) (string, error)
lookupPort func(net.IP, uint64) error
lookupAddrEnabled bool
lookupCountryEnabled bool
lookupCityEnabled bool
lookupPortEnabled bool
}
func NewOracle() *DefaultOracle {
return &DefaultOracle{
lookupAddr: func(net.IP) ([]string, error) { return nil, nil },
lookupCountry: func(net.IP) (string, error) { return "", nil },
lookupCity: func(net.IP) (string, error) { return "", nil },
lookupPort: func(net.IP, uint64) error { return nil },
}
}
func (r *DefaultOracle) LookupAddr(ip net.IP) ([]string, error) {
return r.lookupAddr(ip)
}
func (r *DefaultOracle) LookupCountry(ip net.IP) (string, error) {
return r.lookupCountry(ip)
}
func (r *DefaultOracle) LookupCity(ip net.IP) (string, error) {
return r.lookupCity(ip)
}
func (r *DefaultOracle) LookupPort(ip net.IP, port uint64) error {
return r.lookupPort(ip, port)
}
func (r *DefaultOracle) EnableLookupAddr() {
r.lookupAddr = lookupAddr
r.lookupAddrEnabled = true
}
func (r *DefaultOracle) EnableLookupCountry(filepath string) error {
db, err := geoip2.Open(filepath)
if err != nil {
return err
}
r.lookupCountry = func(ip net.IP) (string, error) {
return lookupCountry(db, ip)
}
r.lookupCountryEnabled = true
return nil
}
func (r *DefaultOracle) EnableLookupCity(filepath string) error {
db, err := geoip2.Open(filepath)
if err != nil {
return err
}
r.lookupCity = func(ip net.IP) (string, error) {
return lookupCity(db, ip)
}
r.lookupCityEnabled = true
return nil
}
func (r *DefaultOracle) EnableLookupPort() {
r.lookupPort = lookupPort
r.lookupPortEnabled = true
}
func (r *DefaultOracle) IsLookupAddrEnabled() bool { return r.lookupAddrEnabled }
func (r *DefaultOracle) IsLookupCountryEnabled() bool { return r.lookupCountryEnabled }
func (r *DefaultOracle) IsLookupCityEnabled() bool { return r.lookupCityEnabled }
func (r *DefaultOracle) IsLookupPortEnabled() bool { return r.lookupPortEnabled }
func lookupAddr(ip net.IP) ([]string, error) {
names, err := net.LookupAddr(ip.String())
for i, _ := range names {
names[i] = strings.TrimRight(names[i], ".") // Always return unrooted name
}
return names, err
}
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 lookupCountry(db *geoip2.Reader, ip net.IP) (string, error) {
record, err := db.Country(ip)
if err != nil {
return "", err
}
if country, exists := record.Country.Names["en"]; exists {
return country, nil
}
if country, exists := record.RegisteredCountry.Names["en"]; exists {
return country, nil
}
return "Unknown", fmt.Errorf("could not determine country for IP: %s", ip)
}
func lookupCity(db *geoip2.Reader, ip net.IP) (string, error) {
record, err := db.City(ip)
if err != nil {
return "", err
}
if city, exists := record.City.Names["en"]; exists {
return city, nil
}
return "Unknown", fmt.Errorf("could not determine city for IP: %s", ip)
}

55
cmd/echoip/main.go Normal file
View File

@ -0,0 +1,55 @@
package main
import (
"log"
flags "github.com/jessevdk/go-flags"
"os"
"github.com/mpolden/echoip/http"
"github.com/mpolden/echoip/iputil"
"github.com/mpolden/echoip/iputil/geo"
)
func main() {
var opts struct {
CountryDBPath string `short:"f" long:"country-db" description:"Path to GeoIP country database" value-name:"FILE" default:""`
CityDBPath string `short:"c" long:"city-db" description:"Path to GeoIP city database" value-name:"FILE" default:""`
Listen string `short:"l" long:"listen" description:"Listening address" value-name:"ADDR" default:":8080"`
ReverseLookup bool `short:"r" long:"reverse-lookup" description:"Perform reverse hostname lookups"`
PortLookup bool `short:"p" long:"port-lookup" description:"Enable port lookup"`
Template string `short:"t" long:"template" description:"Path to template" default:"index.html" value-name:"FILE"`
IPHeaders []string `short:"H" long:"trusted-header" description:"Header to trust for remote IP, if present (e.g. X-Real-IP)" value-name:"NAME"`
}
_, err := flags.ParseArgs(&opts, os.Args)
if err != nil {
os.Exit(1)
}
log := log.New(os.Stderr, "echoip: ", 0)
r, err := geo.Open(opts.CountryDBPath, opts.CityDBPath)
if err != nil {
log.Fatal(err)
}
server := http.New(r)
server.Template = opts.Template
server.IPHeaders = opts.IPHeaders
if opts.ReverseLookup {
log.Println("Enabling reverse lookup")
server.LookupAddr = iputil.LookupAddr
}
if opts.PortLookup {
log.Println("Enabling port lookup")
server.LookupPort = iputil.LookupPort
}
if len(opts.IPHeaders) > 0 {
log.Printf("Trusting header(s) %+v to contain correct remote IP", opts.IPHeaders)
}
log.Printf("Listening on http://%s", opts.Listen)
if err := server.ListenAndServe(opts.Listen); err != nil {
log.Fatal(err)
}
}

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
// +heroku install ./...
// +heroku goVersion go1.11
module github.com/mpolden/echoip
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jessevdk/go-flags v1.4.0
github.com/oschwald/geoip2-golang v1.2.1
github.com/oschwald/maxminddb-golang v1.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.2.2 // indirect
golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5 // indirect
)

14
go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/oschwald/geoip2-golang v1.2.1 h1:3iz+jmeJc6fuCyWeKgtXSXu7+zvkxJbHFXkMT5FVebU=
github.com/oschwald/geoip2-golang v1.2.1/go.mod h1:0LTTzix/Ao1uMvOhAV4iLU0Lz7eCrP94qZWBTDKf0iE=
github.com/oschwald/maxminddb-golang v1.2.1 h1:1wUyw1BYyCY7E0bbG8lD7P5aPDFIsRr611otw6LOJtM=
github.com/oschwald/maxminddb-golang v1.2.1/go.mod h1:3jhIUymTJ5VREKyIhWm66LJiQt04F0UCDdodShpjWsY=
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/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5 h1:MF92a0wJ3gzSUVBpjcwdrDr5+klMFRNEEu6Mev4n00I=
golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

7
heroku.yml Normal file
View File

@ -0,0 +1,7 @@
build:
languages:
- go
pre:
- make geoip-download
run:
web: echoip -f data/country.mmdb -c data/city.mmdb -p -r -H CF-Connecting-IP -H X-Forwarded-For -l :$PORT

View File

@ -1,4 +1,4 @@
package api
package http
import "net/http"

319
http/http.go Normal file
View File

@ -0,0 +1,319 @@
package http
import (
"encoding/json"
"fmt"
"html/template"
"path/filepath"
"strings"
"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
gr geo.Reader
}
type Response struct {
IP net.IP `json:"ip"`
IPDecimal *big.Int `json:"ip_decimal"`
Country string `json:"country,omitempty"`
CountryEU *bool `json:"country_eu,omitempty"`
CountryISO string `json:"country_iso,omitempty"`
City string `json:"city,omitempty"`
Hostname string `json:"hostname,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
type PortResponse struct {
IP net.IP `json:"ip"`
Port uint64 `json:"port"`
Reachable bool `json:"reachable"`
}
func New(db geo.Reader) *Server {
return &Server{gr: db}
}
func ipFromForwardedForHeader(v string) string {
sep := strings.Index(v, ",")
if sep == -1 {
return v
}
return v[:sep]
}
func ipFromRequest(headers []string, r *http.Request) (net.IP, error) {
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 (s *Server) newResponse(r *http.Request) (Response, error) {
ip, err := ipFromRequest(s.IPHeaders, r)
if err != nil {
return Response{}, err
}
ipDecimal := iputil.ToDecimal(ip)
country, _ := s.gr.Country(ip)
city, _ := s.gr.City(ip)
var hostname string
if s.LookupAddr != nil {
hostname, _ = s.LookupAddr(ip)
}
return Response{
IP: ip,
IPDecimal: ipDecimal,
Country: country.Name,
CountryISO: country.ISO,
CountryEU: country.IsEU,
City: city.Name,
Hostname: hostname,
Latitude: city.Latitude,
Longitude: city.Longitude,
}, 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 > 65355 {
return PortResponse{Port: port}, fmt.Errorf("invalid port: %d", port)
}
ip, err := ipFromRequest(s.IPHeaders, r)
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)
if err != nil {
return internalServerError(err)
}
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 internalServerError(err)
}
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 internalServerError(err)
}
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 internalServerError(err)
}
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 internalServerError(err)
}
fmt.Fprintf(w, "%s,%s\n", formatCoordinate(response.Latitude), formatCoordinate(response.Longitude))
return nil
}
func (s *Server) JSONHandler(w http.ResponseWriter, r *http.Request) *appError {
response, err := s.newResponse(r)
if err != nil {
return internalServerError(err).AsJSON()
}
b, err := json.Marshal(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(fmt.Sprintf("Invalid port: %d", response.Port)).AsJSON()
}
b, err := json.Marshal(response)
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 internalServerError(err)
}
t, err := template.ParseFiles(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
JSON string
Port bool
}{
response,
r.Host,
string(json),
s.LookupPort != nil,
}
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", "Wget", "fetch libfetch", "Go", "Go-http-client", "ddclient":
return true
}
return false
}
type appHandler func(http.ResponseWriter, *http.Request) *appError
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError
// When Content-Type for error is JSON, we need to marshal the response into JSON
if e.IsJSON() {
var data = struct {
Error string `json:"error"`
}{e.Message}
b, err := json.Marshal(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)
}
// Browser
r.Route("GET", "/", s.DefaultHandler)
// Port testing
if s.LookupPort != nil {
r.RoutePrefix("GET", "/port/", s.PortHandler)
}
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)
}

View File

@ -1,28 +1,33 @@
package api
package http
import (
"io/ioutil"
"log"
"math/big"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/mpolden/echoip/iputil/geo"
)
type mockOracle struct{}
func lookupAddr(net.IP) (string, error) { return "localhost", nil }
func lookupPort(net.IP, uint64) error { return nil }
func (r *mockOracle) LookupAddr(net.IP) ([]string, error) { return []string{"localhost"}, nil }
func (r *mockOracle) LookupCountry(net.IP) (string, error) { return "Elbonia", nil }
func (r *mockOracle) LookupCity(net.IP) (string, error) { return "Bornyasherk", nil }
func (r *mockOracle) LookupPort(net.IP, uint64) error { return nil }
func (r *mockOracle) IsLookupAddrEnabled() bool { return true }
func (r *mockOracle) IsLookupCountryEnabled() bool { return true }
func (r *mockOracle) IsLookupCityEnabled() bool { return true }
func (r *mockOracle) IsLookupPortEnabled() bool { return true }
type testDb struct{}
func newTestAPI() *API {
return &API{oracle: &mockOracle{}}
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", Latitude: 63.416667, Longitude: 10.416667}, nil
}
func (t *testDb) IsEmpty() bool { return false }
func testServer() *Server {
return &Server{gr: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort}
}
func httpGet(url string, acceptMediaType string, userAgent string) (string, int, error) {
@ -48,7 +53,7 @@ func httpGet(url string, acceptMediaType string, userAgent string) (string, int,
func TestCLIHandlers(t *testing.T) {
log.SetOutput(ioutil.Discard)
s := httptest.NewServer(newTestAPI().Router())
s := httptest.NewServer(testServer().Handler())
var tests = []struct {
url string
@ -61,6 +66,8 @@ func TestCLIHandlers(t *testing.T) {
{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, "", ""},
}
@ -79,25 +86,28 @@ func TestCLIHandlers(t *testing.T) {
}
}
func TestJSONHandlers(t *testing.T) {
func TestDisabledHandlers(t *testing.T) {
log.SetOutput(ioutil.Discard)
s := httptest.NewServer(newTestAPI().Router())
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, `{"ip":"127.0.0.1","ip_decimal":2130706433,"country":"Elbonia","city":"Bornyasherk","hostname":"localhost"}`, 200},
{s.URL + "/port/foo", `{"error":"404 page not found"}`, 404},
{s.URL + "/port/0", `{"error":"Invalid port: 0"}`, 400},
{s.URL + "/port/65356", `{"error":"Invalid port: 65356"}`, 400},
{s.URL + "/port/31337", `{"ip":"127.0.0.1","port":31337,"reachable":true}`, 200},
{s.URL + "/foo", `{"error":"404 page not found"}`, 404},
{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", `{"ip":"127.0.0.1","ip_decimal":2130706433}`, 200},
}
for _, tt := range tests {
out, status, err := httpGet(tt.url, jsonMediaType, "curl/7.2.6.0")
out, status, err := httpGet(tt.url, "", "")
if err != nil {
t.Fatal(err)
}
@ -110,18 +120,54 @@ func TestJSONHandlers(t *testing.T) {
}
}
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, `{"ip":"127.0.0.1","ip_decimal":2130706433,"country":"Elbonia","country_eu":false,"country_iso":"EB","city":"Bornyasherk","hostname":"localhost","latitude":63.416667,"longitude":10.416667}`, 200},
{s.URL + "/port/foo", `{"error":"Invalid port: 0"}`, 400},
{s.URL + "/port/0", `{"error":"Invalid port: 0"}`, 400},
{s.URL + "/port/65356", `{"error":"Invalid port: 65356"}`, 400},
{s.URL + "/port/31337", `{"ip":"127.0.0.1","port":31337,"reachable":true}`, 200},
{s.URL + "/foo", `{"error":"404 page not found"}`, 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 TestIPFromRequest(t *testing.T) {
var tests = []struct {
remoteAddr string
headerKey string
headerValue string
trustedHeader string
out string
remoteAddr string
headerKey string
headerValue string
trustedHeaders []string
out string
}{
{"127.0.0.1:9999", "", "", "", "127.0.0.1"}, // No header given
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", "", "127.0.0.1"}, // Trusted header is empty
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", "X-Foo-Bar", "127.0.0.1"}, // Trusted header does not match
{"127.0.0.1:9999", "X-Real-IP", "1.3.3.7", "X-Real-IP", "1.3.3.7"}, // Trusted header matches
{"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
}
for _, tt := range tests {
r := &http.Request{
@ -129,7 +175,7 @@ func TestIPFromRequest(t *testing.T) {
Header: http.Header{},
}
r.Header.Add(tt.headerKey, tt.headerValue)
ip, err := ipFromRequest(tt.trustedHeader, r)
ip, err := ipFromRequest(tt.trustedHeaders, r)
if err != nil {
t.Fatal(err)
}
@ -161,24 +207,8 @@ func TestCLIMatcher(t *testing.T) {
}
for _, tt := range tests {
r := &http.Request{Header: http.Header{"User-Agent": []string{tt.in}}}
if got := cliMatcher(r, nil); got != tt.out {
if got := cliMatcher(r); got != tt.out {
t.Errorf("Expected %t, got %t for %q", tt.out, got, tt.in)
}
}
}
func TestIPToDecimal(t *testing.T) {
var tests = []struct {
in string
out *big.Int
}{
{"127.0.0.1", big.NewInt(2130706433)},
{"::1", big.NewInt(1)},
}
for _, tt := range tests {
i := ipToDecimal(net.ParseIP(tt.in))
if i.Cmp(tt.out) != 0 {
t.Errorf("Expected %d, got %d for IP %s", tt.out, i, tt.in)
}
}
}

73
http/router.go Normal file
View 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)
}

View File

@ -5,11 +5,12 @@
<title>What is my IP address? &mdash; {{ .Host }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="What is my IP address?">
<link href="//fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/pure/0.6.2/pure-min.css">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/pure/0.6.2/grids-responsive-min.css">
<link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pure/0.6.2/pure-min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pure/0.6.2/grids-responsive-min.css">
<style>
html, .pure-g [class *= "pure-u"] {
background-color: white;
font-family: "Open Sans", sans-serif;
}
pre {
@ -42,7 +43,7 @@
</div>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<h2>CLI examples:</h2>
<h2>CLI examples</h2>
<pre>
$ curl {{ .Host }}
{{ .IP }}
@ -58,63 +59,67 @@ $ fetch -qo- https://{{ .Host }}
$ bat -print=b {{ .Host }}/ip
{{ .IP }}</pre>
{{ if .IsLookupCountryEnabled }}
<h2>Country lookup:</h2>
{{ if .Country }}
<h2>Country lookup</h2>
<pre>
$ http {{ .Host }}/country
{{ .Country }}</pre>
{{ .Country }}
$ http {{ .Host }}/country-iso
{{ .CountryISO }}</pre>
{{ end }}
{{ if .IsLookupCityEnabled }}
<h2>City lookup:</h2>
{{ if .City }}
<h2>City lookup</h2>
<pre>
$ http {{ .Host }}/city
{{ .City }}</pre>
{{ end }}
</div>
<div class="pure-u-1 pure-u-md-1-2">
<h2>JSON output:</h2>
<h2>JSON output</h2>
<pre>
$ http {{ .Host }}/json
{ {{ if .IsLookupCityEnabled }}
"city": "{{ .City }}",{{ end }}{{ if .IsLookupCountryEnabled }}
"country": "{{ .Country }}",{{ end }}{{ if .IsLookupAddrEnabled }}
"hostname": "{{ .Hostname }}",{{ end }}
"ip": "{{ .IP }}",
"ip_decimal": {{ .IPDecimal }}
}</pre>
<p>Setting the Accept header to application/json also works.</p>
<h2>Plain output:</h2>
{{ .JSON }}</pre>
<p>Setting the <code>Accept: application/json</code> header also works as expected.</p>
<h2>Plain output</h2>
<p>Always returns the IP address including a trailing newline, regardless of user agent.</p>
<pre>
$ http {{ .Host }}/ip
{{ .IP }}</pre>
{{ if .IsLookupPortEnabled }}
<h2>Testing port connectivity:</h2>
{{ if .Port }}
<h2>Port testing</h2>
<pre>
$ http {{ .Host }}/port/8080
{
"ip": "{{ .IP }}",
"port": 8080,
"reachable": false
"ip": "{{ .IP }}",
"port": 8080,
"reachable": false
}</pre>
{{ end }}
</div>
<div class="pure-u-1 pure-u-md-1-1">
<div class="pure-u-1 pure-u-md-1-2">
<h2>FAQ</h2>
<h3>How do I force IPv4 or IPv6 lookup?</h3>
<p>IPv4 or IPv6 lookup can be forced by using the <a href="//v4.ifconfig.co">v4</a> and <a href="//v6.ifconfig.co">v6<a> subdomains.</p>
<p>As of 2018-07-25 it's no longer possible to force protocol using
the <i>v4</i> and <i>v6</i> subdomains. IPv4 or IPv6 still can be forced
by passing the appropiate flag to your client, e.g <code>curl -4</code>
or <code>curl -6</code>.</p>
<h3>Is using this service from automated scripts/tools permitted?</h3>
<p>Yes, as long as the rate limit is respected. <em>Please limit
automated requests to 1 request per minute</em>. No guarantee is made
for requests that exceed this limit. They may be rate-limited (with a
429 response code) or dropped entirely.</p>
<h3>Is automated use of this service permitted?</h3>
<p>
Yes, as long as the rate limit is respected. The rate limit is in
place to ensure a fair service for all.
</p>
<p>
<em>Please limit automated requests to 1 request per minute</em>. No
guarantee is made for requests that exceed this limit. They may be
rate-limited, with a 429 status code, or dropped entirely.
</p>
<h3>Can I run my own service?</h3>
<p>Yes, the source code and documentation is available on <a href="https://github.com/mpolden/ipd">GitHub</a>.</p>
<p>Yes, the source code and documentation is available on <a href="https://github.com/mpolden/echoip">GitHub</a>.</p>
</div>
</div>
<a href="https://github.com/mpolden/ipd" class="github-corner"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
<a href="https://github.com/mpolden/echoip" class="github-corner"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
</body>
</html>

101
iputil/geo/geo.go Normal file
View File

@ -0,0 +1,101 @@
package geo
import (
"math"
"net"
geoip2 "github.com/oschwald/geoip2-golang"
)
type Reader interface {
Country(net.IP) (Country, error)
City(net.IP) (City, error)
IsEmpty() bool
}
type Country struct {
Name string
ISO string
IsEU *bool
}
type City struct {
Name string
Latitude float64
Longitude float64
}
type geoip struct {
country *geoip2.Reader
city *geoip2.Reader
}
func Open(countryDB, cityDB string) (Reader, error) {
var country, city *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
}
return &geoip{country: country, city: city}, 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 !math.IsNaN(record.Location.Latitude) {
city.Latitude = record.Location.Latitude
}
if !math.IsNaN(record.Location.Longitude) {
city.Longitude = record.Location.Longitude
}
return city, nil
}
func (g *geoip) IsEmpty() bool {
return g.country == nil && g.city == nil
}

38
iputil/iputil.go Normal file
View 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
View 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)
}
}
}

71
main.go
View File

@ -1,71 +0,0 @@
package main
import (
"net/http"
flags "github.com/jessevdk/go-flags"
"os"
"github.com/sirupsen/logrus"
"github.com/mpolden/ipd/api"
)
func main() {
var opts struct {
CountryDBPath string `short:"f" long:"country-db" description:"Path to GeoIP country database" value-name:"FILE" default:""`
CityDBPath string `short:"c" long:"city-db" description:"Path to GeoIP city database" value-name:"FILE" default:""`
Listen string `short:"l" long:"listen" description:"Listening address" value-name:"ADDR" default:":8080"`
ReverseLookup bool `short:"r" long:"reverse-lookup" description:"Perform reverse hostname lookups"`
PortLookup bool `short:"p" long:"port-lookup" description:"Enable port lookup"`
Template string `short:"t" long:"template" description:"Path to template" default:"index.html" value-name:"FILE"`
IPHeader string `short:"H" long:"trusted-header" description:"Header to trust for remote IP, if present (e.g. X-Real-IP)" value-name:"NAME"`
LogLevel string `short:"L" long:"log-level" description:"Log level to use" default:"info" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic"`
}
_, err := flags.ParseArgs(&opts, os.Args)
if err != nil {
os.Exit(1)
}
log := logrus.New()
level, err := logrus.ParseLevel(opts.LogLevel)
if err != nil {
log.Fatal(err)
}
log.Level = level
oracle := api.NewOracle()
if opts.ReverseLookup {
log.Println("Enabling reverse lookup")
oracle.EnableLookupAddr()
}
if opts.PortLookup {
log.Println("Enabling port lookup")
oracle.EnableLookupPort()
}
if opts.CountryDBPath != "" {
log.Printf("Enabling country lookup (using database: %s)", opts.CountryDBPath)
if err := oracle.EnableLookupCountry(opts.CountryDBPath); err != nil {
log.Fatal(err)
}
}
if opts.CityDBPath != "" {
log.Printf("Enabling city lookup (using database: %s)", opts.CityDBPath)
if err := oracle.EnableLookupCity(opts.CityDBPath); err != nil {
log.Fatal(err)
}
}
if opts.IPHeader != "" {
log.Printf("Trusting header %s to contain correct remote IP", opts.IPHeader)
}
api := api.New(oracle, log)
api.Template = opts.Template
api.IPHeader = opts.IPHeader
log.Printf("Listening on http://%s", opts.Listen)
if err := http.ListenAndServe(opts.Listen, api.Router()); err != nil {
log.Fatal(err)
}
}