diff --git a/geoip_shop_manager.py b/geoip_shop_manager.py index c761aa4..8718c44 100644 --- a/geoip_shop_manager.py +++ b/geoip_shop_manager.py @@ -10,7 +10,7 @@ Supports three modes: - php-only: GeoIP blocking without CrowdSec - bot-only: Block only bots, shop remains globally accessible -v3.3.0: Added option to activate only direct shops (not behind Link11) +v3.4.0: Added file-based rate-limiting for bot blocking """ import os @@ -45,10 +45,15 @@ BLOCKING_FILE = "geoip_blocking.php" CACHE_FILE = "geoip_ip_ranges.cache" LOG_FILE = "geoip_blocked.log" CROWDSEC_QUEUE_FILE = "geoip_crowdsec_queue.log" +RATELIMIT_DIR = "geoip_ratelimit" WATCHER_SCRIPT = "/usr/local/bin/geoip_crowdsec_watcher.py" SYSTEMD_SERVICE = "/etc/systemd/system/geoip-crowdsec-watcher.service" ACTIVE_SHOPS_FILE = "/var/lib/crowdsec/geoip_active_shops.json" +# Rate-Limit Defaults +DEFAULT_RATE_LIMIT = 30 # Requests pro Minute +DEFAULT_BAN_DURATION = 5 # Minuten + # Minimum expected IP ranges per region (for validation) MIN_RANGES = { "dach": 1000, # DE+AT+CH should have at least 1000 ranges @@ -239,7 +244,7 @@ else {{ echo "OK:" . count($data); }} # ============================================================================= -# PHP TEMPLATES WITH FAIL-OPEN +# PHP TEMPLATES WITH FAIL-OPEN AND RATE-LIMITING # ============================================================================= GEOIP_SCRIPT_TEMPLATE = ''' $expiry_date) return; $log_file = __DIR__ . '/{log_file}'; $crowdsec_queue = __DIR__ . '/{crowdsec_queue}'; +$ratelimit_dir = __DIR__ . '/{ratelimit_dir}'; +$bans_dir = $ratelimit_dir . '/bans'; +$counts_dir = $ratelimit_dir . '/counts'; +// Rate-Limit Configuration +$rate_limit = {rate_limit}; // Requests per minute +$ban_duration = {ban_duration}; // Ban duration in seconds +$window_size = 60; // Window size in seconds (1 minute) +$cleanup_probability = 100; // 1 in X chance to run cleanup + +$visitor_ip = $_SERVER['REMOTE_ADDR'] ?? ''; +$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + +// Create hash for this visitor (IP + User-Agent) +$visitor_hash = md5($visitor_ip . '|' . $user_agent); + +// Ensure directories exist +if (!is_dir($bans_dir)) @mkdir($bans_dir, 0755, true); +if (!is_dir($counts_dir)) @mkdir($counts_dir, 0755, true); + +// === STEP 1: Check if visitor is banned === +$ban_file = "$bans_dir/$visitor_hash.ban"; +if (file_exists($ban_file)) {{ + $ban_until = (int)@file_get_contents($ban_file); + if (time() < $ban_until) {{ + // Still banned - immediate 403 + header('HTTP/1.1 403 Forbidden'); + header('Retry-After: ' . ($ban_until - time())); + exit; + }} + // Ban expired - remove file + @unlink($ban_file); +}} + +// === STEP 2: Bot detection === $bot_patterns = [ {bot_patterns} ]; -$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (empty($user_agent)) return; $detected_bot = null; @@ -440,34 +479,139 @@ foreach ($bot_patterns as $bot_name => $pattern) {{ }} }} -if ($detected_bot !== null) {{ - $timestamp = date('Y-m-d H:i:s'); - $visitor_ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown'; - $uri = $_SERVER['REQUEST_URI'] ?? '/'; - @file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | UA: $user_agent | URI: $uri\\n", FILE_APPEND | LOCK_EX); - @file_put_contents($crowdsec_queue, "$timestamp|$visitor_ip|{shop_name}\\n", FILE_APPEND | LOCK_EX); - header('HTTP/1.1 403 Forbidden'); - exit; +// Not a bot - allow through +if ($detected_bot === null) return; + +// === STEP 3: Rate-Limit Check === +$count_file = "$counts_dir/$visitor_hash.count"; +$current_time = time(); +$count = 1; +$window_start = $current_time; + +if (file_exists($count_file)) {{ + $fp = @fopen($count_file, 'c+'); + if ($fp && flock($fp, LOCK_EX)) {{ + $content = fread($fp, 100); + if (!empty($content)) {{ + $parts = explode('|', $content); + if (count($parts) === 2) {{ + $window_start = (int)$parts[0]; + $count = (int)$parts[1]; + + if ($current_time - $window_start > $window_size) {{ + // New window + $window_start = $current_time; + $count = 1; + }} else {{ + $count++; + }} + }} + }} + + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, "$window_start|$count"); + flock($fp, LOCK_UN); + fclose($fp); + }} +}} else {{ + @file_put_contents($count_file, "$window_start|$count", LOCK_EX); }} + +// === STEP 4: Check if limit exceeded === +$is_banned = false; +if ($count > $rate_limit) {{ + // Create ban + $ban_until = $current_time + $ban_duration; + @file_put_contents($ban_file, $ban_until, LOCK_EX); + $is_banned = true; +}} + +// === STEP 5: Log and block === +$timestamp = date('Y-m-d H:i:s'); +$uri = $_SERVER['REQUEST_URI'] ?? '/'; + +if ($is_banned) {{ + $ban_minutes = $ban_duration / 60; + @file_put_contents($log_file, "[$timestamp] BANNED: $detected_bot | IP: $visitor_ip | Exceeded $rate_limit req/min | Ban: {{$ban_minutes}}m | UA: $user_agent\\n", FILE_APPEND | LOCK_EX); +}} else {{ + @file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | Count: $count/$rate_limit | URI: $uri\\n", FILE_APPEND | LOCK_EX); +}} + +@file_put_contents($crowdsec_queue, "$timestamp|$visitor_ip|{shop_name}\\n", FILE_APPEND | LOCK_EX); + +// === STEP 6: Probabilistic cleanup === +if (rand(1, $cleanup_probability) === 1) {{ + $now = time(); + // Clean expired bans + foreach (glob("$bans_dir/*.ban") as $f) {{ + $ban_time = (int)@file_get_contents($f); + if ($now > $ban_time) @unlink($f); + }} + // Clean old count files (older than 2x window) + foreach (glob("$counts_dir/*.count") as $f) {{ + if ($now - filemtime($f) > $window_size * 2) @unlink($f); + }} +}} + +header('HTTP/1.1 403 Forbidden'); +if ($is_banned) {{ + header('Retry-After: ' . $ban_duration); +}} +exit; ''' BOT_ONLY_SCRIPT_TEMPLATE_NO_CROWDSEC = ''' $expiry_date) return; $log_file = __DIR__ . '/{log_file}'; +$ratelimit_dir = __DIR__ . '/{ratelimit_dir}'; +$bans_dir = $ratelimit_dir . '/bans'; +$counts_dir = $ratelimit_dir . '/counts'; +// Rate-Limit Configuration +$rate_limit = {rate_limit}; // Requests per minute +$ban_duration = {ban_duration}; // Ban duration in seconds +$window_size = 60; // Window size in seconds (1 minute) +$cleanup_probability = 100; // 1 in X chance to run cleanup + +$visitor_ip = $_SERVER['REMOTE_ADDR'] ?? ''; +$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + +// Create hash for this visitor (IP + User-Agent) +$visitor_hash = md5($visitor_ip . '|' . $user_agent); + +// Ensure directories exist +if (!is_dir($bans_dir)) @mkdir($bans_dir, 0755, true); +if (!is_dir($counts_dir)) @mkdir($counts_dir, 0755, true); + +// === STEP 1: Check if visitor is banned === +$ban_file = "$bans_dir/$visitor_hash.ban"; +if (file_exists($ban_file)) {{ + $ban_until = (int)@file_get_contents($ban_file); + if (time() < $ban_until) {{ + // Still banned - immediate 403 + header('HTTP/1.1 403 Forbidden'); + header('Retry-After: ' . ($ban_until - time())); + exit; + }} + // Ban expired - remove file + @unlink($ban_file); +}} + +// === STEP 2: Bot detection === $bot_patterns = [ {bot_patterns} ]; -$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (empty($user_agent)) return; $detected_bot = null; @@ -478,14 +622,84 @@ foreach ($bot_patterns as $bot_name => $pattern) {{ }} }} -if ($detected_bot !== null) {{ - $timestamp = date('Y-m-d H:i:s'); - $visitor_ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown'; - $uri = $_SERVER['REQUEST_URI'] ?? '/'; - @file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | UA: $user_agent | URI: $uri\\n", FILE_APPEND | LOCK_EX); - header('HTTP/1.1 403 Forbidden'); - exit; +// Not a bot - allow through +if ($detected_bot === null) return; + +// === STEP 3: Rate-Limit Check === +$count_file = "$counts_dir/$visitor_hash.count"; +$current_time = time(); +$count = 1; +$window_start = $current_time; + +if (file_exists($count_file)) {{ + $fp = @fopen($count_file, 'c+'); + if ($fp && flock($fp, LOCK_EX)) {{ + $content = fread($fp, 100); + if (!empty($content)) {{ + $parts = explode('|', $content); + if (count($parts) === 2) {{ + $window_start = (int)$parts[0]; + $count = (int)$parts[1]; + + if ($current_time - $window_start > $window_size) {{ + // New window + $window_start = $current_time; + $count = 1; + }} else {{ + $count++; + }} + }} + }} + + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, "$window_start|$count"); + flock($fp, LOCK_UN); + fclose($fp); + }} +}} else {{ + @file_put_contents($count_file, "$window_start|$count", LOCK_EX); }} + +// === STEP 4: Check if limit exceeded === +$is_banned = false; +if ($count > $rate_limit) {{ + // Create ban + $ban_until = $current_time + $ban_duration; + @file_put_contents($ban_file, $ban_until, LOCK_EX); + $is_banned = true; +}} + +// === STEP 5: Log and block === +$timestamp = date('Y-m-d H:i:s'); +$uri = $_SERVER['REQUEST_URI'] ?? '/'; + +if ($is_banned) {{ + $ban_minutes = $ban_duration / 60; + @file_put_contents($log_file, "[$timestamp] BANNED: $detected_bot | IP: $visitor_ip | Exceeded $rate_limit req/min | Ban: {{$ban_minutes}}m | UA: $user_agent\\n", FILE_APPEND | LOCK_EX); +}} else {{ + @file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | Count: $count/$rate_limit | URI: $uri\\n", FILE_APPEND | LOCK_EX); +}} + +// === STEP 6: Probabilistic cleanup === +if (rand(1, $cleanup_probability) === 1) {{ + $now = time(); + // Clean expired bans + foreach (glob("$bans_dir/*.ban") as $f) {{ + $ban_time = (int)@file_get_contents($f); + if ($now > $ban_time) @unlink($f); + }} + // Clean old count files (older than 2x window) + foreach (glob("$counts_dir/*.count") as $f) {{ + if ($now - filemtime($f) > $window_size * 2) @unlink($f); + }} +}} + +header('HTTP/1.1 403 Forbidden'); +if ($is_banned) {{ + header('Retry-After: ' . $ban_duration); +}} +exit; ''' # ============================================================================= @@ -619,18 +833,23 @@ def uninstall_watcher_service(): print(" ✅ Service deinstalliert") -def add_shop_to_active(shop, mode="php+crowdsec", geo_region="dach"): +def add_shop_to_active(shop, mode="php+crowdsec", geo_region="dach", rate_limit=None, ban_duration=None): os.makedirs(os.path.dirname(ACTIVE_SHOPS_FILE), exist_ok=True) shops = {} if os.path.isfile(ACTIVE_SHOPS_FILE): with open(ACTIVE_SHOPS_FILE, 'r') as f: shops = json.load(f) - shops[shop] = { + shop_data = { "activated": datetime.now().isoformat(), "expiry": (datetime.now() + timedelta(hours=72)).isoformat(), "mode": mode, "geo_region": geo_region } + if rate_limit is not None: + shop_data["rate_limit"] = rate_limit + if ban_duration is not None: + shop_data["ban_duration"] = ban_duration + shops[shop] = shop_data with open(ACTIVE_SHOPS_FILE, 'w') as f: json.dump(shops, f, indent=2) @@ -655,6 +874,18 @@ def get_shop_geo_region(shop): return "dach" +def get_shop_rate_limit_config(shop): + """Get rate limit configuration for a shop""" + if not os.path.isfile(ACTIVE_SHOPS_FILE): + return None, None + try: + with open(ACTIVE_SHOPS_FILE, 'r') as f: + shop_data = json.load(f).get(shop, {}) + return shop_data.get("rate_limit"), shop_data.get("ban_duration") + except: + return None, None + + def get_shop_activation_time(shop): if not os.path.isfile(ACTIVE_SHOPS_FILE): return None @@ -763,6 +994,36 @@ def select_mode(): return "php+crowdsec" +def select_rate_limit(): + """Ask user for rate-limit configuration. Returns (rate_limit, ban_duration_seconds)""" + print(f"\n🚦 Rate-Limit Konfiguration:") + + # Rate limit + rate_input = input(f" Requests pro Minute bevor Ban [{DEFAULT_RATE_LIMIT}]: ").strip() + try: + rate_limit = int(rate_input) if rate_input else DEFAULT_RATE_LIMIT + if rate_limit < 1: + rate_limit = DEFAULT_RATE_LIMIT + except ValueError: + rate_limit = DEFAULT_RATE_LIMIT + + # Ban duration + ban_input = input(f" Ban-Dauer in Minuten [{DEFAULT_BAN_DURATION}]: ").strip() + try: + ban_minutes = int(ban_input) if ban_input else DEFAULT_BAN_DURATION + if ban_minutes < 1: + ban_minutes = DEFAULT_BAN_DURATION + except ValueError: + ban_minutes = DEFAULT_BAN_DURATION + + ban_seconds = ban_minutes * 60 + + print(f"\n ✅ Rate-Limit: {rate_limit} req/min") + print(f" ✅ Ban-Dauer: {ban_minutes} Minuten") + + return rate_limit, ban_seconds + + def get_mode_icon(mode): """Return icon for mode""" icons = { @@ -819,11 +1080,12 @@ def get_link11_shops(available_shops): # MAIN FUNCTIONS # ============================================================================= -def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach"): +def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach", rate_limit=None, ban_duration=None): httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') index_php = os.path.join(httpdocs, 'index.php') backup_php = os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}') blocking_file = os.path.join(httpdocs, BLOCKING_FILE) + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) is_bot_mode = is_bot_only_mode(mode) @@ -849,6 +1111,8 @@ def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach" print(f"\n🔧 Aktiviere {region_info['icon']} {region_info['name']} für: {shop}") if is_bot_mode: print(f" Modus: Nur Bot-Blocking (weltweit erreichbar)") + if rate_limit and ban_duration: + print(f" Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") else: print(f" Erlaubt: {region_info['description']}") print(f" CrowdSec: {'Ja' if uses_crowdsec(mode) else 'Nein'}") @@ -894,6 +1158,16 @@ def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach" # Select appropriate template if is_bot_mode: + # Create rate-limit directories + os.makedirs(os.path.join(ratelimit_path, 'bans'), exist_ok=True) + os.makedirs(os.path.join(ratelimit_path, 'counts'), exist_ok=True) + + # Use defaults if not specified + if rate_limit is None: + rate_limit = DEFAULT_RATE_LIMIT + if ban_duration is None: + ban_duration = DEFAULT_BAN_DURATION * 60 # Convert to seconds + if uses_crowdsec(mode): template = BOT_ONLY_SCRIPT_TEMPLATE else: @@ -904,8 +1178,11 @@ def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach" expiry_timestamp=expiry.strftime('%Y-%m-%d %H:%M:%S'), log_file=LOG_FILE, crowdsec_queue=CROWDSEC_QUEUE_FILE, + ratelimit_dir=RATELIMIT_DIR, shop_name=shop, - bot_patterns=generate_php_bot_patterns() + bot_patterns=generate_php_bot_patterns(), + rate_limit=rate_limit, + ban_duration=ban_duration ) else: countries_array = generate_php_countries_array(geo_region) @@ -950,7 +1227,11 @@ def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach" # Step 4: Register if not silent: print("\n[4/4] Registriere Shop...") - add_shop_to_active(shop, mode, geo_region) + + if is_bot_mode: + add_shop_to_active(shop, mode, geo_region, rate_limit, ban_duration) + else: + add_shop_to_active(shop, mode, geo_region) if not silent: print("\n" + "=" * 60) @@ -962,6 +1243,7 @@ def activate_blocking(shop, silent=False, mode="php+crowdsec", geo_region="dach" print(f" 🛡️ Fail-Open: Bei Cache-Fehlern wird Traffic durchgelassen") else: print(f" 🤖 {len(BOT_PATTERNS)} Bot-Patterns aktiv") + print(f" 🚦 Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") print(f" Gültig bis: {expiry.strftime('%Y-%m-%d %H:%M:%S CET')}") print("=" * 60) @@ -972,6 +1254,7 @@ def deactivate_blocking(shop, silent=False): httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') index_php = os.path.join(httpdocs, 'index.php') backup_php = os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}') + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) shop_mode = get_shop_mode(shop) shop_geo = get_shop_geo_region(shop) @@ -983,7 +1266,7 @@ def deactivate_blocking(shop, silent=False): # Restore backup if not silent: - print("\n[1/4] PHP-Blocking entfernen...") + print("\n[1/5] PHP-Blocking entfernen...") if os.path.isfile(backup_php): shutil.move(backup_php, index_php) @@ -995,23 +1278,34 @@ def deactivate_blocking(shop, silent=False): with open(index_php, 'w') as f: f.write('\n'.join(lines)) + # Remove files for f in [os.path.join(httpdocs, x) for x in [BLOCKING_FILE, CACHE_FILE, LOG_FILE, CROWDSEC_QUEUE_FILE]]: if os.path.isfile(f): os.remove(f) + # Remove rate-limit directory if not silent: - print("\n[2/4] Deregistriere Shop...") + print("\n[2/5] Rate-Limit Daten entfernen...") + if os.path.isdir(ratelimit_path): + shutil.rmtree(ratelimit_path) + if not silent: + print(" ✅ Rate-Limit Verzeichnis gelöscht") + elif not silent: + print(" ℹ️ Kein Rate-Limit Verzeichnis vorhanden") + + if not silent: + print("\n[3/5] Deregistriere Shop...") remove_shop_from_active(shop) if not silent: - print("\n[3/4] CrowdSec-Decisions entfernen...") + print("\n[4/5] CrowdSec-Decisions entfernen...") if uses_crowdsec(shop_mode) and check_crowdsec(): cleanup_crowdsec_decisions(shop) elif not silent: print(" ℹ️ Keine CrowdSec-Synchronisation aktiv") if not silent: - print("\n[4/4] Prüfe Watcher-Service...") + print("\n[5/5] Prüfe Watcher-Service...") crowdsec_shops = [s for s in get_active_shops() if uses_crowdsec(get_shop_mode(s))] if not crowdsec_shops: uninstall_watcher_service() @@ -1046,13 +1340,17 @@ def activate_all_shops(): if is_bot_mode: geo_region = "none" region_info = get_geo_region_info("none") + rate_limit, ban_duration = select_rate_limit() else: geo_region = select_geo_region() region_info = get_geo_region_info(geo_region) + rate_limit, ban_duration = None, None print(f"\n⚠️ Modus: {get_mode_description(mode)} {get_mode_icon(mode)}") if not is_bot_mode: print(f" Region: {region_info['icon']} {region_info['name']}") + else: + print(f" Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") if input(f"\nFortfahren? (ja/nein): ").strip().lower() not in ['ja', 'j', 'yes', 'y']: print("\n❌ Abgebrochen") @@ -1073,6 +1371,7 @@ def activate_all_shops(): index_php = os.path.join(httpdocs, 'index.php') backup_php = os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}') blocking_file = os.path.join(httpdocs, BLOCKING_FILE) + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) if os.path.isfile(backup_php) or not os.path.isfile(index_php): print(f" ⚠️ Übersprungen") @@ -1100,6 +1399,10 @@ def activate_all_shops(): expiry = datetime.now() + timedelta(hours=72) if is_bot_mode: + # Create rate-limit directories + os.makedirs(os.path.join(ratelimit_path, 'bans'), exist_ok=True) + os.makedirs(os.path.join(ratelimit_path, 'counts'), exist_ok=True) + if uses_crowdsec(mode): template = BOT_ONLY_SCRIPT_TEMPLATE else: @@ -1110,8 +1413,11 @@ def activate_all_shops(): expiry_timestamp=expiry.strftime('%Y-%m-%d %H:%M:%S'), log_file=LOG_FILE, crowdsec_queue=CROWDSEC_QUEUE_FILE, + ratelimit_dir=RATELIMIT_DIR, shop_name=shop, - bot_patterns=generate_php_bot_patterns() + bot_patterns=generate_php_bot_patterns(), + rate_limit=rate_limit, + ban_duration=ban_duration ) else: template = GEOIP_SCRIPT_TEMPLATE if uses_crowdsec(mode) else GEOIP_SCRIPT_TEMPLATE_PHP_ONLY @@ -1132,8 +1438,8 @@ def activate_all_shops(): f.write(geoip_content) if is_bot_mode: - add_shop_to_active(shop, mode, geo_region) - print(f" ✅ Aktiviert ({len(BOT_PATTERNS)} Bot-Patterns)") + add_shop_to_active(shop, mode, geo_region, rate_limit, ban_duration) + print(f" ✅ Aktiviert ({len(BOT_PATTERNS)} Bot-Patterns, {rate_limit} req/min)") else: print(f" ⏳ Cache generieren...") cache_ok, count, _ = generate_and_validate_cache(httpdocs, geo_region) @@ -1146,6 +1452,8 @@ def activate_all_shops(): print(f" ✅ {success_count} Shop(s) aktiviert") if not is_bot_mode: print(f" 🛡️ Fail-Open bei Cache-Fehlern aktiv") + else: + print(f" 🚦 Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") print(f"{'=' * 60}") @@ -1190,13 +1498,17 @@ def activate_direct_shops_only(): if is_bot_mode: geo_region = "none" region_info = get_geo_region_info("none") + rate_limit, ban_duration = select_rate_limit() else: geo_region = select_geo_region() region_info = get_geo_region_info(geo_region) + rate_limit, ban_duration = None, None print(f"\n⚠️ Modus: {get_mode_description(mode)} {get_mode_icon(mode)}") if not is_bot_mode: print(f" Region: {region_info['icon']} {region_info['name']}") + else: + print(f" Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") print(f" Aktiviert: {len(direct_shops)} direkte Shop(s)") print(f" Übersprungen: {len(link11_shops)} Link11-Shop(s)") @@ -1219,6 +1531,7 @@ def activate_direct_shops_only(): index_php = os.path.join(httpdocs, 'index.php') backup_php = os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}') blocking_file = os.path.join(httpdocs, BLOCKING_FILE) + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) if os.path.isfile(backup_php) or not os.path.isfile(index_php): print(f" ⚠️ Übersprungen") @@ -1246,6 +1559,10 @@ def activate_direct_shops_only(): expiry = datetime.now() + timedelta(hours=72) if is_bot_mode: + # Create rate-limit directories + os.makedirs(os.path.join(ratelimit_path, 'bans'), exist_ok=True) + os.makedirs(os.path.join(ratelimit_path, 'counts'), exist_ok=True) + if uses_crowdsec(mode): template = BOT_ONLY_SCRIPT_TEMPLATE else: @@ -1256,8 +1573,11 @@ def activate_direct_shops_only(): expiry_timestamp=expiry.strftime('%Y-%m-%d %H:%M:%S'), log_file=LOG_FILE, crowdsec_queue=CROWDSEC_QUEUE_FILE, + ratelimit_dir=RATELIMIT_DIR, shop_name=shop, - bot_patterns=generate_php_bot_patterns() + bot_patterns=generate_php_bot_patterns(), + rate_limit=rate_limit, + ban_duration=ban_duration ) else: template = GEOIP_SCRIPT_TEMPLATE if uses_crowdsec(mode) else GEOIP_SCRIPT_TEMPLATE_PHP_ONLY @@ -1278,8 +1598,8 @@ def activate_direct_shops_only(): f.write(geoip_content) if is_bot_mode: - add_shop_to_active(shop, mode, geo_region) - print(f" ✅ Aktiviert ({len(BOT_PATTERNS)} Bot-Patterns)") + add_shop_to_active(shop, mode, geo_region, rate_limit, ban_duration) + print(f" ✅ Aktiviert ({len(BOT_PATTERNS)} Bot-Patterns, {rate_limit} req/min)") else: print(f" ⏳ Cache generieren...") cache_ok, count, _ = generate_and_validate_cache(httpdocs, geo_region) @@ -1293,6 +1613,8 @@ def activate_direct_shops_only(): print(f" ⏭️ {len(link11_shops)} Link11-Shop(s) übersprungen") if not is_bot_mode: print(f" 🛡️ Fail-Open bei Cache-Fehlern aktiv") + else: + print(f" 🚦 Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") print(f"{'=' * 60}") @@ -1322,6 +1644,7 @@ def deactivate_all_shops(): httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') backup_php = os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}') index_php = os.path.join(httpdocs, 'index.php') + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) if os.path.isfile(backup_php): shutil.move(backup_php, index_php) @@ -1330,6 +1653,10 @@ def deactivate_all_shops(): if os.path.isfile(f): os.remove(f) + # Remove rate-limit directory + if os.path.isdir(ratelimit_path): + shutil.rmtree(ratelimit_path) + remove_shop_from_active(shop) if check_crowdsec(): cleanup_crowdsec_decisions(shop) @@ -1344,9 +1671,11 @@ def deactivate_all_shops(): def get_shop_log_stats(shop): httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') log_file = os.path.join(httpdocs, LOG_FILE) + ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR) php_blocks = 0 ips = {} bots = {} # Track bot statistics + bans = 0 # Track rate-limit bans shop_mode = get_shop_mode(shop) is_bot_mode = is_bot_only_mode(shop_mode) @@ -1358,8 +1687,15 @@ def get_shop_log_stats(shop): ip, ua = None, 'Unknown' detected_bot = None + # Check if this is a ban line + if 'BANNED: ' in line: + bans += 1 + try: + detected_bot = line.split('BANNED: ')[1].split(' |')[0].strip() + except: + pass # Parse bot-only format: BOT: botname | IP: ... - if 'BOT: ' in line: + elif 'BOT: ' in line: try: detected_bot = line.split('BOT: ')[1].split(' |')[0].strip() except: @@ -1391,7 +1727,22 @@ def get_shop_log_stats(shop): if bot_name != 'Unbekannt': bots[bot_name] = bots.get(bot_name, 0) + 1 - return php_blocks, ips, bots, get_shop_activation_time(shop) + # Count active bans from file system + active_bans = 0 + bans_dir = os.path.join(ratelimit_path, 'bans') + if os.path.isdir(bans_dir): + now = time.time() + for ban_file in os.listdir(bans_dir): + if ban_file.endswith('.ban'): + try: + with open(os.path.join(bans_dir, ban_file), 'r') as f: + ban_until = int(f.read().strip()) + if now < ban_until: + active_bans += 1 + except: + pass + + return php_blocks, ips, bots, get_shop_activation_time(shop), bans, active_bans def show_logs(shop): @@ -1402,7 +1753,7 @@ def show_logs(shop): region_info = get_geo_region_info(shop_geo) is_bot_mode = is_bot_only_mode(shop_mode) - blocks, ips, bots, activation_time = get_shop_log_stats(shop) + blocks, ips, bots, activation_time, total_bans, active_bans = get_shop_log_stats(shop) if activation_time: runtime = (datetime.now() - activation_time).total_seconds() / 60 @@ -1420,7 +1771,11 @@ def show_logs(shop): valid, count, err = validate_existing_cache(httpdocs, shop_geo) print(f"✅ Cache: {count:,} Ranges" if valid else f"⚠️ Cache: {err}") else: + rate_limit, ban_duration = get_shop_rate_limit_config(shop) print(f"🤖 Bot-Patterns: {len(BOT_PATTERNS)} aktiv") + if rate_limit and ban_duration: + print(f"🚦 Rate-Limit: {rate_limit} req/min, Ban: {ban_duration // 60} min") + print(f"🚫 Bans: {total_bans} ausgelöst, {active_bans} aktiv") # Show bot statistics for bot-only mode if bots: @@ -1456,6 +1811,8 @@ def show_all_logs(): print(f" {COLOR_GREEN}Grün = hinter Link11{COLOR_RESET} | {COLOR_RED}Rot = Direkt{COLOR_RESET}") total_php_blocks = 0 + total_bans = 0 + total_active_bans = 0 shop_php_stats = {} # shop -> {'blocks': N, 'runtime_minutes': float, 'req_min': float, 'ips': {}, 'bots': {}} all_ips = {} # ip -> {'count': N, 'ua': user_agent, 'shops': {shop: count}} all_bots = {} # bot_name -> count @@ -1463,8 +1820,10 @@ def show_all_logs(): # Collect PHP stats for shop in active_shops: - blocks, ips, bots, activation_time = get_shop_log_stats(shop) + blocks, ips, bots, activation_time, bans, active_bans = get_shop_log_stats(shop) total_php_blocks += blocks + total_bans += bans + total_active_bans += active_bans # Calculate runtime and req/min if activation_time: @@ -1479,7 +1838,9 @@ def show_all_logs(): 'runtime_minutes': runtime_minutes, 'req_min': req_min, 'ips': ips, - 'bots': bots + 'bots': bots, + 'bans': bans, + 'active_bans': active_bans } if runtime_minutes > total_minutes: @@ -1554,6 +1915,26 @@ def show_all_logs(): print(f" │ └─➤ Top: {top_ip_addr} ({display_name}) - {top_ip_count}x, {top_ip_req_min:.1f} req/min") + # Display Rate-Limit Bans + if total_bans > 0 or total_active_bans > 0: + print(f"\n🚫 Rate-Limit Bans: {total_bans} ausgelöst, {total_active_bans} aktiv") + for shop in sorted(shop_php_stats.keys()): + stats = shop_php_stats[shop] + if stats['bans'] > 0 or stats['active_bans'] > 0: + link11_info = check_link11(shop) + if link11_info['is_link11']: + shop_colored = f"{COLOR_GREEN}{shop}{COLOR_RESET}" + else: + shop_colored = f"{COLOR_RED}{shop}{COLOR_RESET}" + + geo_region = get_shop_geo_region(shop) + region_info = get_geo_region_info(geo_region) + geo_icon = region_info['icon'] + mode_icon = get_mode_icon(get_shop_mode(shop)) + + bar = "█" * min(stats['bans'] // 2, 20) if stats['bans'] > 0 else "" + print(f" ├─ {shop_colored} {geo_icon} {mode_icon}: {stats['bans']} bans ({stats['active_bans']} aktiv) {bar}") + # Display CrowdSec bans print(f"\n🛡️ CrowdSec-Bans gesamt: {total_crowdsec}") if crowdsec_stats: @@ -1633,9 +2014,10 @@ def show_all_logs(): def main(): print("\n" + "=" * 60) - print(" GeoIP Shop Blocker Manager v3.3.0") + print(" GeoIP Shop Blocker Manager v3.4.0") print(" 🇩🇪🇦🇹🇨🇭 DACH | 🇪🇺 Eurozone+GB | 🤖 Bot-Only") print(" 🛡️ Mit Cache-Validierung und Fail-Open") + print(" 🚦 Mit File-basiertem Rate-Limiting") print("=" * 60) print(f" {'✅' if check_crowdsec() else '⚠️ '} CrowdSec") @@ -1674,13 +2056,15 @@ def main(): if is_bot_mode: geo = "none" region_info = get_geo_region_info("none") + rate_limit, ban_duration = select_rate_limit() else: geo = select_geo_region() region_info = get_geo_region_info(geo) + rate_limit, ban_duration = None, None confirm_msg = f"\n{region_info['icon']} {get_mode_description(mode)} aktivieren für '{available[idx]}'? (ja/nein): " if input(confirm_msg).lower() in ['ja', 'j']: - activate_blocking(available[idx], mode=mode, geo_region=geo) + activate_blocking(available[idx], mode=mode, geo_region=geo, rate_limit=rate_limit, ban_duration=ban_duration) except: print("❌ Ungültig") @@ -1731,7 +2115,7 @@ def main(): shop_mode = get_shop_mode(shop) mode_icon = get_mode_icon(shop_mode) is_bot_mode = is_bot_only_mode(shop_mode) - blocks, _, bots, activation_time = get_shop_log_stats(shop) + blocks, _, bots, activation_time, total_bans, active_bans = get_shop_log_stats(shop) runtime = (datetime.now() - activation_time).total_seconds() / 60 if activation_time else 0 httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') @@ -1739,7 +2123,10 @@ def main(): print(f" {format_shop_with_link11(shop)} {region_info['icon']} {mode_icon}") if is_bot_mode: - print(f" {blocks} blocks, {format_duration(runtime)}, {len(BOT_PATTERNS)} Bot-Patterns") + rate_limit, ban_duration = get_shop_rate_limit_config(shop) + rl_str = f", {rate_limit} req/min" if rate_limit else "" + ban_str = f", {active_bans} aktive Bans" if active_bans > 0 else "" + print(f" {blocks} blocks, {format_duration(runtime)}, {len(BOT_PATTERNS)} Bot-Patterns{rl_str}{ban_str}") else: valid, count, _ = validate_existing_cache(httpdocs, get_shop_geo_region(shop)) cache_str = f"✅{count:,}" if valid else "⚠️"