shield

package module
v0.0.0-...-efd5389 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 24 Imported by: 0

README

Caddy Shield

A Caddy module to block requests from clients found in IP blocklists.

  • ✅ Multiple feeds supported, with built-in IPsum, FireHOL, and AbuseIPDB presets
  • ✅ Custom HTTP and HTTPS blocklist sources
  • ✅ Automatic background feed refreshes, asynchronous and independent, with last-known-good retention, conditional requests, retry backoff, and jitter
  • ✅ Static allow and deny entries, with allow entries taking precedence
  • ✅ Configurable per-site responses for blocked requests
  • ✅ Efficient IPv4 and IPv6 request lookups using efficient immutable IP sets (netipx.IPSet)
  • ✅ Structured logging for list refreshes and blocked requests

Build

Build Caddy with xcaddy:

xcaddy build --with github.com/dmarcwise/caddy-shield

To build a local copy of the module, clone this repository and run:

xcaddy build --with github.com/dmarcwise/caddy-shield=.

Configuration

{
	shield {
		source ipsum_3
		source firehol_level1

		refresh_interval 1h
		timeout 30s

		allow 203.0.113.10
		deny 198.51.100.20

		response {
			status 403
			header Content-Type "text/plain; charset=utf-8"
			body "Access denied\n"
		}
	}
}

example.com {
	shield
	reverse_proxy localhost:8080
}

The global shield block defines shared feeds and policy but does not enable the middleware by itself. Add shield to each site that should use it.

Site policy is additive. An allow entry always wins over a deny entry, regardless of whether it was declared globally or on the site:

example.com {
	shield {
		allow 192.0.2.42
		deny 192.0.2.0/24

		response {
			status 451
			header X-Blocked-IP "{shield.client_ip}"
			body "Blocked: {shield.client_ip}\n"
		}
	}

	reverse_proxy localhost:8080
}

Site response fields replace their global counterparts. Headers with different names are retained.

Sources

No source is enabled automatically. Sources are configured in the global shield block and names must be unique.

Built-in presets

Enable a preset by name:

source ipsum_3

Customize the refresh interval with:

source firehol_level1 {
	refresh_interval 15m
}

The available presets are:

Name Description
ipsum_1 IPsum, present on at least 1 source list
ipsum_2 IPsum, present on at least 2 source lists
ipsum_3 IPsum, present on at least 3 source lists
firehol_level1 FireHOL level 1, designed for the lowest false-positive risk
firehol_level2 FireHOL level 2, recent attackers from roughly the last 48 hours
firehol_level3 FireHOL level 3, attacks, spyware, and malware
firehol_level4 FireHOL level 4, wider coverage with greater false-positive risk
borestad_abuseipdb_1d Borestad AbuseIPDB, score ~100 reported within 1 day
borestad_abuseipdb_7d Borestad AbuseIPDB, score ~100 reported within 7 days
borestad_abuseipdb_30d Borestad AbuseIPDB, score ~100 reported within 30 days

Note that AbuseIPDB blocklists are based on user reports and may block search engines or AI search bots.

Preset URLs cannot be overridden. To use a mirror or different URL, configure a custom source with a different name.

Custom sources

A custom source requires a unique name and an absolute HTTP or HTTPS URL:

source company_blocklist {
	url https://example.com/blocklist.txt
	refresh_interval 30m
}

The refresh_interval field is optional for both preset and custom sources. When omitted, the global interval is used.

Source files may contain IP addresses or CIDR prefixes, one per line. Blank lines, # comments, and columns after the first whitespace-delimited field are ignored.

For a low risk initial configuration, start with:

shield {
	source ipsum_3
	source firehol_level1
}

Note that no blocklist is guaranteed to be free of false positives. Make sure you monitor the decision logs after rollout.

Client IPs and proxies

Shield uses the client IP resolved by Caddy. It does not read X-Forwarded-For directly. If Caddy runs behind another proxy, configure Caddy's trusted proxies so the resolved address represents the original client:

