1
0
Fork 0

Import existing project

This commit is contained in:
Caileb 2025-05-26 12:42:36 -05:00
parent 7887817595
commit 80b0cc4939
125 changed files with 16980 additions and 0 deletions

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"hmac_secret":"HJ9EY5oN0pO71AUN/2faoYnJhZyTr875iWbw6Nl8rc8=","created_at":"2025-04-28T18:28:59.6204724-05:00","updated_at":"2025-04-28T18:33:55.3259956-05:00"}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
~t(ðæ4¥ ø2³Dî•æHello Badger

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"hmac_secret":"HJ9EY5oN0pO71AUN/2faoYnJhZyTr875iWbw6Nl8rc8=","created_at":"2025-04-28T18:28:59.6204724-05:00","updated_at":"2025-04-28T18:33:55.3259956-05:00"}

View file

@ -0,0 +1 @@
~t(ðæ4¥ ø2³Dî•æHello Badger

39
checkpoint_service/go.mod Normal file
View file

@ -0,0 +1,39 @@
module checkpoint_service
go 1.24.1
require (
github.com/BurntSushi/toml v1.5.0
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396
github.com/dgraph-io/badger/v4 v4.7.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/mileusna/useragent v1.3.5
github.com/oschwald/geoip2-golang v1.11.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

76
checkpoint_service/go.sum Normal file
View file

@ -0,0 +1,76 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 h1:W2HK1IdCnCGuLUeyizSCkwvBjdj0ZL7mxnJYQ3poyzI=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396/go.mod h1:tGWUZLZp9ajsxUOnHmFFLnqnlKXsCn6GReG4jAD59H0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,66 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"time"
"checkpoint_service/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/logger"
)
func main() {
// Parse command-line flags
port := flag.String("port", "8080", "Port to listen on")
skipCheckpoint := flag.Bool("skip-checkpoint", false, "Skip the checkpoint middleware")
flag.Parse()
// Create Fiber app
app := fiber.New()
// Request logging
app.Use(logger.New())
// Response compression
app.Use(compress.New())
// Load and apply middleware plugins
for _, handler := range middleware.LoadPlugins(*skipCheckpoint) {
app.Use(handler)
}
log.Println("Loaded middleware plugins")
// API group for proof-of-work endpoints
api := app.Group("/api")
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
api.Get("/verify", middleware.VerifyCheckpointHandler)
// Start the server in a goroutine
go func() {
addr := ":" + *port
log.Printf("Checkpoint service starting on %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
// Graceful shutdown on SIGINT/SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.ShutdownWithContext(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exiting")
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Checkpoint Middleware Configuration (checkpoint.toml)
#
# All durations are parsed via time.ParseDuration (e.g. "24h").
# Arrays and tables map directly to the Config struct fields.
# -----------------------------------------------------------------------------
# === GENERAL SETTINGS ===
# Number of leading zeros required in PoW hash
Difficulty = 4
# Validity period for issued tokens
TokenExpiration = "24h"
# Name of the cookie used to store the checkpoint token
CookieName = "checkpoint_token"
# Domain attribute for the cookie; empty = host-only (localhost)
CookieDomain = ""
# Length of the random salt in bytes for challenges
SaltLength = 16
# === RATE LIMITING & EXPIRATION ===
# Max PoW verification attempts per IP per hour
MaxAttemptsPerHour = 10
# Max age for used nonces before cleanup
MaxNonceAge = "24h"
# Time allowed for solving a challenge
ChallengeExpiration = "5m"
# === PERSISTENCE PATHS ===
# File where HMAC secret is stored
SecretConfigPath = "./data/checkpoint_secret.json"
# Directory for BadgerDB token store
TokenStoreDBPath = "./data/checkpoint_tokendb"
# Ordered fallback paths for interstitial HTML
InterstitialPaths = [
"./public/static/pow-interstitial.html",
"./develop/static/pow-interstitial.html"
]
# === SECURITY SETTINGS ===
# Enable Proof-of-Space-Time consistency checks
CheckPoSTimes = true
# Allowed ratio between slowest and fastest PoS runs
PoSTimeConsistencyRatio = 1.35
# === HTML CHECKPOINT EXCLUSIONS ===
# Path prefixes to skip PoW interstitial
HTMLCheckpointExclusions = ["/api"]
# File extensions to skip PoW check
HTMLCheckpointExcludedExtensions = { ".jpg" = true, ".jpeg" = true, ".png" = true, ".gif" = true, ".svg" = true, ".webp" = true, ".ico" = true, ".bmp" = true, ".tif" = true, ".tiff" = true, ".mp4" = true, ".webm" = true, ".css" = true, ".js" = true, ".mjs" = true, ".woff" = true, ".woff2" = true, ".ttf" = true, ".otf" = true, ".eot" = true, ".json" = true, ".xml" = true, ".txt" = true, ".pdf" = true, ".map" = true, ".wasm" = true }
# === QUERY SANITIZATION ===
# Regex patterns (case-insensitive) to block in query strings
DangerousQueryPatterns = [
"(?i)union\\s+select",
"(?i)drop\\s+table",
"(?i)insert\\s+into",
"(?i)<script",
"(?i)javascript:",
"(?i)onerror=",
]
# Block queries containing ';', '`', or '\\'
BlockDangerousPathChars = true
# === USER-AGENT VALIDATION ===
# Path prefixes to skip UA validation
UserAgentValidationExclusions = ["/api"]
# Required UA prefix per path prefix
[UserAgentRequiredPrefixes]
"/demo1" = "Dart/"
# === REVERSE PROXY MAPPINGS ===
# Hostname-to-backend URL map
[ReverseProxyMappings]
"jellyfin.caileb.com" = "http://192.168.0.2:8096"
"archive.caileb.com" = "http://192.168.0.2:7461"
"music.caileb.com" = "http://192.168.0.2:4533"
"gallery.caileb.com" = "http://192.168.0.2:2283"

View file

@ -0,0 +1,39 @@
# IPFilter Configuration
# Page shown when a request is blocked
defaultBlockPage = "default-block.html"
# Cache block decisions (seconds)
ipBlockCacheTTLSec = 300
# Country codes to block
blockedCountryCodes = [
"IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ",
"LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG",
"TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL"
]
# === CONTINENT-BASED BLOCKING ===
blockedContinentCodes = ["AF", "SA", "AS", "AN"]
# === ASN NUMBER GROUPS ===
[blockedASNs]
# empty by default
# === ASN NAME GROUPS ===
[blockedASNNames]
"Data Center" = [
"Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS",
"Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247",
"Datacamp", "Datapacket", "Amanah", "Hern Labs"
]
# === CUSTOM BLOCK PAGES ===
[countryBlockPages]
IN = "india-block.html"
[continentBlockPages]
# none by default
# Custom pages by ASN group
[asnGroupBlockPages]
"Data Center" = "datacenter-block.html"

View file

@ -0,0 +1,498 @@
// IPFilter middleware blocks requests from unwanted IPs based on GeoIP, ASN, and other rules.
// Package middleware provides IP and User-Agent filtering to protect against abuse.
// Features:
// - GeoIP country/continent blocking
// - ASN blocking
// - Data center detection
// - Bot detection
// - Cache-optimized lookups
package middleware
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/cloudflare/ahocorasick"
"github.com/gofiber/fiber/v2"
geoip2 "github.com/oschwald/geoip2-golang"
)
// --- IP Block Cache --- //
// blockCacheEntry stores the result of a block check for an IP
type blockCacheEntry struct {
blocked bool
blockType string
blockValue string
customPage string
asnOrgName string
expiresAt time.Time
}
// Cache for IP block lookups (TTL set via config)
var (
ipBlockCache map[string]blockCacheEntry
ipBlockCacheMutex sync.RWMutex
ipBlockCacheTTL time.Duration
)
// --- GeoIP Configuration ---
var (
geoIPCountryDBPath = "./data/GeoLite2-Country.mmdb"
geoIPASNDBPath = "./data/GeoLite2-ASN.mmdb"
geoipCountryReader *geoip2.Reader
geoipASNReader *geoip2.Reader
// Lists and pages (populated from ipfilter.toml)
blockedCountryCodes map[string]bool
blockedContinentCodes map[string]bool
blockedASNs map[string][]uint
blockedASNNames map[string][]string
countryBlockPages map[string]string
continentBlockPages map[string]string
asnGroupBlockPages map[string]string
defaultBlockPage string
// Aho-Corasick matchers for ASN name groups
asnNameMatchers map[string]*ahocorasick.Matcher
asnNameMatchersMutex sync.RWMutex
)
// init loads ipfilter.toml, sets up all block lists, and hooks this into our plugin registry
func init() {
// Load full configuration from ipfilter.toml
type ipfilterConfig struct {
BlockedCountryCodes []string `toml:"blockedCountryCodes"`
BlockedContinentCodes []string `toml:"blockedContinentCodes"`
BlockedASNs map[string][]int `toml:"blockedASNs"`
BlockedASNNames map[string][]string `toml:"blockedASNNames"`
CountryBlockPages map[string]string `toml:"countryBlockPages"`
ContinentBlockPages map[string]string `toml:"continentBlockPages"`
ASNGroupBlockPages map[string]string `toml:"asnGroupBlockPages"`
DefaultBlockPage string `toml:"defaultBlockPage"`
IPBlockCacheTTLSec int `toml:"ipBlockCacheTTLSec"`
}
var cfg ipfilterConfig
if err := LoadConfig("ipfilter", &cfg); err != nil {
log.Fatalf("Failed to load ipfilter config: %v", err)
}
// override blockedCountryCodes
blockedCountryCodes = make(map[string]bool)
for _, c := range cfg.BlockedCountryCodes {
blockedCountryCodes[c] = true
}
// override blockedContinentCodes
blockedContinentCodes = make(map[string]bool)
for _, c := range cfg.BlockedContinentCodes {
blockedContinentCodes[c] = true
}
// override blockedASNs
newASNs := make(map[string][]uint)
for group, arr := range cfg.BlockedASNs {
uintArr := make([]uint, len(arr))
for i, v := range arr {
uintArr[i] = uint(v)
}
newASNs[group] = uintArr
}
blockedASNs = newASNs
// override blockedASNNames
blockedASNNames = cfg.BlockedASNNames
// override page maps
countryBlockPages = cfg.CountryBlockPages
continentBlockPages = cfg.ContinentBlockPages
asnGroupBlockPages = cfg.ASNGroupBlockPages
// override default page
defaultBlockPage = cfg.DefaultBlockPage
// override cache TTL
ipBlockCacheTTL = time.Duration(cfg.IPBlockCacheTTLSec) * time.Second
// Initialize the cache
ipBlockCache = make(map[string]blockCacheEntry)
// Initialize GeoIP databases
initGeoIP()
// Build Aho-Corasick matchers for ASN name matching
buildASNNameMatchers()
// Register IP-block plugin
RegisterPlugin("ipfilter", IPBlockMiddleware)
}
// IPBlockMiddleware returns a handler that stops requests coming from blocked IPs
// Use this early so bad traffic gets rejected fast
func IPBlockMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the client IP
clientIP := getRealIP(c)
// Check IP blocklist (GeoIP/ASN)
if blocked, blockType, blockValue, customPage, asnOrgName := isBlockedIPExtended(clientIP); blocked {
// For API requests, return JSON response
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Access denied from your location or network.",
"reason": "geoip",
"type": blockType,
"value": blockValue,
"asn_org": asnOrgName,
})
}
// For HTML requests, serve a block page
// Log block information
log.Printf("Blocking access: type=%s, value=%s, custom_page=%s, asn_org=%s",
blockType, blockValue, customPage, asnOrgName)
// If no custom page specified, use the default
if customPage == "" {
customPage = defaultBlockPage
}
// Try to serve the block page
possiblePaths := []string{
"./public/static/" + customPage,
"./public/html/" + customPage,
"./public/" + customPage,
"./develop/static/" + customPage,
}
var htmlContentBytes []byte
var err error
foundPath := ""
for _, path := range possiblePaths {
if htmlContentBytes, err = os.ReadFile(path); err == nil {
// Found the file, serve it
foundPath = path
break
}
}
if foundPath != "" {
// Found the file, serve it
c.Status(fiber.StatusForbidden)
c.Set("Content-Type", "text/html; charset=utf-8")
// Replace placeholder with ASN Org Name if available
htmlContent := string(htmlContentBytes)
replaceValue := "Blocked Network" // Default fallback text
if asnOrgName != "" {
replaceValue = asnOrgName
}
htmlContent = strings.Replace(htmlContent, "{{.ASNName}}", replaceValue, -1)
return c.SendString(htmlContent)
}
// If no custom or default page was found, fall back to default message
return c.Status(fiber.StatusForbidden).SendString("Access denied from your location or network.")
}
// Not blocked, continue to next middleware
return c.Next()
}
}
// initGeoIP loads the GeoLite2 databases
func initGeoIP() {
var err error
geoipCountryReader, err = geoip2.Open(geoIPCountryDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 Country database at %s: %v. GeoIP country/continent blocking disabled.", geoIPCountryDBPath, err)
}
geoipASNReader, err = geoip2.Open(geoIPASNDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 ASN database at %s: %v. GeoIP ASN blocking disabled.", geoIPASNDBPath, err)
}
}
// isBlockedIP checks if the IP address is blocked based on GeoIP or ASN.
func isBlockedIP(ipStr string) bool {
blocked, _, _, _, _ := isBlockedIPExtended(ipStr)
return blocked
}
// isBlockedIPExtended checks if the IP address is blocked and returns detailed information.
// Returns:
// - blocked: true if IP is blocked
// - blockType: "country", "continent", "asn_number_group", or "asn_name_group"
// - blockValue: the country code, continent code, or ASN group name (that triggered the block)
// - customPage: path to custom block page if defined, or empty string
// - asnOrgName: the specific ASN Organization name if blockType is "asn_name_group"
func isBlockedIPExtended(ipStr string) (blocked bool, blockType string, blockValue string, customPage string, asnOrgName string) {
// Safeguard against empty IPs causing issues
if ipStr == "" {
log.Printf("ERROR: Empty IP passed to isBlockedIPExtended")
return false, "", "", "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
log.Printf("WARNING: Could not parse IP address for GeoIP check: %s", ipStr)
return false, "", "", "", "" // Cannot check invalid IP
}
// --- Check Cache First --- //
ipBlockCacheMutex.RLock()
entry, found := ipBlockCache[ipStr]
ipBlockCacheMutex.RUnlock() // Unlock immediately after reading
if found && time.Now().Before(entry.expiresAt) {
// Return cached result
return entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName
}
// --- End Cache Check --- //
var countryRecord *geoip2.Country
var asnRecord *geoip2.ASN
var countryErr, asnErr error
var wg sync.WaitGroup
// Concurrently perform GeoIP lookups if readers are available
if geoipCountryReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
countryRecord, countryErr = geoipCountryReader.Country(ip)
}()
}
if geoipASNReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
asnRecord, asnErr = geoipASNReader.ASN(ip)
}()
}
// Wait for lookups to complete
wg.Wait()
// --- Process Results --- //
// 1. Check Country/Continent
if countryRecord != nil && countryErr == nil {
// Check country first
if blockedCountryCodes[countryRecord.Country.IsoCode] {
blockType = "country"
blockValue = countryRecord.Country.IsoCode
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = countryBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
// Then check continent
if blockedContinentCodes[countryRecord.Continent.Code] {
blockType = "continent"
blockValue = countryRecord.Continent.Code
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = continentBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
} else if countryErr != nil && !strings.Contains(countryErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP country lookup error for IP %s: %v", ipStr, countryErr)
}
// 2. Check ASN (Number and Name)
if asnRecord != nil && asnErr == nil {
clientASN := asnRecord.AutonomousSystemNumber
asnOrg := asnRecord.AutonomousSystemOrganization // Keep org name for potential use
// Check ASN Number Groups
for groupName, asnList := range blockedASNs {
for _, blockedASN := range asnList {
if clientASN == blockedASN {
blockType = "asn_number_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d - %s)", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
}
}
// Check ASN Name Groups (Case-Insensitive Substring Match)
if asnOrg != "" {
lowerASNOrg := strings.ToLower(asnOrg)
// Acquire read lock for accessing the global matchers map
asnNameMatchersMutex.RLock()
defer asnNameMatchersMutex.RUnlock()
for groupName, matcher := range asnNameMatchers {
if matcher == nil { // Check added during previous fix, keep it
log.Printf("WARNING: Nil matcher found for group: %s during check", groupName)
continue
}
// Use Aho-Corasick matcher - protect against any potential panic
var matches []int
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic in Aho-Corasick: %v", r)
matches = nil // Ensure it's empty if we panic
}
}()
// Match against the globally shared matcher (assumed read-safe)
matches = matcher.Match([]byte(lowerASNOrg))
}()
if len(matches) > 0 {
blockType = "asn_name_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d, Org: '%s')", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
// No need to unlock here, defer handles it
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, asnOrg)
}
}
// RUnlock happens via defer
}
} else if asnErr != nil && !strings.Contains(asnErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP ASN lookup error for IP %s: %v", ipStr, asnErr)
}
// --- Cache the result before returning --- //
computedEntry := blockCacheEntry{
blocked: false,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
ipBlockCacheMutex.Lock()
ipBlockCache[ipStr] = computedEntry
ipBlockCacheMutex.Unlock()
return false, "", "", "", "" // Not blocked
}
// Helper function to cache block results
func cacheAndReturnBlockResult(ipStr string, blockType string, blockValue string, customPage string, asnOrgName string) (bool, string, string, string, string) {
// Create the cache entry
computedEntry := blockCacheEntry{
blocked: true,
blockType: blockType,
blockValue: blockValue,
customPage: customPage,
asnOrgName: asnOrgName,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
// Use a separate defer+recover to ensure we don't crash the entire server
// if there's any issue with the cache
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic while caching result: %v", r)
}
}()
ipBlockCacheMutex.Lock()
defer ipBlockCacheMutex.Unlock() // Use defer to ensure unlock happens
ipBlockCache[ipStr] = computedEntry
}()
return true, blockType, blockValue, customPage, asnOrgName
}
// buildASNNameMatchers creates Aho-Corasick matchers for faster ASN name checking
func buildASNNameMatchers() {
// Acquire write lock before modifying the global map
asnNameMatchersMutex.Lock()
defer asnNameMatchersMutex.Unlock()
// Clear any existing matchers first
asnNameMatchers = make(map[string]*ahocorasick.Matcher)
for groupName, nameList := range blockedASNNames {
// Skip if the name list is empty
if len(nameList) == 0 {
log.Printf("Skipping matcher build for empty group: %s", groupName)
continue
}
// Convert names to lowercase byte slices for case-insensitive matching
dict := make([][]byte, 0, len(nameList))
for _, name := range nameList {
if name != "" {
dict = append(dict, []byte(strings.ToLower(name)))
}
}
// Only create a matcher if we have patterns
if len(dict) > 0 {
// Use a recovery mechanism in case the matcher creation fails
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC while building Aho-Corasick matcher for group %s: %v", groupName, r)
// Ensure the entry for this group is nil if creation failed
asnNameMatchers[groupName] = nil
}
}()
// This assignment happens under the write lock
asnNameMatchers[groupName] = ahocorasick.NewMatcher(dict)
log.Printf("Built Aho-Corasick matcher for ASN name group: %s (%d patterns)", groupName, len(dict))
}()
} else {
log.Printf("No valid patterns found for ASN name group: %s", groupName)
}
}
// Unlock happens via defer
}
// ReloadGeoIPDatabases closes and reopens the GeoIP database readers
// to load updated database files. Safe to call while the server is running.
func ReloadGeoIPDatabases() {
// Close existing readers if they're open
if geoipCountryReader != nil {
geoipCountryReader.Close()
geoipCountryReader = nil
}
if geoipASNReader != nil {
geoipASNReader.Close()
geoipASNReader = nil
}
// Re-initialize the readers
initGeoIP()
log.Printf("GeoIP databases reloaded")
}
// getRealIP gets the real client IP when behind a reverse proxy
// It checks X-Forwarded-For header first, then falls back to c.IP()
func getRealIP(c *fiber.Ctx) string {
// Check X-Forwarded-For header first
if xff := c.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// The first one is the original client IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Validate it's a real IP
if net.ParseIP(clientIP) != nil {
log.Printf("Using X-Forwarded-For IP: %s (original: %s)", clientIP, c.IP())
return clientIP
}
}
}
// Also check for custom Remote-Addr header that might be set by some proxies
if remoteAddr := c.Get("$remote_addr"); remoteAddr != "" {
// Validate it's a real IP
if net.ParseIP(remoteAddr) != nil {
log.Printf("Using $remote_addr IP: %s (original: %s)", remoteAddr, c.IP())
return remoteAddr
}
}
// Fallback to default IP
return c.IP()
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Checkpoint Middleware Configuration (checkpoint.toml)
#
# All durations are parsed via time.ParseDuration (e.g. "24h").
# Arrays and tables map directly to the Config struct fields.
# -----------------------------------------------------------------------------
# === GENERAL SETTINGS ===
# Number of leading zeros required in PoW hash
Difficulty = 4
# Validity period for issued tokens
TokenExpiration = "24h"
# Name of the cookie used to store the checkpoint token
CookieName = "checkpoint_token"
# Domain attribute for the cookie; empty = host-only (localhost)
CookieDomain = ""
# Length of the random salt in bytes for challenges
SaltLength = 16
# === RATE LIMITING & EXPIRATION ===
# Max PoW verification attempts per IP per hour
MaxAttemptsPerHour = 10
# Max age for used nonces before cleanup
MaxNonceAge = "24h"
# Time allowed for solving a challenge
ChallengeExpiration = "5m"
# === PERSISTENCE PATHS ===
# File where HMAC secret is stored
SecretConfigPath = "./data/checkpoint_secret.json"
# Directory for BadgerDB token store
TokenStoreDBPath = "./data/checkpoint_tokendb"
# Ordered fallback paths for interstitial HTML
InterstitialPaths = [
"./public/static/pow-interstitial.html",
"./develop/static/pow-interstitial.html"
]
# === SECURITY SETTINGS ===
# Enable Proof-of-Space-Time consistency checks
CheckPoSTimes = true
# Allowed ratio between slowest and fastest PoS runs
PoSTimeConsistencyRatio = 1.35
# === HTML CHECKPOINT EXCLUSIONS ===
# Path prefixes to skip PoW interstitial
HTMLCheckpointExclusions = ["/api"]
# File extensions to skip PoW check
HTMLCheckpointExcludedExtensions = { ".jpg" = true, ".jpeg" = true, ".png" = true, ".gif" = true, ".svg" = true, ".webp" = true, ".ico" = true, ".bmp" = true, ".tif" = true, ".tiff" = true, ".mp4" = true, ".webm" = true, ".css" = true, ".js" = true, ".mjs" = true, ".woff" = true, ".woff2" = true, ".ttf" = true, ".otf" = true, ".eot" = true, ".json" = true, ".xml" = true, ".txt" = true, ".pdf" = true, ".map" = true, ".wasm" = true }
# === QUERY SANITIZATION ===
# Regex patterns (case-insensitive) to block in query strings
DangerousQueryPatterns = [
"(?i)union\\s+select",
"(?i)drop\\s+table",
"(?i)insert\\s+into",
"(?i)<script",
"(?i)javascript:",
"(?i)onerror=",
]
# Block queries containing ';', '`', or '\\'
BlockDangerousPathChars = true
# === USER-AGENT VALIDATION ===
# Path prefixes to skip UA validation
UserAgentValidationExclusions = ["/api"]
# Required UA prefix per path prefix
[UserAgentRequiredPrefixes]
"/demo1" = "Dart/"
# === REVERSE PROXY MAPPINGS ===
# Hostname-to-backend URL map
[ReverseProxyMappings]
"jellyfin.caileb.com" = "http://192.168.0.2:8096"
"archive.caileb.com" = "http://192.168.0.2:7461"
"music.caileb.com" = "http://192.168.0.2:4533"
"gallery.caileb.com" = "http://192.168.0.2:2283"

