diff --git a/Makefile b/Makefile index 1fc275b..b8db1d5 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 0000000..d522297 --- /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 0000000..01fe0e2 --- /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 ae97ac7..0000000 --- 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 d730587..0000000 --- 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 729824b..f39050f 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - +