add ipinfo middleware
This commit is contained in:
parent
b26ef7439e
commit
6fd78737dc
5 changed files with 278 additions and 0 deletions
23
README.md
23
README.md
|
|
@ -49,3 +49,26 @@ import "git.juancwu.dev/juancwu/lightmux-contrib/recoverer"
|
||||||
|
|
||||||
mux.Use(recoverer.New())
|
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.
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -5,4 +5,10 @@ go 1.26.2
|
||||||
require (
|
require (
|
||||||
git.juancwu.dev/juancwu/errx v0.1.0
|
git.juancwu.dev/juancwu/errx v0.1.0
|
||||||
git.juancwu.dev/juancwu/splinter 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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
6
go.sum
6
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/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 h1:ZGvvzyi24hZw/yFAwpUsHtj+q+fh9I2KIGmOAILWD5Q=
|
||||||
git.juancwu.dev/juancwu/splinter v0.1.0/go.mod h1:dAYsRQfS6tqWynEGz8xMCtIJUN7+KIp3jLE7kgO3yKE=
|
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=
|
||||||
|
|
|
||||||
85
ipinfo/ipinfo.go
Normal file
85
ipinfo/ipinfo.go
Normal file
|
|
@ -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...)
|
||||||
|
}
|
||||||
158
ipinfo/ipinfo_test.go
Normal file
158
ipinfo/ipinfo_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue