ipguard

package module
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 18, 2026 License: MIT Imports: 15 Imported by: 0

README

IPGuard

IPGuard is a Go library for IP filtering with auto-banning, whitelist/blacklist, geographic blocking, HTTP middleware with trusted proxy support, and PROXY protocol v1/v2 decoding. It filters unwanted traffic at both the TCP listener and HTTP handler levels, extracting real client IPs behind reverse proxies and load balancers, using zero non-stdlib dependencies.

Installation

go get github.com/dvstc/ipguard

Packages

  • ipguard (root) — core guard logic: Config, Guard, IsBlocked, RecordFailure, WrapListener, WrapHandler, WrapErrorLog, WrapListenerProxyProto, Snapshot, hooks, functional options
  • ipguard/tgeo — TGEO binary format: Encode/Decode, Table/LoadTableFromBytes (fast IPv4-to-country lookup), Compile, Merge, VerifyAndWrite, Meta
  • ipguard/tgeo/fetch — curated table loader: fetch.Table(ctx, fetch.WithURL(...)) downloads a pre-compiled TGEO file from any feed URL, with HTTP conditional request caching
  • ipguard/tgeo/sources — geolocation data fetchers: RIR (NRO delegation), BGP (CAIDA RouteViews), DBIP (DB-IP Lite CSV), with built-in HTTP conditional request caching

Usage

Minimal: Blacklist + WrapListener
import "github.com/dvstc/ipguard"

g, err := ipguard.New(ipguard.Config{
    Blacklist: []string{"203.0.113.0/24", "198.51.100.50"},
})
if err != nil {
    log.Fatal(err)
}
defer g.Close()

ln, _ := net.Listen("tcp", ":8080")
guarded := g.WrapListener(ln, "http")
// guarded.Accept() silently drops blacklisted connections
http.Serve(guarded, handler)
Full: Auto-Ban + Geo + Hooks
import (
    "github.com/dvstc/ipguard"
    "github.com/dvstc/ipguard/tgeo"
)

table, _ := tgeo.LoadTable("/data/geoip/iploc.bin")

g, err := ipguard.New(ipguard.Config{
    Whitelist:    []string{"10.0.0.0/8"},
    Blacklist:    []string{"203.0.113.0/24"},
    MaxRetry:     5,
    FindTime:     10 * time.Minute,
    BanTime:      1 * time.Hour,
    GeoMode:      ipguard.GeoBlock,
    GeoCountries: []string{"CN", "RU"},
},
    ipguard.WithGeo(table),
    ipguard.WithHooks(&ipguard.Hooks{
        OnBanned: func(e ipguard.BanEvent) {
            log.Printf("banned %s after %d failures", e.IP, e.Failures)
        },
    }),
    ipguard.WithLogger(log.Default()),
)
if err != nil {
    log.Fatal(err)
}
defer g.Close()
g.Start(context.Background())

// Record failures from your auth layer
g.RecordFailure(clientIP, "https")

// Check blocking without WrapListener
if blocked, reason := g.IsBlocked(clientIP); blocked {
    log.Printf("blocked %s: %s", clientIP, reason)
}

// Hot-swap geo data without restart
newTable, _ := tgeo.LoadTable("/data/geoip/iploc-v2.bin")
g.SetGeoLookup(newTable)
Recidivist Escalation (Permanent Bans)

IPs that are repeatedly banned can be automatically promoted to permanent bans. The consumer owns persistence - IPGuard signals via hooks, and the consumer stores/restores via functional options.

import "github.com/dvstc/ipguard"

// Load previously persisted permanent bans from your database
permaBannedIPs := loadFromDB() // returns []string

