caddyhole/caddyhole.go

266 lines
6.3 KiB
Go

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(_ 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)
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) {
// 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)
)