View file

@ -0,0 +1,39 @@
# IPFilter Configuration
# Page shown when a request is blocked
defaultBlockPage = "default-block.html"
# Cache block decisions (seconds)
ipBlockCacheTTLSec = 300
# Country codes to block
blockedCountryCodes = [
"IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ",
"LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG",
"TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL"
]
# === CONTINENT-BASED BLOCKING ===
blockedContinentCodes = ["AF", "SA", "AS", "AN"]
# === ASN NUMBER GROUPS ===
[blockedASNs]
# empty by default
# === ASN NAME GROUPS ===
[blockedASNNames]
"Data Center" = [
"Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS",
"Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247",
"Datacamp", "Datapacket", "Amanah", "Hern Labs"
]
# === CUSTOM BLOCK PAGES ===
[countryBlockPages]
IN = "india-block.html"
[continentBlockPages]
# none by default
# Custom pages by ASN group
[asnGroupBlockPages]
"Data Center" = "datacenter-block.html"

View file

@ -0,0 +1,498 @@
// IPFilter middleware blocks requests from unwanted IPs based on GeoIP, ASN, and other rules.
// Package middleware provides IP and User-Agent filtering to protect against abuse.
// Features:
// - GeoIP country/continent blocking
// - ASN blocking
// - Data center detection
// - Bot detection
// - Cache-optimized lookups
package middleware
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/cloudflare/ahocorasick"
"github.com/gofiber/fiber/v2"
geoip2 "github.com/oschwald/geoip2-golang"
)
// --- IP Block Cache --- //
// blockCacheEntry stores the result of a block check for an IP
type blockCacheEntry struct {
blocked bool
blockType string
blockValue string
customPage string
asnOrgName string
expiresAt time.Time
}
// Cache for IP block lookups (TTL set via config)
var (
ipBlockCache map[string]blockCacheEntry
ipBlockCacheMutex sync.RWMutex
ipBlockCacheTTL time.Duration
)
// --- GeoIP Configuration ---
var (
geoIPCountryDBPath = "./data/GeoLite2-Country.mmdb"
geoIPASNDBPath = "./data/GeoLite2-ASN.mmdb"
geoipCountryReader *geoip2.Reader
geoipASNReader *geoip2.Reader
// Lists and pages (populated from ipfilter.toml)
blockedCountryCodes map[string]bool
blockedContinentCodes map[string]bool
blockedASNs map[string][]uint
blockedASNNames map[string][]string
countryBlockPages map[string]string
continentBlockPages map[string]string
asnGroupBlockPages map[string]string
defaultBlockPage string
// Aho-Corasick matchers for ASN name groups
asnNameMatchers map[string]*ahocorasick.Matcher
asnNameMatchersMutex sync.RWMutex
)
// init loads ipfilter.toml, sets up all block lists, and hooks this into our plugin registry
func init() {
// Load full configuration from ipfilter.toml
type ipfilterConfig struct {
BlockedCountryCodes []string `toml:"blockedCountryCodes"`
BlockedContinentCodes []string `toml:"blockedContinentCodes"`
BlockedASNs map[string][]int `toml:"blockedASNs"`
BlockedASNNames map[string][]string `toml:"blockedASNNames"`
CountryBlockPages map[string]string `toml:"countryBlockPages"`
ContinentBlockPages map[string]string `toml:"continentBlockPages"`
ASNGroupBlockPages map[string]string `toml:"asnGroupBlockPages"`
DefaultBlockPage string `toml:"defaultBlockPage"`
IPBlockCacheTTLSec int `toml:"ipBlockCacheTTLSec"`
}
var cfg ipfilterConfig
if err := LoadConfig("ipfilter", &cfg); err != nil {
log.Fatalf("Failed to load ipfilter config: %v", err)
}
// override blockedCountryCodes
blockedCountryCodes = make(map[string]bool)
for _, c := range cfg.BlockedCountryCodes {
blockedCountryCodes[c] = true
}
// override blockedContinentCodes
blockedContinentCodes = make(map[string]bool)
for _, c := range cfg.BlockedContinentCodes {
blockedContinentCodes[c] = true
}
// override blockedASNs
newASNs := make(map[string][]uint)
for group, arr := range cfg.BlockedASNs {
uintArr := make([]uint, len(arr))
for i, v := range arr {
uintArr[i] = uint(v)
}
newASNs[group] = uintArr
}
blockedASNs = newASNs
// override blockedASNNames
blockedASNNames = cfg.BlockedASNNames
// override page maps
countryBlockPages = cfg.CountryBlockPages
continentBlockPages = cfg.ContinentBlockPages
asnGroupBlockPages = cfg.ASNGroupBlockPages
// override default page
defaultBlockPage = cfg.DefaultBlockPage
// override cache TTL
ipBlockCacheTTL = time.Duration(cfg.IPBlockCacheTTLSec) * time.Second
// Initialize the cache
ipBlockCache = make(map[string]blockCacheEntry)
// Initialize GeoIP databases
initGeoIP()
// Build Aho-Corasick matchers for ASN name matching
buildASNNameMatchers()
// Register IP-block plugin
RegisterPlugin("ipfilter", IPBlockMiddleware)
}
// IPBlockMiddleware returns a handler that stops requests coming from blocked IPs
// Use this early so bad traffic gets rejected fast
func IPBlockMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the client IP
clientIP := getRealIP(c)
// Check IP blocklist (GeoIP/ASN)
if blocked, blockType, blockValue, customPage, asnOrgName := isBlockedIPExtended(clientIP); blocked {
// For API requests, return JSON response
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Access denied from your location or network.",
"reason": "geoip",
"type": blockType,
"value": blockValue,
"asn_org": asnOrgName,
})
}
// For HTML requests, serve a block page
// Log block information
log.Printf("Blocking access: type=%s, value=%s, custom_page=%s, asn_org=%s",
blockType, blockValue, customPage, asnOrgName)
// If no custom page specified, use the default
if customPage == "" {
customPage = defaultBlockPage
}
// Try to serve the block page
possiblePaths := []string{
"./public/static/" + customPage,
"./public/html/" + customPage,
"./public/" + customPage,
"./develop/static/" + customPage,
}
var htmlContentBytes []byte
var err error
foundPath := ""
for _, path := range possiblePaths {
if htmlContentBytes, err = os.ReadFile(path); err == nil {
// Found the file, serve it
foundPath = path
break
}
}
if foundPath != "" {
// Found the file, serve it
c.Status(fiber.StatusForbidden)
c.Set("Content-Type", "text/html; charset=utf-8")
// Replace placeholder with ASN Org Name if available
htmlContent := string(htmlContentBytes)
replaceValue := "Blocked Network" // Default fallback text
if asnOrgName != "" {
replaceValue = asnOrgName
}
htmlContent = strings.Replace(htmlContent, "{{.ASNName}}", replaceValue, -1)
return c.SendString(htmlContent)
}
// If no custom or default page was found, fall back to default message
return c.Status(fiber.StatusForbidden).SendString("Access denied from your location or network.")
}
// Not blocked, continue to next middleware
return c.Next()
}
}
// initGeoIP loads the GeoLite2 databases
func initGeoIP() {
var err error
geoipCountryReader, err = geoip2.Open(geoIPCountryDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 Country database at %s: %v. GeoIP country/continent blocking disabled.", geoIPCountryDBPath, err)
}
geoipASNReader, err = geoip2.Open(geoIPASNDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 ASN database at %s: %v. GeoIP ASN blocking disabled.", geoIPASNDBPath, err)
}
}
// isBlockedIP checks if the IP address is blocked based on GeoIP or ASN.
func isBlockedIP(ipStr string) bool {
blocked, _, _, _, _ := isBlockedIPExtended(ipStr)
return blocked
}
// isBlockedIPExtended checks if the IP address is blocked and returns detailed information.
// Returns:
// - blocked: true if IP is blocked
// - blockType: "country", "continent", "asn_number_group", or "asn_name_group"
// - blockValue: the country code, continent code, or ASN group name (that triggered the block)
// - customPage: path to custom block page if defined, or empty string
// - asnOrgName: the specific ASN Organization name if blockType is "asn_name_group"
func isBlockedIPExtended(ipStr string) (blocked bool, blockType string, blockValue string, customPage string, asnOrgName string) {
// Safeguard against empty IPs causing issues
if ipStr == "" {
log.Printf("ERROR: Empty IP passed to isBlockedIPExtended")
return false, "", "", "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
log.Printf("WARNING: Could not parse IP address for GeoIP check: %s", ipStr)
return false, "", "", "", "" // Cannot check invalid IP
}
// --- Check Cache First --- //
ipBlockCacheMutex.RLock()
entry, found := ipBlockCache[ipStr]
ipBlockCacheMutex.RUnlock() // Unlock immediately after reading
if found && time.Now().Before(entry.expiresAt) {
// Return cached result
return entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName
}
// --- End Cache Check --- //
var countryRecord *geoip2.Country
var asnRecord *geoip2.ASN
var countryErr, asnErr error
var wg sync.WaitGroup
// Concurrently perform GeoIP lookups if readers are available
if geoipCountryReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
countryRecord, countryErr = geoipCountryReader.Country(ip)
}()
}
if geoipASNReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
asnRecord, asnErr = geoipASNReader.ASN(ip)
}()
}
// Wait for lookups to complete
wg.Wait()
// --- Process Results --- //
// 1. Check Country/Continent
if countryRecord != nil && countryErr == nil {
// Check country first
if blockedCountryCodes[countryRecord.Country.IsoCode] {
blockType = "country"
blockValue = countryRecord.Country.IsoCode
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = countryBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
// Then check continent
if blockedContinentCodes[countryRecord.Continent.Code] {
blockType = "continent"
blockValue = countryRecord.Continent.Code
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = continentBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
} else if countryErr != nil && !strings.Contains(countryErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP country lookup error for IP %s: %v", ipStr, countryErr)
}
// 2. Check ASN (Number and Name)
if asnRecord != nil && asnErr == nil {
clientASN := asnRecord.AutonomousSystemNumber
asnOrg := asnRecord.AutonomousSystemOrganization // Keep org name for potential use
// Check ASN Number Groups
for groupName, asnList := range blockedASNs {
for _, blockedASN := range asnList {
if clientASN == blockedASN {
blockType = "asn_number_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d - %s)", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
}
}
// Check ASN Name Groups (Case-Insensitive Substring Match)
if asnOrg != "" {
lowerASNOrg := strings.ToLower(asnOrg)
// Acquire read lock for accessing the global matchers map
asnNameMatchersMutex.RLock()
defer asnNameMatchersMutex.RUnlock()
for groupName, matcher := range asnNameMatchers {
if matcher == nil { // Check added during previous fix, keep it
log.Printf("WARNING: Nil matcher found for group: %s during check", groupName)
continue
}
// Use Aho-Corasick matcher - protect against any potential panic
var matches []int
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic in Aho-Corasick: %v", r)
matches = nil // Ensure it's empty if we panic
}
}()
// Match against the globally shared matcher (assumed read-safe)
matches = matcher.Match([]byte(lowerASNOrg))
}()
if len(matches) > 0 {
blockType = "asn_name_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d, Org: '%s')", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
// No need to unlock here, defer handles it
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, asnOrg)
}
}
// RUnlock happens via defer
}
} else if asnErr != nil && !strings.Contains(asnErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP ASN lookup error for IP %s: %v", ipStr, asnErr)
}
// --- Cache the result before returning --- //
computedEntry := blockCacheEntry{
blocked: false,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
ipBlockCacheMutex.Lock()
ipBlockCache[ipStr] = computedEntry
ipBlockCacheMutex.Unlock()
return false, "", "", "", "" // Not blocked
}
// Helper function to cache block results
func cacheAndReturnBlockResult(ipStr string, blockType string, blockValue string, customPage string, asnOrgName string) (bool, string, string, string, string) {
// Create the cache entry
computedEntry := blockCacheEntry{
blocked: true,
blockType: blockType,
blockValue: blockValue,
customPage: customPage,
asnOrgName: asnOrgName,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
// Use a separate defer+recover to ensure we don't crash the entire server
// if there's any issue with the cache
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic while caching result: %v", r)
}
}()
ipBlockCacheMutex.Lock()
defer ipBlockCacheMutex.Unlock() // Use defer to ensure unlock happens
ipBlockCache[ipStr] = computedEntry
}()
return true, blockType, blockValue, customPage, asnOrgName
}
// buildASNNameMatchers creates Aho-Corasick matchers for faster ASN name checking
func buildASNNameMatchers() {
// Acquire write lock before modifying the global map
asnNameMatchersMutex.Lock()
defer asnNameMatchersMutex.Unlock()
// Clear any existing matchers first
asnNameMatchers = make(map[string]*ahocorasick.Matcher)
for groupName, nameList := range blockedASNNames {
// Skip if the name list is empty
if len(nameList) == 0 {
log.Printf("Skipping matcher build for empty group: %s", groupName)
continue
}
// Convert names to lowercase byte slices for case-insensitive matching
dict := make([][]byte, 0, len(nameList))
for _, name := range nameList {
if name != "" {
dict = append(dict, []byte(strings.ToLower(name)))
}
}
// Only create a matcher if we have patterns
if len(dict) > 0 {
// Use a recovery mechanism in case the matcher creation fails
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC while building Aho-Corasick matcher for group %s: %v", groupName, r)
// Ensure the entry for this group is nil if creation failed
asnNameMatchers[groupName] = nil
}
}()
// This assignment happens under the write lock
asnNameMatchers[groupName] = ahocorasick.NewMatcher(dict)
log.Printf("Built Aho-Corasick matcher for ASN name group: %s (%d patterns)", groupName, len(dict))
}()
} else {
log.Printf("No valid patterns found for ASN name group: %s", groupName)
}
}
// Unlock happens via defer
}
// ReloadGeoIPDatabases closes and reopens the GeoIP database readers
// to load updated database files. Safe to call while the server is running.
func ReloadGeoIPDatabases() {
// Close existing readers if they're open
if geoipCountryReader != nil {
geoipCountryReader.Close()
geoipCountryReader = nil
}
if geoipASNReader != nil {
geoipASNReader.Close()
geoipASNReader = nil
}
// Re-initialize the readers
initGeoIP()
log.Printf("GeoIP databases reloaded")
}
// getRealIP gets the real client IP when behind a reverse proxy
// It checks X-Forwarded-For header first, then falls back to c.IP()
func getRealIP(c *fiber.Ctx) string {
// Check X-Forwarded-For header first
if xff := c.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// The first one is the original client IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Validate it's a real IP
if net.ParseIP(clientIP) != nil {
log.Printf("Using X-Forwarded-For IP: %s (original: %s)", clientIP, c.IP())
return clientIP
}
}
}
// Also check for custom Remote-Addr header that might be set by some proxies
if remoteAddr := c.Get("$remote_addr"); remoteAddr != "" {
// Validate it's a real IP
if net.ParseIP(remoteAddr) != nil {
log.Printf("Using $remote_addr IP: %s (original: %s)", remoteAddr, c.IP())
return remoteAddr
}
}
// Fallback to default IP
return c.IP()
}