{
	servers {
		trusted_proxies static private_ranges
		trusted_proxies_strict
	}

	shield {
		source ipsum_3
	}
}

A direct internet-facing Caddy instance does not need this configuration. See Caddy's trusted proxy documentation for deployments with CDNs or load balancers.

Reference

Options in the global shield block:

Option Default Description
source <name> none Enables a built-in preset or defines a custom source. May be repeated.
refresh_interval <duration> 1h Default refresh interval for sources.
timeout <duration> 30s HTTP timeout for each download.
max_size <size> 32 MiB Maximum downloaded body size. Values such as 16MB are accepted.
max_entries <count> 2000000 Maximum accepted entries per source.
allow <IP/CIDR...> none Adds static allow entries.
deny <IP/CIDR...> none Adds static deny entries.
fail_open <bool> true Allows requests when no feed snapshot is available.
response HTTP 403 Configures the blocked status, headers, and body.

At least one global source or deny entry is required. Site-level shield blocks accept allow, deny, fail_open, and response; sources and download settings remain global.

The default blocked response is:

HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8

Request blocked

Customize the response with the response block:

response {
	status 403
	header Content-Type application/json
	body "{\"error\":\"blocked\"}"
}

Status codes must be between 400 and 599. Header values and bodies support Caddy placeholders, including:

  • {shield.client_ip} or {client_ip}: the resolved address
  • {shield.reason}: blocklist or unavailable
  • {shield.sources}: comma-separated names of every matching feed; empty for static denies and unavailable feeds
  • Standard Caddy request placeholders such as {http.request.method}

Performance

When tested on an Apple M2 Pro, IP lookups against one million distinct ranges took about 0.1 µs for an allowed request. A blocked request takes about 0.25 µs when checking two sources for attribution.

Lookups allocate no memory per request. The module uses up to about 96 MB of RAM per million unique IP ranges (after the lists are loaded in the IPSet). It will usually use less because overlapping and adjacent ranges are combined.

Metrics

When Caddy's metrics endpoint is enabled, Shield exports:

  • caddy_shield_decisions_total{decision,reason}: increments once for every request Shield allows or blocks
  • caddy_shield_source_blocks_total{source}: increments for every source matching a blocked request

The decision is allow or block. Its reason is one of allowlist, not_listed, unavailable, static_deny, or source. If a blocked address appears in multiple sources, the decision counter increments once and each matching source counter increments once.

Refresh behavior

Each source refreshes independently. Successful results are published atomically; a failed refresh keeps that source's last-known-good entries while the other sources continue normally. Conditional requests use ETag and Last-Modified when provided by the server, and retries use bounded exponential backoff with jitter.

Downloads begin asynchronously when Caddy starts. Until the first source succeeds, requests not covered by static allow or deny entries follow fail_open.

Logging

Blocklist refreshes are logged at debug, info, or warning level. Block decisions use debug level.

For example:

2026/08/09 12:44:16.114	INFO	shield	blocklist refreshed	{"source": "playground", "status": 200, "duration": 0.001493625, "accepted": 1, "invalid": 0, "ranges": 1}
2026/08/09 12:44:18.300	DEBUG	shield	blocklist unchanged	{"source": "playground", "status": 304, "duration": 0.003635333}
2026/08/09 12:44:22.481	DEBUG	http.handlers.shield	request blocked	{"client_ip": "127.0.0.1", "reason": "blocklist", "sources": ["playground"], "host": "127.0.0.1:8080", "uri": "/", "status": 403}
2026/08/09 12:44:22.481	INFO	http.log.access	handled request	{"request": {"remote_ip": "127.0.0.1", "remote_port": "63228", "client_ip": "127.0.0.1", "proto": "HTTP/1.1", "method": "GET", "host": "127.0.0.1:8080", "uri": "/", "headers": {"Accept": ["*/*"], "User-Agent": ["curl/8.7.1"]}}, "bytes_read": 0, "user_id": "", "duration": 0.000199667, "size": 19, "status": 403, "resp_headers": {"Server": ["Caddy"], "Content-Type": ["text/plain; charset=utf-8"]}}