g, err := ipguard.New(ipguard.Config{
    MaxRetry:         5,
    FindTime:         10 * time.Minute,
    BanTime:          1 * time.Hour,
    PermaBanAfter:    3,                // promote to permanent after 3 bans
    RecidivismWindow: 24 * time.Hour,   // forget ban history after 24h of good behavior
},
    ipguard.WithPermaBans(permaBannedIPs),
    ipguard.WithGeo(table),
    ipguard.WithHooks(&ipguard.Hooks{
        OnBanned: func(e ipguard.BanEvent) {
            log.Printf("temp ban %s (country=%s, ban #%d)", e.IP, e.Country, e.BanCount)
        },
        OnPermaBanned: func(e ipguard.PermaBanEvent) {
            log.Printf("PERMANENT ban %s (country=%s, ban #%d)", e.IP, e.Country, e.BanCount)
            saveToDB(e.IP) // persist for next restart
        },
        OnUnbanned: func(e ipguard.UnbanEvent) {
            if e.Reason == "manual" {
                removeFromDB(e.IP) // operator cleared the ban
            }
        },
        OnWarning: func(msg string, data map[string]string) {
            // Fired when permanent ban count exceeds 10,000 (rate-limited to 1/hr)
            log.Printf("WARNING: %s — top country: %s (%s IPs)",
                msg, data["top_country"], data["top_country_count"])
        },
    }),
)
if err != nil {
    log.Fatal(err)
}
defer g.Close()
g.Start(context.Background())

// Manual promotion is also supported
g.PermaBan("203.0.113.50")

// Unban clears both the ban and its history
g.Unban("203.0.113.50")

// Snapshot includes permanent ban status and geo enrichment
snap := g.Snapshot()
for _, ban := range snap.Bans {
    fmt.Printf("ip=%s permanent=%v country=%s ban_count=%d\n",
        ban.IP, ban.Permanent, ban.Country, ban.BanCount)
}
fmt.Printf("active=%d permanent=%d history=%d\n",
    snap.Stats.ActiveBans, snap.Stats.PermanentBans, snap.Stats.BanHistorySize)
HTTP Middleware

WrapHandler wraps an http.Handler with IP filtering. It extracts the real client IP from reverse proxy headers with trusted proxy validation, blocks requests from banned/blacklisted IPs with 403, and optionally records failures based on HTTP response status codes.

Direct (no proxy):

guarded, err := guard.WrapHandler(handler)
if err != nil {
    log.Fatal(err)
}
http.ListenAndServe(":8080", guarded)

Behind HAProxy (HTTP mode):

HAProxy in HTTP mode (mode http) adds X-Forwarded-For by default. Trust the HAProxy IP(s) and read the header:

guarded, err := guard.WrapHandler(handler,
    ipguard.WithTrustedProxies("10.0.0.1/32"),  // HAProxy frontend IP
    ipguard.WithIPHeader("X-Forwarded-For"),
    ipguard.WithFailureCodes(401, 404),
)

Corresponding HAProxy config:

frontend http_front
    bind *:80
    option forwardfor
    default_backend app

backend app
    server go_app 10.0.0.2:8080 check

Behind nginx:

guarded, err := guard.WrapHandler(handler,
    ipguard.WithTrustedProxies("10.0.0.0/8", "172.16.0.0/12"),
    ipguard.WithIPHeader("X-Forwarded-For"),
)

Behind Cloudflare:

guarded, err := guard.WrapHandler(handler,
    ipguard.WithTrustedProxies(
        "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
        "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
        "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
        "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
        "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
    ),
    ipguard.WithIPHeader("CF-Connecting-IP"),
    ipguard.WithFailureCodes(401, 404),
)

With auto-failure recording:

When WithFailureCodes is set, the middleware automatically calls RecordFailure when the inner handler responds with one of the configured status codes. This lets IPGuard auto-ban IPs that repeatedly trigger 401/404/etc. without any manual wiring.

TLS Error Interception

WrapErrorLog intercepts TLS handshake failures from http.Server.ErrorLog and feeds them into the auto-ban pipeline. This catches scanners that probe with bad TLS connections (unsupported versions, missing SNI, garbage bytes) before any HTTP request is formed -- the gap between WrapListener and WrapHandler.