View file

@ -0,0 +1,47 @@
// Package middleware contains a simple plugin system for Fiber middleware.
// Register plugins by name and factory, then main.go will load them automatically.
package middleware
import (
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/gofiber/fiber/v2"
)
// Plugin holds a plugin's name and a function that makes its handler.
type Plugin struct {
Name string
Factory func() fiber.Handler
}
// registry stores every plugin we've registered.
var registry []Plugin
// RegisterPlugin tags a plugin with a name and a factory so we can use it in the app.
func RegisterPlugin(name string, factory func() fiber.Handler) {
registry = append(registry, Plugin{Name: name, Factory: factory})
}
// LoadPlugins returns the handler functions for each plugin.
// If skipCheckpoint is true, it skips the plugin named "checkpoint".
func LoadPlugins(skipCheckpoint bool) []fiber.Handler {
var handlers []fiber.Handler
for _, p := range registry {
if skipCheckpoint && p.Name == "checkpoint" {
continue
}
handlers = append(handlers, p.Factory())
}
return handlers
}
// LoadConfig loads the TOML file at middleware/config/[name].toml
// and decodes it into the struct you provide.
func LoadConfig(name string, v interface{}) error {
path := filepath.Join("middleware", "config", name+".toml")
if _, err := toml.DecodeFile(path, v); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,47 @@
// Package middleware contains a simple plugin system for Fiber middleware.
// Register plugins by name and factory, then main.go will load them automatically.
package middleware
import (
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/gofiber/fiber/v2"
)
// Plugin holds a plugin's name and a function that makes its handler.
type Plugin struct {
Name string
Factory func() fiber.Handler
}
// registry stores every plugin we've registered.
var registry []Plugin
// RegisterPlugin tags a plugin with a name and a factory so we can use it in the app.
func RegisterPlugin(name string, factory func() fiber.Handler) {
registry = append(registry, Plugin{Name: name, Factory: factory})
}
// LoadPlugins returns the handler functions for each plugin.
// If skipCheckpoint is true, it skips the plugin named "checkpoint".
func LoadPlugins(skipCheckpoint bool) []fiber.Handler {
var handlers []fiber.Handler
for _, p := range registry {
if skipCheckpoint && p.Name == "checkpoint" {
continue
}
handlers = append(handlers, p.Factory())
}
return handlers
}
// LoadConfig loads the TOML file at middleware/config/[name].toml
// and decodes it into the struct you provide.
func LoadConfig(name string, v interface{}) error {
path := filepath.Join("middleware", "config", name+".toml")
if _, err := toml.DecodeFile(path, v); err != nil {
return err
}
return nil
}