Initial import.

This commit is contained in:
Kyle Isom 2025-10-30 16:12:24 -07:00
commit 17e3eab464
3 changed files with 574 additions and 0 deletions

176
README.md Normal file
View File

@ -0,0 +1,176 @@
# CaddyHole
A Caddy module that blocks clients based on country and sends crawlers/bots a random amount of data from `/dev/random`.
## Features
- **Country-based blocking**: Block requests from specific countries using GeoIP2 database
- **Bot/Crawler detection**: Automatically detect bots and crawlers based on User-Agent
- **Bot tarpit**: Send bots/crawlers random data from `/dev/random` to waste their resources
- **Configurable**: Customize blocked countries and amount of data sent to bots
## Installation
To use this module, you need to build Caddy with this plugin included. You can use [xcaddy](https://github.com/caddyserver/xcaddy):
```bash
xcaddy build --with git.wntrmute.dev/kyle/caddyhole
```
## Configuration
### Caddyfile
```caddyfile
example.com {
caddyhole {
# Path to GeoIP2 database (optional, required for country blocking)
database /path/to/GeoLite2-Country.mmdb
# List of country ISO codes to block (optional)
block_countries CN RU KP IN IL
# Minimum bytes to send to bots (optional, default: 1MB)
min_bot_bytes 1048576
# Maximum bytes to send to bots (optional, default: 100MB)
max_bot_bytes 104857600
}
# Your other handlers here
respond "Hello, World!"
}
```
### JSON Config
```json
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":443"],
"routes": [
{
"handle": [
{
"handler": "caddyhole",
"database_path": "/path/to/GeoLite2-Country.mmdb",
"blocked_countries": ["CN", "RU", "KP"],
"min_bot_bytes": 1048576,
"max_bot_bytes": 104857600
},
{
"handler": "static_response",
"body": "Hello, World!"
}
]
}
]
}
}
}
}
}
```
## How It Works
### Bot Detection
The module detects bots/crawlers by examining the `User-Agent` header. It looks for common bot signatures including:
- bot, crawler, spider, scraper
- curl, wget
- python-requests, python-urllib
- go-http-client
- java, perl, ruby, php
- http_request
When a bot is detected, the module:
1. Generates a random amount of data between `min_bot_bytes` and `max_bot_bytes`
2. Streams that amount of random data from `/dev/random` (or `/dev/urandom` as fallback)
3. Returns HTTP 200 OK to make the bot think it succeeded
4. Never passes the request to downstream handlers
### Country Blocking
The module uses MaxMind's GeoIP2 database to determine the country of the client based on their IP address. If the country code matches any in the `blocked_countries` list:
1. Returns HTTP 403 Forbidden
2. Sends "Access denied" message
3. Never passes the request to downstream handlers
The module checks the following headers for the client IP (in order):
1. `X-Forwarded-For` (first IP)
2. `X-Real-IP`
3. `RemoteAddr`
### Execution Order
The module processes requests in this order:
1. Check if request is from a bot → If yes, send random data
2. Check if request is from a blocked country → If yes, return 403
3. Otherwise, pass to the next handler
## GeoIP2 Database
To use country blocking, you need a GeoIP2 database. You can download the free GeoLite2 Country database from MaxMind:
1. Sign up for a free account at [MaxMind](https://www.maxmind.com/en/geolite2/signup)
2. Download the GeoLite2 Country database in MMDB format
3. Extract the `.mmdb` file
4. Configure the `database` path in your Caddyfile
## Examples
### Block only specific countries (no bot handling)
```caddyfile
example.com {
caddyhole {
database /path/to/GeoLite2-Country.mmdb
block_countries CN RU
}
respond "Hello, World!"
}
```
### Bot tarpit only (no country blocking)
```caddyfile
example.com {
caddyhole {
min_bot_bytes 10485760 # 10MB
max_bot_bytes 1073741824 # 1GB
}
respond "Hello, World!"
}
```
### Full protection
```caddyfile
example.com {
caddyhole {
database /path/to/GeoLite2-Country.mmdb
block_countries CN RU KP IR
min_bot_bytes 52428800 # 50MB
max_bot_bytes 524288000 # 500MB
}
respond "Hello, World!"
}
```
## Notes
- Bot detection happens before country blocking, so bots will get random data regardless of their country
- The random data is streamed directly from `/dev/random` (or `/dev/urandom`), which may impact system entropy on some systems
- The `Content-Type` is set to `application/octet-stream` and `Content-Length` is set to make the response appear legitimate
- Country blocking requires a GeoIP2 database; without it, no country blocking occurs
- All configuration parameters are optional, but you need at least `database` and `block_countries` for country blocking to work
## License
This module is provided as-is for use with Caddy.

265
caddyhole.go Normal file
View File

@ -0,0 +1,265 @@
package caddyhole
import (
"fmt"
"io"
"math/rand"
"net"
"net/http"
"os"
"strings"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/oschwald/geoip2-golang"
)
func init() {
caddy.RegisterModule(CaddyHole{})
httpcaddyfile.RegisterHandlerDirective("caddyhole", parseCaddyfile)
}
// CaddyHole implements a Caddy module that blocks clients based on country
// and sends crawlers/bots random data from /dev/random.
type CaddyHole struct {
// Path to the GeoIP2 database file
DatabasePath string `json:"database_path,omitempty"`
// List of country ISO codes to block
BlockedCountries []string `json:"blocked_countries,omitempty"`
// Minimum bytes to send to bots (default: 1MB)
MinBotBytes int64 `json:"min_bot_bytes,omitempty"`
// Maximum bytes to send to bots (default: 100MB)
MaxBotBytes int64 `json:"max_bot_bytes,omitempty"`
db *geoip2.Reader
}
// CaddyModule returns the Caddy module information.
func (CaddyHole) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.caddyhole",
New: func() caddy.Module { return new(CaddyHole) },
}
}
// Provision sets up the CaddyHole module.
func (c *CaddyHole) Provision(ctx caddy.Context) error {
// Set defaults
if c.MinBotBytes == 0 {
c.MinBotBytes = 1024 * 1024 // 1MB
}
if c.MaxBotBytes == 0 {
c.MaxBotBytes = 100 * 1024 * 1024 // 100MB
}
// Open GeoIP2 database if path is provided
if c.DatabasePath != "" {
db, err := geoip2.Open(c.DatabasePath)
if err != nil {
return fmt.Errorf("failed to open GeoIP2 database: %v", err)
}
c.db = db
}
return nil
}
// Cleanup closes the GeoIP2 database.
func (c *CaddyHole) Cleanup() error {
if c.db != nil {
return c.db.Close()
}
return nil
}
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (c *CaddyHole) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
// Check if the request is from a bot/crawler
if c.isBot(r) {
c.feedBot(w, r)
return nil
}
// Check if the request should be blocked based on country
if c.shouldBlock(r) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Access denied"))
return nil
}
// Continue to the next handler
return next.ServeHTTP(w, r)
}
// isBot checks if the request is from a bot/crawler based on User-Agent.
func (c *CaddyHole) isBot(r *http.Request) bool {
userAgent := strings.ToLower(r.Header.Get("User-Agent"))
// Common bot/crawler identifiers
botSignatures := []string{
"bot", "crawler", "spider", "scraper", "curl", "wget",
"python-requests", "python-urllib", "go-http-client",
"java", "perl", "ruby", "php", "http_request",
}
for _, sig := range botSignatures {
if strings.Contains(userAgent, sig) {
return true
}
}
return false
}
// feedBot sends random data from /dev/random to the bot.
func (c *CaddyHole) feedBot(w http.ResponseWriter, r *http.Request) {
// Calculate random amount of bytes to send
bytesToSend := c.MinBotBytes
if c.MaxBotBytes > c.MinBotBytes {
bytesToSend += rand.Int63n(c.MaxBotBytes - c.MinBotBytes)
}
// Open /dev/random
devRandom, err := os.Open("/dev/random")
if err != nil {
// Fallback to /dev/urandom if /dev/random is not available
devRandom, err = os.Open("/dev/urandom")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
defer devRandom.Close()
// Set headers to make it look like a legitimate response
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", bytesToSend))
w.WriteHeader(http.StatusOK)
// Copy random data to the response
io.CopyN(w, devRandom, bytesToSend)
}
// shouldBlock checks if the request should be blocked based on country.
func (c *CaddyHole) shouldBlock(r *http.Request) bool {
if c.db == nil || len(c.BlockedCountries) == 0 {
return false
}
// Get the client IP address
ip := c.getClientIP(r)
if ip == nil {
return false
}
// Look up the country for the IP
record, err := c.db.Country(ip)
if err != nil {
return false
}
// Check if the country is in the blocked list
for _, blocked := range c.BlockedCountries {
if strings.EqualFold(record.Country.IsoCode, blocked) {
return true
}
}
return false
}
// getClientIP extracts the client IP address from the request.
func (c *CaddyHole) getClientIP(r *http.Request) net.IP {
// Check X-Forwarded-For header first
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
ips := strings.Split(xff, ",")
if len(ips) > 0 {
ipStr := strings.TrimSpace(ips[0])
if ip := net.ParseIP(ipStr); ip != nil {
return ip
}
}
}
// Check X-Real-IP header
xri := r.Header.Get("X-Real-IP")
if xri != "" {
if ip := net.ParseIP(xri); ip != nil {
return ip
}
}
// Fall back to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return nil
}
return net.ParseIP(host)
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (c *CaddyHole) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
for d.NextBlock(0) {
switch d.Val() {
case "database":
if !d.NextArg() {
return d.ArgErr()
}
c.DatabasePath = d.Val()
case "block_countries":
c.BlockedCountries = d.RemainingArgs()
if len(c.BlockedCountries) == 0 {
return d.ArgErr()
}
case "min_bot_bytes":
if !d.NextArg() {
return d.ArgErr()
}
var err error
_, err = fmt.Sscanf(d.Val(), "%d", &c.MinBotBytes)
if err != nil {
return d.Errf("invalid min_bot_bytes value: %v", err)
}
case "max_bot_bytes":
if !d.NextArg() {
return d.ArgErr()
}
var err error
_, err = fmt.Sscanf(d.Val(), "%d", &c.MaxBotBytes)
if err != nil {
return d.Errf("invalid max_bot_bytes value: %v", err)
}
default:
return d.Errf("unrecognized subdirective: %s", d.Val())
}
}
}
return nil
}
// parseCaddyfile unmarshals tokens from h into a new Middleware.
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var c CaddyHole
err := c.UnmarshalCaddyfile(h.Dispenser)
return &c, err
}
// Interface guards
var (
_ caddy.Provisioner = (*CaddyHole)(nil)
_ caddy.CleanerUpper = (*CaddyHole)(nil)
_ caddyhttp.MiddlewareHandler = (*CaddyHole)(nil)
_ caddyfile.Unmarshaler = (*CaddyHole)(nil)
)

133
go.mod Normal file
View File

@ -0,0 +1,133 @@
module git.wntrmute.dev/kyle/caddyhole
go 1.25.1
require (
github.com/caddyserver/caddy/v2 v2.10.2
github.com/oschwald/geoip2-golang v1.13.0
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/KimMachineGun/automemlimit v0.7.4 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/caddyserver/certmagic v0.24.0 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/ccoveille/go-safecast v1.6.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/coreos/go-oidc/v3 v3.14.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.2.0 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/cel-go v0.26.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.0 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/acmez/v3 v3.1.2 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.23.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/slackhq/nebula v1.9.5 // indirect
github.com/smallstep/certificates v0.28.4 // indirect
github.com/smallstep/cli-utils v0.12.1 // indirect
github.com/smallstep/linkedca v0.23.0 // indirect
github.com/smallstep/nosql v0.7.0 // indirect
github.com/smallstep/pkcs7 v0.2.1 // indirect
github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect
github.com/smallstep/truststore v0.13.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.3.10 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.step.sm/crypto v0.67.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/api v0.240.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.0 // indirect
)