To enable debug logging for Caddy Shield without enabling debug logging for all of Caddy, use:

{
    # Exclude Shield events from the default logger
	log default {
		exclude shield http.handlers.shield
	}

    # Create a separate logger for Shield events
	log shield {
		level DEBUG
		include shield http.handlers.shield
	}

	shield {
		# ...
	}
}

Note that standard non-blocked request logging is separate and is enabled with the log directive inside a site block.

Development

go test ./...
go test -race ./...
go test -run '^$' -bench '^BenchmarkIPLookup$' -benchmem ./...

For a manual end-to-end walkthrough, see the local playground.

Documentation

Overview

Package shield implements a Caddy HTTP middleware that blocks requests from addresses found in configured IP reputation feeds.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type App

type App struct {
	Sources         []Source       `json:"sources,omitempty"`
	RefreshInterval caddy.Duration `json:"refresh_interval,omitempty"`
	Timeout         caddy.Duration `json:"timeout,omitempty"`
	MaxSize         int64          `json:"max_size,omitempty"`
	MaxEntries      int            `json:"max_entries,omitempty"`
	Allow           []string       `json:"allow,omitempty"`
	Deny            []string       `json:"deny,omitempty"`
	Response        Response       `json:"response,omitempty"`
	FailOpen        *bool          `json:"fail_open,omitempty"`
	// contains filtered or unexported fields
}

App owns the server-wide Shield policy, feed refresh workers, and dynamic blocklist snapshot.

func (App) CaddyModule

func (App) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*App) Cleanup

func (app *App) Cleanup() error

Cleanup also handles configurations which were provisioned for validation but never started.

func (*App) Provision

func (app *App) Provision(ctx caddy.Context) error

Provision prepares immutable static policy and the refresh manager without starting network activity.

func (*App) Start

func (app *App) Start() error

Start launches all feed refresh workers asynchronously.

func (*App) Stop

func (app *App) Stop() error

Stop cancels downloads, stops workers, and closes idle HTTP connections.

func (*App) UnmarshalCaddyfile

func (app *App) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile parses the global shield app configuration.

func (App) Validate

func (app App) Validate() error

Validate validates the global app configuration.

type Handler

type Handler struct {
	Allow    []string `json:"allow,omitempty"`
	Deny     []string `json:"deny,omitempty"`
	Response Response `json:"response,omitempty"`
	FailOpen *bool    `json:"fail_open,omitempty"`
	// contains filtered or unexported fields
}

Handler blocks requests whose client address is present in a configured source or explicit deny entry.

func (Handler) CaddyModule

func (Handler) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information.

func (*Handler) Provision

func (h *Handler) Provision(ctx caddy.Context) error

Provision resolves the global Shield app and compiles additive site policy.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error

ServeHTTP resolves Caddy's trusted client IP and either rejects the request or invokes the next handler.

func (*Handler) UnmarshalCaddyfile

func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile parses the shield directive.

func (Handler) Validate

func (h Handler) Validate() error

Validate validates site-local policy overrides.

type ParseStats

type ParseStats struct {
	Lines    int
	Accepted int
	Ignored  int
	Invalid  int
}

ParseStats describes how a feed was interpreted. Invalid entries are retained as a count so refresh policy can reject unexpectedly malformed feeds without making the parser format-specific.

type Response

type Response struct {
	StatusCode int                 `json:"status_code,omitempty"`
	Headers    map[string][]string `json:"headers,omitempty"`
	Body       *string             `json:"body,omitempty"`
}

Response configures the response returned for a blocked request.

type Source

type Source struct {
	Name            string         `json:"name"`
	URL             string         `json:"url,omitempty"`
	RefreshInterval caddy.Duration `json:"refresh_interval,omitempty"`
}

Source describes a blocklist feed. A source may reference a built-in preset by name or provide its own HTTP(S) URL.

Jump to

Keyboard shortcuts

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