diff --git a/jtl-wafi-agent.py b/jtl-wafi-agent.py index cc0fd53..3efb6a3 100644 --- a/jtl-wafi-agent.py +++ b/jtl-wafi-agent.py @@ -41,7 +41,7 @@ from logging.handlers import RotatingFileHandler # ============================================================================= # VERSION # ============================================================================= -VERSION = "3.0.0" +VERSION = "3.1.0" # ============================================================================= # PFADE - AGENT @@ -79,6 +79,32 @@ STATS_UPDATE_INTERVAL = 10 # Sekunden # ============================================================================= AGENT_UPDATE_URL = "https://git.jtl-hosting.de/thomasciesla/JTL-WAFI/raw/branch/main/jtl-wafi-agent.py" +# ============================================================================= +# IP-API CONFIGURATION +# ============================================================================= +IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,isp,org,as" +IP_API_RATE_LIMIT = 45 # Requests pro Minute (Free Tier) +IP_INFO_CACHE_TTL = 86400 # 24 Stunden Cache + +# ============================================================================= +# BAN/WHITELIST FILES +# ============================================================================= +BANNED_IPS_FILE = "jtl-wafi_banned_ips.json" +WHITELISTED_IPS_FILE = "jtl-wafi_whitelisted_ips.json" +AUTO_BAN_CONFIG_FILE = "jtl-wafi_autoban_config.json" + +# ============================================================================= +# STATISTICS WINDOWS (Sekunden) +# ============================================================================= +STATS_WINDOWS = { + '5m': 300, + '10m': 600, + '15m': 900, + '30m': 1800, + '60m': 3600 +} +DEFAULT_STATS_WINDOW = '5m' + # ============================================================================= # LOG ROTATION # ============================================================================= @@ -1004,6 +1030,517 @@ def restart_php_fpm(domain: str) -> dict: } +# ============================================================================= +# IP-INFO CACHE (ip-api.com) +# ============================================================================= +# Global IP-Info Cache +_ip_info_cache: Dict[str, Dict[str, Any]] = {} +_ip_api_last_request = 0.0 +_ip_api_request_count = 0 + + +def get_ip_info(ip: str) -> Dict[str, Any]: + """ + Holt IP-Informationen von ip-api.com mit Caching. + + Returns: + Dict mit country, countryCode, isp, org, as (ASN) + """ + global _ip_info_cache, _ip_api_last_request, _ip_api_request_count + + # Prüfe Cache + if ip in _ip_info_cache: + cached = _ip_info_cache[ip] + if time.time() - cached.get('_cached_at', 0) < IP_INFO_CACHE_TTL: + return cached + + # Rate-Limiting + now = time.time() + if now - _ip_api_last_request < 60: + if _ip_api_request_count >= IP_API_RATE_LIMIT: + logger.debug(f"IP-API Rate-Limit erreicht, verwende Cache für {ip}") + return _ip_info_cache.get(ip, {'country': 'Unknown', 'countryCode': 'XX', 'isp': '', 'org': '', 'as': ''}) + else: + _ip_api_last_request = now + _ip_api_request_count = 0 + + try: + url = IP_API_URL.format(ip=ip) + request = urllib.request.Request( + url, + headers={'User-Agent': f'JTL-WAFi-Agent/{VERSION}'} + ) + with urllib.request.urlopen(request, timeout=5) as response: + data = json.loads(response.read().decode('utf-8')) + + _ip_api_request_count += 1 + + if data.get('status') == 'success': + result = { + 'country': data.get('country', 'Unknown'), + 'countryCode': data.get('countryCode', 'XX'), + 'isp': data.get('isp', ''), + 'org': data.get('org', ''), + 'as': data.get('as', ''), + '_cached_at': time.time() + } + _ip_info_cache[ip] = result + return result + else: + logger.debug(f"IP-API Fehler für {ip}: {data.get('message', 'Unknown error')}") + return {'country': 'Unknown', 'countryCode': 'XX', 'isp': '', 'org': '', 'as': ''} + + except Exception as e: + logger.debug(f"IP-API Request fehlgeschlagen für {ip}: {e}") + return {'country': 'Unknown', 'countryCode': 'XX', 'isp': '', 'org': '', 'as': ''} + + +def get_cached_ip_info(ip: str) -> Optional[Dict[str, Any]]: + """Gibt gecachte IP-Info zurück ohne API-Call.""" + return _ip_info_cache.get(ip) + + +# ============================================================================= +# LIVE STATISTICS TRACKER +# ============================================================================= +class LiveStatsTracker: + """ + Trackt Live-Statistiken für einen Shop mit Rolling Window. + Speichert IP-Zugriffe, Requests und erkennt verdächtige IPs. + """ + + def __init__(self, shop: str, window_seconds: int = 300): + self.shop = shop + self.window_seconds = window_seconds + + # Rolling Window Data (deque mit Timestamps) + self.ip_requests: Dict[str, List[float]] = {} # IP -> [timestamps] + self.path_requests: Dict[str, List[float]] = {} # Path -> [timestamps] + self.blocked_paths: Dict[str, List[float]] = {} # Path -> [blocked timestamps] + self.ip_404s: Dict[str, List[float]] = {} # IP -> [404 timestamps] + + # Human/Bot Counters + self.human_requests: List[float] = [] + self.bot_requests: List[float] = [] + + # IP-Info Cache (pro Shop) + self.ip_info: Dict[str, Dict] = {} + + def cleanup_old_data(self): + """Entfernt Daten außerhalb des Zeitfensters.""" + cutoff = time.time() - self.window_seconds + + def cleanup_dict(d: Dict[str, List[float]]) -> Dict[str, List[float]]: + return {k: [t for t in v if t > cutoff] for k, v in d.items() if any(t > cutoff for t in v)} + + self.ip_requests = cleanup_dict(self.ip_requests) + self.path_requests = cleanup_dict(self.path_requests) + self.blocked_paths = cleanup_dict(self.blocked_paths) + self.ip_404s = cleanup_dict(self.ip_404s) + + self.human_requests = [t for t in self.human_requests if t > cutoff] + self.bot_requests = [t for t in self.bot_requests if t > cutoff] + + def record_request(self, ip: str, path: str, is_bot: bool, is_blocked: bool = False, is_404: bool = False): + """Zeichnet einen Request auf.""" + now = time.time() + + # IP-Request + if ip not in self.ip_requests: + self.ip_requests[ip] = [] + self.ip_requests[ip].append(now) + + # Path-Request + if path not in self.path_requests: + self.path_requests[path] = [] + self.path_requests[path].append(now) + + # Blocked Path + if is_blocked: + if path not in self.blocked_paths: + self.blocked_paths[path] = [] + self.blocked_paths[path].append(now) + + # 404 Error + if is_404: + if ip not in self.ip_404s: + self.ip_404s[ip] = [] + self.ip_404s[ip].append(now) + + # Human/Bot Counter + if is_bot: + self.bot_requests.append(now) + else: + self.human_requests.append(now) + + # IP-Info abrufen (async im Hintergrund) + if ip not in self.ip_info: + self.ip_info[ip] = get_ip_info(ip) + + def get_top_ips(self, limit: int = 10) -> List[Dict[str, Any]]: + """Gibt Top IPs mit Zusatzinfos zurück.""" + self.cleanup_old_data() + + ip_counts = [(ip, len(times)) for ip, times in self.ip_requests.items()] + ip_counts.sort(key=lambda x: x[1], reverse=True) + + result = [] + for ip, count in ip_counts[:limit]: + info = self.ip_info.get(ip) or get_ip_info(ip) + result.append({ + 'ip': ip, + 'count': count, + 'country': info.get('countryCode', 'XX'), + 'country_name': info.get('country', 'Unknown'), + 'org': info.get('org') or info.get('isp', ''), + 'asn': info.get('as', '') + }) + return result + + def get_top_requests(self, limit: int = 10) -> List[Dict[str, Any]]: + """Gibt Top Requests zurück.""" + self.cleanup_old_data() + + path_counts = [(path, len(times)) for path, times in self.path_requests.items()] + path_counts.sort(key=lambda x: x[1], reverse=True) + + return [{'path': path, 'count': count} for path, count in path_counts[:limit]] + + def get_top_blocked(self, limit: int = 10) -> List[Dict[str, Any]]: + """Gibt Top geblockte Requests zurück.""" + self.cleanup_old_data() + + blocked_counts = [(path, len(times)) for path, times in self.blocked_paths.items()] + blocked_counts.sort(key=lambda x: x[1], reverse=True) + + return [{'path': path, 'count': count} for path, count in blocked_counts[:limit]] + + def get_suspicious_ips(self, min_requests: int = 50, min_404s: int = 5) -> List[Dict[str, Any]]: + """ + Erkennt verdächtige IPs basierend auf: + - Hohe Request-Rate + - Viele 404-Errors + """ + self.cleanup_old_data() + + suspicious = [] + for ip, times in self.ip_requests.items(): + count = len(times) + error_count = len(self.ip_404s.get(ip, [])) + + # Verdächtig wenn: viele Requests ODER viele 404s + is_suspicious = count >= min_requests or error_count >= min_404s + + if is_suspicious: + info = self.ip_info.get(ip) or get_ip_info(ip) + suspicious.append({ + 'ip': ip, + 'count': count, + 'errors': error_count, + 'country': info.get('countryCode', 'XX'), + 'country_name': info.get('country', 'Unknown'), + 'org': info.get('org') or info.get('isp', ''), + 'asn': info.get('as', ''), + 'reason': 'High rate' if count >= min_requests else 'Many 404s' + }) + + suspicious.sort(key=lambda x: x['count'], reverse=True) + return suspicious[:10] + + def get_human_rpm(self) -> float: + """Gibt Human Requests pro Minute zurück.""" + self.cleanup_old_data() + minutes = self.window_seconds / 60 + return round(len(self.human_requests) / minutes, 2) if minutes > 0 else 0.0 + + def get_bot_rpm(self) -> float: + """Gibt Bot Requests pro Minute zurück.""" + self.cleanup_old_data() + minutes = self.window_seconds / 60 + return round(len(self.bot_requests) / minutes, 2) if minutes > 0 else 0.0 + + def get_stats(self) -> Dict[str, Any]: + """Gibt alle Statistiken zurück.""" + self.cleanup_old_data() + return { + 'top_ips': self.get_top_ips(), + 'top_requests': self.get_top_requests(), + 'top_blocked': self.get_top_blocked(), + 'suspicious_ips': self.get_suspicious_ips(), + 'human_rpm': self.get_human_rpm(), + 'bot_rpm': self.get_bot_rpm(), + 'window_seconds': self.window_seconds + } + + +# Global Stats Trackers (pro Shop) +_shop_stats_trackers: Dict[str, LiveStatsTracker] = {} + + +def get_shop_stats_tracker(shop: str, window_seconds: int = 300) -> LiveStatsTracker: + """Gibt den Stats-Tracker für einen Shop zurück (erstellt falls nötig).""" + if shop not in _shop_stats_trackers: + _shop_stats_trackers[shop] = LiveStatsTracker(shop, window_seconds) + elif _shop_stats_trackers[shop].window_seconds != window_seconds: + # Window geändert - neuen Tracker erstellen + _shop_stats_trackers[shop] = LiveStatsTracker(shop, window_seconds) + return _shop_stats_trackers[shop] + + +# ============================================================================= +# BAN/WHITELIST MANAGEMENT +# ============================================================================= +def get_banned_ips(shop: str) -> Dict[str, Dict[str, Any]]: + """Lädt gebannte IPs für einen Shop.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + ban_file = os.path.join(httpdocs, BANNED_IPS_FILE) + + if not os.path.isfile(ban_file): + return {} + + try: + with open(ban_file, 'r') as f: + data = json.load(f) + + # Abgelaufene Bans entfernen + now = time.time() + active_bans = {} + for ip, ban_data in data.items(): + expires = ban_data.get('expires', 0) + if expires == -1 or expires > now: # -1 = permanent + active_bans[ip] = ban_data + + return active_bans + except: + return {} + + +def save_banned_ips(shop: str, banned_ips: Dict[str, Dict[str, Any]]): + """Speichert gebannte IPs für einen Shop.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + ban_file = os.path.join(httpdocs, BANNED_IPS_FILE) + + try: + with open(ban_file, 'w') as f: + json.dump(banned_ips, f, indent=2) + + # Owner setzen + uid, gid = get_most_common_owner(httpdocs) + set_owner(ban_file, uid, gid) + except Exception as e: + logger.error(f"Fehler beim Speichern der Bans für {shop}: {e}") + + +def ban_ip(shop: str, ip: str, duration: int, reason: str = "manual") -> Dict[str, Any]: + """ + Bannt eine IP für einen Shop. + + Args: + shop: Shop-Domain + ip: IP-Adresse + duration: Dauer in Sekunden (-1 für permanent) + reason: Grund für den Ban + + Returns: + Dict mit success, message + """ + banned_ips = get_banned_ips(shop) + + now = time.time() + expires = -1 if duration == -1 else int(now + duration) + + banned_ips[ip] = { + 'banned_at': int(now), + 'expires': expires, + 'reason': reason, + 'banned_by': 'manual' + } + + save_banned_ips(shop, banned_ips) + + logger.info(f"IP {ip} gebannt für {shop}: {reason} (Dauer: {'permanent' if duration == -1 else f'{duration}s'})") + + return { + 'success': True, + 'message': f'IP {ip} gebannt', + 'expires': expires + } + + +def unban_ip(shop: str, ip: str) -> Dict[str, Any]: + """Entfernt Ban für eine IP.""" + banned_ips = get_banned_ips(shop) + + if ip in banned_ips: + del banned_ips[ip] + save_banned_ips(shop, banned_ips) + logger.info(f"IP {ip} entbannt für {shop}") + return {'success': True, 'message': f'IP {ip} entbannt'} + + return {'success': False, 'message': 'IP nicht in Ban-Liste'} + + +def get_whitelisted_ips(shop: str) -> Dict[str, Dict[str, Any]]: + """Lädt Whitelist für einen Shop.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + whitelist_file = os.path.join(httpdocs, WHITELISTED_IPS_FILE) + + if not os.path.isfile(whitelist_file): + return {} + + try: + with open(whitelist_file, 'r') as f: + return json.load(f) + except: + return {} + + +def save_whitelisted_ips(shop: str, whitelisted_ips: Dict[str, Dict[str, Any]]): + """Speichert Whitelist für einen Shop.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + whitelist_file = os.path.join(httpdocs, WHITELISTED_IPS_FILE) + + try: + with open(whitelist_file, 'w') as f: + json.dump(whitelisted_ips, f, indent=2) + + uid, gid = get_most_common_owner(httpdocs) + set_owner(whitelist_file, uid, gid) + except Exception as e: + logger.error(f"Fehler beim Speichern der Whitelist für {shop}: {e}") + + +def whitelist_ip(shop: str, ip: str, description: str = "") -> Dict[str, Any]: + """ + Fügt IP zur Whitelist hinzu. + + Args: + shop: Shop-Domain + ip: IP-Adresse oder CIDR (z.B. 192.168.1.0/24) + description: Beschreibung + """ + whitelisted = get_whitelisted_ips(shop) + + whitelisted[ip] = { + 'added_at': int(time.time()), + 'description': description, + 'added_by': 'manual' + } + + save_whitelisted_ips(shop, whitelisted) + + # Wenn IP gebannt ist, entbannen + banned = get_banned_ips(shop) + if ip in banned: + del banned[ip] + save_banned_ips(shop, banned) + + logger.info(f"IP {ip} zur Whitelist hinzugefügt für {shop}") + + return {'success': True, 'message': f'IP {ip} zur Whitelist hinzugefügt'} + + +def remove_from_whitelist(shop: str, ip: str) -> Dict[str, Any]: + """Entfernt IP von der Whitelist.""" + whitelisted = get_whitelisted_ips(shop) + + if ip in whitelisted: + del whitelisted[ip] + save_whitelisted_ips(shop, whitelisted) + logger.info(f"IP {ip} von Whitelist entfernt für {shop}") + return {'success': True, 'message': f'IP {ip} von Whitelist entfernt'} + + return {'success': False, 'message': 'IP nicht in Whitelist'} + + +# ============================================================================= +# AUTO-BAN CONFIGURATION +# ============================================================================= +def get_autoban_config(shop: str) -> Dict[str, Any]: + """Lädt Auto-Ban Konfiguration für einen Shop.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + config_file = os.path.join(httpdocs, AUTO_BAN_CONFIG_FILE) + + default_config = { + 'enabled': False, + 'max_requests_per_minute': 100, + 'max_404s_per_minute': 10, + 'max_login_attempts': 5, + 'ban_duration': 3600 # 1 Stunde + } + + if not os.path.isfile(config_file): + return default_config + + try: + with open(config_file, 'r') as f: + config = json.load(f) + return {**default_config, **config} + except: + return default_config + + +def save_autoban_config(shop: str, config: Dict[str, Any]): + """Speichert Auto-Ban Konfiguration.""" + httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs') + config_file = os.path.join(httpdocs, AUTO_BAN_CONFIG_FILE) + + try: + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + + uid, gid = get_most_common_owner(httpdocs) + set_owner(config_file, uid, gid) + except Exception as e: + logger.error(f"Fehler beim Speichern der Auto-Ban Config für {shop}: {e}") + + +def check_autoban(shop: str, ip: str, stats_tracker: LiveStatsTracker) -> Optional[str]: + """ + Prüft ob eine IP automatisch gebannt werden soll. + + Returns: + Grund für Ban oder None wenn kein Ban nötig + """ + config = get_autoban_config(shop) + + if not config.get('enabled', False): + return None + + # Whitelist Check + whitelisted = get_whitelisted_ips(shop) + for wl_ip in whitelisted.keys(): + if '/' in wl_ip: + if ip_in_cidr(ip, wl_ip): + return None + elif ip == wl_ip: + return None + + # Bereits gebannt? + banned = get_banned_ips(shop) + if ip in banned: + return None + + # Request-Rate prüfen + ip_requests = stats_tracker.ip_requests.get(ip, []) + cutoff = time.time() - 60 + recent_requests = len([t for t in ip_requests if t > cutoff]) + + if recent_requests >= config.get('max_requests_per_minute', 100): + ban_ip(shop, ip, config.get('ban_duration', 3600), f"Auto-Ban: {recent_requests} req/min") + return f"Rate limit exceeded ({recent_requests} req/min)" + + # 404-Rate prüfen + ip_404s = stats_tracker.ip_404s.get(ip, []) + recent_404s = len([t for t in ip_404s if t > cutoff]) + + if recent_404s >= config.get('max_404s_per_minute', 10): + ban_ip(shop, ip, config.get('ban_duration', 3600), f"Auto-Ban: {recent_404s} 404s/min") + return f"Too many 404 errors ({recent_404s}/min)" + + return None + + # ============================================================================= # BOT DETECTION FUNCTIONS # ============================================================================= @@ -1140,11 +1677,16 @@ $cleanup_probability = 100; // 1 in X chance to run cleanup $visitor_ip = $_SERVER['REMOTE_ADDR'] ?? ''; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; +$uri = $_SERVER['REQUEST_URI'] ?? '/'; // Ensure directories exist if (!is_dir($bans_dir)) @mkdir($bans_dir, 0777, true); if (!is_dir($counts_dir)) @mkdir($counts_dir, 0777, true); +// === IP Ban/Whitelist Files === +$ip_ban_file = __DIR__ . '/jtl-wafi_banned_ips.json'; +$ip_whitelist_file = __DIR__ . '/jtl-wafi_whitelisted_ips.json'; + // === IP-in-CIDR Check Function === function ip_in_cidr($ip, $cidr) {{ if (strpos($cidr, '/') === false) return false; @@ -1156,6 +1698,53 @@ function ip_in_cidr($ip, $cidr) {{ return ($ip_long & $mask_long) === ($subnet_long & $mask_long); }} +// === Check IP Ban/Whitelist Function === +function check_ip_in_list($ip, $list) {{ + if (empty($list) || !is_array($list)) return false; + foreach (array_keys($list) as $entry) {{ + if (strpos($entry, '/') !== false) {{ + if (ip_in_cidr($ip, $entry)) return $entry; + }} elseif ($entry === $ip) {{ + return $entry; + }} + }} + return false; +}} + +// === STEP 0: Check IP Whitelist (skip all rate limiting if whitelisted) === +$is_whitelisted = false; +if (file_exists($ip_whitelist_file)) {{ + $whitelist = @json_decode(@file_get_contents($ip_whitelist_file), true); + if (check_ip_in_list($visitor_ip, $whitelist)) {{ + $is_whitelisted = true; + }} +}} + +// === STEP 0.5: Check IP Ban (block immediately if banned) === +if (!$is_whitelisted && file_exists($ip_ban_file)) {{ + $bans = @json_decode(@file_get_contents($ip_ban_file), true); + if (is_array($bans) && isset($bans[$visitor_ip])) {{ + $ban_data = $bans[$visitor_ip]; + $expires = $ban_data['expires'] ?? 0; + if ($expires === -1 || $expires > time()) {{ + $timestamp = date('Y-m-d H:i:s'); + $reason = $ban_data['reason'] ?? 'IP banned'; + $remaining = $expires === -1 ? 'permanent' : ($expires - time()) . 's'; + @file_put_contents($log_file, "[$timestamp] BLOCKED_IP: $visitor_ip | Reason: $reason | Remaining: $remaining\\n", FILE_APPEND | LOCK_EX); + header('HTTP/1.1 403 Forbidden'); + if ($expires !== -1) header('Retry-After: ' . ($expires - time())); + exit; + }} + }} +}} + +// Whitelisted IPs skip all rate limiting +if ($is_whitelisted) {{ + $timestamp = date('Y-m-d H:i:s'); + @file_put_contents($log_file, "[$timestamp] WHITELISTED | IP: $visitor_ip | URI: $uri\\n", FILE_APPEND | LOCK_EX); + return; +}} + // === Bot IP Ranges (für getarnte Bots) === $bot_ip_ranges = [ {bot_ip_ranges} @@ -1325,11 +1914,16 @@ $country_cache_dir = $ratelimit_dir . '/country_cache'; $visitor_ip = $_SERVER['REMOTE_ADDR'] ?? ''; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; +$uri = $_SERVER['REQUEST_URI'] ?? '/'; // Ensure directories exist if (!is_dir($ratelimit_dir)) @mkdir($ratelimit_dir, 0777, true); if (!is_dir($country_cache_dir)) @mkdir($country_cache_dir, 0777, true); +// === IP Ban/Whitelist Files === +$ip_ban_file = __DIR__ . '/jtl-wafi_banned_ips.json'; +$ip_whitelist_file = __DIR__ . '/jtl-wafi_whitelisted_ips.json'; + // === IP-in-CIDR Check Function === function ip_in_cidr($ip, $cidr) {{ if (strpos($cidr, '/') === false) return false; @@ -1341,6 +1935,46 @@ function ip_in_cidr($ip, $cidr) {{ return ($ip_long & $mask_long) === ($subnet_long & $mask_long); }} +// === Check IP Ban/Whitelist Function === +function check_ip_in_list($ip, $list) {{ + if (empty($list) || !is_array($list)) return false; + foreach (array_keys($list) as $entry) {{ + if (strpos($entry, '/') !== false) {{ + if (ip_in_cidr($ip, $entry)) return $entry; + }} elseif ($entry === $ip) {{ + return $entry; + }} + }} + return false; +}} + +// === Check IP Whitelist === +$is_whitelisted = false; +if (file_exists($ip_whitelist_file)) {{ + $whitelist = @json_decode(@file_get_contents($ip_whitelist_file), true); + if (check_ip_in_list($visitor_ip, $whitelist)) {{ + $is_whitelisted = true; + }} +}} + +// === Check IP Ban (block even in monitor mode) === +if (!$is_whitelisted && file_exists($ip_ban_file)) {{ + $bans = @json_decode(@file_get_contents($ip_ban_file), true); + if (is_array($bans) && isset($bans[$visitor_ip])) {{ + $ban_data = $bans[$visitor_ip]; + $expires = $ban_data['expires'] ?? 0; + if ($expires === -1 || $expires > time()) {{ + $timestamp = date('Y-m-d H:i:s'); + $reason = $ban_data['reason'] ?? 'IP banned'; + $remaining = $expires === -1 ? 'permanent' : ($expires - time()) . 's'; + @file_put_contents($log_file, "[$timestamp] BLOCKED_IP: $visitor_ip | Reason: $reason | Remaining: $remaining\\n", FILE_APPEND | LOCK_EX); + header('HTTP/1.1 403 Forbidden'); + if ($expires !== -1) header('Retry-After: ' . ($expires - time())); + exit; + }} + }} +}} + // === Country Detection via IP === function get_country_for_ip($ip, $country_cache_dir) {{ // Most common countries first for performance @@ -1503,6 +2137,7 @@ $country_cache_dir = $ratelimit_dir . '/country_cache'; $visitor_ip = $_SERVER['REMOTE_ADDR'] ?? ''; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; +$uri = $_SERVER['REQUEST_URI'] ?? '/'; // Ensure directories exist if ($bot_mode) {{ @@ -1515,6 +2150,10 @@ if ($country_mode) {{ if (!is_dir($country_cache_dir)) @mkdir($country_cache_dir, 0777, true); }} +// === IP Ban/Whitelist Files === +$ip_ban_file = __DIR__ . '/jtl-wafi_banned_ips.json'; +$ip_whitelist_file = __DIR__ . '/jtl-wafi_whitelisted_ips.json'; + // === IP-in-CIDR Check Function === function ip_in_cidr($ip, $cidr) {{ if (strpos($cidr, '/') === false) return false; @@ -1526,6 +2165,53 @@ function ip_in_cidr($ip, $cidr) {{ return ($ip_long & $mask_long) === ($subnet_long & $mask_long); }} +// === Check IP Ban/Whitelist Function === +function check_ip_in_list($ip, $list) {{ + if (empty($list) || !is_array($list)) return false; + foreach (array_keys($list) as $entry) {{ + if (strpos($entry, '/') !== false) {{ + if (ip_in_cidr($ip, $entry)) return $entry; + }} elseif ($entry === $ip) {{ + return $entry; + }} + }} + return false; +}} + +// === STEP 0: Check IP Whitelist === +$is_whitelisted = false; +if (file_exists($ip_whitelist_file)) {{ + $whitelist = @json_decode(@file_get_contents($ip_whitelist_file), true); + if (check_ip_in_list($visitor_ip, $whitelist)) {{ + $is_whitelisted = true; + }} +}} + +// === STEP 0.5: Check IP Ban === +if (!$is_whitelisted && file_exists($ip_ban_file)) {{ + $bans = @json_decode(@file_get_contents($ip_ban_file), true); + if (is_array($bans) && isset($bans[$visitor_ip])) {{ + $ban_data = $bans[$visitor_ip]; + $expires = $ban_data['expires'] ?? 0; + if ($expires === -1 || $expires > time()) {{ + $timestamp = date('Y-m-d H:i:s'); + $reason = $ban_data['reason'] ?? 'IP banned'; + $remaining = $expires === -1 ? 'permanent' : ($expires - time()) . 's'; + @file_put_contents($log_file, "[$timestamp] BLOCKED_IP: $visitor_ip | Reason: $reason | Remaining: $remaining\\n", FILE_APPEND | LOCK_EX); + header('HTTP/1.1 403 Forbidden'); + if ($expires !== -1) header('Retry-After: ' . ($expires - time())); + exit; + }} + }} +}} + +// Whitelisted IPs skip all rate limiting +if ($is_whitelisted) {{ + $timestamp = date('Y-m-d H:i:s'); + @file_put_contents($log_file, "[$timestamp] WHITELISTED | IP: $visitor_ip | URI: $uri\\n", FILE_APPEND | LOCK_EX); + return; +}} + // === Country Detection === function get_country_for_ip($ip, $country_cache_dir) {{ $countries = ['de', 'at', 'ch', 'us', 'gb', 'fr', 'nl', 'it', 'es', 'pl', 'be', 'se', 'no', 'dk', 'fi', @@ -2764,6 +3450,30 @@ class JTLWAFiAgent: elif event_type == 'command.update': await self._handle_update_command(event_data) + elif event_type == 'command.livestats': + await self._handle_livestats_command(event_data) + + elif event_type == 'command.ban': + await self._handle_ban_command(event_data) + + elif event_type == 'command.unban': + await self._handle_unban_command(event_data) + + elif event_type == 'command.whitelist': + await self._handle_whitelist_command(event_data) + + elif event_type == 'command.unwhitelist': + await self._handle_unwhitelist_command(event_data) + + elif event_type == 'command.get_bans': + await self._handle_get_bans_command(event_data) + + elif event_type == 'command.get_whitelist': + await self._handle_get_whitelist_command(event_data) + + elif event_type == 'command.autoban_config': + await self._handle_autoban_config_command(event_data) + elif event_type == 'log.subscribe': shop = event_data.get('shop') if shop: @@ -2994,6 +3704,263 @@ class JTLWAFiAgent: 'message': error_msg }) + async def _handle_livestats_command(self, data: Dict[str, Any]): + """Gibt Live-Statistiken für einen Shop zurück.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + window = data.get('window', DEFAULT_STATS_WINDOW) + + if not shop: + await self._send_event('livestats.result', { + 'command_id': command_id, + 'success': False, + 'error': 'Shop nicht angegeben' + }) + return + + try: + window_seconds = STATS_WINDOWS.get(window, STATS_WINDOWS[DEFAULT_STATS_WINDOW]) + tracker = get_shop_stats_tracker(shop, window_seconds) + stats = tracker.get_stats() + + # Banned/Whitelisted IPs hinzufügen + stats['banned_ips'] = get_banned_ips(shop) + stats['whitelisted_ips'] = get_whitelisted_ips(shop) + stats['autoban_config'] = get_autoban_config(shop) + + await self._send_event('livestats.result', { + 'command_id': command_id, + 'success': True, + 'shop': shop, + 'stats': stats + }) + except Exception as e: + logger.error(f"Fehler bei livestats für {shop}: {e}") + await self._send_event('livestats.result', { + 'command_id': command_id, + 'success': False, + 'error': str(e) + }) + + async def _handle_ban_command(self, data: Dict[str, Any]): + """Bannt eine IP für einen Shop.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + ip = data.get('ip') + duration = data.get('duration', 3600) # Default: 1 Stunde + reason = data.get('reason', 'Manual ban') + + if not shop or not ip: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': 'Shop oder IP nicht angegeben' + }) + return + + try: + result = ban_ip(shop, ip, duration, reason) + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'success' if result['success'] else 'error', + 'message': result['message'], + 'shop': shop, + 'ip': ip + }) + except Exception as e: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': str(e) + }) + + async def _handle_unban_command(self, data: Dict[str, Any]): + """Entfernt Ban für eine IP.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + ip = data.get('ip') + + if not shop or not ip: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': 'Shop oder IP nicht angegeben' + }) + return + + try: + result = unban_ip(shop, ip) + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'success' if result['success'] else 'error', + 'message': result['message'], + 'shop': shop, + 'ip': ip + }) + except Exception as e: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': str(e) + }) + + async def _handle_whitelist_command(self, data: Dict[str, Any]): + """Fügt IP zur Whitelist hinzu.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + ip = data.get('ip') + description = data.get('description', '') + + if not shop or not ip: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': 'Shop oder IP nicht angegeben' + }) + return + + try: + result = whitelist_ip(shop, ip, description) + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'success' if result['success'] else 'error', + 'message': result['message'], + 'shop': shop, + 'ip': ip + }) + except Exception as e: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': str(e) + }) + + async def _handle_unwhitelist_command(self, data: Dict[str, Any]): + """Entfernt IP von Whitelist.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + ip = data.get('ip') + + if not shop or not ip: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': 'Shop oder IP nicht angegeben' + }) + return + + try: + result = remove_from_whitelist(shop, ip) + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'success' if result['success'] else 'error', + 'message': result['message'], + 'shop': shop, + 'ip': ip + }) + except Exception as e: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': str(e) + }) + + async def _handle_get_bans_command(self, data: Dict[str, Any]): + """Gibt gebannte IPs für einen Shop zurück.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + + if not shop: + await self._send_event('bans.result', { + 'command_id': command_id, + 'success': False, + 'error': 'Shop nicht angegeben' + }) + return + + try: + banned = get_banned_ips(shop) + await self._send_event('bans.result', { + 'command_id': command_id, + 'success': True, + 'shop': shop, + 'banned_ips': banned + }) + except Exception as e: + await self._send_event('bans.result', { + 'command_id': command_id, + 'success': False, + 'error': str(e) + }) + + async def _handle_get_whitelist_command(self, data: Dict[str, Any]): + """Gibt Whitelist für einen Shop zurück.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + + if not shop: + await self._send_event('whitelist.result', { + 'command_id': command_id, + 'success': False, + 'error': 'Shop nicht angegeben' + }) + return + + try: + whitelisted = get_whitelisted_ips(shop) + await self._send_event('whitelist.result', { + 'command_id': command_id, + 'success': True, + 'shop': shop, + 'whitelisted_ips': whitelisted + }) + except Exception as e: + await self._send_event('whitelist.result', { + 'command_id': command_id, + 'success': False, + 'error': str(e) + }) + + async def _handle_autoban_config_command(self, data: Dict[str, Any]): + """Konfiguriert Auto-Ban für einen Shop.""" + command_id = data.get('command_id', 'unknown') + shop = data.get('shop') + config = data.get('config') + + if not shop: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': 'Shop nicht angegeben' + }) + return + + try: + if config: + # Konfiguration speichern + save_autoban_config(shop, config) + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'success', + 'message': f'Auto-Ban Konfiguration für {shop} gespeichert', + 'shop': shop + }) + else: + # Konfiguration abrufen + current_config = get_autoban_config(shop) + await self._send_event('autoban_config.result', { + 'command_id': command_id, + 'success': True, + 'shop': shop, + 'config': current_config + }) + except Exception as e: + await self._send_event('command.result', { + 'command_id': command_id, + 'status': 'error', + 'message': str(e) + }) + async def _periodic_tasks(self): """Führt periodische Tasks aus.""" while self.running: