diff --git a/api/api.go b/api/api.go index 32670a1..1de96f8 100644 --- a/api/api.go +++ b/api/api.go @@ -3,81 +3,58 @@ package api import ( "encoding/json" "fmt" + "html/template" "io" "log" "net" "net/http" - "net/url" "path/filepath" "regexp" "strings" - "html/template" - "github.com/gorilla/mux" geoip2 "github.com/oschwald/geoip2-golang" ) -const ( - IP_HEADER = "x-ifconfig-ip" - COUNTRY_HEADER = "x-ifconfig-country" - HOSTNAME_HEADER = "x-ifconfig-hostname" - TEXT_PLAIN = "text/plain; charset=utf-8" - APPLICATION_JSON = "application/json" -) +const APPLICATION_JSON = "application/json" var cliUserAgentExp = regexp.MustCompile(`^(?i)((curl|wget|fetch\slibfetch|Go-http-client)\/.*|Go\s1\.1\spackage\shttp)$`) type API struct { CORS bool - ReverseLookup bool Template string lookupAddr func(string) ([]string, error) lookupCountry func(net.IP) (string, error) ipFromRequest func(*http.Request) (net.IP, error) } +type Response struct { + IP net.IP `json:"ip"` + Country string `json:"country,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + func New() *API { return &API{ - lookupAddr: net.LookupAddr, + lookupAddr: func(addr string) (names []string, err error) { return nil, nil }, lookupCountry: func(ip net.IP) (string, error) { return "", nil }, ipFromRequest: ipFromRequest, } } -func NewWithGeoIP(filepath string) (*API, error) { +func (a *API) EnableCountryLookup(filepath string) error { db, err := geoip2.Open(filepath) if err != nil { - return nil, err + return err } - api := New() - api.lookupCountry = func(ip net.IP) (string, error) { + a.lookupCountry = func(ip net.IP) (string, error) { return lookupCountry(db, ip) } - return api, nil + return nil } -type Cmd struct { - Name string - Args string -} - -func (c *Cmd) String() string { - return c.Name + " " + c.Args -} - -func cmdFromQueryParams(values url.Values) Cmd { - cmd, exists := values["cmd"] - if !exists || len(cmd) == 0 { - return Cmd{Name: "curl"} - } - switch cmd[0] { - case "fetch": - return Cmd{Name: "fetch", Args: "-qo -"} - case "wget": - return Cmd{Name: "wget", Args: "-qO -"} - } - return Cmd{Name: "curl"} +func (a *API) EnableReverseLookup() { + a.lookupAddr = net.LookupAddr } func ipFromRequest(r *http.Request) (net.IP, error) { @@ -99,19 +76,6 @@ func ipFromRequest(r *http.Request) (net.IP, error) { return ip, nil } -func headerPairFromRequest(r *http.Request) (string, string, error) { - vars := mux.Vars(r) - header, ok := vars["header"] - if !ok { - header = IP_HEADER - } - value := r.Header.Get(header) - if value == "" { - return "", "", fmt.Errorf("no value found for: %s", header) - } - return header, value, nil -} - func lookupCountry(db *geoip2.Reader, ip net.IP) (string, error) { if db == nil { return "", nil @@ -126,71 +90,77 @@ func lookupCountry(db *geoip2.Reader, ip net.IP) (string, error) { if country, exists := record.RegisteredCountry.Names["en"]; exists { return country, nil } - return "", fmt.Errorf("could not determine country for IP: %s", ip) + return "Unknown", fmt.Errorf("could not determine country for IP: %s", ip) } -func (a *API) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError { - cmd := cmdFromQueryParams(r.URL.Query()) - funcMap := template.FuncMap{"ToLower": strings.ToLower} - t, err := template.New(filepath.Base(a.Template)).Funcs(funcMap).ParseFiles(a.Template) +func (a *API) newResponse(r *http.Request) (Response, error) { + ip, err := a.ipFromRequest(r) + if err != nil { + return Response{}, err + } + country, err := a.lookupCountry(ip) + if err != nil { + log.Print(err) + } + hostnames, err := a.lookupAddr(ip.String()) + if err != nil { + log.Print(err) + } + return Response{ + IP: ip, + Country: country, + Hostname: strings.Join(hostnames, " "), + }, nil +} + +func (a *API) CLIHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := a.newResponse(r) if err != nil { return internalServerError(err) } - b, err := json.MarshalIndent(r.Header, "", " ") - if err != nil { - return internalServerError(err) - } - - var data = struct { - IP string - JSON string - Header http.Header - Cmd - }{r.Header.Get(IP_HEADER), string(b), r.Header, cmd} - - if err := t.Execute(w, &data); err != nil { - return internalServerError(err) + if r.URL.Path == "/country" { + io.WriteString(w, response.Country+"\n") + } else { + io.WriteString(w, response.IP.String()+"\n") } return nil } func (a *API) JSONHandler(w http.ResponseWriter, r *http.Request) *appError { - k, v, err := headerPairFromRequest(r) - contentType := APPLICATION_JSON + response, err := a.newResponse(r) if err != nil { - return notFound(err).WithContentType(contentType) + return internalServerError(err).AsJSON() } - value := map[string]string{k: v} - b, err := json.MarshalIndent(value, "", " ") + b, err := json.Marshal(response) if err != nil { - return internalServerError(err).WithContentType(contentType) + return internalServerError(err).AsJSON() } - w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Type", APPLICATION_JSON) w.Write(b) return nil } -func (a *API) JSONAllHandler(w http.ResponseWriter, r *http.Request) *appError { - contentType := APPLICATION_JSON - b, err := json.MarshalIndent(r.Header, "", " ") +func (a *API) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError { + response, err := a.newResponse(r) if err != nil { - return internalServerError(err).WithContentType(contentType) + return internalServerError(err) + } + t, err := template.New(filepath.Base(a.Template)).ParseFiles(a.Template) + if err != nil { + return internalServerError(err) + } + if err := t.Execute(w, &response); err != nil { + return internalServerError(err) } - w.Header().Set("Content-Type", contentType) - w.Write(b) return nil } -func (a *API) CLIHandler(w http.ResponseWriter, r *http.Request) *appError { - _, v, err := headerPairFromRequest(r) - if err != nil { - return notFound(err) +func (a *API) NotFoundHandler(w http.ResponseWriter, r *http.Request) *appError { + err := notFound(nil).WithMessage("404 page not found") + if r.Header.Get("accept") == APPLICATION_JSON { + err = err.AsJSON() } - if !strings.HasSuffix(v, "\n") { - v += "\n" - } - io.WriteString(w, v) - return nil + return err } func cliMatcher(r *http.Request, rm *mux.RouteMatch) bool { @@ -199,25 +169,6 @@ func cliMatcher(r *http.Request, rm *mux.RouteMatch) bool { func (a *API) requestFilter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip, err := a.ipFromRequest(r) - if err != nil { - log.Print(err) - r.Header.Set(IP_HEADER, "") - } else { - r.Header.Set(IP_HEADER, ip.String()) - country, err := a.lookupCountry(ip) - if err != nil { - log.Print(err) - } - r.Header.Set(COUNTRY_HEADER, country) - } - if a.ReverseLookup { - hostname, err := a.lookupAddr(ip.String()) - if err != nil { - log.Print(err) - } - r.Header.Set(HOSTNAME_HEADER, strings.Join(hostname, ", ")) - } if a.CORS { w.Header().Set("Access-Control-Allow-Methods", "GET") w.Header().Set("Access-Control-Allow-Origin", "*") @@ -233,27 +184,23 @@ func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if e.Error != nil { log.Print(e.Error) } - contentType := e.ContentType - if contentType == "" { - contentType = TEXT_PLAIN - } - response := e.Response - if response == "" { - response = e.Error.Error() - } + // 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"` - }{response} - b, err := json.MarshalIndent(data, "", " ") + }{e.Message} + b, err := json.Marshal(data) if err != nil { panic(err) } - response = string(b) + e.Message = string(b) + } + // Set Content-Type of response if set in error + if e.ContentType != "" { + w.Header().Set("Content-Type", e.ContentType) } - w.Header().Set("Content-Type", contentType) w.WriteHeader(e.Code) - io.WriteString(w, response) + io.WriteString(w, e.Message) } } @@ -262,18 +209,18 @@ func (a *API) Handlers() http.Handler { // JSON r.Handle("/", appHandler(a.JSONHandler)).Methods("GET").Headers("Accept", APPLICATION_JSON) - r.Handle("/all", appHandler(a.JSONAllHandler)).Methods("GET").Headers("Accept", APPLICATION_JSON) - r.Handle("/all.json", appHandler(a.JSONAllHandler)).Methods("GET") - r.Handle("/{header}", appHandler(a.JSONHandler)).Methods("GET").Headers("Accept", APPLICATION_JSON) - r.Handle("/{header}.json", appHandler(a.JSONHandler)).Methods("GET") // CLI r.Handle("/", appHandler(a.CLIHandler)).Methods("GET").MatcherFunc(cliMatcher) - r.Handle("/{header}", appHandler(a.CLIHandler)).Methods("GET").MatcherFunc(cliMatcher) + r.Handle("/ip", appHandler(a.CLIHandler)).Methods("GET").MatcherFunc(cliMatcher) + r.Handle("/country", appHandler(a.CLIHandler)).Methods("GET").MatcherFunc(cliMatcher) - // Default + // Browser r.Handle("/", appHandler(a.DefaultHandler)).Methods("GET") + // Not found handler which returns JSON when appropriate + r.NotFoundHandler = appHandler(a.NotFoundHandler) + // Pass all requests through the request filter return a.requestFilter(r) } diff --git a/api/api_test.go b/api/api_test.go index 045f183..b3ba6ba 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1,14 +1,12 @@ package api import ( - "fmt" + "encoding/json" "io/ioutil" "log" "net" "net/http" "net/http/httptest" - "net/url" - "reflect" "strings" "testing" ) @@ -24,7 +22,6 @@ func newTestAPI() *API { ipFromRequest: func(*http.Request) (net.IP, error) { return net.ParseIP("127.0.0.1"), nil }, - ReverseLookup: true, } } @@ -50,16 +47,15 @@ func httpGet(url string, json bool, userAgent string) (string, int, error) { } func TestGetIP(t *testing.T) { - log.SetOutput(ioutil.Discard) - toJSON := func(k string, v string) string { - return fmt.Sprintf("{\n \"%s\": \"%s\"\n}", k, v) + //log.SetOutput(ioutil.Discard) + toJSON := func(r Response) string { + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + return string(b) } s := httptest.NewServer(newTestAPI().Handlers()) - jsonAll := "{\n \"Accept-Encoding\": [\n \"gzip\"\n ]," + - "\n \"X-Ifconfig-Country\": [\n \"Elbonia\"\n ]," + - "\n \"X-Ifconfig-Hostname\": [\n \"localhost\"\n ]," + - "\n \"X-Ifconfig-Ip\": [\n \"127.0.0.1\"\n ]\n}" - var tests = []struct { url string json bool @@ -73,11 +69,9 @@ func TestGetIP(t *testing.T) { {s.URL, false, "127.0.0.1\n", "Go 1.1 package http", 200}, {s.URL, false, "127.0.0.1\n", "Go-http-client/1.1", 200}, {s.URL, false, "127.0.0.1\n", "Go-http-client/2.0", 200}, - {s.URL + "/x-ifconfig-ip.json", false, toJSON("x-ifconfig-ip", "127.0.0.1"), "", 200}, - {s.URL, true, toJSON("x-ifconfig-ip", "127.0.0.1"), "", 200}, - {s.URL + "/foo", false, "no value found for: foo", "curl/7.26.0", 404}, - {s.URL + "/foo", true, "{\n \"error\": \"no value found for: foo\"\n}", "curl/7.26.0", 404}, - {s.URL + "/all.json", false, jsonAll, "", 200}, + {s.URL, true, toJSON(Response{IP: net.ParseIP("127.0.0.1"), Country: "Elbonia", Hostname: "localhost"}), "", 200}, + {s.URL + "/foo", false, "404 page not found", "curl/7.26.0", 404}, + {s.URL + "/foo", true, "{\"error\":\"404 page not found\"}", "curl/7.26.0", 404}, } for _, tt := range tests { @@ -97,7 +91,6 @@ func TestGetIP(t *testing.T) { func TestGetIPWithoutReverse(t *testing.T) { log.SetOutput(ioutil.Discard) api := newTestAPI() - api.ReverseLookup = false s := httptest.NewServer(api.Handlers()) out, _, err := httpGet(s.URL, false, "curl/7.26.0") @@ -128,25 +121,6 @@ func TestIPFromRequest(t *testing.T) { } } -func TestCmdFromParameters(t *testing.T) { - var tests = []struct { - in url.Values - out Cmd - }{ - {url.Values{}, Cmd{Name: "curl"}}, - {url.Values{"cmd": []string{"foo"}}, Cmd{Name: "curl"}}, - {url.Values{"cmd": []string{"curl"}}, Cmd{Name: "curl"}}, - {url.Values{"cmd": []string{"fetch"}}, Cmd{Name: "fetch", Args: "-qo -"}}, - {url.Values{"cmd": []string{"wget"}}, Cmd{Name: "wget", Args: "-qO -"}}, - } - for _, tt := range tests { - cmd := cmdFromQueryParams(tt.in) - if !reflect.DeepEqual(cmd, tt.out) { - t.Errorf("Expected %+v, got %+v", tt.out, cmd) - } - } -} - 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 " + diff --git a/api/error.go b/api/error.go index e688c8d..dfbd9bb 100644 --- a/api/error.go +++ b/api/error.go @@ -4,21 +4,25 @@ import "net/http" type appError struct { Error error - Response string + Message string Code int ContentType string } func internalServerError(err error) *appError { - return &appError{Error: err, Response: "Internal server error", Code: http.StatusInternalServerError} + return &appError{ + Error: err, + Message: "Internal server error", + Code: http.StatusInternalServerError, + } } func notFound(err error) *appError { return &appError{Error: err, Code: http.StatusNotFound} } -func (e *appError) WithContentType(contentType string) *appError { - e.ContentType = contentType +func (e *appError) AsJSON() *appError { + e.ContentType = APPLICATION_JSON return e } @@ -27,8 +31,8 @@ func (e *appError) WithCode(code int) *appError { return e } -func (e *appError) WithResponse(response string) *appError { - e.Response = response +func (e *appError) WithMessage(message string) *appError { + e.Message = message return e } diff --git a/index.html b/index.html index 3d5ac7e..163b89d 100644 --- a/index.html +++ b/index.html @@ -8,75 +8,80 @@ -
-
+
+

What is my IP address?

-

Your IP:

{{ .IP }}

- curl - wget - fetch +

Multiple command line HTTP clients are supported, + including curl, GNU + Wget + and fetch.

- - - - - - - - - - - - - {{ if $self := . }} - {{ range $key, $value := .Header }} - - - - - {{end}} - {{end}} - - - -
CommandResponse
{{ .Cmd.String }} ifconfig.co{{ .IP }}
{{ $self.Cmd.String }} ifconfig.co/{{ ToLower $key }}{{ index $self.Header $key 0 }}
{{ .Cmd.String }} ifconfig.co/all.json
{{ .JSON }}
-