diff --git a/Makefile b/Makefile
index 1fc275bd..b8db1d5e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,19 +1,15 @@
-NAME = ifconfig
+NAME=ifconfigd
-all: deps test build
-
-fmt:
- gofmt -w=true *.go
+all: deps test install
deps:
go get -d -v
-build:
- @mkdir -p bin
- go build -o bin/$(NAME)
+fmt:
+ go fmt ./...
test:
- go test
+ go test ./...
-docker-image:
- docker build -t martinp/ifconfig .
+install:
+ go install
diff --git a/api/api.go b/api/api.go
new file mode 100644
index 00000000..d522297d
--- /dev/null
+++ b/api/api.go
@@ -0,0 +1,212 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "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"
+)
+
+var cliUserAgentExp = regexp.MustCompile("^(?i)(curl|wget|fetch\\slibfetch)\\/.*$")
+
+type API struct {
+ db *geoip2.Reader
+ CORS bool
+ Template string
+}
+
+func New() *API { return &API{} }
+
+func NewWithGeoIP(filepath string) (*API, error) {
+ db, err := geoip2.Open(filepath)
+ if err != nil {
+ return nil, err
+ }
+ return &API{db: db}, 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 ipFromRequest(r *http.Request) (net.IP, error) {
+ var host string
+ realIP := r.Header.Get("X-Real-IP")
+ var err error
+ if realIP != "" {
+ host = realIP
+ } else {
+ host, _, err = net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return nil, err
+ }
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return nil, fmt.Errorf("could not parse IP: %s", host)
+ }
+ return ip, nil
+}
+
+func headerKeyFromRequest(r *http.Request) string {
+ vars := mux.Vars(r)
+ key, ok := vars["key"]
+ if !ok {
+ return ""
+ }
+ return key
+}
+
+func (a *API) LookupCountry(ip net.IP) (string, error) {
+ if a.db == nil {
+ return "", nil
+ }
+ record, err := a.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 "", fmt.Errorf("could not determine country for IP: %s", ip)
+}
+
+func (a *API) defaultHandler(w http.ResponseWriter, r *http.Request) {
+ cmd := cmdFromQueryParams(r.URL.Query())
+ funcMap := template.FuncMap{"ToLower": strings.ToLower}
+ t, err := template.New(filepath.Base(a.Template)).Funcs(funcMap).ParseFiles(a.Template)
+ if err != nil {
+ log.Print(err)
+ return
+ }
+ b, err := json.MarshalIndent(r.Header, "", " ")
+ if err != nil {
+ log.Print(err)
+ return
+ }
+
+ 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 {
+ log.Print(err)
+ }
+}
+
+func (a *API) jsonHandler(w http.ResponseWriter, r *http.Request) {
+ key := headerKeyFromRequest(r)
+ if key == "" {
+ key = IP_HEADER
+ }
+ value := map[string]string{key: r.Header.Get(key)}
+ b, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ log.Print(err)
+ return
+ }
+ w.Write(b)
+}
+
+func (a *API) cliHandler(w http.ResponseWriter, r *http.Request) {
+ key := headerKeyFromRequest(r)
+ if key == "" {
+ key = IP_HEADER
+ }
+ value := r.Header.Get(key)
+ if !strings.HasSuffix(value, "\n") {
+ value += "\n"
+ }
+ io.WriteString(w, value)
+}
+
+func cliMatcher(r *http.Request, rm *mux.RouteMatch) bool {
+ return cliUserAgentExp.MatchString(r.UserAgent())
+}
+
+func (a *API) requestFilter(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ip, err := ipFromRequest(r)
+ if err != nil {
+ r.Header.Set(IP_HEADER, err.Error())
+ } else {
+ r.Header.Set(IP_HEADER, ip.String())
+ country, err := a.LookupCountry(ip)
+ if err != nil {
+ r.Header.Set(COUNTRY_HEADER, err.Error())
+ } else {
+ r.Header.Set(COUNTRY_HEADER, country)
+ }
+ }
+ if a.CORS {
+ w.Header().Set("Access-Control-Allow-Methods", "GET")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (a *API) Handlers() http.Handler {
+ r := mux.NewRouter()
+
+ // JSON
+ r.HandleFunc("/", a.jsonHandler).Methods("GET").Headers("Accept", "application/json")
+ r.HandleFunc("/{key}", a.jsonHandler).Methods("GET").Headers("Accept", "application/json")
+ r.HandleFunc("/{key}.json", a.jsonHandler).Methods("GET")
+
+ // CLI
+ r.HandleFunc("/", a.cliHandler).Methods("GET").MatcherFunc(cliMatcher)
+ r.HandleFunc("/{key}", a.cliHandler).Methods("GET").MatcherFunc(cliMatcher)
+
+ // Default
+ r.HandleFunc("/", a.defaultHandler).Methods("GET")
+
+ // Pass all requests through the request filter
+ return a.requestFilter(r)
+}
+
+func (a *API) ListenAndServe(addr string) error {
+ http.Handle("/", a.Handlers())
+ return http.ListenAndServe(addr, nil)
+}
diff --git a/api/api_test.go b/api/api_test.go
new file mode 100644
index 00000000..01fe0e2c
--- /dev/null
+++ b/api/api_test.go
@@ -0,0 +1,120 @@
+package api
+
+import (
+ "fmt"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "testing"
+)
+
+func httpGet(url string, json bool, userAgent string) (string, error) {
+ r, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return "", err
+ }
+ if json {
+ r.Header.Set("Accept", "application/json")
+ }
+ r.Header.Set("User-Agent", userAgent)
+ res, err := http.DefaultClient.Do(r)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ data, err := ioutil.ReadAll(res.Body)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func TestGetIP(t *testing.T) {
+ toJSON := func(k string, v string) string {
+ return fmt.Sprintf("{\n \"%s\": \"%s\"\n}", k, v)
+ }
+ s := httptest.NewServer(New().Handlers())
+ var tests = []struct {
+ url string
+ json bool
+ out string
+ userAgent string
+ }{
+ {s.URL, false, "127.0.0.1\n", "curl/7.26.0"},
+ {s.URL, false, "127.0.0.1\n", "Wget/1.13.4 (linux-gnu)"},
+ {s.URL, false, "127.0.0.1\n", "fetch libfetch/2.0"},
+ {s.URL + "/x-ifconfig-ip.json", false, toJSON("x-ifconfig-ip", "127.0.0.1"), ""},
+ {s.URL, true, toJSON("x-ifconfig-ip", "127.0.0.1"), ""},
+ }
+ for _, tt := range tests {
+ out, err := httpGet(tt.url, tt.json, tt.userAgent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != tt.out {
+ t.Errorf("Expected %q, got %q", tt.out, out)
+ }
+ }
+}
+
+func TestIPFromRequest(t *testing.T) {
+ var tests = []struct {
+ in *http.Request
+ out net.IP
+ }{
+ {&http.Request{RemoteAddr: "1.3.3.7:9999"}, net.ParseIP("1.3.3.7")},
+ {&http.Request{Header: http.Header{"X-Real-Ip": []string{"1.3.3.7"}}}, net.ParseIP("1.3.3.7")},
+ }
+ for _, tt := range tests {
+ ip, err := ipFromRequest(tt.in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ip.Equal(tt.out) {
+ t.Errorf("Expected %s, got %s", tt.out, ip)
+ }
+ }
+}
+
+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 " +
+ "Safari/537.36"
+ var tests = []struct {
+ in string
+ out bool
+ }{
+ {"curl/7.26.0", true},
+ {"Wget/1.13.4 (linux-gnu)", true},
+ {"fetch libfetch/2.0", true},
+ {browserUserAgent, false},
+ }
+ for _, tt := range tests {
+ r := &http.Request{Header: http.Header{"User-Agent": []string{tt.in}}}
+ if got := cliMatcher(r, nil); got != tt.out {
+ t.Errorf("Expected %t, got %t for %q", tt.out, got, tt.in)
+ }
+ }
+}
diff --git a/ifconfig.go b/ifconfig.go
deleted file mode 100644
index ae97ac7b..00000000
--- a/ifconfig.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "github.com/jessevdk/go-flags"
- "github.com/oschwald/geoip2-golang"
- "html/template"
- "io"
- "log"
- "net"
- "net/http"
- "net/url"
- "os"
- "regexp"
- "strings"
-)
-
-var agentExp = regexp.MustCompile("^(?i)(curl|wget|fetch\\slibfetch)\\/.*$")
-
-type Client struct {
- IP net.IP
- JSON string
- Header http.Header
- Cmd
-}
-
-type Cmd struct {
- Name string
- Args string
-}
-
-type Ifconfig struct {
- DB *geoip2.Reader
-}
-
-func (c *Cmd) String() string {
- return c.Name + " " + c.Args
-}
-
-func isCLI(userAgent string) bool {
- return agentExp.MatchString(userAgent)
-}
-
-func parseRealIP(req *http.Request) net.IP {
- var host string
- realIP := req.Header.Get("X-Real-IP")
- if realIP != "" {
- host = realIP
- } else {
- host, _, _ = net.SplitHostPort(req.RemoteAddr)
- }
- return net.ParseIP(host)
-}
-
-func pathToKey(path string) string {
- trimmed := strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".json")
- return strings.ToLower(trimmed)
-}
-
-func isJSON(req *http.Request) bool {
- return strings.HasSuffix(req.URL.Path, ".json") ||
- strings.Contains(req.Header.Get("Accept"), "application/json")
-}
-
-func (i *Ifconfig) LookupCountry(ip net.IP) (string, error) {
- if i.DB == nil {
- return "", nil
- }
- record, err := i.DB.Country(ip)
- if err != nil {
- return "", err
- }
- country, exists := record.Country.Names["en"]
- if !exists {
- country, exists = record.RegisteredCountry.Names["en"]
- if !exists {
- return "", fmt.Errorf(
- "could not determine country for IP: %s", ip)
- }
- }
- return country, nil
-}
-
-func (i *Ifconfig) JSON(req *http.Request, key string) (string, error) {
- var header http.Header
- if key == "all" {
- header = req.Header
- } else {
- header = http.Header{
- key: []string{req.Header.Get(key)},
- }
- }
- b, err := json.MarshalIndent(header, "", " ")
- if err != nil {
- return "", err
- }
- return string(b), nil
-}
-
-func (i *Ifconfig) Plain(req *http.Request, key string, ip net.IP) string {
- if key == "" || key == "ip" {
- return fmt.Sprintf("%s\n", ip)
- }
- return fmt.Sprintf("%s\n", req.Header.Get(key))
-}
-
-func lookupCmd(values url.Values) Cmd {
- cmd, exists := values["cmd"]
- if !exists || len(cmd) == 0 {
- return Cmd{Name: "curl"}
- }
- switch cmd[0] {
- case "curl":
- return Cmd{Name: "curl"}
- case "fetch":
- return Cmd{Name: "fetch", Args: "-qo -"}
- case "wget":
- return Cmd{Name: "wget", Args: "-qO -"}
- }
- return Cmd{Name: "curl"}
-}
-
-func (i *Ifconfig) handler(w http.ResponseWriter, req *http.Request) {
- if req.Method != "GET" {
- http.Error(w, "Invalid request method", 405)
- return
- }
- ip := parseRealIP(req)
- key := pathToKey(req.URL.Path)
- cmd := lookupCmd(req.URL.Query())
- country, err := i.LookupCountry(ip)
- if err != nil {
- log.Print(err)
- }
- req.Header["X-Ip-Country"] = []string{country}
- if isJSON(req) {
- out, err := i.JSON(req, key)
- if err != nil {
- log.Print(err)
- http.Error(w, "Failed to marshal JSON", 500)
- return
- }
- io.WriteString(w, out)
- } else if isCLI(req.UserAgent()) {
- io.WriteString(w, i.Plain(req, key, ip))
- } else {
- funcMap := template.FuncMap{
- "ToLower": strings.ToLower,
- }
- t, _ := template.
- New("index.html").
- Funcs(funcMap).
- ParseFiles("index.html")
- b, err := json.MarshalIndent(req.Header, "", " ")
- if err != nil {
- log.Print(err)
- http.Error(w, "Failed to marshal JSON", 500)
- return
- }
- client := &Client{
- IP: ip,
- JSON: string(b),
- Header: req.Header,
- Cmd: cmd,
- }
- t.Execute(w, client)
- }
-}
-
-func Create(path string) (*Ifconfig, error) {
- if path == "" {
- log.Print("Path to GeoIP database not given. Country lookup will be disabled")
- return &Ifconfig{}, nil
- }
- db, err := geoip2.Open(path)
- if err != nil {
- return nil, err
- }
- return &Ifconfig{DB: db}, nil
-}
-
-func main() {
- var opts struct {
- DBPath string `short:"f" long:"file" description:"Path to GeoIP database" value-name:"FILE" default:""`
- Listen string `short:"l" long:"listen" description:"Listening address" value-name:"ADDR" default:":8080"`
- }
- _, err := flags.ParseArgs(&opts, os.Args)
- if err != nil {
- os.Exit(1)
- }
- i, err := Create(opts.DBPath)
- if err != nil {
- log.Fatal(err)
- }
-
- http.HandleFunc("/", i.handler)
- log.Printf("Listening on %s", opts.Listen)
- if err := http.ListenAndServe(opts.Listen, nil); err != nil {
- log.Fatal("ListenAndServe: ", err)
- }
-}
diff --git a/ifconfig_test.go b/ifconfig_test.go
deleted file mode 100644
index d730587c..00000000
--- a/ifconfig_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package main
-
-import (
- "net/url"
- "testing"
-)
-
-func TestIsCLI(t *testing.T) {
- userAgents := []string{"curl/7.26.0", "Wget/1.13.4 (linux-gnu)",
- "fetch libfetch/2.0"}
-
- for _, userAgent := range userAgents {
- if !isCLI(userAgent) {
- t.Errorf("Expected true for %s", userAgent)
- }
- }
-
- 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"
- if isCLI(browserUserAgent) {
- t.Errorf("Expected false for %s", browserUserAgent)
- }
-}
-
-func TestPathToKey(t *testing.T) {
- if key := pathToKey("/ip"); key != "ip" {
- t.Fatalf("Expected 'ip', got '%s'", key)
- }
- if key := pathToKey("/User-Agent"); key != "user-agent" {
- t.Fatalf("Expected 'user-agent', got '%s'", key)
- }
- if key := pathToKey("/all.json"); key != "all" {
- t.Fatalf("Expected 'all', got '%s'", key)
- }
-}
-
-func TestLookupCmd(t *testing.T) {
- values := url.Values{"cmd": []string{"curl"}}
- if v := lookupCmd(values); v.Name != "curl" {
- t.Fatalf("Expected 'curl', got '%s'", v)
- }
- values = url.Values{"cmd": []string{"foo"}}
- if v := lookupCmd(values); v.Name != "curl" {
- t.Fatalf("Expected 'curl', got '%s'", v)
- }
- values = url.Values{}
- if v := lookupCmd(values); v.Name != "curl" {
- t.Fatalf("Expected 'curl', got '%s'", v)
- }
- values = url.Values{"cmd": []string{"wget"}}
- if v := lookupCmd(values); v.Name != "wget" {
- t.Fatalf("Expected 'wget', got '%s'", v)
- }
- values = url.Values{"cmd": []string{"fetch"}}
- if v := lookupCmd(values); v.Name != "fetch" {
- t.Fatalf("Expected 'fetch', got '%s'", v)
- }
-}
diff --git a/index.html b/index.html
index 729824ba..f39050f7 100644
--- a/index.html
+++ b/index.html
@@ -6,7 +6,7 @@
-
+