🦈🏠🐜 Initial Commit 🐜🦈🏠

This commit is contained in:
Jason
2022-02-14 16:45:10 -05:00
commit 964ab574ea
23 changed files with 2384 additions and 0 deletions

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