Import existing project
This commit is contained in:
parent
7887817595
commit
80b0cc4939
125 changed files with 16980 additions and 0 deletions
1482
checkpoint_service/middleware/checkpoint.go
Normal file
1482
checkpoint_service/middleware/checkpoint.go
Normal file
File diff suppressed because it is too large
Load diff
77
checkpoint_service/middleware/config/checkpoint.toml
Normal file
77
checkpoint_service/middleware/config/checkpoint.toml
Normal 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"
|
||||
39
checkpoint_service/middleware/config/ipfilter.toml
Normal file
39
checkpoint_service/middleware/config/ipfilter.toml
Normal 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"
|
||||
498
checkpoint_service/middleware/ipfilter.go
Normal file
498
checkpoint_service/middleware/ipfilter.go
Normal 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()
|
||||
}
|
||||
1482
checkpoint_service/middleware/middleware/checkpoint.go
Normal file
1482
checkpoint_service/middleware/middleware/checkpoint.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
498
checkpoint_service/middleware/middleware/ipfilter.go
Normal file
498
checkpoint_service/middleware/middleware/ipfilter.go
Normal 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()
|
||||
}
|
||||
47
checkpoint_service/middleware/middleware/plugin.go
Normal file
47
checkpoint_service/middleware/middleware/plugin.go
Normal 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
|
||||
}
|
||||
47
checkpoint_service/middleware/plugin.go
Normal file
47
checkpoint_service/middleware/plugin.go
Normal 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
|
||||
}
|
||||
Reference in a new issue