From 6fd78737dcae50355f24c0830868a1ccbebdf7a6 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 26 Apr 2026 22:14:42 +0000 Subject: [PATCH] add ipinfo middleware --- README.md | 23 ++++++ go.mod | 6 ++ go.sum | 6 ++ ipinfo/ipinfo.go | 85 +++++++++++++++++++++++ ipinfo/ipinfo_test.go | 158 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 ipinfo/ipinfo.go create mode 100644 ipinfo/ipinfo_test.go diff --git a/README.md b/README.md index 9eee35d..03e6447 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,26 @@ import "git.juancwu.dev/juancwu/lightmux-contrib/recoverer" mux.Use(recoverer.New()) ``` + +### `ipinfo` + +Looks up the client IP against the [ipinfo.io](https://ipinfo.io) API via the [official Go SDK](https://github.com/ipinfo/go) and attaches the `*ipinfo.Core` result to the request context. Pair with `realip` upstream so the lookup uses the originating client IP rather than the proxy peer. + +```go +import ( + sdk "github.com/ipinfo/go/v2/ipinfo" + "git.juancwu.dev/juancwu/lightmux-contrib/ipinfo" + "git.juancwu.dev/juancwu/lightmux-contrib/realip" +) + +client := sdk.NewClient(nil, nil, "YOUR_TOKEN") +mux.Use(realip.New(), ipinfo.New(client, nil)) + +mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if info, ok := ipinfo.From(r.Context()); ok { + fmt.Fprintf(w, "hello from %s, %s\n", info.City, info.Country) + } +}) +``` + +Loopback, private, link-local, and unspecified addresses are skipped to preserve API quota. Lookup failures are logged at warn level via [splinter](https://git.juancwu.dev/juancwu/splinter) (pass `nil` for `splinter.Default()` resolved at request time, or supply a custom `*splinter.Logger`) and let the request through with no context value — handlers should treat the `From` lookup as optional. diff --git a/go.mod b/go.mod index 60781c4..1c4fc88 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,10 @@ go 1.26.2 require ( git.juancwu.dev/juancwu/errx v0.1.0 git.juancwu.dev/juancwu/splinter v0.1.0 + github.com/ipinfo/go/v2 v2.14.0 +) + +require ( + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 // indirect ) diff --git a/go.sum b/go.sum index 207ed5b..3b0d732 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,9 @@ git.juancwu.dev/juancwu/errx v0.1.0 h1:92yA0O1BkKGXcoEiWtxwH/ztXCjoV1KSTMtKpm3gd git.juancwu.dev/juancwu/errx v0.1.0/go.mod h1:7jNhBOwcZ/q7zDD6mln3QCJBYZ8T6h+dAdxVfykprTk= git.juancwu.dev/juancwu/splinter v0.1.0 h1:ZGvvzyi24hZw/yFAwpUsHtj+q+fh9I2KIGmOAILWD5Q= git.juancwu.dev/juancwu/splinter v0.1.0/go.mod h1:dAYsRQfS6tqWynEGz8xMCtIJUN7+KIp3jLE7kgO3yKE= +github.com/ipinfo/go/v2 v2.14.0 h1:suK/cRZd91ycwz4dkxvq89ywC4ZZnZWarDkf2Ll3LSw= +github.com/ipinfo/go/v2 v2.14.0/go.mod h1:YMqoJR6iO7Sq2X7s0lgD9DnX9UjldRMp6QJZk30+48w= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4= +golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/ipinfo/ipinfo.go b/ipinfo/ipinfo.go new file mode 100644 index 0000000..7038187 --- /dev/null +++ b/ipinfo/ipinfo.go @@ -0,0 +1,85 @@ +// Package ipinfo enriches incoming requests with geolocation data from the +// ipinfo.io API and attaches the result to the request context for downstream +// handlers to consume via From. +package ipinfo + +import ( + "context" + "net" + "net/http" + + "git.juancwu.dev/juancwu/errx" + "git.juancwu.dev/juancwu/splinter" + "github.com/ipinfo/go/v2/ipinfo" +) + +const op = "ipinfo" + +type ctxKey struct{} + +// New returns a middleware that looks up the client IP (parsed from +// r.RemoteAddr) against the ipinfo.io API and attaches the *ipinfo.Core +// result to the request context. Pair with realip.New() upstream so the +// lookup uses the originating client IP rather than the proxy peer. +// +// Pass nil for the logger to use splinter.Default() resolved at request time. +// +// Loopback, private, link-local, and unspecified addresses are skipped so the +// upstream API quota is preserved. Lookup errors are logged at warn level but +// do not abort the request — downstream handlers should treat the context +// value as optional via From. +func New(client *ipinfo.Client, l *splinter.Logger) func(http.Handler) http.Handler { + if client == nil { + panic(errx.New(op, "client must not be nil")) + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := parseClientIP(r.RemoteAddr) + if ip == nil || isLocal(ip) { + next.ServeHTTP(w, r) + return + } + info, err := client.GetIPInfo(ip) + if err != nil { + warn(l, "ipinfo.lookup_failed", + "client", ip.String(), + "err", errx.Wrap(op, err), + ) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, info))) + }) + } +} + +// From returns the *ipinfo.Core attached to ctx by the middleware. The second +// return value reports whether a lookup ran and produced a result. +func From(ctx context.Context) (*ipinfo.Core, bool) { + info, ok := ctx.Value(ctxKey{}).(*ipinfo.Core) + return info, ok +} + +func parseClientIP(remoteAddr string) net.IP { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + return net.ParseIP(host) +} + +func isLocal(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsUnspecified() +} + +func warn(l *splinter.Logger, msg string, args ...any) { + if l == nil { + splinter.Warn(msg, args...) + return + } + l.Warn(msg, args...) +} diff --git a/ipinfo/ipinfo_test.go b/ipinfo/ipinfo_test.go new file mode 100644 index 0000000..314faa4 --- /dev/null +++ b/ipinfo/ipinfo_test.go @@ -0,0 +1,158 @@ +package ipinfo + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + sdk "github.com/ipinfo/go/v2/ipinfo" +) + +func newClient(t *testing.T, handler http.HandlerFunc) *sdk.Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + c := sdk.NewClient(srv.Client(), nil, "") + u, err := url.Parse(srv.URL + "/") + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + c.BaseURL = u + return c +} + +func TestNewAttachesCoreOnSuccess(t *testing.T) { + const ip = "8.8.8.8" + hits := 0 + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + hits++ + if got := r.URL.Path; got != "/"+ip { + t.Errorf("ipinfo path = %q, want /%s", got, ip) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ip":"` + ip + `","city":"San Francisco","country":"US"}`)) + }) + + var seen *sdk.Core + h := New(client, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info, ok := From(r.Context()) + if !ok { + t.Fatal("From: expected info on context") + } + seen = info + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = ip + ":54321" + h.ServeHTTP(httptest.NewRecorder(), req) + + if hits != 1 { + t.Fatalf("expected 1 ipinfo call, got %d", hits) + } + if seen == nil || seen.City != "San Francisco" || seen.Country != "US" { + t.Errorf("unexpected core: %+v", seen) + } +} + +func TestNewAcceptsBareIPRemoteAddr(t *testing.T) { + const ip = "8.8.8.8" + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ip":"` + ip + `","country":"US"}`)) + }) + + var ok bool + h := New(client, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, ok = From(r.Context()) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = ip // no port — realip leaves it bare + h.ServeHTTP(httptest.NewRecorder(), req) + + if !ok { + t.Error("From: expected info on context for bare IP RemoteAddr") + } +} + +func TestNewSkipsLocalAddresses(t *testing.T) { + cases := []string{ + "127.0.0.1:1234", // loopback + "10.0.0.1:1234", // private + "192.168.1.1:1234", // private + "169.254.0.1:1234", // link-local + "[::1]:1234", // IPv6 loopback + "[fe80::1]:1234", // IPv6 link-local + "not-an-addr", // unparseable + "", // empty + } + called := false + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + for _, ra := range cases { + t.Run(ra, func(t *testing.T) { + var ok bool + h := New(client, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, ok = From(r.Context()) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = ra + h.ServeHTTP(httptest.NewRecorder(), req) + if ok { + t.Errorf("expected no context value for %q", ra) + } + }) + } + if called { + t.Error("ipinfo API should not be called for local/unparseable addresses") + } +} + +func TestNewPassesThroughOnLookupError(t *testing.T) { + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + served := false + var ok bool + h := New(client, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + served = true + _, ok = From(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "8.8.8.8:54321" + h.ServeHTTP(rr, req) + + if !served { + t.Fatal("downstream handler not invoked after lookup error") + } + if ok { + t.Error("From should report no info when lookup failed") + } + if rr.Code != http.StatusOK { + t.Errorf("response status = %d, want 200", rr.Code) + } +} + +func TestNewPanicsOnNilClient(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on nil client") + } + }() + _ = New(nil, nil) +} + +func TestFromEmptyContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if _, ok := From(req.Context()); ok { + t.Error("From: expected ok=false on empty context") + } +}