https://github.com/abczzz13/clientip
Security-first client IP extraction for Go net/http services with trusted proxy validation, configurable source priority, and optional Prometheus metrics.
https://github.com/abczzz13/clientip
client-ip go golang ip-extraction net-http reverse-proxy security trusted-proxy x-forwarded-for x-real-ip
Last synced: about 1 month ago
JSON representation
Security-first client IP extraction for Go net/http services with trusted proxy validation, configurable source priority, and optional Prometheus metrics.
- Host: GitHub
- URL: https://github.com/abczzz13/clientip
- Owner: abczzz13
- License: mit
- Created: 2026-02-06T08:38:42.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-04-23T19:05:36.000Z (about 1 month ago)
- Last Synced: 2026-04-23T20:22:23.588Z (about 1 month ago)
- Topics: client-ip, go, golang, ip-extraction, net-http, reverse-proxy, security, trusted-proxy, x-forwarded-for, x-real-ip
- Language: Go
- Homepage:
- Size: 394 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# clientip
[](https://github.com/abczzz13/clientip/actions/workflows/ci.yml)
[](https://pkg.go.dev/github.com/abczzz13/clientip)
[](LICENSE)
Secure client IP extraction for `net/http` and framework-agnostic request inputs with trusted proxy validation, explicit source modeling, and request-scoped resolver caching.
## Stability
This project is pre-`v1.0.0` and still before `v0.1.0`, so public APIs may change as the package evolves.
Any breaking changes are called out in `CHANGELOG.md`.
This README tracks the current `main` branch rather than the latest tagged release.
## Contents
- [Install](#install)
- [Choose the API](#choose-the-api)
- [Common Setups](#common-setups)
- [Rules To Remember](#rules-to-remember)
- [Quick Start](#quick-start)
- [Preferred Resolution And Fallback](#preferred-resolution-and-fallback)
- [Framework-Agnostic Input](#framework-agnostic-input)
- [Presets](#presets)
- [Config](#config)
- [Low-Level Extraction](#low-level-extraction)
- [Errors](#errors)
- [Logging](#logging)
- [Prometheus Metrics](#prometheus-metrics)
- [Security Guidance](#security-guidance)
- [Compatibility](#compatibility)
- [Performance](#performance)
- [Maintainer Notes (Multi-Module)](#maintainer-notes-multi-module)
- [License](#license)
## Install
```bash
go get github.com/abczzz13/clientip
```
Optional Prometheus adapter:
```bash
go get github.com/abczzz13/clientip/prometheus
```
> Version note: the published adapter module is still pinned to `github.com/abczzz13/clientip v0.0.6` until the next coordinated release. This README documents the current `main`-branch API, so the Prometheus snippets below describe the upcoming adapter wiring after that release lands.
## Choose the API
Use this as a quick decision guide:
| Need | Use |
| --- | --- |
| Security-sensitive or audit-oriented result | `Resolver.ResolveStrict` or `Extractor` |
| Best-effort operational IP with explicit fallback | `Resolver.ResolvePreferred` |
| Framework integration without `*http.Request` | `Input` with `Resolver` or `Extractor` |
| Parse `RemoteAddr` outside extraction | `ParseRemoteAddr` |
| Coarse policy branching on error categories | `ClassifyError` |
Construct an `Extractor` once and reuse it. Build a `Resolver` on top when you want strict or preferred request-scoped resolution.
## Common Setups
Most integrations start with a preset and only drop to a fully manual `Config` when the proxy topology is unusual.
Direct app-to-client traffic:
```go
extractor, err := clientip.New(clientip.PresetDirectConnection())
```
Reverse proxy on the same host:
```go
extractor, err := clientip.New(clientip.PresetLoopbackReverseProxy())
```
Reverse proxy on a VM or private network:
```go
extractor, err := clientip.New(clientip.PresetVMReverseProxy())
```
Custom trusted header source:
```go
extractor, err := clientip.New(clientip.Config{
TrustedProxyPrefixes: clientip.LoopbackProxyPrefixes(),
Sources: []clientip.Source{
clientip.HeaderSource("CF-Connecting-IP"),
clientip.SourceRemoteAddr,
},
})
```
Framework request input:
Use `Input` when the framework does not hand you `*http.Request` directly. The same extractor and resolver rules still apply.
## Rules To Remember
- `Resolver` is the primary integration-facing API; `Extractor` is the lower-level strict primitive.
- Header-based sources require `TrustedProxyPrefixes`.
- Prefer a preset first, then tweak `Config` only when needed.
- Only configure one proxy-chain source at a time: `SourceForwarded` or `SourceXForwardedFor`.
- Preferred fallback is operationally useful, but not suitable for authorization, ACLs, or trust-boundary enforcement.
- `Input.Headers` must preserve repeated header lines as separate slice entries; merging them breaks duplicate detection and chain parsing semantics.
## Quick Start
Use `Resolver.ResolveStrict` for security-sensitive or audit-oriented decisions.
```go
extractor, err := clientip.New(clientip.PresetLoopbackReverseProxy())
if err != nil {
log.Fatal(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{})
if err != nil {
log.Fatal(err)
}
req := &http.Request{RemoteAddr: "127.0.0.1:12345", Header: make(http.Header)}
req.Header.Set("X-Forwarded-For", "8.8.8.8")
req, resolution := resolver.ResolveStrict(req)
if resolution.Err != nil {
log.Fatal(resolution.Err)
}
fmt.Printf("Client IP: %s from %s\n", resolution.IP, resolution.Source)
if cached, ok := clientip.StrictResolutionFromContext(req.Context()); ok {
fmt.Printf("Cached: %s\n", cached.IP)
}
```
## Preferred Resolution And Fallback
Use `Resolver.ResolvePreferred` when best-effort client IPs are operationally useful, such as rate limiting, analytics, or request tracing.
```go
extractor, err := clientip.New(clientip.Config{
TrustedProxyPrefixes: clientip.LoopbackProxyPrefixes(),
Sources: []clientip.Source{clientip.SourceXForwardedFor},
})
if err != nil {
log.Fatal(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{
PreferredFallback: clientip.PreferredFallbackRemoteAddr,
})
if err != nil {
log.Fatal(err)
}
req := &http.Request{RemoteAddr: "1.1.1.1:12345", Header: make(http.Header)}
_, resolution := resolver.ResolvePreferred(req)
if resolution.Err != nil {
log.Fatal(resolution.Err)
}
fmt.Printf("Client IP: %s from %s (fallback=%t)\n", resolution.IP, resolution.Source, resolution.FallbackUsed)
```
Important fallback guidance:
- Preferred fallback is explicit and only lives on `Resolver`.
- `PreferredFallbackRemoteAddr` is operationally useful, but it is not equivalent to validated proxy-header extraction.
- Preferred resolution is not suitable for authorization, ACLs, or other trust-boundary decisions.
- Fallback observability is result-only in this phase. Inspect `Resolution.FallbackUsed`; do not expect a separate fallback metric or log event.
If you want a synthetic fallback value instead of `RemoteAddr`, set `ResolverConfig{PreferredFallback: clientip.PreferredFallbackStaticIP, StaticFallbackIP: ...}`. Successful static fallback reports `SourceStaticFallback`.
## Framework-Agnostic Input
Use `Input` with either `Extractor` or `Resolver` when your framework does not expose `*http.Request` directly.
```go
input := clientip.Input{
Context: ctx,
RemoteAddr: remoteAddr,
Path: path,
Headers: headersProvider,
}
input, resolution := resolver.ResolveInputStrict(input)
if resolution.Err != nil {
// handle error
}
if cached, ok := clientip.StrictResolutionFromContext(input.Context); ok {
_ = cached
}
```
`Input.Headers` must preserve repeated header lines as separate slice entries. Do not merge duplicate lines into a single comma-joined string.
For `fasthttp`/Fiber style integrations:
```go
input := clientip.Input{
Context: c.UserContext(),
RemoteAddr: c.Context().RemoteAddr().String(),
Path: c.Path(),
Headers: clientip.HeaderValuesFunc(func(name string) []string {
raw := c.Context().Request.Header.PeekAll(name)
if len(raw) == 0 {
return nil
}
values := make([]string, len(raw))
for i, v := range raw {
values[i] = string(v)
}
return values
}),
}
```
## Presets
Presets return a flat `clientip.Config` that you can pass directly to `New` or tweak before construction.
- `PresetDirectConnection()` uses `RemoteAddr` only.
- `PresetLoopbackReverseProxy()` trusts loopback proxies and prioritizes `X-Forwarded-For` before `RemoteAddr`.
- `PresetVMReverseProxy()` trusts loopback and common private-network proxy ranges and prioritizes `X-Forwarded-For` before `RemoteAddr`.
```go
extractor, err := clientip.New(clientip.PresetVMReverseProxy())
if err != nil {
log.Fatal(err)
}
```
If you need to tweak a preset, modify the returned config before calling `New`:
```go
cfg := clientip.PresetVMReverseProxy()
cfg.Sources = []clientip.Source{clientip.SourceForwarded, clientip.SourceRemoteAddr}
extractor, err := clientip.New(cfg)
if err != nil {
log.Fatal(err)
}
```
Presets configure `Config`, not `ResolverConfig`. Preferred resolver fallback stays an explicit resolver-level choice.
## Config
`Config` stays flat in the current API.
Most callers should start from `PresetDirectConnection`, `PresetLoopbackReverseProxy`, or `PresetVMReverseProxy`, then adjust the returned `Config` if they need custom trust ranges, source order, or observability wiring.
Important fields:
- `TrustedProxyPrefixes []netip.Prefix`
- `MinTrustedProxies int`
- `MaxTrustedProxies int`
- `AllowPrivateIPs bool`
- `AllowedReservedClientPrefixes []netip.Prefix`
- `MaxChainLength int`
- `ChainSelection ChainSelection`
- `DebugInfo bool`
- `Sources []Source`
- `Logger Logger`
- `Metrics Metrics`
Useful helpers:
- `DefaultConfig()`
- `ParseCIDRs(...string)`
- `LoopbackProxyPrefixes()`
- `PrivateProxyPrefixes()`
- `LocalProxyPrefixes()`
- `ProxyPrefixesFromAddrs(...netip.Addr)`
Built-in extractor sources:
- `SourceForwarded`
- `SourceXForwardedFor`
- `SourceXRealIP`
- `SourceRemoteAddr`
- `HeaderSource(name)` for custom headers
Resolver-only result source:
- `SourceStaticFallback`
`Extractor` walks `Config.Sources` in order. `ErrSourceUnavailable` allows the next source to run, while security-significant failures remain terminal.
## Low-Level Extraction
Use `Extractor` directly when you want strict extraction without request-scoped caching or preferred fallback.
```go
extractor, err := clientip.New(clientip.DefaultConfig())
if err != nil {
log.Fatal(err)
}
extraction, err := extractor.Extract(req)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Client IP: %s from %s\n", extraction.IP, extraction.Source)
```
Framework-agnostic extraction is also available:
```go
extraction, err := extractor.ExtractInput(input)
if err != nil {
log.Fatal(err)
}
```
## Errors
Typed errors remain the detailed error surface:
```go
_, resolution := resolver.ResolveStrict(req)
if resolution.Err != nil {
switch {
case errors.Is(resolution.Err, clientip.ErrMultipleSingleIPHeaders):
case errors.Is(resolution.Err, clientip.ErrInvalidForwardedHeader):
case errors.Is(resolution.Err, clientip.ErrUntrustedProxy):
case errors.Is(resolution.Err, clientip.ErrNoTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrTooFewTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrTooManyTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrInvalidIP):
case errors.Is(resolution.Err, clientip.ErrSourceUnavailable):
case errors.Is(resolution.Err, clientip.ErrNilRequest):
}
}
```
`ClassifyError` provides a smaller policy-oriented layer on top of those typed errors:
```go
switch clientip.ClassifyError(resolution.Err) {
case clientip.ResultSuccess:
case clientip.ResultUnavailable:
case clientip.ResultInvalid:
case clientip.ResultUntrusted:
case clientip.ResultMalformed:
case clientip.ResultCanceled:
case clientip.ResultUnknown:
}
```
`ResultUnknown` covers non-nil errors outside the package's standard extraction and resolution categories.
`ErrInvalidForwardedHeader` covers malformed RFC7239 syntax, including present-but-empty `Forwarded` values and empty elements or parameters introduced by stray delimiters. In strict extraction, malformed `Forwarded` remains terminal and does not fall through to a lower-priority source.
Typed chain-related errors expose additional context:
- `ProxyValidationError`: `Chain`, `TrustedProxyCount`, `MinTrustedProxies`, `MaxTrustedProxies`
- `InvalidIPError`: `Chain`, `ExtractedIP`, `Index`, `TrustedProxies`
- `RemoteAddrError`: `RemoteAddr`
- `ChainTooLongError`: `ChainLength`, `MaxLength`
## Logging
Logging is disabled by default. Set `Config.Logger` to opt in.
```go
extractor, err := clientip.New(clientip.Config{
Logger: slog.Default(),
})
```
The logger interface intentionally matches `slog.Logger.WarnContext`:
```go
type Logger interface {
WarnContext(context.Context, string, ...any)
}
```
The context passed to logger calls comes from `req.Context()` (`Extract`) or `Input.Context` (`ExtractInput`).
Security event labels passed through `Metrics.RecordSecurityEvent(...)` are the stable exported `clientip.SecurityEvent...` constants.
## Prometheus Metrics
Construct Prometheus metrics explicitly and pass them through `Config.Metrics`.
This constructor-based wiring reflects the current `main` branch. Tagged-release consumers of `github.com/abczzz13/clientip/prometheus` still get the older adapter release that depends on root `v0.0.6` until the next coordinated adapter tag is published.
```go
import clientipprom "github.com/abczzz13/clientip/prometheus"
metrics, err := clientipprom.New()
if err != nil {
panic(err)
}
extractor, err := clientip.New(clientip.Config{Metrics: metrics})
if err != nil {
panic(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{})
if err != nil {
panic(err)
}
```
With a custom registerer:
```go
registry := prometheus.NewRegistry()
metrics, err := clientipprom.NewWithRegisterer(registry)
if err != nil {
panic(err)
}
```
## Security Guidance
- Use `ResolveStrict` or `Extractor` for security-sensitive and audit-oriented behavior.
- Use `ResolvePreferred` only when explicit fallback is acceptable for operational reasons.
- Do not use preferred fallback for authorization, ACLs, or trust-boundary enforcement.
- Do not include multiple competing header-based sources for security decisions.
- Do not trust broad proxy CIDRs unless they are truly under your control.
- Header-based sources require `TrustedProxyPrefixes`.
- `LeftmostUntrustedIP` only makes sense when trusted proxy prefixes are configured.
## Compatibility
- Core module (`github.com/abczzz13/clientip`) supports Go `1.21+`.
- Optional Prometheus adapter (`github.com/abczzz13/clientip/prometheus`) has a minimum Go version of `1.21`; CI currently validates consumer mode on Go `1.21.x` and `1.26.x` against the released root module until the next coordinated adapter release is tagged.
- The published adapter module is currently pinned to `github.com/abczzz13/clientip v0.0.6`; the constructor-based `Config.Metrics` wiring documented above becomes the tagged-release path once that coordinated release ships.
- Prometheus client dependency in the adapter is pinned to `github.com/prometheus/client_golang v1.21.1`.
## Performance
- Extraction is `O(n)` in proxy-chain length.
- `Extractor` is safe for concurrent reuse.
- `Resolver` adds request-scoped caching on top of a reusable extractor.
Benchmark workflow with `just`:
```bash
# Capture a stable baseline (6 samples by default)
just bench-save before "BenchmarkExtract|BenchmarkChainAnalysis|BenchmarkParseIP"
# Make changes, then capture again
just bench-save after "BenchmarkExtract|BenchmarkChainAnalysis|BenchmarkParseIP"
# Compare with benchstat table output (delta + significance)
just bench-compare-saved before after
```
You can compare arbitrary files directly via `just bench-compare `.
## Maintainer Notes (Multi-Module)
- `prometheus/go.mod` intentionally does not use a local `replace` directive for `github.com/abczzz13/clientip`.
- For local co-development, create an uncommitted workspace with `go work init . ./prometheus`.
- Validate the adapter as a consumer with `GOWORK=off go -C prometheus test ./...`; until the next coordinated release, this intentionally exercises the released root module `v0.0.6` instead of the unreleased workspace API.
- `just` and CI validate the adapter in consumer mode by default (`GOWORK=off`); set `CLIENTIP_ADAPTER_GOWORK=auto` locally when you intentionally want workspace-mode adapter checks.
- Release in this order: tag root module `vX.Y.Z`, bump `prometheus/go.mod` to that version, then tag adapter module `prometheus/vX.Y.Z`.
## License
See `LICENSE`.