Minimal (forwards to guard's logger):

srv := &http.Server{
    Handler:  handler,
    ErrorLog: guard.WrapErrorLog(nil),
}

With fallback (preserves log level):

errorLog := slog.NewLogLogger(logger.Handler(), slog.LevelError)
srv := &http.Server{
    Handler:  handler,
    ErrorLog: guard.WrapErrorLog(errorLog),
}

The fallback logger receives all messages (TLS and non-TLS) at the consumer's chosen level. TLS handshake errors additionally trigger RecordFailure, so the same IP hitting MaxRetry bad handshakes within FindTime gets auto-banned and WrapListener drops all future TCP connections.

PROXY Protocol (TCP)

WrapListenerProxyProto wraps a net.Listener to decode PROXY protocol v1/v2 headers from trusted load balancers. Use this for non-HTTP services (SSH, SMTP, game servers, etc.) behind L4 load balancers that speak PROXY protocol.

Behind HAProxy (TCP mode with PROXY protocol):

HAProxy in TCP mode (mode tcp) can send PROXY protocol headers to preserve the real client IP. This is the standard approach for non-HTTP services like SSH, SMTP, or game servers behind a load balancer.

ln, _ := net.Listen("tcp", ":2222")
guarded, err := guard.WrapListenerProxyProto(ln, "ssh",
    []string{"10.0.0.1/32"},  // HAProxy frontend IP
)
if err != nil {
    log.Fatal(err)
}
for {
    conn, err := guarded.Accept()
    if err != nil {
        break
    }
    // conn.RemoteAddr() returns the real client IP from the PROXY header
    go handleSSH(conn)
}

Corresponding HAProxy config:

frontend ssh_front
    bind *:22
    mode tcp
    default_backend ssh_back

backend ssh_back
    mode tcp
    server go_app 10.0.0.2:2222 send-proxy-v2 check

Supports auto-detection of v1 (text) and v2 (binary) PROXY headers. Use send-proxy in HAProxy for v1 or send-proxy-v2 for v2. Connections from non-trusted sources pass through without PROXY header parsing, using RemoteAddr directly for filtering.

The simplest way to get geo blocking working. Point fetch.Table at a URL serving a pre-compiled TGEO binary (gzipped). Uses HTTP conditional requests so subsequent calls only transfer data when the upstream file has changed.

import "github.com/dvstc/ipguard/tgeo/fetch"

table, err := fetch.Table(ctx,
    fetch.WithURL("https://your-tgeo-feed.example.com/latest.tgeo.gz"),
)
if err != nil {
    log.Fatal(err)
}
guard.SetGeoLookup(table)

A feed can also publish a meta.json alongside the binary for lightweight version checks without downloading the full file. The JSON matches the tgeo.Meta struct:

{
  "version": "1",
  "published_at": "2026-04-17T12:00:00Z",
  "checksum": "sha256:...",
  "size": 4823041,
  "download_url": "https://your-tgeo-feed.example.com/latest.tgeo.gz",
  "sources": ["rir-nro", "bgp-caida", "dbip-lite"],
  "license": "CC-BY-4.0"
}

### Geo Data: Custom Sources (Advanced)

For consumers who want to use their own sources, customize the pipeline, or add proprietary data. All source fetchers include HTTP conditional request caching by default (sends `If-None-Match` / `If-Modified-Since` headers to upstream servers).

```go
import (
    "github.com/dvstc/ipguard/tgeo"
    "github.com/dvstc/ipguard/tgeo/sources"
)

ctx := context.Background()

// Fetch from public data sources
rir := &sources.RIR{}
ranges, asnMap, _ := rir.FetchWithASN(ctx)

bgp := &sources.BGP{ASNMap: asnMap}
bgpRanges, _ := bgp.Fetch(ctx)

dbip := &sources.DBIP{}
dbipRanges, _ := dbip.Fetch(ctx)

// Merge with priority-based conflict resolution
merged, stats := tgeo.Merge(map[string]tgeo.SourceData{
    rir.Name():  {Ranges: ranges, Priority: rir.Priority()},
    bgp.Name():  {Ranges: bgpRanges, Priority: bgp.Priority()},
    dbip.Name(): {Ranges: dbipRanges, Priority: dbip.Priority()},
})

// Compile to TGEO binary
result, _ := tgeo.Compile(merged)

// result.GzipData   — compressed binary, ready to serve/store
// result.Checksum   — "sha256:..." for integrity verification
// result.EntryCount — number of IPv4 ranges
// result.Countries  — number of distinct country codes
Applying a TGEO Update
import "github.com/dvstc/ipguard/tgeo"

// After downloading compressed TGEO data and its checksum:
err := tgeo.VerifyAndWrite(compressed, meta.Checksum, "/data/geoip/iploc.bin")
if err != nil {
    log.Fatal(err)
}

// Load and hot-swap into a running guard
table, _ := tgeo.LoadTable("/data/geoip/iploc.bin")
guard.SetGeoLookup(table)

Design

See DESIGN.md for the full API surface, TGEO binary format specification, evaluation order, and architectural decisions.

License

MIT

Documentation

Index

Constants

View Source
const (
	ReasonBlacklist = "blacklist"
	ReasonAutoBan   = "auto_ban"
	ReasonPermaBan  = "permanent_ban"
	ReasonGeo       = "geo"
	ReasonInvalidIP = "invalid_ip"
)

Reason constants returned by Guard.IsBlocked. Whitelisted IPs return (false, ""), not a reason string, because they are allowed rather than blocked.

Variables

This section is empty.

Functions

This section is empty.

Types

type BanEvent

type BanEvent struct {
	IP        string
	Transport string
	Failures  int
	BanCount  int    // total times this IP has been banned (including this one)
	Country   string // from current geo data, "" if unavailable
}

BanEvent is emitted when an IP is auto-banned after exceeding the failure threshold.

type BanRecord

type BanRecord struct {
	IP        string
	BannedAt  time.Time
	ExpiresAt time.Time // zero value for permanent bans
	Failures  int
	Permanent bool
	BanCount  int
	Country   string // derived from current GeoLookup, "" if unavailable
}

BanRecord represents a single active ban entry.

type BlockEvent

type BlockEvent struct {
	IP        string
	Reason    string // one of the Reason* constants
	Transport string
	Country   string // from current geo data, "" if unavailable
}

BlockEvent is emitted when a connection or request is blocked.

type Config

type Config struct {
	Whitelist []string // IPs/CIDRs that are never blocked
	Blacklist []string // IPs/CIDRs that are always blocked

	MaxRetry int           // failures within FindTime to trigger a ban (0 = disabled)
	FindTime time.Duration // sliding window for counting failures
	BanTime  time.Duration // how long an auto-ban lasts

	MaxTrackedIPs int // max IPs tracked for auto-ban (0 = default 1,000,000)

	PermaBanAfter    int           // auto-promote to permanent after N bans (0 = disabled)
	RecidivismWindow time.Duration // how long ban history is remembered (0 = forever)

	GeoMode      GeoMode  // GeoDisabled, GeoAllow, or GeoBlock
	GeoCountries []string // ISO 3166-1 alpha-2 country codes
}

Config controls the behavior of a Guard instance. Zero values disable each feature: an empty Config produces a guard that blocks nothing.

  • Whitelist/Blacklist: CIDR notation ("10.0.0.0/8") or bare IPs ("1.2.3.4")
  • MaxRetry == 0: auto-ban disabled
  • GeoMode == GeoDisabled: no geographic filtering

type GeoLookup

type GeoLookup interface {
	LookupCountry(ip netip.Addr) string
}

GeoLookup is the interface for geographic IP lookups. The tgeo.Table type satisfies this interface, but any implementation will work.

type GeoMode

type GeoMode int

GeoMode controls how geographic filtering is applied.

const (
	// GeoDisabled means no geographic filtering (zero value).
	GeoDisabled GeoMode = iota
	// GeoAllow only allows connections from listed countries.
	GeoAllow
	// GeoBlock blocks connections from listed countries.
	GeoBlock
)

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard provides IP filtering with whitelist/blacklist, auto-banning, and optional geographic filtering. It is safe for concurrent use.

func New

func New(cfg Config, opts ...Option) (*Guard, error)

New creates a Guard with the given configuration and options. Returns an error if the config contains invalid CIDRs or conflicting geo settings.

func (*Guard) Close

func (g *Guard) Close()

Close cancels background goroutines.

func (*Guard) IsBlocked

func (g *Guard) IsBlocked(ip string) (bool, string)

IsBlocked checks whether an IP address should be blocked. Evaluation order: whitelist (bypass) -> blacklist -> permanent_ban -> auto_ban -> geo. Returns (blocked, reason) where reason is one of the Reason* constants.

func (*Guard) PermaBan

func (g *Guard) PermaBan(ip string) bool

PermaBan permanently bans an IP. If the IP is already permanently banned, this is a no-op (returns true without firing hooks). Works on IPs with an active temp ban, an expired ban, or no prior record.

func (*Guard) Reconfigure

func (g *Guard) Reconfigure(cfg Config) error

Reconfigure applies a new configuration to a running guard. Returns an error if the new config is invalid.

func (*Guard) RecordFailure

func (g *Guard) RecordFailure(ip, transport string)

RecordFailure records an authentication or handshake failure for an IP. When failures within FindTime reach MaxRetry, the IP is auto-banned.

func (*Guard) SetGeoLookup

func (g *Guard) SetGeoLookup(gl GeoLookup)

SetGeoLookup atomically replaces the geographic lookup implementation. This is used for hot-reloading geo data without restarting the guard.

func (*Guard) Snapshot

func (g *Guard) Snapshot() Snapshot

Snapshot returns a consistent, read-only view of the guard's current state.

func (*Guard) Start

func (g *Guard) Start(ctx context.Context)

Start begins background cleanup and summary goroutines. The goroutines run until ctx is cancelled or Close is called.

func (*Guard) Unban

func (g *Guard) Unban(ip string) bool

Unban manually removes a ban (temporary or permanent) for the given IP. Also clears the IP's ban history. Returns true if the IP was banned.

func (*Guard) WrapErrorLog added in v0.1.2

func (g *Guard) WrapErrorLog(fallback *log.Logger) *log.Logger

WrapErrorLog returns a *log.Logger suitable for http.Server.ErrorLog. It intercepts Go's TLS handshake error messages, extracts the client IP, and calls RecordFailure so the existing auto-ban logic can act on repeated TLS failures (scanners probing with bad SNI, unsupported versions, etc.).

The fallback logger receives all messages (TLS and non-TLS) at whatever level the consumer configured. If fallback is nil, messages are forwarded to the guard's own logger (set via WithLogger). If both are nil, messages are silently consumed.

Typical usage:

errorLog := slog.NewLogLogger(logger.Handler(), slog.LevelError)
srv := &http.Server{
    ErrorLog: guard.WrapErrorLog(errorLog),
}

func (*Guard) WrapHandler added in v0.1.1

func (g *Guard) WrapHandler(h http.Handler, opts ...HandlerOption) (http.Handler, error)

WrapHandler returns an http.Handler that checks IsBlocked before passing requests to h, and optionally records failures based on response status codes. Returns an error if options are misconfigured.

func (*Guard) WrapListener

func (g *Guard) WrapListener(ln net.Listener, transport string) net.Listener

WrapListener wraps a net.Listener so that connections from blocked IPs are dropped at the TCP level before any protocol handshake.

func (*Guard) WrapListenerProxyProto added in v0.1.1

func (g *Guard) WrapListenerProxyProto(ln net.Listener, transport string, trusted []string, opts ...ProxyProtoOption) (net.Listener, error)

WrapListenerProxyProto wraps a net.Listener to decode PROXY protocol v1/v2 headers from trusted proxy sources and filter connections using the real client IP. The trusted parameter is required and must contain at least one valid CIDR or IP for the upstream load balancer(s).

type HandlerOption added in v0.1.1

type HandlerOption func(*handlerConfig)

HandlerOption configures the HTTP middleware returned by WrapHandler.

func WithFailureCodes added in v0.1.1

func WithFailureCodes(codes ...int) HandlerOption

WithFailureCodes configures HTTP status codes that automatically trigger RecordFailure after the inner handler responds (e.g. 401, 404).

func WithIPExtractor added in v0.1.1

func WithIPExtractor(fn func(*http.Request) string) HandlerOption

WithIPExtractor sets a custom function to extract the client IP from the request. When set, trusted proxy validation is bypassed entirely.

func WithIPHeader added in v0.1.1

func WithIPHeader(header string) HandlerOption

WithIPHeader sets the HTTP header to read for the real client IP (e.g. "X-Forwarded-For", "CF-Connecting-IP", "X-Real-IP"). Requires WithTrustedProxies to also be set.

func WithTransport added in v0.1.1

func WithTransport(transport string) HandlerOption

WithTransport overrides the auto-detected transport string. By default, transport is "https" when r.TLS != nil, "http" otherwise.

func WithTrustedProxies added in v0.1.1

func WithTrustedProxies(cidrs ...string) HandlerOption

WithTrustedProxies declares which CIDRs are trusted reverse proxies. Only requests arriving from these IPs will have their forwarding headers consulted for the real client IP.

type Hooks

type Hooks struct {
	OnBlocked     func(BlockEvent)
	OnBanned      func(BanEvent)
	OnUnbanned    func(UnbanEvent)
	OnPermaBanned func(PermaBanEvent)
	OnWarning     func(message string, data map[string]string)
}

Hooks provides optional callbacks for guard events. Set any function field to receive notifications; nil fields are silently skipped.

type Logger

type Logger interface {
	Printf(format string, v ...any)
}

Logger is a minimal logging interface satisfied by *log.Logger.

type Option

type Option func(*Guard)

Option configures a Guard during construction.

func WithClock

func WithClock(fn func() time.Time) Option

WithClock overrides the time source (useful for testing).

func WithGeo

func WithGeo(gl GeoLookup) Option

WithGeo sets the initial GeoLookup implementation.

func WithHooks

func WithHooks(h *Hooks) Option

WithHooks attaches event callbacks to the guard.

func WithLogger

func WithLogger(l Logger) Option

WithLogger sets the logger for block/ban/summary output.

func WithPermaBans

func WithPermaBans(ips []string) Option

WithPermaBans loads a set of permanently banned IPs at construction time. These IPs are blocked immediately without expiry. This is used to restore permanent bans from consumer-managed persistence on startup. Pass WithClock before WithPermaBans if deterministic timestamps are needed.

type PermaBanEvent

type PermaBanEvent struct {
	IP        string
	Transport string // empty for manual PermaBan()
	BanCount  int
	Country   string
}

PermaBanEvent is emitted when an IP is promoted to a permanent ban, either by recidivist auto-escalation or manual PermaBan() call.

type ProxyProtoOption added in v0.1.1

type ProxyProtoOption func(*proxyProtoConfig)

ProxyProtoOption configures the PROXY protocol listener.

func WithProxyProtoTimeout added in v0.1.1

func WithProxyProtoTimeout(d time.Duration) ProxyProtoOption

WithProxyProtoTimeout sets the read deadline for parsing the PROXY protocol header from trusted sources. Default is 5 seconds.

type Snapshot

type Snapshot struct {
	Config   Config
	Bans     []BanRecord
	Stats    Stats
	GeoReady bool
}

Snapshot is a read-only point-in-time view of the guard's state, suitable for dashboard integration.

type Stats

type Stats struct {
	BlacklistBlocks int64
	AutoBanBlocks   int64
	PermaBanBlocks  int64
	GeoBlocks       int64
	ActiveBans      int // total: temp + permanent
	PermanentBans   int // subset of ActiveBans that are permanent
	BanHistorySize  int // number of IPs in recidivism tracking
}

Stats holds cumulative counters for guard activity since the last summary reset.

type UnbanEvent

type UnbanEvent struct {
	IP     string
	Reason string // "expired" or "manual"
}

UnbanEvent is emitted when a ban is removed, either by expiry or manual action.

Directories

Path Synopsis
fetch
Package fetch provides a single-call way to download a pre-compiled TGEO table from a remote URL.
Package fetch provides a single-call way to download a pre-compiled TGEO table from a remote URL.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL