mirror of https://github.com/mpolden/echoip
458 lines
12 KiB
Go
458 lines
12 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/ioutil"
|
|
"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
|
|
}
|
|
|
|
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 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) CLIASNHandler(w http.ResponseWriter, r *http.Request) *appError {
|
|
response, err := s.newResponse(r)
|
|
if err != nil {
|
|
return internalServerError(err)
|
|
}
|
|
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 internalServerError(err).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 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
|
|
BoxLatTop float64
|
|
BoxLatBottom float64
|
|
BoxLonLeft float64
|
|
BoxLonRight float64
|
|
JSON string
|
|
Port 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,
|
|
}
|
|
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":
|
|
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
|
|
// 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.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)
|
|
}
|