VPN, proxy, and Tor detection in Go
End-to-end Go integration of the IPLogs public API using only the standard library: typed verdict, net/http with context.WithTimeout, per-IP cache, middleware, batch scoring via /v1/bulk-check, and tests against an httptest.Server. No SDK, no API key.
Type the verdict
package iplogs
type Verdict string
const (
VerdictClean Verdict = "clean"
VerdictSuspicious Verdict = "suspicious"
VerdictVPNLikely Verdict = "vpn_likely"
VerdictVPNDetected Verdict = "vpn_detected"
)
type IPInfo struct {
IP string `json:"ip"`
ASN string `json:"asn,omitempty"`
Org string `json:"org,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
IsVPN bool `json:"is_vpn,omitempty"`
VPNProvider string `json:"vpn_provider,omitempty"`
VPNProviderSources []string `json:"vpn_provider_sources,omitempty"`
}
type Signal struct {
Type string `json:"type"`
Weight float64 `json:"weight"`
Matched bool `json:"matched"`
Detail string `json:"detail"`
}
type CheckResponse struct {
Verdict Verdict `json:"verdict"`
Score float64 `json:"score"`
IsVPN bool `json:"is_vpn"`
Confidence float64 `json:"confidence"`
Signals []Signal `json:"signals"`
IPInfo IPInfo `json:"ip_info"`
RequestID string `json:"request_id"`
}Quick start
package iplogs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
}
func New() *Client {
return &Client{
BaseURL: "https://iplogs.com",
HTTPClient: &http.Client{
Timeout: 8 * time.Second, // server cap is ~8s; client gives one extra
},
}
}
func (c *Client) Check(ctx context.Context, ip string) (*CheckResponse, error) {
body, _ := json.Marshal(map[string]string{"ip": ip})
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/v1/check", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("iplogs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("iplogs: HTTP %d", resp.StatusCode)
}
var out CheckResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("iplogs decode: %w", err)
}
return &out, nil
}One *http.Client per process is the right shape. Always pass a context.Context so the call cancels if the caller goes away.
Read the visitor IP from a trusted proxy
package iplogs
import (
"net"
"net/http"
"strings"
)
// trustedProxies are the immediate-peer addresses whose proxy headers
// we trust. Caddy and Nginx running on the same host are 127.0.0.1 or ::1.
var trustedProxies = map[string]struct{}{
"127.0.0.1": {},
"::1": {},
}
func ClientIP(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
if _, ok := trustedProxies[peer]; !ok {
return peer // direct request: ignore any proxy headers
}
if cf := r.Header.Get("CF-Connecting-IP"); cf != "" {
return strings.TrimSpace(cf)
}
// Caddy / Nginx append the real peer at the end. Read the LAST entry.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return peer
}HTTP middleware
package iplogs
import (
"context"
"net/http"
)
type ctxKey struct{}
// Middleware attaches the IPLogs verdict to the request context. Per-handler
// code reads it with iplogs.From(ctx) and decides policy.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := ClientIP(r)
if ip != "" {
if v, err := c.Check(r.Context(), ip); err == nil {
r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, v))
}
// Fail open: missing verdict means per-handler default policy.
}
next.ServeHTTP(w, r)
})
}
func From(ctx context.Context) *CheckResponse {
v, _ := ctx.Value(ctxKey{}).(*CheckResponse)
return v
}Per-IP cache
package iplogs
import (
"context"
"sync"
"time"
)
type cachedVerdict struct {
resp *CheckResponse
at time.Time
}
type CachedClient struct {
*Client
cache sync.Map // ip -> cachedVerdict
ttl time.Duration
}
func NewCached(c *Client, ttl time.Duration) *CachedClient {
return &CachedClient{Client: c, ttl: ttl}
}
func (c *CachedClient) Check(ctx context.Context, ip string) (*CheckResponse, error) {
if v, ok := c.cache.Load(ip); ok {
cv := v.(cachedVerdict)
if time.Since(cv.at) < c.ttl {
return cv.resp, nil
}
}
resp, err := c.Client.Check(ctx, ip)
if err != nil {
return nil, err
}
c.cache.Store(ip, cachedVerdict{resp: resp, at: time.Now()})
return resp, nil
}A 5-minute TTL is a good default. For a fleet, swap sync.Map for a Redis client with SET key value EX 300 so workers share the cache. The single-process variant above costs effectively nothing in memory at typical IP cardinality.
Batch scoring with /v1/bulk-check
type BulkRow struct {
IP string `json:"ip"`
Verdict Verdict `json:"verdict,omitempty"`
Score float64 `json:"score"`
IsVPN bool `json:"is_vpn"`
VPNProvider string `json:"vpn_provider,omitempty"`
CountryCode string `json:"country_code,omitempty"`
ASN string `json:"asn,omitempty"`
Org string `json:"org,omitempty"`
Error string `json:"error,omitempty"`
}
type bulkResp struct {
Total int `json:"total"`
DurationMs int64 `json:"duration_ms"`
Results []BulkRow `json:"results"`
}
func (c *Client) BulkCheck(ctx context.Context, ips []string) ([]BulkRow, error) {
body, _ := json.Marshal(map[string][]string{"ips": ips})
ctx, cancel := context.WithTimeout(ctx, 95*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/v1/bulk-check", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out bulkResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Results, nil
}/v1/bulk-check accepts up to 100 IPs per request. Chunk larger lists yourself and iterate; the per-row response is intentionally compact so a 100-IP batch returns ~10 KB rather than ~30 KB.
Testing without hitting the network
package iplogs
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestCheckClean(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"verdict":"clean","score":0,"is_vpn":false,
"confidence":0.15,"signals":[],"ip_info":{"ip":"1.1.1.1"},
"request_id":"req_test"}`))
}))
defer srv.Close()
c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
out, err := c.Check(context.Background(), "1.1.1.1")
if err != nil { t.Fatal(err) }
if out.Verdict != VerdictClean {
t.Errorf("got %q, want clean", out.Verdict)
}
}FAQ
Do I need an SDK or library to call IPLogs from Go?
No. The stdlib net/http, encoding/json, and context packages cover the integration in around 30 lines. Adding a dependency for one POST to one URL is overkill — keep the surface small and easy to audit.
Should I share an http.Client across requests?
Yes. Always reuse a single http.Client (or fasthttp client) per process. Creating a new client per request leaks connections and disables connection pooling. Set a Transport with sane MaxIdleConns and IdleConnTimeout if you expect bursts.
How should I cache verdicts in Go?
sync.Map with a goroutine that evicts entries past their TTL works for a single binary. For multi-instance services, use Redis with EX 300 keyed by the IP string. The verdict struct is small JSON; memory cost is negligible vs the round-trip latency you save.
Where should the verdict call sit in a Go HTTP server?
Behind a middleware that runs the check, stores the verdict in the request context, and lets per-handler code apply policy. Wrap chi.Router or http.Handler the same way: a middleware that decorates the request and forwards to the next handler.
How do I test the integration without hitting the network?
Stand up an httptest.Server that returns canned JSON and point your client at it via a configurable base URL. The verdict shape is stable: Verdict (one of clean / suspicious / vpn_likely / vpn_detected), Score (0–1), IsVPN, IPInfo, Signals[]. Assert on the URL, method, and body.
See the API docs for the full schema, the Python guide and Node.js guide for the equivalent integrations, and the accuracy benchmark for the published false-positive rate.