4509 lines
162 KiB
Python
4509 lines
162 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
JTL-WAFi Agent v3.0.0 - WebSocket Real-Time Agent
|
|
|
|
Features:
|
|
- WebSocket-basierte Echtzeit-Kommunikation
|
|
- Integrierter Shop-Manager (kein separates Script mehr)
|
|
- Token-basierte Authentifizierung
|
|
- On-Demand Live-Log-Streaming
|
|
- Auto-Reconnect mit Exponential Backoff
|
|
- Auto-Update: Agent über Git aktualisieren
|
|
- PHP-FPM Restart: OPcache automatisch leeren
|
|
|
|
v3.0.0: Auto-Update, FPM-Restart, Country-Detection Bugfix
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import socket
|
|
import struct
|
|
import hashlib
|
|
import logging
|
|
import asyncio
|
|
import ssl
|
|
import shutil
|
|
import subprocess
|
|
import re
|
|
import ipaddress
|
|
import signal
|
|
import platform
|
|
import threading
|
|
import urllib.request
|
|
import urllib.error
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from collections import Counter
|
|
from typing import Optional, Dict, Any, List, Set, Callable
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
# =============================================================================
|
|
# VERSION
|
|
# =============================================================================
|
|
VERSION = "3.1.0"
|
|
|
|
# =============================================================================
|
|
# PFADE - AGENT
|
|
# =============================================================================
|
|
CONFIG_DIR = "/etc/jtl-wafi"
|
|
TOKEN_FILE = "/etc/jtl-wafi/token"
|
|
LOG_FILE = "/var/log/jtl-wafi.log"
|
|
|
|
# =============================================================================
|
|
# PFADE - SHOPS
|
|
# =============================================================================
|
|
VHOSTS_DIR = "/var/www/vhosts"
|
|
ACTIVE_SHOPS_FILE = "/var/lib/jtl-wafi/active_shops.json"
|
|
|
|
# =============================================================================
|
|
# SHOP-DATEIEN
|
|
# =============================================================================
|
|
BACKUP_SUFFIX = ".jtl-wafi_backup"
|
|
BLOCKING_FILE = "jtl-wafi.php"
|
|
CACHE_FILE = "jtl-wafi_ip_ranges.cache"
|
|
SHOP_LOG_FILE = "jtl-wafi_blocked.log"
|
|
RATELIMIT_DIR = "jtl-wafi_ratelimit"
|
|
|
|
# =============================================================================
|
|
# WEBSOCKET
|
|
# =============================================================================
|
|
DEFAULT_DASHBOARD_URL = "wss://shop000.jtl-hosting.de:8000/ws/agent"
|
|
HEARTBEAT_INTERVAL = 60 # Sekunden
|
|
RECONNECT_BASE_DELAY = 1 # Sekunden
|
|
RECONNECT_MAX_DELAY = 60 # Sekunden
|
|
STATS_UPDATE_INTERVAL = 10 # Sekunden
|
|
|
|
# =============================================================================
|
|
# AUTO-UPDATE
|
|
# =============================================================================
|
|
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
|
|
|
|
# =============================================================================
|
|
# GLOBAL COUNTRY CACHE (ipdeny.com - shared between all shops)
|
|
# =============================================================================
|
|
GLOBAL_COUNTRY_CACHE_DIR = "/var/lib/jtl-wafi/country_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
|
|
# =============================================================================
|
|
LOG_MAX_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
LOG_BACKUP_COUNT = 3
|
|
|
|
# =============================================================================
|
|
# RATE-LIMIT DEFAULTS
|
|
# =============================================================================
|
|
DEFAULT_RATE_LIMIT = 30 # Requests pro Minute
|
|
DEFAULT_BAN_DURATION = 5 # Minuten
|
|
|
|
# =============================================================================
|
|
# COUNTRY PRESETS (ersetzt GEO_REGIONS)
|
|
# =============================================================================
|
|
COUNTRY_PRESET_EU_PLUS = [
|
|
"de", "at", "ch", "be", "cy", "ee", "es", "fi", "fr", "gb",
|
|
"gr", "hr", "ie", "it", "lt", "lu", "lv", "mt", "nl", "pt", "si", "sk"
|
|
]
|
|
|
|
COUNTRY_PRESET_DACH = ["de", "at", "ch"]
|
|
|
|
# =============================================================================
|
|
# LINK11
|
|
# =============================================================================
|
|
LINK11_IP = "128.65.223.106"
|
|
|
|
# =============================================================================
|
|
# DNS CACHE
|
|
# =============================================================================
|
|
DNS_CACHE = {}
|
|
|
|
# =============================================================================
|
|
# COUNTRIES - Vollständige Länderliste (ISO 3166-1 Alpha-2)
|
|
# =============================================================================
|
|
COUNTRIES = {
|
|
"ad": {"name": "Andorra", "flag": "🇦🇩"},
|
|
"ae": {"name": "VAE", "flag": "🇦🇪"},
|
|
"af": {"name": "Afghanistan", "flag": "🇦🇫"},
|
|
"ag": {"name": "Antigua und Barbuda", "flag": "🇦🇬"},
|
|
"al": {"name": "Albanien", "flag": "🇦🇱"},
|
|
"am": {"name": "Armenien", "flag": "🇦🇲"},
|
|
"ao": {"name": "Angola", "flag": "🇦🇴"},
|
|
"ar": {"name": "Argentinien", "flag": "🇦🇷"},
|
|
"at": {"name": "Österreich", "flag": "🇦🇹"},
|
|
"au": {"name": "Australien", "flag": "🇦🇺"},
|
|
"az": {"name": "Aserbaidschan", "flag": "🇦🇿"},
|
|
"ba": {"name": "Bosnien-Herzegowina", "flag": "🇧🇦"},
|
|
"bb": {"name": "Barbados", "flag": "🇧🇧"},
|
|
"bd": {"name": "Bangladesch", "flag": "🇧🇩"},
|
|
"be": {"name": "Belgien", "flag": "🇧🇪"},
|
|
"bf": {"name": "Burkina Faso", "flag": "🇧🇫"},
|
|
"bg": {"name": "Bulgarien", "flag": "🇧🇬"},
|
|
"bh": {"name": "Bahrain", "flag": "🇧🇭"},
|
|
"bi": {"name": "Burundi", "flag": "🇧🇮"},
|
|
"bj": {"name": "Benin", "flag": "🇧🇯"},
|
|
"bn": {"name": "Brunei", "flag": "🇧🇳"},
|
|
"bo": {"name": "Bolivien", "flag": "🇧🇴"},
|
|
"br": {"name": "Brasilien", "flag": "🇧🇷"},
|
|
"bs": {"name": "Bahamas", "flag": "🇧🇸"},
|
|
"bt": {"name": "Bhutan", "flag": "🇧🇹"},
|
|
"bw": {"name": "Botswana", "flag": "🇧🇼"},
|
|
"by": {"name": "Belarus", "flag": "🇧🇾"},
|
|
"bz": {"name": "Belize", "flag": "🇧🇿"},
|
|
"ca": {"name": "Kanada", "flag": "🇨🇦"},
|
|
"cd": {"name": "DR Kongo", "flag": "🇨🇩"},
|
|
"cf": {"name": "Zentralafrikanische Republik", "flag": "🇨🇫"},
|
|
"cg": {"name": "Kongo", "flag": "🇨🇬"},
|
|
"ch": {"name": "Schweiz", "flag": "🇨🇭"},
|
|
"ci": {"name": "Elfenbeinküste", "flag": "🇨🇮"},
|
|
"cl": {"name": "Chile", "flag": "🇨🇱"},
|
|
"cm": {"name": "Kamerun", "flag": "🇨🇲"},
|
|
"cn": {"name": "China", "flag": "🇨🇳"},
|
|
"co": {"name": "Kolumbien", "flag": "🇨🇴"},
|
|
"cr": {"name": "Costa Rica", "flag": "🇨🇷"},
|
|
"cu": {"name": "Kuba", "flag": "🇨🇺"},
|
|
"cv": {"name": "Kap Verde", "flag": "🇨🇻"},
|
|
"cy": {"name": "Zypern", "flag": "🇨🇾"},
|
|
"cz": {"name": "Tschechien", "flag": "🇨🇿"},
|
|
"de": {"name": "Deutschland", "flag": "🇩🇪"},
|
|
"dj": {"name": "Dschibuti", "flag": "🇩🇯"},
|
|
"dk": {"name": "Dänemark", "flag": "🇩🇰"},
|
|
"dm": {"name": "Dominica", "flag": "🇩🇲"},
|
|
"do": {"name": "Dominikanische Republik", "flag": "🇩🇴"},
|
|
"dz": {"name": "Algerien", "flag": "🇩🇿"},
|
|
"ec": {"name": "Ecuador", "flag": "🇪🇨"},
|
|
"ee": {"name": "Estland", "flag": "🇪🇪"},
|
|
"eg": {"name": "Ägypten", "flag": "🇪🇬"},
|
|
"er": {"name": "Eritrea", "flag": "🇪🇷"},
|
|
"es": {"name": "Spanien", "flag": "🇪🇸"},
|
|
"et": {"name": "Äthiopien", "flag": "🇪🇹"},
|
|
"fi": {"name": "Finnland", "flag": "🇫🇮"},
|
|
"fj": {"name": "Fidschi", "flag": "🇫🇯"},
|
|
"fr": {"name": "Frankreich", "flag": "🇫🇷"},
|
|
"ga": {"name": "Gabun", "flag": "🇬🇦"},
|
|
"gb": {"name": "Großbritannien", "flag": "🇬🇧"},
|
|
"gd": {"name": "Grenada", "flag": "🇬🇩"},
|
|
"ge": {"name": "Georgien", "flag": "🇬🇪"},
|
|
"gh": {"name": "Ghana", "flag": "🇬🇭"},
|
|
"gm": {"name": "Gambia", "flag": "🇬🇲"},
|
|
"gn": {"name": "Guinea", "flag": "🇬🇳"},
|
|
"gq": {"name": "Äquatorialguinea", "flag": "🇬🇶"},
|
|
"gr": {"name": "Griechenland", "flag": "🇬🇷"},
|
|
"gt": {"name": "Guatemala", "flag": "🇬🇹"},
|
|
"gw": {"name": "Guinea-Bissau", "flag": "🇬🇼"},
|
|
"gy": {"name": "Guyana", "flag": "🇬🇾"},
|
|
"hk": {"name": "Hongkong", "flag": "🇭🇰"},
|
|
"hn": {"name": "Honduras", "flag": "🇭🇳"},
|
|
"hr": {"name": "Kroatien", "flag": "🇭🇷"},
|
|
"ht": {"name": "Haiti", "flag": "🇭🇹"},
|
|
"hu": {"name": "Ungarn", "flag": "🇭🇺"},
|
|
"id": {"name": "Indonesien", "flag": "🇮🇩"},
|
|
"ie": {"name": "Irland", "flag": "🇮🇪"},
|
|
"il": {"name": "Israel", "flag": "🇮🇱"},
|
|
"in": {"name": "Indien", "flag": "🇮🇳"},
|
|
"iq": {"name": "Irak", "flag": "🇮🇶"},
|
|
"ir": {"name": "Iran", "flag": "🇮🇷"},
|
|
"is": {"name": "Island", "flag": "🇮🇸"},
|
|
"it": {"name": "Italien", "flag": "🇮🇹"},
|
|
"jm": {"name": "Jamaika", "flag": "🇯🇲"},
|
|
"jo": {"name": "Jordanien", "flag": "🇯🇴"},
|
|
"jp": {"name": "Japan", "flag": "🇯🇵"},
|
|
"ke": {"name": "Kenia", "flag": "🇰🇪"},
|
|
"kg": {"name": "Kirgisistan", "flag": "🇰🇬"},
|
|
"kh": {"name": "Kambodscha", "flag": "🇰🇭"},
|
|
"km": {"name": "Komoren", "flag": "🇰🇲"},
|
|
"kn": {"name": "St. Kitts und Nevis", "flag": "🇰🇳"},
|
|
"kp": {"name": "Nordkorea", "flag": "🇰🇵"},
|
|
"kr": {"name": "Südkorea", "flag": "🇰🇷"},
|
|
"kw": {"name": "Kuwait", "flag": "🇰🇼"},
|
|
"kz": {"name": "Kasachstan", "flag": "🇰🇿"},
|
|
"la": {"name": "Laos", "flag": "🇱🇦"},
|
|
"lb": {"name": "Libanon", "flag": "🇱🇧"},
|
|
"lc": {"name": "St. Lucia", "flag": "🇱🇨"},
|
|
"li": {"name": "Liechtenstein", "flag": "🇱🇮"},
|
|
"lk": {"name": "Sri Lanka", "flag": "🇱🇰"},
|
|
"lr": {"name": "Liberia", "flag": "🇱🇷"},
|
|
"ls": {"name": "Lesotho", "flag": "🇱🇸"},
|
|
"lt": {"name": "Litauen", "flag": "🇱🇹"},
|
|
"lu": {"name": "Luxemburg", "flag": "🇱🇺"},
|
|
"lv": {"name": "Lettland", "flag": "🇱🇻"},
|
|
"ly": {"name": "Libyen", "flag": "🇱🇾"},
|
|
"ma": {"name": "Marokko", "flag": "🇲🇦"},
|
|
"mc": {"name": "Monaco", "flag": "🇲🇨"},
|
|
"md": {"name": "Moldau", "flag": "🇲🇩"},
|
|
"me": {"name": "Montenegro", "flag": "🇲🇪"},
|
|
"mg": {"name": "Madagaskar", "flag": "🇲🇬"},
|
|
"mk": {"name": "Nordmazedonien", "flag": "🇲🇰"},
|
|
"ml": {"name": "Mali", "flag": "🇲🇱"},
|
|
"mm": {"name": "Myanmar", "flag": "🇲🇲"},
|
|
"mn": {"name": "Mongolei", "flag": "🇲🇳"},
|
|
"mo": {"name": "Macau", "flag": "🇲🇴"},
|
|
"mr": {"name": "Mauretanien", "flag": "🇲🇷"},
|
|
"mt": {"name": "Malta", "flag": "🇲🇹"},
|
|
"mu": {"name": "Mauritius", "flag": "🇲🇺"},
|
|
"mv": {"name": "Malediven", "flag": "🇲🇻"},
|
|
"mw": {"name": "Malawi", "flag": "🇲🇼"},
|
|
"mx": {"name": "Mexiko", "flag": "🇲🇽"},
|
|
"my": {"name": "Malaysia", "flag": "🇲🇾"},
|
|
"mz": {"name": "Mosambik", "flag": "🇲🇿"},
|
|
"na": {"name": "Namibia", "flag": "🇳🇦"},
|
|
"ne": {"name": "Niger", "flag": "🇳🇪"},
|
|
"ng": {"name": "Nigeria", "flag": "🇳🇬"},
|
|
"ni": {"name": "Nicaragua", "flag": "🇳🇮"},
|
|
"nl": {"name": "Niederlande", "flag": "🇳🇱"},
|
|
"no": {"name": "Norwegen", "flag": "🇳🇴"},
|
|
"np": {"name": "Nepal", "flag": "🇳🇵"},
|
|
"nz": {"name": "Neuseeland", "flag": "🇳🇿"},
|
|
"om": {"name": "Oman", "flag": "🇴🇲"},
|
|
"pa": {"name": "Panama", "flag": "🇵🇦"},
|
|
"pe": {"name": "Peru", "flag": "🇵🇪"},
|
|
"pg": {"name": "Papua-Neuguinea", "flag": "🇵🇬"},
|
|
"ph": {"name": "Philippinen", "flag": "🇵🇭"},
|
|
"pk": {"name": "Pakistan", "flag": "🇵🇰"},
|
|
"pl": {"name": "Polen", "flag": "🇵🇱"},
|
|
"pt": {"name": "Portugal", "flag": "🇵🇹"},
|
|
"py": {"name": "Paraguay", "flag": "🇵🇾"},
|
|
"qa": {"name": "Katar", "flag": "🇶🇦"},
|
|
"ro": {"name": "Rumänien", "flag": "🇷🇴"},
|
|
"rs": {"name": "Serbien", "flag": "🇷🇸"},
|
|
"ru": {"name": "Russland", "flag": "🇷🇺"},
|
|
"rw": {"name": "Ruanda", "flag": "🇷🇼"},
|
|
"sa": {"name": "Saudi-Arabien", "flag": "🇸🇦"},
|
|
"sb": {"name": "Salomonen", "flag": "🇸🇧"},
|
|
"sc": {"name": "Seychellen", "flag": "🇸🇨"},
|
|
"sd": {"name": "Sudan", "flag": "🇸🇩"},
|
|
"se": {"name": "Schweden", "flag": "🇸🇪"},
|
|
"sg": {"name": "Singapur", "flag": "🇸🇬"},
|
|
"si": {"name": "Slowenien", "flag": "🇸🇮"},
|
|
"sk": {"name": "Slowakei", "flag": "🇸🇰"},
|
|
"sl": {"name": "Sierra Leone", "flag": "🇸🇱"},
|
|
"sm": {"name": "San Marino", "flag": "🇸🇲"},
|
|
"sn": {"name": "Senegal", "flag": "🇸🇳"},
|
|
"so": {"name": "Somalia", "flag": "🇸🇴"},
|
|
"sr": {"name": "Suriname", "flag": "🇸🇷"},
|
|
"ss": {"name": "Südsudan", "flag": "🇸🇸"},
|
|
"sv": {"name": "El Salvador", "flag": "🇸🇻"},
|
|
"sy": {"name": "Syrien", "flag": "🇸🇾"},
|
|
"sz": {"name": "Eswatini", "flag": "🇸🇿"},
|
|
"td": {"name": "Tschad", "flag": "🇹🇩"},
|
|
"tg": {"name": "Togo", "flag": "🇹🇬"},
|
|
"th": {"name": "Thailand", "flag": "🇹🇭"},
|
|
"tj": {"name": "Tadschikistan", "flag": "🇹🇯"},
|
|
"tl": {"name": "Osttimor", "flag": "🇹🇱"},
|
|
"tm": {"name": "Turkmenistan", "flag": "🇹🇲"},
|
|
"tn": {"name": "Tunesien", "flag": "🇹🇳"},
|
|
"to": {"name": "Tonga", "flag": "🇹🇴"},
|
|
"tr": {"name": "Türkei", "flag": "🇹🇷"},
|
|
"tt": {"name": "Trinidad und Tobago", "flag": "🇹🇹"},
|
|
"tw": {"name": "Taiwan", "flag": "🇹🇼"},
|
|
"tz": {"name": "Tansania", "flag": "🇹🇿"},
|
|
"ua": {"name": "Ukraine", "flag": "🇺🇦"},
|
|
"ug": {"name": "Uganda", "flag": "🇺🇬"},
|
|
"us": {"name": "USA", "flag": "🇺🇸"},
|
|
"uy": {"name": "Uruguay", "flag": "🇺🇾"},
|
|
"uz": {"name": "Usbekistan", "flag": "🇺🇿"},
|
|
"va": {"name": "Vatikanstadt", "flag": "🇻🇦"},
|
|
"vc": {"name": "St. Vincent und die Grenadinen", "flag": "🇻🇨"},
|
|
"ve": {"name": "Venezuela", "flag": "🇻🇪"},
|
|
"vn": {"name": "Vietnam", "flag": "🇻🇳"},
|
|
"vu": {"name": "Vanuatu", "flag": "🇻🇺"},
|
|
"ws": {"name": "Samoa", "flag": "🇼🇸"},
|
|
"xk": {"name": "Kosovo", "flag": "🇽🇰"},
|
|
"ye": {"name": "Jemen", "flag": "🇾🇪"},
|
|
"za": {"name": "Südafrika", "flag": "🇿🇦"},
|
|
"zm": {"name": "Sambia", "flag": "🇿🇲"},
|
|
"zw": {"name": "Simbabwe", "flag": "🇿🇼"},
|
|
}
|
|
|
|
# Default Country Rate-Limit Settings
|
|
DEFAULT_COUNTRY_RATE_LIMIT = 100 # Requests pro Minute pro Land
|
|
DEFAULT_COUNTRY_BAN_DURATION = 600 # Sekunden (10 Minuten)
|
|
|
|
# =============================================================================
|
|
# BOT IP RANGES - Für Bots die sich mit normalem User-Agent tarnen
|
|
# =============================================================================
|
|
BOT_IP_RANGES = {
|
|
# Alibaba Cloud / Alibaba Spider - tarnt sich oft mit normalem UA
|
|
# Große zusammengefasste Blöcke basierend auf APNIC/WHOIS-Daten
|
|
'Alibaba-Bot': [
|
|
# === Alibaba Cloud Singapore (ASEPL-SG) - Hauptblöcke ===
|
|
'43.0.0.0/9', # 43.0.0.0 - 43.127.255.255 (8.3 Mio IPs)
|
|
'8.128.0.0/10', # 8.128.0.0 - 8.191.255.255 (4.2 Mio IPs)
|
|
'8.208.0.0/12', # 8.208.0.0 - 8.223.255.255 (1 Mio IPs)
|
|
|
|
# === Alibaba Cloud Singapore - Weitere Blöcke ===
|
|
'47.74.0.0/15', # 47.74.0.0 - 47.75.255.255
|
|
'47.76.0.0/16', # 47.76.0.0 - 47.76.255.255
|
|
'47.88.0.0/15', # 47.88.0.0 - 47.89.255.255
|
|
'47.90.0.0/15', # 47.90.0.0 - 47.91.255.255
|
|
'47.241.0.0/16', # 47.241.0.0 - 47.241.255.255
|
|
'47.52.0.0/15', # 47.52.0.0 - 47.53.255.255 (HK)
|
|
'47.56.0.0/15', # 47.56.0.0 - 47.57.255.255 (HK)
|
|
'149.129.0.0/16', # 149.129.0.0 - 149.129.255.255
|
|
'161.117.0.0/16', # 161.117.0.0 - 161.117.255.255
|
|
'170.33.0.0/16', # 170.33.0.0 - 170.33.255.255
|
|
|
|
# === Alibaba Cloud China (Aliyun) ===
|
|
'39.96.0.0/13', # 39.96.0.0 - 39.103.255.255
|
|
'39.104.0.0/14', # 39.104.0.0 - 39.107.255.255
|
|
'39.108.0.0/16', # 39.108.0.0 - 39.108.255.255
|
|
'101.132.0.0/15', # 101.132.0.0 - 101.133.255.255
|
|
'106.14.0.0/15', # 106.14.0.0 - 106.15.255.255
|
|
'112.124.0.0/16', # 112.124.0.0 - 112.124.255.255
|
|
'114.55.0.0/16', # 114.55.0.0 - 114.55.255.255
|
|
'115.28.0.0/15', # 115.28.0.0 - 115.29.255.255
|
|
'116.62.0.0/16', # 116.62.0.0 - 116.62.255.255
|
|
'118.31.0.0/16', # 118.31.0.0 - 118.31.255.255
|
|
'119.23.0.0/16', # 119.23.0.0 - 119.23.255.255
|
|
'120.24.0.0/14', # 120.24.0.0 - 120.27.255.255
|
|
'120.55.0.0/16', # 120.55.0.0 - 120.55.255.255
|
|
'120.76.0.0/14', # 120.76.0.0 - 120.79.255.255
|
|
'121.40.0.0/14', # 121.40.0.0 - 121.43.255.255
|
|
'121.196.0.0/14', # 121.196.0.0 - 121.199.255.255
|
|
'139.196.0.0/16', # 139.196.0.0 - 139.196.255.255
|
|
'139.224.0.0/16', # 139.224.0.0 - 139.224.255.255
|
|
'140.205.0.0/16', # 140.205.0.0 - 140.205.255.255
|
|
'182.92.0.0/16', # 182.92.0.0 - 182.92.255.255
|
|
|
|
# === Alibaba Sonstige ===
|
|
'203.107.0.0/16', # Alibaba DNS
|
|
'103.206.40.0/22', # Alibaba Cloud SG
|
|
'185.218.176.0/22', # Alibaba Cloud
|
|
],
|
|
}
|
|
|
|
# =============================================================================
|
|
# BOT DETECTION PATTERNS
|
|
# =============================================================================
|
|
BOT_PATTERNS = {
|
|
# =========================================================================
|
|
# AI/LLM SERVICES
|
|
# =========================================================================
|
|
'ChatGPT-User': r'chatgpt-user',
|
|
'ChatGPT-Operator': r'chatgpt-operator',
|
|
'ChatGPT-Agent': r'chatgpt agent',
|
|
'ChatGPT': r'chatgpt',
|
|
'GPTBot (OpenAI)': r'gptbot',
|
|
'OAI-SearchBot (OpenAI)': r'oai-searchbot',
|
|
'OpenAI': r'openai',
|
|
'ClaudeBot (Anthropic)': r'claudebot',
|
|
'Claude-User': r'claude-user',
|
|
'Claude-Web': r'claude-web',
|
|
'Claude-SearchBot': r'claude-searchbot',
|
|
'Anthropic-AI': r'anthropic-ai',
|
|
'Anthropic': r'anthropic',
|
|
'Gemini-Deep-Research': r'gemini-deep-research',
|
|
'Google-NotebookLM': r'google-notebooklm',
|
|
'NotebookLM': r'notebooklm',
|
|
'GoogleAgent-Mariner': r'googleagent-mariner',
|
|
'PerplexityBot': r'perplexitybot',
|
|
'Perplexity-User': r'perplexity-user',
|
|
'Perplexity': r'perplexity',
|
|
'Cohere-AI': r'cohere-ai',
|
|
'Cohere-Training-Crawler': r'cohere-training-data-crawler',
|
|
'Cohere': r'cohere',
|
|
'MistralAI-User': r'mistralai-user',
|
|
'MistralAI': r'mistralai',
|
|
'Mistral': r'mistral',
|
|
'DeepSeekBot': r'deepseekbot',
|
|
'DeepSeek': r'deepseek',
|
|
'Bytespider (TikTok/ByteDance)': r'bytespider',
|
|
'TikTokSpider': r'tiktokspider',
|
|
'ByteDance': r'bytedance',
|
|
'AI2Bot-DeepResearchEval': r'ai2bot-deepresearcheval',
|
|
'AI2Bot-Dolma': r'ai2bot-dolma',
|
|
'AI2Bot (Allen Institute)': r'ai2bot',
|
|
'CCBot (Common Crawl)': r'ccbot',
|
|
'Diffbot': r'diffbot',
|
|
'img2dataset': r'img2dataset',
|
|
'LAIONDownloader': r'laiondownloader',
|
|
'LAION-HuggingFace': r'laion-huggingface',
|
|
'LAION': r'laion',
|
|
'HuggingFace': r'huggingface',
|
|
'BedrockBot (AWS)': r'bedrockbot',
|
|
'DuckAssistBot': r'duckassistbot',
|
|
'PhindBot': r'phindbot',
|
|
'YouBot': r'youbot',
|
|
'iAskSpider': r'iaskspider',
|
|
'iAskBot': r'iaskbot',
|
|
'ChatGLM-Spider': r'chatglm-spider',
|
|
'Panscient': r'panscient',
|
|
'Devin (Cognition)': r'devin',
|
|
'Manus-User': r'manus-user',
|
|
'TwinAgent': r'twinagent',
|
|
'NovaAct': r'novaact',
|
|
'FirecrawlAgent': r'firecrawlagent',
|
|
'Firecrawl': r'firecrawl',
|
|
'Crawl4AI': r'crawl4ai',
|
|
'Crawlspace': r'crawlspace',
|
|
'Cloudflare-AutoRAG': r'cloudflare-autorag',
|
|
'TerraCotta': r'terracotta',
|
|
'Thinkbot': r'thinkbot',
|
|
# =========================================================================
|
|
# SUCHMASCHINEN
|
|
# =========================================================================
|
|
'Googlebot-Image': r'googlebot-image',
|
|
'Googlebot-Video': r'googlebot-video',
|
|
'Googlebot-News': r'googlebot-news',
|
|
'Googlebot-Discovery': r'googlebot-discovery',
|
|
'Googlebot': r'googlebot',
|
|
'Google-Extended': r'google-extended',
|
|
'Google-CloudVertexBot': r'google-cloudvertexbot',
|
|
'Google-Firebase': r'google-firebase',
|
|
'Google-InspectionTool': r'google-inspectiontool',
|
|
'GoogleOther-Image': r'googleother-image',
|
|
'GoogleOther-Video': r'googleother-video',
|
|
'GoogleOther': r'googleother',
|
|
'Storebot-Google': r'storebot-google',
|
|
'AdsBot-Google': r'adsbot-google',
|
|
'Bingbot (Microsoft)': r'bingbot',
|
|
'BingPreview': r'bingpreview',
|
|
'MSNBot': r'msnbot',
|
|
'Baiduspider': r'baiduspider',
|
|
'Baidu': r'baidu',
|
|
'YandexBot': r'yandexbot',
|
|
'YandexAdditionalBot': r'yandexadditionalbot',
|
|
'YandexAdditional': r'yandexadditional',
|
|
'Yandex': r'yandex',
|
|
'DuckDuckBot': r'duckduckbot',
|
|
'DuckDuckGo': r'duckduckgo',
|
|
'Applebot-Extended': r'applebot-extended',
|
|
'Applebot': r'applebot',
|
|
'Yahoo Slurp': r'slurp',
|
|
'Sogou': r'sogou',
|
|
'Sosospider': r'sosospider',
|
|
'NaverBot': r'naverbot',
|
|
'Naver': r'naver',
|
|
'SeznamBot': r'seznambot',
|
|
'MojeekBot': r'mojeekbot',
|
|
'QwantBot': r'qwantbot',
|
|
'PetalBot (Huawei)': r'petalbot',
|
|
'CocCocBot': r'coccocbot',
|
|
'Exabot': r'exabot',
|
|
'BraveBot': r'bravebot',
|
|
'Bravest': r'bravest',
|
|
'SeekportBot': r'seekportbot',
|
|
# =========================================================================
|
|
# SEO & MARKETING TOOLS
|
|
# =========================================================================
|
|
'AhrefsBot': r'ahrefsbot',
|
|
'Ahrefs': r'ahrefs',
|
|
'SemrushBot-OCOB': r'semrushbot-ocob',
|
|
'SemrushBot-SWA': r'semrushbot-swa',
|
|
'SemrushBot': r'semrushbot',
|
|
'Semrush': r'semrush',
|
|
'MJ12Bot (Majestic)': r'mj12bot',
|
|
'Majestic': r'majestic',
|
|
'DotBot (Moz)': r'dotbot',
|
|
'RogerBot (Moz)': r'rogerbot',
|
|
'Screaming Frog': r'screaming frog',
|
|
'BLEXBot': r'blexbot',
|
|
'DataForSEOBot': r'dataforseobot',
|
|
'Linkdex': r'linkdex',
|
|
'SearchmetricsBot': r'searchmetricsbot',
|
|
# =========================================================================
|
|
# SOCIAL MEDIA
|
|
# =========================================================================
|
|
'Facebook External Hit': r'facebookexternalhit',
|
|
'FacebookBot': r'facebookbot',
|
|
'Facebot': r'facebot',
|
|
'Meta-ExternalAgent': r'meta-externalagent',
|
|
'Meta-ExternalFetcher': r'meta-externalfetcher',
|
|
'Meta-WebIndexer': r'meta-webindexer',
|
|
'Facebook': r'facebook',
|
|
'Twitterbot': r'twitterbot',
|
|
'Twitter': r'twitter',
|
|
'Instagram': r'instagram',
|
|
'LinkedInBot': r'linkedinbot',
|
|
'LinkedIn': r'linkedin',
|
|
'Pinterestbot': r'pinterestbot',
|
|
'Pinterest': r'pinterest',
|
|
'WhatsApp': r'whatsapp',
|
|
'TelegramBot': r'telegrambot',
|
|
'Telegram': r'telegram',
|
|
'DiscordBot': r'discordbot',
|
|
'Discord': r'discord',
|
|
'Slackbot': r'slackbot',
|
|
'Slack': r'slack',
|
|
'Quora-Bot': r'quora-bot',
|
|
'Snapchat': r'snapchat',
|
|
'RedditBot': r'redditbot',
|
|
# =========================================================================
|
|
# E-COMMERCE & PREISVERGLEICH
|
|
# =========================================================================
|
|
'Amazonbot': r'amazonbot',
|
|
'Amazon-Kendra': r'amazon-kendra',
|
|
'AmazonBuyForMe': r'amazonbuyforme',
|
|
'AMZNKAssocBot': r'amznkassocbot',
|
|
'Alibaba-Bot': r'alibaba|alibabagroup|aliyun|alicdn|alimama|taobao|tmall|1688\.com',
|
|
'AlibabaSpider': r'alibabaspider',
|
|
'Aliyun': r'aliyun',
|
|
'GeedoShopProductFinder': r'geedoshopproductfinder',
|
|
'Geedo': r'geedo',
|
|
'ShopWiki': r'shopwiki',
|
|
'PriceGrabber': r'pricegrabber',
|
|
'Shopify': r'shopify',
|
|
'Idealo': r'idealo',
|
|
'Guenstiger.de': r'guenstiger',
|
|
'Billiger.de': r'billiger',
|
|
'Ladenzeile': r'ladenzeile',
|
|
'Kelkoo': r'kelkoo',
|
|
'PriceRunner': r'pricerunner',
|
|
# =========================================================================
|
|
# ARCHIV & RESEARCH
|
|
# =========================================================================
|
|
'Archive.org Bot': r'archive\.org_bot|archive-org-bot',
|
|
'Internet Archive': r'ia_archiver|ia-archiver',
|
|
'Wayback Machine': r'wayback',
|
|
'Heritrix': r'heritrix',
|
|
'Apache Nutch': r'nutch',
|
|
'Common Crawl': r'commoncrawl',
|
|
# =========================================================================
|
|
# MONITORING & UPTIME
|
|
# =========================================================================
|
|
'UptimeRobot': r'uptimerobot',
|
|
'Pingdom': r'pingdom',
|
|
'StatusCake': r'statuscake',
|
|
'Site24x7': r'site24x7',
|
|
'NewRelic': r'newrelic',
|
|
'Datadog': r'datadog',
|
|
'GTmetrix': r'gtmetrix',
|
|
'PageSpeed Insights': r'pagespeed',
|
|
'Chrome Lighthouse': r'chrome-lighthouse',
|
|
# =========================================================================
|
|
# DOWNLOAD & SCRAPER TOOLS
|
|
# =========================================================================
|
|
'HTTrack': r'httrack',
|
|
'Teleport Pro': r'teleportpro|teleport pro',
|
|
'Teleport': r'teleport',
|
|
'GetRight': r'getright',
|
|
'FlashGet': r'flashget',
|
|
'LeechFTP': r'leechftp',
|
|
'LeechGet': r'leechget',
|
|
'Leech': r'leech',
|
|
'Offline Explorer': r'offline explorer',
|
|
'Offline Navigator': r'offline navigator',
|
|
'Offline Tool': r'offline',
|
|
'WebCopier': r'webcopier',
|
|
'WebCopy': r'webcopy',
|
|
'WebRipper': r'webripper',
|
|
'WebReaper': r'webreaper',
|
|
'WebStripper': r'webstripper',
|
|
'WebSauger': r'websauger',
|
|
'WebZIP': r'webzip',
|
|
'WebWhacker': r'webwhacker',
|
|
'WebBandit': r'webbandit',
|
|
'SiteSucker': r'sitesucker',
|
|
'SiteSnagger': r'sitesnagger',
|
|
'BlackWidow': r'blackwidow',
|
|
'Mass Downloader': r'mass downloader',
|
|
'Download Demon': r'download demon',
|
|
'Download Ninja': r'download ninja',
|
|
'Download Master': r'download master',
|
|
'FreshDownload': r'freshdownload',
|
|
'SmartDownload': r'smartdownload',
|
|
'RealDownload': r'realdownload',
|
|
'StarDownloader': r'stardownloader',
|
|
'Net Vampire': r'net vampire',
|
|
'NetAnts': r'netants',
|
|
'NetZIP': r'netzip',
|
|
'Go!Zilla': r'go!zilla|gozilla',
|
|
'Grabber': r'grabber',
|
|
'PageGrabber': r'pagegrabber',
|
|
'EirGrabber': r'eirgrabber',
|
|
'EmailSiphon': r'emailsiphon',
|
|
'EmailCollector': r'emailcollector',
|
|
'EmailWolf': r'emailwolf',
|
|
'Email Extractor': r'email extractor',
|
|
'ExtractorPro': r'extractorpro',
|
|
'HarvestMan': r'harvestman',
|
|
'Harvest': r'harvest',
|
|
'Collector': r'collector',
|
|
'Vacuum': r'vacuum',
|
|
'WebVac': r'webvac',
|
|
'Zeus': r'zeus',
|
|
'ScrapeBox': r'scrapebox',
|
|
'Xenu Link Sleuth': r'xenu',
|
|
'Larbin': r'larbin',
|
|
'Grub': r'grub',
|
|
# =========================================================================
|
|
# HTTP LIBRARIES & FRAMEWORKS
|
|
# =========================================================================
|
|
'Python-Requests': r'python-requests',
|
|
'Python-urllib': r'python-urllib',
|
|
'Python-HTTPX': r'python-httpx',
|
|
'Python HTTP': r'python/',
|
|
'aiohttp': r'aiohttp',
|
|
'HTTPX': r'httpx/',
|
|
'cURL': r'curl/|^curl',
|
|
'Wget': r'wget/|^wget',
|
|
'Go-HTTP-Client': r'go-http-client',
|
|
'Go HTTP': r'go http|go-http',
|
|
'Java HTTP Client': r'java/|java ',
|
|
'Apache-HttpClient': r'apache-httpclient',
|
|
'Jakarta Commons': r'jakarta',
|
|
'Axios': r'axios/|axios',
|
|
'Node-Fetch': r'node-fetch',
|
|
'Got (Node.js)': r'got/',
|
|
'libwww-perl': r'libwww-perl',
|
|
'LWP (Perl)': r'lwp::|lwp/',
|
|
'WWW-Mechanize': r'www-mechanize',
|
|
'Mechanize': r'mechanize',
|
|
'Scrapy': r'scrapy/|scrapy',
|
|
'HTTP.rb': r'http\.rb',
|
|
'Typhoeus': r'typhoeus',
|
|
'OkHttp': r'okhttp/|okhttp',
|
|
'CFNetwork': r'cfnetwork',
|
|
'WinHTTP': r'winhttp',
|
|
'Indy Library': r'indy library',
|
|
'Chilkat': r'chilkat',
|
|
'httplib': r'httplib',
|
|
'ApacheBench': r'apachebench',
|
|
'Guzzle (PHP)': r'guzzle',
|
|
'Requests': r'requests/',
|
|
# =========================================================================
|
|
# SECURITY SCANNER
|
|
# =========================================================================
|
|
'Nessus': r'nessus',
|
|
'SQLMap': r'sqlmap',
|
|
'Netsparker': r'netsparker',
|
|
'Nikto': r'nikto',
|
|
'Acunetix': r'acunetix',
|
|
'Burp Suite': r'burpsuite|burp',
|
|
'OWASP ZAP': r'owasp zap',
|
|
'OpenVAS': r'openvas',
|
|
'Nmap': r'nmap',
|
|
'Masscan': r'masscan',
|
|
'WPScan': r'wpscan',
|
|
# =========================================================================
|
|
# HEADLESS BROWSERS & AUTOMATION
|
|
# =========================================================================
|
|
'PhantomJS': r'phantomjs',
|
|
'Headless Chrome': r'headlesschrome',
|
|
'Headless Browser': r'headless',
|
|
'Selenium': r'selenium',
|
|
'Puppeteer': r'puppeteer',
|
|
'Playwright': r'playwright',
|
|
'Cypress': r'cypress',
|
|
# =========================================================================
|
|
# FEED READER & RSS
|
|
# =========================================================================
|
|
'FeedFetcher': r'feedfetcher',
|
|
'FeedParser': r'feedparser',
|
|
'Feedly': r'feedly',
|
|
'Inoreader': r'inoreader',
|
|
'NewsBlur': r'newsblur',
|
|
# =========================================================================
|
|
# WEITERE BEKANNTE BOTS
|
|
# =========================================================================
|
|
'OmgiliBot': r'omgilibot',
|
|
'Omgili': r'omgili',
|
|
'Webzio-Extended': r'webzio-extended',
|
|
'Webzio': r'webzio',
|
|
'Timpibot': r'timpibot',
|
|
'PanguBot': r'pangubot',
|
|
'ImagesiftBot': r'imagesiftbot',
|
|
'Kangaroo Bot': r'kangaroo bot',
|
|
'QualifiedBot': r'qualifiedbot',
|
|
'VelenPublicWebCrawler': r'velenpublicwebcrawler',
|
|
'Linguee Bot': r'linguee bot',
|
|
'Linguee': r'linguee',
|
|
'QuillBot': r'quillbot',
|
|
'TurnitinBot': r'turnitinbot',
|
|
'Turnitin': r'turnitin',
|
|
'ZanistaBot': r'zanistabot',
|
|
'WRTNBot': r'wrtnbot',
|
|
'WARDBot': r'wardbot',
|
|
'ShapBot': r'shapbot',
|
|
'LinerBot': r'linerbot',
|
|
'LinkupBot': r'linkupbot',
|
|
'KlaviyoAIBot': r'klaviyoaibot',
|
|
'KunatoCrawler': r'kunatocrawler',
|
|
'IbouBot': r'iboubot',
|
|
'BuddyBot': r'buddybot',
|
|
'BrightBot': r'brightbot',
|
|
'Channel3Bot': r'channel3bot',
|
|
'Andibot': r'andibot',
|
|
'Anomura': r'anomura',
|
|
'Awario': r'awario',
|
|
'BigSur.ai': r'bigsur',
|
|
'Cotoyogi': r'cotoyogi',
|
|
'AddSearchBot': r'addsearchbot',
|
|
'aiHitBot': r'aihitbot',
|
|
'Atlassian-Bot': r'atlassian-bot',
|
|
'RainBot': r'rainbot',
|
|
'TinyTestBot': r'tinytestbot',
|
|
'Brandwatch': r'brandwatch',
|
|
'Meltwater': r'meltwater',
|
|
'Netvibes': r'netvibes',
|
|
'BitlyBot': r'bitlybot',
|
|
'Mail.ru Bot': r'mail\.ru',
|
|
'YaK': r'yak',
|
|
}
|
|
|
|
# Generische Patterns (Fallback für unbekannte Bots)
|
|
GENERIC_BOT_PATTERNS = [
|
|
'bot', 'crawler', 'spider', 'scraper', 'fetch', 'scan', 'check',
|
|
'monitor', 'probe', 'index', 'archive', 'capture', 'reader',
|
|
'download', 'mirror', 'ripper', 'collector', 'extractor', 'siphon',
|
|
'copier', 'sucker', 'bandit', 'stripper', 'whacker', 'reaper',
|
|
'robot', 'agent', 'seeker', 'finder', 'walker', 'roam', 'snagger',
|
|
]
|
|
|
|
# =============================================================================
|
|
# LOGGING SETUP
|
|
# =============================================================================
|
|
def setup_logging(debug: bool = False):
|
|
"""Konfiguriert Logging mit Rotation."""
|
|
log_level = logging.DEBUG if debug else logging.INFO
|
|
|
|
# Formatter
|
|
formatter = logging.Formatter(
|
|
'%(asctime)s [%(levelname)s] %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
|
|
# Console Handler
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setFormatter(formatter)
|
|
console_handler.setLevel(log_level)
|
|
|
|
# File Handler mit Rotation
|
|
handlers = [console_handler]
|
|
|
|
log_dir = os.path.dirname(LOG_FILE)
|
|
if log_dir and os.path.exists(log_dir):
|
|
try:
|
|
file_handler = RotatingFileHandler(
|
|
LOG_FILE,
|
|
maxBytes=LOG_MAX_SIZE,
|
|
backupCount=LOG_BACKUP_COUNT
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
file_handler.setLevel(log_level)
|
|
handlers.append(file_handler)
|
|
except PermissionError:
|
|
pass
|
|
|
|
# Logger konfigurieren
|
|
logger = logging.getLogger('jtl_wafi_agent')
|
|
logger.setLevel(log_level)
|
|
logger.handlers = handlers
|
|
|
|
return logger
|
|
|
|
# Global Logger (wird in main() initialisiert)
|
|
logger = logging.getLogger('jtl_wafi_agent')
|
|
|
|
|
|
# =============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# =============================================================================
|
|
def utc_now() -> datetime:
|
|
"""Gibt aktuelle UTC Zeit zurück."""
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
"""Gibt aktuelle UTC Zeit als ISO-String zurück."""
|
|
return utc_now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
|
|
|
|
def format_duration(minutes: float) -> str:
|
|
"""Formatiert Minuten als lesbare Dauer."""
|
|
if minutes < 60:
|
|
return f"{int(minutes)}m"
|
|
hours = minutes / 60
|
|
if hours < 24:
|
|
return f"{int(hours)}h {int(minutes % 60)}m"
|
|
return f"{int(hours / 24)}d {int(hours % 24)}h"
|
|
|
|
|
|
# =============================================================================
|
|
# OWNERSHIP HELPER FUNCTIONS
|
|
# =============================================================================
|
|
def get_most_common_owner(httpdocs_path: str):
|
|
"""
|
|
Ermittelt die häufigste uid:gid-Kombination im httpdocs-Verzeichnis.
|
|
Gibt (uid, gid) zurück oder (None, None) wenn nicht ermittelbar.
|
|
"""
|
|
if not os.path.isdir(httpdocs_path):
|
|
return None, None
|
|
|
|
owner_counts = Counter()
|
|
|
|
try:
|
|
for entry in os.listdir(httpdocs_path):
|
|
entry_path = os.path.join(httpdocs_path, entry)
|
|
try:
|
|
stat_info = os.stat(entry_path)
|
|
owner_counts[(stat_info.st_uid, stat_info.st_gid)] += 1
|
|
except (OSError, IOError):
|
|
continue
|
|
except (OSError, IOError):
|
|
return None, None
|
|
|
|
if not owner_counts:
|
|
return None, None
|
|
|
|
most_common = owner_counts.most_common(1)[0][0]
|
|
return most_common
|
|
|
|
|
|
def set_owner(path: str, uid: int, gid: int, recursive: bool = False):
|
|
"""
|
|
Setzt Owner und Gruppe für eine Datei oder einen Ordner.
|
|
Optional rekursiv für Verzeichnisse.
|
|
"""
|
|
if uid is None or gid is None:
|
|
return
|
|
|
|
try:
|
|
os.chown(path, uid, gid)
|
|
|
|
if recursive and os.path.isdir(path):
|
|
for root, dirs, files in os.walk(path):
|
|
for d in dirs:
|
|
try:
|
|
os.chown(os.path.join(root, d), uid, gid)
|
|
except (OSError, IOError):
|
|
pass
|
|
for f in files:
|
|
try:
|
|
os.chown(os.path.join(root, f), uid, gid)
|
|
except (OSError, IOError):
|
|
pass
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
|
|
# =============================================================================
|
|
# PHP-FPM RESTART FUNCTIONS
|
|
# =============================================================================
|
|
def extract_domain_from_path(shop_path: str) -> Optional[str]:
|
|
"""
|
|
Extrahiert Domain aus Pfad wie /var/www/vhosts/example.com/httpdocs.
|
|
|
|
Args:
|
|
shop_path: Shop-Pfad oder Domain
|
|
|
|
Returns:
|
|
Domain-String oder None
|
|
"""
|
|
# Wenn es ein Pfad ist
|
|
if '/' in shop_path:
|
|
match = re.search(r'/var/www/vhosts/([^/]+)', shop_path)
|
|
if match:
|
|
return match.group(1)
|
|
# Ansonsten ist es vermutlich schon eine Domain
|
|
return shop_path
|
|
|
|
|
|
def find_php_fpm_service(domain: str) -> Optional[str]:
|
|
"""
|
|
Findet den PHP-FPM Service für eine Domain.
|
|
|
|
Sucht nach Services im Format: plesk-php{version}-fpm_{domain}_{id}.service
|
|
|
|
Args:
|
|
domain: Shop-Domain (z.B. "example.de")
|
|
|
|
Returns:
|
|
Service-Name oder None wenn nicht gefunden
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
['systemctl', 'list-units', '--type=service', '--all', '--no-legend'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
logger.warning(f"systemctl list-units fehlgeschlagen: {result.stderr}")
|
|
return None
|
|
|
|
for line in result.stdout.split('\n'):
|
|
# Suche nach plesk-php*-fpm_{domain}_
|
|
if 'plesk-php' in line and f'fpm_{domain}_' in line:
|
|
# Extrahiere Service-Namen (erstes Feld)
|
|
parts = line.split()
|
|
if parts:
|
|
service_name = parts[0]
|
|
logger.debug(f"PHP-FPM Service gefunden für {domain}: {service_name}")
|
|
return service_name
|
|
|
|
logger.debug(f"Kein PHP-FPM Service für {domain} gefunden")
|
|
return None
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("systemctl list-units Timeout")
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Fehler beim Suchen des PHP-FPM Service: {e}")
|
|
return None
|
|
|
|
|
|
def restart_php_fpm(domain: str) -> dict:
|
|
"""
|
|
Startet den PHP-FPM Service für eine Domain neu.
|
|
|
|
Args:
|
|
domain: Shop-Domain (z.B. "example.de")
|
|
|
|
Returns:
|
|
Dict mit 'success', 'service' und 'message'
|
|
"""
|
|
# Domain aus Pfad extrahieren falls nötig
|
|
clean_domain = extract_domain_from_path(domain)
|
|
if not clean_domain:
|
|
return {
|
|
'success': False,
|
|
'service': None,
|
|
'message': f'Konnte Domain nicht extrahieren aus: {domain}'
|
|
}
|
|
|
|
service = find_php_fpm_service(clean_domain)
|
|
if not service:
|
|
return {
|
|
'success': False,
|
|
'service': None,
|
|
'message': f'Kein PHP-FPM Service gefunden für {clean_domain}'
|
|
}
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['systemctl', 'restart', service],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
logger.info(f"PHP-FPM Service {service} neugestartet für {clean_domain}")
|
|
return {
|
|
'success': True,
|
|
'service': service,
|
|
'message': f'OPcache geleert ({service})'
|
|
}
|
|
else:
|
|
error_msg = result.stderr.strip() if result.stderr else 'Unknown error'
|
|
logger.error(f"Fehler beim Neustart von {service}: {error_msg}")
|
|
return {
|
|
'success': False,
|
|
'service': service,
|
|
'message': f'Restart fehlgeschlagen: {error_msg}'
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error(f"Timeout beim Neustart von {service}")
|
|
return {
|
|
'success': False,
|
|
'service': service,
|
|
'message': 'Restart Timeout (>30s)'
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Exception beim PHP-FPM Restart: {e}")
|
|
return {
|
|
'success': False,
|
|
'service': service,
|
|
'message': str(e)
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# IP-INFO CACHE (ipdeny.com for country, ip-api.com for ISP/ASN)
|
|
# =============================================================================
|
|
# Global IP-Info Cache
|
|
_ip_info_cache: Dict[str, Dict[str, Any]] = {}
|
|
_ip_api_last_request = 0.0
|
|
_ip_api_request_count = 0
|
|
|
|
# Global Country Ranges Cache (loaded once from ipdeny.com files)
|
|
_country_ranges_cache: Dict[str, List[tuple]] = {} # country -> [(network_int, mask_int), ...]
|
|
_country_cache_loaded = False
|
|
|
|
|
|
def _load_global_country_cache():
|
|
"""Lädt alle Country-Ranges aus dem globalen Cache."""
|
|
global _country_ranges_cache, _country_cache_loaded
|
|
|
|
if _country_cache_loaded:
|
|
return
|
|
|
|
if not os.path.isdir(GLOBAL_COUNTRY_CACHE_DIR):
|
|
logger.debug(f"Global Country Cache nicht vorhanden: {GLOBAL_COUNTRY_CACHE_DIR}")
|
|
_country_cache_loaded = True
|
|
return
|
|
|
|
for country in COUNTRY_CODES:
|
|
cache_file = os.path.join(GLOBAL_COUNTRY_CACHE_DIR, f"{country}.ranges")
|
|
if not os.path.isfile(cache_file):
|
|
continue
|
|
|
|
try:
|
|
with open(cache_file, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Parse PHP serialized format: a:N:{i:0;s:LEN:"CIDR";...}
|
|
ranges = []
|
|
import re
|
|
for match in re.finditer(r's:\d+:"([^"]+)"', content):
|
|
cidr = match.group(1)
|
|
if '/' in cidr:
|
|
try:
|
|
subnet, mask = cidr.split('/')
|
|
subnet_int = struct.unpack('!I', socket.inet_aton(subnet))[0]
|
|
mask_int = (0xFFFFFFFF << (32 - int(mask))) & 0xFFFFFFFF
|
|
ranges.append((subnet_int, mask_int))
|
|
except:
|
|
pass
|
|
|
|
if ranges:
|
|
_country_ranges_cache[country.upper()] = ranges
|
|
logger.debug(f"Country ranges geladen: {country.upper()} ({len(ranges)} ranges)")
|
|
except Exception as e:
|
|
logger.debug(f"Fehler beim Laden von {country}: {e}")
|
|
|
|
_country_cache_loaded = True
|
|
logger.info(f"Global Country Cache geladen: {len(_country_ranges_cache)} Länder")
|
|
|
|
|
|
def get_country_for_ip_cached(ip: str) -> str:
|
|
"""
|
|
Ermittelt das Land für eine IP aus dem globalen ipdeny.com Cache.
|
|
Konsistent mit PHP-Templates!
|
|
|
|
Returns:
|
|
2-Letter Country Code (z.B. 'DE') oder 'XX' wenn unbekannt
|
|
"""
|
|
global _country_ranges_cache
|
|
|
|
# Cache laden falls noch nicht geschehen
|
|
if not _country_cache_loaded:
|
|
_load_global_country_cache()
|
|
|
|
try:
|
|
ip_int = struct.unpack('!I', socket.inet_aton(ip))[0]
|
|
except:
|
|
return 'XX'
|
|
|
|
# Suche in allen Ländern (häufigste zuerst)
|
|
priority_countries = ['DE', 'AT', 'CH', 'US', 'GB', 'FR', 'NL', 'IT', 'ES', 'PL']
|
|
|
|
for country in priority_countries:
|
|
if country in _country_ranges_cache:
|
|
for subnet_int, mask_int in _country_ranges_cache[country]:
|
|
if (ip_int & mask_int) == (subnet_int & mask_int):
|
|
return country
|
|
|
|
# Restliche Länder
|
|
for country, ranges in _country_ranges_cache.items():
|
|
if country in priority_countries:
|
|
continue
|
|
for subnet_int, mask_int in ranges:
|
|
if (ip_int & mask_int) == (subnet_int & mask_int):
|
|
return country
|
|
|
|
return 'XX'
|
|
|
|
|
|
def get_ip_info(ip: str) -> Dict[str, Any]:
|
|
"""
|
|
Holt IP-Informationen mit konsistenter Country-Detection.
|
|
- Country: aus ipdeny.com Cache (konsistent mit PHP!)
|
|
- ISP/ASN: aus ip-api.com (optional, mit Rate-Limiting)
|
|
|
|
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
|
|
|
|
# Country aus ipdeny.com Cache (konsistent mit PHP!)
|
|
country_code = get_country_for_ip_cached(ip)
|
|
|
|
# Basis-Ergebnis mit Country aus lokalem Cache
|
|
result = {
|
|
'country': country_code if country_code != 'XX' else 'Unknown',
|
|
'countryCode': country_code,
|
|
'isp': '',
|
|
'org': '',
|
|
'as': '',
|
|
'_cached_at': time.time()
|
|
}
|
|
|
|
# Optional: ISP/ASN von ip-api.com holen (mit Rate-Limiting)
|
|
now = time.time()
|
|
if now - _ip_api_last_request >= 60:
|
|
_ip_api_last_request = now
|
|
_ip_api_request_count = 0
|
|
|
|
if _ip_api_request_count < IP_API_RATE_LIMIT:
|
|
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=3) as response:
|
|
data = json.loads(response.read().decode('utf-8'))
|
|
|
|
_ip_api_request_count += 1
|
|
|
|
if data.get('status') == 'success':
|
|
result['isp'] = data.get('isp', '')
|
|
result['org'] = data.get('org', '')
|
|
result['as'] = data.get('as', '')
|
|
# Country NUR überschreiben wenn lokaler Cache 'XX' war
|
|
if country_code == 'XX' and data.get('countryCode'):
|
|
result['country'] = data.get('country', 'Unknown')
|
|
result['countryCode'] = data.get('countryCode', 'XX')
|
|
except Exception as e:
|
|
logger.debug(f"IP-API Request fehlgeschlagen für {ip}: {e}")
|
|
|
|
_ip_info_cache[ip] = result
|
|
return result
|
|
|
|
|
|
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
|
|
# =============================================================================
|
|
def ip_in_cidr(ip_str: str, cidr_str: str) -> bool:
|
|
"""Prüft ob eine IP in einem CIDR-Netz liegt."""
|
|
try:
|
|
ip = ipaddress.ip_address(ip_str)
|
|
network = ipaddress.ip_network(cidr_str, strict=False)
|
|
return ip in network
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def detect_bot(user_agent: str, ip: str = None) -> str:
|
|
"""
|
|
Erkennt Bots anhand des User-Agents und/oder der IP.
|
|
IP-basierte Erkennung hat Priorität (für getarnte Bots).
|
|
Gibt den Anzeigenamen zurück.
|
|
"""
|
|
# SCHRITT 1: IP-basierte Erkennung (höchste Priorität)
|
|
if ip:
|
|
for bot_name, ip_ranges in BOT_IP_RANGES.items():
|
|
for cidr in ip_ranges:
|
|
if ip_in_cidr(ip, cidr):
|
|
return bot_name
|
|
|
|
if not user_agent or user_agent == 'Unknown':
|
|
return 'Unbekannt'
|
|
|
|
# SCHRITT 2: Spezifische User-Agent Patterns
|
|
for bot_name, pattern in BOT_PATTERNS.items():
|
|
if re.search(pattern, user_agent, re.IGNORECASE):
|
|
return bot_name
|
|
|
|
# SCHRITT 3: Generische Patterns als Fallback
|
|
ua_lower = user_agent.lower()
|
|
for pattern in GENERIC_BOT_PATTERNS:
|
|
if pattern in ua_lower:
|
|
return f'Bot ({pattern})'
|
|
|
|
return 'Unbekannt'
|
|
|
|
|
|
# =============================================================================
|
|
# LINK11 CHECK
|
|
# =============================================================================
|
|
def check_link11(domain: str) -> Dict[str, Any]:
|
|
"""Prüft ob eine Domain hinter Link11 ist."""
|
|
global DNS_CACHE
|
|
|
|
if domain in DNS_CACHE:
|
|
return DNS_CACHE[domain]
|
|
|
|
try:
|
|
ip = socket.gethostbyname(domain)
|
|
is_link11 = (ip == LINK11_IP)
|
|
DNS_CACHE[domain] = {'is_link11': is_link11, 'ip': ip}
|
|
return DNS_CACHE[domain]
|
|
except socket.gaierror:
|
|
DNS_CACHE[domain] = {'is_link11': False, 'ip': 'N/A'}
|
|
return DNS_CACHE[domain]
|
|
|
|
|
|
def get_country_preset(preset_name: str) -> List[str]:
|
|
"""Gibt die Länder eines Presets zurück."""
|
|
if preset_name == "eu_plus":
|
|
return COUNTRY_PRESET_EU_PLUS
|
|
elif preset_name == "dach":
|
|
return COUNTRY_PRESET_DACH
|
|
return []
|
|
|
|
|
|
# =============================================================================
|
|
# PHP TEMPLATE GENERATORS
|
|
# =============================================================================
|
|
def generate_php_unlimited_countries(countries: List[str]) -> str:
|
|
"""Generiert PHP-Array der unlimitierten Länder."""
|
|
return ", ".join([f"'{c.lower()}'" for c in countries])
|
|
|
|
|
|
def generate_php_bot_patterns() -> str:
|
|
"""Generiert PHP-Array der Bot-Patterns."""
|
|
patterns = []
|
|
for bot_name, pattern in BOT_PATTERNS.items():
|
|
escaped_pattern = pattern.replace("'", "\\'").replace("/", "\\/")
|
|
safe_bot_name = bot_name.replace("'", "\\'")
|
|
patterns.append(f"'{safe_bot_name}' => '/{escaped_pattern}/i'")
|
|
return ",\n ".join(patterns)
|
|
|
|
|
|
def generate_php_generic_patterns() -> str:
|
|
"""Generiert PHP-Array der generischen Patterns."""
|
|
patterns = [f"'{pattern}'" for pattern in GENERIC_BOT_PATTERNS]
|
|
return ", ".join(patterns)
|
|
|
|
|
|
def generate_php_bot_ip_ranges() -> str:
|
|
"""Generiert PHP-Array für IP-basierte Bot-Erkennung."""
|
|
lines = []
|
|
for bot_name, ip_ranges in BOT_IP_RANGES.items():
|
|
safe_bot_name = bot_name.replace("'", "\\'")
|
|
ranges_str = ", ".join([f"'{r}'" for r in ip_ranges])
|
|
lines.append(f"'{safe_bot_name}' => [{ranges_str}]")
|
|
return ",\n ".join(lines)
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# PHP TEMPLATES - BOT RATE-LIMITING
|
|
# =============================================================================
|
|
BOT_SCRIPT_TEMPLATE = '''<?php
|
|
/**
|
|
* Bot Rate-Limiting Script - By Bot-Type
|
|
* Valid until: {expiry_date}
|
|
* Rate-limits known bots/crawlers BY BOT-TYPE (not by IP)
|
|
* All requests from the same bot-type share ONE counter
|
|
* Includes IP-based detection for bots that disguise their User-Agent
|
|
* Rate-Limit: {rate_limit} req/min, Ban: {ban_duration_min} min
|
|
*/
|
|
|
|
$expiry_date = strtotime('{expiry_timestamp}');
|
|
if (time() > $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 for this bot-type
|
|
$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'] ?? '';
|
|
$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;
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
$ip_long = ip2long($ip);
|
|
$subnet_long = ip2long($subnet);
|
|
if ($ip_long === false || $subnet_long === false) return false;
|
|
$mask_long = -1 << (32 - (int)$mask);
|
|
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}
|
|
];
|
|
|
|
// === Bot Detection Patterns (User-Agent) ===
|
|
$bot_patterns = [
|
|
{bot_patterns}
|
|
];
|
|
|
|
$generic_patterns = [{generic_patterns}];
|
|
|
|
// === STEP 0: IP-basierte Bot-Erkennung (höchste Priorität) ===
|
|
$detected_bot = null;
|
|
|
|
if (!empty($visitor_ip)) {{
|
|
foreach ($bot_ip_ranges as $bot_name => $ip_ranges) {{
|
|
foreach ($ip_ranges as $cidr) {{
|
|
if (ip_in_cidr($visitor_ip, $cidr)) {{
|
|
$detected_bot = $bot_name;
|
|
break 2; // Aus beiden Schleifen ausbrechen
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
// === STEP 1: User-Agent-basierte Erkennung (falls IP nicht erkannt) ===
|
|
if ($detected_bot === null && !empty($user_agent)) {{
|
|
// Check specific patterns first
|
|
foreach ($bot_patterns as $bot_name => $pattern) {{
|
|
if (preg_match($pattern, $user_agent)) {{
|
|
$detected_bot = $bot_name;
|
|
break;
|
|
}}
|
|
}}
|
|
|
|
// Check generic patterns as fallback
|
|
if ($detected_bot === null) {{
|
|
$ua_lower = strtolower($user_agent);
|
|
foreach ($generic_patterns as $pattern) {{
|
|
if (strpos($ua_lower, $pattern) !== false) {{
|
|
$detected_bot = "Bot ($pattern)";
|
|
break;
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
// Not a bot - allow through without any rate limiting
|
|
if ($detected_bot === null) return;
|
|
|
|
// === Create hash based on BOT-TYPE only (not IP!) ===
|
|
$bot_hash = md5($detected_bot);
|
|
|
|
// === STEP 2: Check if this bot-type is banned ===
|
|
$ban_file = "$bans_dir/$bot_hash.ban";
|
|
if (file_exists($ban_file)) {{
|
|
$ban_content = @file_get_contents($ban_file);
|
|
$ban_parts = explode('|', $ban_content, 2);
|
|
$ban_until = (int)$ban_parts[0];
|
|
if (time() < $ban_until) {{
|
|
// Bot-type is banned - log and block
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$remaining = $ban_until - time();
|
|
@file_put_contents($log_file, "[$timestamp] BLOCKED (banned): $detected_bot | IP: $visitor_ip | Remaining: {{$remaining}}s\\n", FILE_APPEND | LOCK_EX);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $remaining);
|
|
exit;
|
|
}}
|
|
// Ban expired - remove file
|
|
@unlink($ban_file);
|
|
}}
|
|
|
|
// === STEP 3: Rate-Limit Check for this bot-type ===
|
|
$count_file = "$counts_dir/$bot_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 ===
|
|
if ($count > $rate_limit) {{
|
|
// Create ban for this bot-type (store timestamp|botname)
|
|
$ban_until = $current_time + $ban_duration;
|
|
@file_put_contents($ban_file, "$ban_until|$detected_bot", LOCK_EX);
|
|
|
|
// Log the ban
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$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 | Total requests: $count\\n", FILE_APPEND | LOCK_EX);
|
|
|
|
// Block this request
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $ban_duration);
|
|
exit;
|
|
}}
|
|
|
|
// === STEP 5: Under limit - log and ALLOW through ===
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|
@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();
|
|
foreach (glob("$bans_dir/*.ban") as $f) {{
|
|
$ban_content = @file_get_contents($f);
|
|
$ban_parts = explode('|', $ban_content, 2);
|
|
$ban_time = (int)$ban_parts[0];
|
|
if ($now > $ban_time) @unlink($f);
|
|
}}
|
|
foreach (glob("$counts_dir/*.count") as $f) {{
|
|
if ($now - filemtime($f) > $window_size * 2) @unlink($f);
|
|
}}
|
|
}}
|
|
|
|
// Bot is under rate limit - ALLOW through (no exit, no 403)
|
|
return;
|
|
'''
|
|
|
|
# =============================================================================
|
|
# PHP TEMPLATES - MONITOR (Full Monitoring without Blocking)
|
|
# =============================================================================
|
|
MONITOR_TEMPLATE = '''<?php
|
|
/**
|
|
* JTL-WAFi Monitor Script - Full Monitoring without Blocking
|
|
* Valid until: {expiry_date}
|
|
* Monitors ALL traffic: Bots AND human visitors by country
|
|
* NO rate-limiting, NO blocking - pure observation mode
|
|
*/
|
|
|
|
$expiry_date = strtotime('{expiry_timestamp}');
|
|
if (time() > $expiry_date) return;
|
|
|
|
$log_file = __DIR__ . '/{log_file}';
|
|
$ratelimit_dir = __DIR__ . '/{ratelimit_dir}';
|
|
$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;
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
$ip_long = ip2long($ip);
|
|
$subnet_long = ip2long($subnet);
|
|
if ($ip_long === false || $subnet_long === false) return false;
|
|
$mask_long = -1 << (32 - (int)$mask);
|
|
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 (local cache only - no HTTP during page load!) ===
|
|
function get_country_for_ip($ip, $country_cache_dir) {{
|
|
// Common countries to check (ordered by traffic likelihood)
|
|
$countries = ['de', 'at', 'ch', 'us', 'gb', 'fr', 'nl', 'it', 'es', 'pl', 'be', 'se', 'no', 'dk', 'fi',
|
|
'ru', 'cn', 'jp', 'kr', 'in', 'br', 'au', 'ca', 'ua', 'cz', 'pt', 'ie', 'gr', 'hu', 'ro',
|
|
'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'lu', 'mt', 'cy', 'tr', 'il', 'za', 'mx', 'ar',
|
|
'cl', 'co', 'pe', 've', 'eg', 'ng', 'ke', 'ma', 'tn', 'pk', 'bd', 'vn', 'th', 'my', 'sg',
|
|
'id', 'ph', 'tw', 'hk', 'nz', 'ae', 'sa', 'qa', 'kw', 'bh', 'om', 'ir', 'iq'];
|
|
|
|
$ip_long = ip2long($ip);
|
|
if ($ip_long === false) return 'XX';
|
|
|
|
foreach ($countries as $country) {{
|
|
$cache_file = "$country_cache_dir/$country.ranges";
|
|
|
|
// Only read from cache - NO HTTP requests during page load!
|
|
if (!file_exists($cache_file)) continue;
|
|
|
|
$ranges = @unserialize(@file_get_contents($cache_file));
|
|
if (empty($ranges) || !is_array($ranges)) continue;
|
|
|
|
foreach ($ranges as $cidr) {{
|
|
if (strpos($cidr, '/') === false) continue;
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
$subnet_long = ip2long($subnet);
|
|
if ($subnet_long === false) continue;
|
|
$mask_long = -1 << (32 - (int)$mask);
|
|
if (($ip_long & $mask_long) === ($subnet_long & $mask_long)) {{
|
|
return strtoupper($country);
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
return 'XX'; // Unknown (cache not loaded or IP not found)
|
|
}}
|
|
|
|
// === Bot IP Ranges (für getarnte Bots) ===
|
|
$bot_ip_ranges = [
|
|
{bot_ip_ranges}
|
|
];
|
|
|
|
// === Bot Detection Patterns (User-Agent) ===
|
|
$bot_patterns = [
|
|
{bot_patterns}
|
|
];
|
|
|
|
$generic_patterns = [{generic_patterns}];
|
|
|
|
// === Detect Bot ===
|
|
$detected_bot = null;
|
|
|
|
if (!empty($visitor_ip)) {{
|
|
foreach ($bot_ip_ranges as $bot_name => $ip_ranges) {{
|
|
foreach ($ip_ranges as $cidr) {{
|
|
if (ip_in_cidr($visitor_ip, $cidr)) {{
|
|
$detected_bot = $bot_name;
|
|
break 2;
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
if ($detected_bot === null && !empty($user_agent)) {{
|
|
foreach ($bot_patterns as $bot_name => $pattern) {{
|
|
if (preg_match($pattern, $user_agent)) {{
|
|
$detected_bot = $bot_name;
|
|
break;
|
|
}}
|
|
}}
|
|
|
|
if ($detected_bot === null) {{
|
|
$ua_lower = strtolower($user_agent);
|
|
foreach ($generic_patterns as $pattern) {{
|
|
if (strpos($ua_lower, $pattern) !== false) {{
|
|
$detected_bot = "Bot ($pattern)";
|
|
break;
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
// === Detect Country ===
|
|
$country = 'XX';
|
|
if (!empty($visitor_ip) && filter_var($visitor_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {{
|
|
$country = get_country_for_ip($visitor_ip, $country_cache_dir);
|
|
}}
|
|
|
|
// === Log Entry ===
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|
|
|
if ($detected_bot !== null) {{
|
|
@file_put_contents($log_file, "[$timestamp] MONITOR_BOT: $detected_bot | IP: $visitor_ip | Country: $country | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
}} else {{
|
|
@file_put_contents($log_file, "[$timestamp] MONITOR_HUMAN | IP: $visitor_ip | Country: $country | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
}}
|
|
|
|
// === ALLOW REQUEST THROUGH - NO BLOCKING ===
|
|
return;
|
|
'''
|
|
|
|
|
|
# =============================================================================
|
|
# PHP TEMPLATES - COMBINED (Bot + Country Rate-Limiting)
|
|
# =============================================================================
|
|
COMBINED_SCRIPT_TEMPLATE = '''<?php
|
|
/**
|
|
* JTL-WAFi Combined Script - Bot + Country Rate-Limiting
|
|
* Valid until: {expiry_date}
|
|
*
|
|
* Configuration:
|
|
* - Bot Mode: {bot_mode_enabled} (Limit: {bot_rate_limit}/min, Ban: {bot_ban_duration_min}min)
|
|
* - Country Mode: {country_mode_enabled} (Limit: {country_rate_limit}/min, Ban: {country_ban_duration_min}min)
|
|
* - Unlimited Countries: {unlimited_countries_list}
|
|
*
|
|
* Logic:
|
|
* 1. Detect bot (User-Agent + IP)
|
|
* 2. Detect country (GeoIP)
|
|
* 3. If bot detected AND bot_mode -> check bot rate-limit
|
|
* 4. If human AND country_mode AND country NOT unlimited -> check country rate-limit
|
|
* 5. Always log (for stats)
|
|
*/
|
|
|
|
$expiry_date = strtotime('{expiry_timestamp}');
|
|
if (time() > $expiry_date) return;
|
|
|
|
// === Configuration ===
|
|
$bot_mode = {bot_mode}; // true oder false
|
|
$country_mode = {country_mode}; // true oder false
|
|
$bot_rate_limit = {bot_rate_limit};
|
|
$bot_ban_duration = {bot_ban_duration};
|
|
$country_rate_limit = {country_rate_limit};
|
|
$country_ban_duration = {country_ban_duration};
|
|
$unlimited_countries = [{unlimited_countries}]; // z.B. 'de', 'at', 'ch', ...
|
|
$window_size = 60;
|
|
$cleanup_probability = 100;
|
|
|
|
// === Paths ===
|
|
$log_file = __DIR__ . '/{log_file}';
|
|
$ratelimit_dir = __DIR__ . '/{ratelimit_dir}';
|
|
$bot_bans_dir = $ratelimit_dir . '/bans';
|
|
$bot_counts_dir = $ratelimit_dir . '/counts';
|
|
$country_bans_dir = $ratelimit_dir . '/country_bans';
|
|
$country_counts_dir = $ratelimit_dir . '/country_counts';
|
|
$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); // Always needed for country detection
|
|
if ($bot_mode) {{
|
|
if (!is_dir($bot_bans_dir)) @mkdir($bot_bans_dir, 0777, true);
|
|
if (!is_dir($bot_counts_dir)) @mkdir($bot_counts_dir, 0777, true);
|
|
}}
|
|
if ($country_mode) {{
|
|
if (!is_dir($country_bans_dir)) @mkdir($country_bans_dir, 0777, true);
|
|
if (!is_dir($country_counts_dir)) @mkdir($country_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;
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
$ip_long = ip2long($ip);
|
|
$subnet_long = ip2long($subnet);
|
|
if ($ip_long === false || $subnet_long === false) return false;
|
|
$mask_long = -1 << (32 - (int)$mask);
|
|
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 (local cache only - no HTTP during page load!) ===
|
|
function get_country_for_ip($ip, $country_cache_dir) {{
|
|
// Common countries to check (ordered by traffic likelihood)
|
|
$countries = ['de', 'at', 'ch', 'us', 'gb', 'fr', 'nl', 'it', 'es', 'pl', 'be', 'se', 'no', 'dk', 'fi',
|
|
'ru', 'cn', 'jp', 'kr', 'in', 'br', 'au', 'ca', 'ua', 'cz', 'pt', 'ie', 'gr', 'hu', 'ro',
|
|
'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'lu', 'mt', 'cy', 'tr', 'il', 'za', 'mx', 'ar',
|
|
'cl', 'co', 'pe', 've', 'eg', 'ng', 'ke', 'ma', 'tn', 'pk', 'bd', 'vn', 'th', 'my', 'sg',
|
|
'id', 'ph', 'tw', 'hk', 'nz', 'ae', 'sa', 'qa', 'kw', 'bh', 'om', 'ir', 'iq'];
|
|
|
|
$ip_long = ip2long($ip);
|
|
if ($ip_long === false) return 'XX';
|
|
|
|
foreach ($countries as $country) {{
|
|
$cache_file = "$country_cache_dir/$country.ranges";
|
|
|
|
// Only read from cache - NO HTTP requests during page load!
|
|
if (!file_exists($cache_file)) continue;
|
|
|
|
$ranges = @unserialize(@file_get_contents($cache_file));
|
|
if (empty($ranges) || !is_array($ranges)) continue;
|
|
|
|
foreach ($ranges as $cidr) {{
|
|
if (strpos($cidr, '/') === false) continue;
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
$subnet_long = ip2long($subnet);
|
|
if ($subnet_long === false) continue;
|
|
$mask_long = -1 << (32 - (int)$mask);
|
|
if (($ip_long & $mask_long) === ($subnet_long & $mask_long)) {{
|
|
return strtoupper($country);
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
return 'XX'; // Unknown (cache not loaded or IP not found)
|
|
}}
|
|
|
|
// === Bot IP Ranges ===
|
|
$bot_ip_ranges = [
|
|
{bot_ip_ranges}
|
|
];
|
|
|
|
// === Bot Detection Patterns ===
|
|
$bot_patterns = [
|
|
{bot_patterns}
|
|
];
|
|
|
|
$generic_patterns = [{generic_patterns}];
|
|
|
|
// === STEP 1: Detect Bot ===
|
|
$detected_bot = null;
|
|
|
|
if (!empty($visitor_ip)) {{
|
|
foreach ($bot_ip_ranges as $bot_name => $ip_ranges) {{
|
|
foreach ($ip_ranges as $cidr) {{
|
|
if (ip_in_cidr($visitor_ip, $cidr)) {{
|
|
$detected_bot = $bot_name;
|
|
break 2;
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
if ($detected_bot === null && !empty($user_agent)) {{
|
|
foreach ($bot_patterns as $bot_name => $pattern) {{
|
|
if (preg_match($pattern, $user_agent)) {{
|
|
$detected_bot = $bot_name;
|
|
break;
|
|
}}
|
|
}}
|
|
|
|
if ($detected_bot === null) {{
|
|
$ua_lower = strtolower($user_agent);
|
|
foreach ($generic_patterns as $pattern) {{
|
|
if (strpos($ua_lower, $pattern) !== false) {{
|
|
$detected_bot = "Bot ($pattern)";
|
|
break;
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
// === STEP 2: Detect Country (always - for logging and stats) ===
|
|
$country = 'XX';
|
|
if (!empty($visitor_ip) && filter_var($visitor_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {{
|
|
$country = get_country_for_ip($visitor_ip, $country_cache_dir);
|
|
}}
|
|
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|
$current_time = time();
|
|
|
|
// === STEP 3: Bot Rate-Limiting (if bot detected AND bot_mode active) ===
|
|
if ($detected_bot !== null && $bot_mode) {{
|
|
$bot_hash = md5($detected_bot);
|
|
$ban_file = "$bot_bans_dir/$bot_hash.ban";
|
|
|
|
// Check if bot is banned
|
|
if (file_exists($ban_file)) {{
|
|
$ban_content = @file_get_contents($ban_file);
|
|
$ban_parts = explode('|', $ban_content, 2);
|
|
$ban_until = (int)$ban_parts[0];
|
|
if ($current_time < $ban_until) {{
|
|
$remaining = $ban_until - $current_time;
|
|
@file_put_contents($log_file, "[$timestamp] BLOCKED_BOT: $detected_bot | IP: $visitor_ip | Country: $country | Remaining: {{$remaining}}s\\n", FILE_APPEND | LOCK_EX);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $remaining);
|
|
exit;
|
|
}}
|
|
@unlink($ban_file);
|
|
}}
|
|
|
|
// Rate-limit check
|
|
$count_file = "$bot_counts_dir/$bot_hash.count";
|
|
$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) {{
|
|
$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);
|
|
}}
|
|
|
|
// Check if limit exceeded
|
|
if ($count > $bot_rate_limit) {{
|
|
$ban_until = $current_time + $bot_ban_duration;
|
|
@file_put_contents($ban_file, "$ban_until|$detected_bot", LOCK_EX);
|
|
$ban_minutes = $bot_ban_duration / 60;
|
|
@file_put_contents($log_file, "[$timestamp] BANNED_BOT: $detected_bot | IP: $visitor_ip | Country: $country | Exceeded $bot_rate_limit req/min | Ban: {{$ban_minutes}}m\\n", FILE_APPEND | LOCK_EX);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $bot_ban_duration);
|
|
exit;
|
|
}}
|
|
|
|
// Bot under limit - log and allow
|
|
@file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | Country: $country | Count: $count/$bot_rate_limit | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
|
|
// Cleanup (probabilistic)
|
|
if (rand(1, $cleanup_probability) === 1) {{
|
|
foreach (glob("$bot_bans_dir/*.ban") as $f) {{
|
|
$ban_content = @file_get_contents($f);
|
|
$ban_time = (int)explode('|', $ban_content, 2)[0];
|
|
if ($current_time > $ban_time) @unlink($f);
|
|
}}
|
|
foreach (glob("$bot_counts_dir/*.count") as $f) {{
|
|
if ($current_time - filemtime($f) > $window_size * 2) @unlink($f);
|
|
}}
|
|
}}
|
|
|
|
return; // Bot handled, don't check country
|
|
}}
|
|
|
|
// === STEP 4: Country Rate-Limiting (if human AND country_mode active) ===
|
|
if ($detected_bot === null && $country_mode) {{
|
|
$country_lower = strtolower($country);
|
|
|
|
// Check if country is unlimited (still log but don't rate-limit)
|
|
if (in_array($country_lower, $unlimited_countries)) {{
|
|
@file_put_contents($log_file, "[$timestamp] HUMAN | IP: $visitor_ip | Country: $country | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
return; // Unlimited country - allow through
|
|
}}
|
|
|
|
// Country needs rate-limiting
|
|
$ban_file = "$country_bans_dir/$country_lower.ban";
|
|
|
|
// Check if country is banned
|
|
if (file_exists($ban_file)) {{
|
|
$ban_until = (int)@file_get_contents($ban_file);
|
|
if ($current_time < $ban_until) {{
|
|
$remaining = $ban_until - $current_time;
|
|
@file_put_contents($log_file, "[$timestamp] BLOCKED_COUNTRY: $country | IP: $visitor_ip | Remaining: {{$remaining}}s\\n", FILE_APPEND | LOCK_EX);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $remaining);
|
|
exit;
|
|
}}
|
|
@unlink($ban_file);
|
|
}}
|
|
|
|
// Rate-limit check
|
|
$count_file = "$country_counts_dir/$country_lower.count";
|
|
$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) {{
|
|
$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);
|
|
}}
|
|
|
|
// Check if limit exceeded
|
|
if ($count > $country_rate_limit) {{
|
|
$ban_until = $current_time + $country_ban_duration;
|
|
@file_put_contents($ban_file, "$ban_until", LOCK_EX);
|
|
$ban_minutes = $country_ban_duration / 60;
|
|
@file_put_contents($log_file, "[$timestamp] BANNED_COUNTRY: $country | IP: $visitor_ip | Exceeded $country_rate_limit req/min | Ban: {{$ban_minutes}}m\\n", FILE_APPEND | LOCK_EX);
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Retry-After: ' . $country_ban_duration);
|
|
exit;
|
|
}}
|
|
|
|
// Human under limit - log and allow
|
|
@file_put_contents($log_file, "[$timestamp] HUMAN | IP: $visitor_ip | Country: $country | Count: $count/$country_rate_limit | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
|
|
// Cleanup (probabilistic)
|
|
if (rand(1, $cleanup_probability) === 1) {{
|
|
foreach (glob("$country_bans_dir/*.ban") as $f) {{
|
|
$ban_time = (int)@file_get_contents($f);
|
|
if ($current_time > $ban_time) @unlink($f);
|
|
}}
|
|
foreach (glob("$country_counts_dir/*.count") as $f) {{
|
|
if ($current_time - filemtime($f) > $window_size * 2) @unlink($f);
|
|
}}
|
|
}}
|
|
|
|
return;
|
|
}}
|
|
|
|
// === STEP 5: No rate-limiting active, just log ===
|
|
if ($detected_bot !== null) {{
|
|
@file_put_contents($log_file, "[$timestamp] BOT: $detected_bot | IP: $visitor_ip | Country: $country | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
}} else {{
|
|
@file_put_contents($log_file, "[$timestamp] HUMAN | IP: $visitor_ip | Country: $country | URI: $uri\\n", FILE_APPEND | LOCK_EX);
|
|
}}
|
|
|
|
return;
|
|
'''
|
|
|
|
|
|
# =============================================================================
|
|
# SHOP REGISTRY FUNCTIONS
|
|
# =============================================================================
|
|
def add_shop_to_active(shop: str,
|
|
bot_mode: bool = True,
|
|
bot_rate_limit: int = None,
|
|
bot_ban_duration: int = None,
|
|
country_mode: bool = False,
|
|
country_rate_limit: int = None,
|
|
country_ban_duration: int = None,
|
|
unlimited_countries: List[str] = None,
|
|
monitor_only: bool = False):
|
|
"""
|
|
Registriert einen Shop als aktiv mit v2.5 Konfiguration.
|
|
|
|
Args:
|
|
shop: Domain des Shops
|
|
bot_mode: Bot-Rate-Limiting aktivieren
|
|
bot_rate_limit: Requests/min pro Bot-Type
|
|
bot_ban_duration: Ban-Dauer in Sekunden für Bots
|
|
country_mode: Country-Rate-Limiting aktivieren
|
|
country_rate_limit: Requests/min pro Land
|
|
country_ban_duration: Ban-Dauer in Sekunden für Länder
|
|
unlimited_countries: Liste der Länder ohne Rate-Limit
|
|
monitor_only: Nur Monitoring, kein Blocking
|
|
"""
|
|
os.makedirs(os.path.dirname(ACTIVE_SHOPS_FILE), exist_ok=True)
|
|
shops = {}
|
|
if os.path.isfile(ACTIVE_SHOPS_FILE):
|
|
try:
|
|
with open(ACTIVE_SHOPS_FILE, 'r') as f:
|
|
shops = json.load(f)
|
|
except:
|
|
shops = {}
|
|
|
|
shop_data = {
|
|
"activated": datetime.now().isoformat(),
|
|
"expiry": (datetime.now() + timedelta(hours=72)).isoformat(),
|
|
"bot_mode": bot_mode,
|
|
"country_mode": country_mode,
|
|
"monitor_only": monitor_only
|
|
}
|
|
|
|
if bot_rate_limit is not None:
|
|
shop_data["bot_rate_limit"] = bot_rate_limit
|
|
if bot_ban_duration is not None:
|
|
shop_data["bot_ban_duration"] = bot_ban_duration
|
|
if country_rate_limit is not None:
|
|
shop_data["country_rate_limit"] = country_rate_limit
|
|
if country_ban_duration is not None:
|
|
shop_data["country_ban_duration"] = country_ban_duration
|
|
if unlimited_countries:
|
|
shop_data["unlimited_countries"] = unlimited_countries
|
|
|
|
shops[shop] = shop_data
|
|
with open(ACTIVE_SHOPS_FILE, 'w') as f:
|
|
json.dump(shops, f, indent=2)
|
|
|
|
|
|
def remove_shop_from_active(shop: str):
|
|
"""Entfernt einen Shop aus der aktiven Liste."""
|
|
if not os.path.isfile(ACTIVE_SHOPS_FILE):
|
|
return
|
|
try:
|
|
with open(ACTIVE_SHOPS_FILE, 'r') as f:
|
|
shops = json.load(f)
|
|
if shop in shops:
|
|
del shops[shop]
|
|
with open(ACTIVE_SHOPS_FILE, 'w') as f:
|
|
json.dump(shops, f, indent=2)
|
|
except:
|
|
pass
|
|
|
|
|
|
def get_shop_config(shop: str) -> Dict[str, Any]:
|
|
"""Gibt die komplette Konfiguration eines Shops zurück."""
|
|
if not os.path.isfile(ACTIVE_SHOPS_FILE):
|
|
return {}
|
|
try:
|
|
with open(ACTIVE_SHOPS_FILE, 'r') as f:
|
|
return json.load(f).get(shop, {})
|
|
except:
|
|
return {}
|
|
|
|
|
|
def get_shop_bot_config(shop: str) -> tuple:
|
|
"""Gibt Bot-Rate-Limit Config zurück (enabled, rate_limit, ban_duration)."""
|
|
config = get_shop_config(shop)
|
|
return (
|
|
config.get("bot_mode", False),
|
|
config.get("bot_rate_limit"),
|
|
config.get("bot_ban_duration")
|
|
)
|
|
|
|
|
|
def get_shop_country_config(shop: str) -> tuple:
|
|
"""Gibt Country-Rate-Limit Config zurück (enabled, rate_limit, ban_duration, unlimited)."""
|
|
config = get_shop_config(shop)
|
|
return (
|
|
config.get("country_mode", False),
|
|
config.get("country_rate_limit"),
|
|
config.get("country_ban_duration"),
|
|
config.get("unlimited_countries", [])
|
|
)
|
|
|
|
|
|
def get_shop_monitor_mode(shop: str) -> bool:
|
|
"""Gibt zurück ob ein Shop im Monitor-Only Modus ist."""
|
|
return get_shop_config(shop).get("monitor_only", False)
|
|
|
|
|
|
def get_shop_activation_time(shop: str) -> Optional[datetime]:
|
|
"""Gibt die Aktivierungszeit eines Shops zurück."""
|
|
if not os.path.isfile(ACTIVE_SHOPS_FILE):
|
|
return None
|
|
try:
|
|
with open(ACTIVE_SHOPS_FILE, 'r') as f:
|
|
activated_str = json.load(f).get(shop, {}).get("activated")
|
|
return datetime.fromisoformat(activated_str) if activated_str else None
|
|
except:
|
|
return None
|
|
|
|
|
|
def get_available_shops() -> List[str]:
|
|
"""Gibt Liste aller verfügbaren Shops zurück."""
|
|
shops = []
|
|
if not os.path.exists(VHOSTS_DIR):
|
|
return shops
|
|
|
|
try:
|
|
for entry in os.listdir(VHOSTS_DIR):
|
|
shop_path = os.path.join(VHOSTS_DIR, entry)
|
|
if os.path.isdir(shop_path) and entry not in ['chroot', 'system', 'default']:
|
|
httpdocs = os.path.join(shop_path, 'httpdocs')
|
|
if os.path.isdir(httpdocs) and os.path.isfile(os.path.join(httpdocs, 'index.php')):
|
|
shops.append(entry)
|
|
except:
|
|
pass
|
|
|
|
return sorted(shops)
|
|
|
|
|
|
def get_active_shops() -> List[str]:
|
|
"""Gibt Liste aller aktiven Shops zurück."""
|
|
active = []
|
|
for shop in get_available_shops():
|
|
httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs')
|
|
if os.path.isfile(os.path.join(httpdocs, BLOCKING_FILE)) or \
|
|
os.path.isfile(os.path.join(httpdocs, f'index.php{BACKUP_SUFFIX}')):
|
|
active.append(shop)
|
|
return active
|
|
|
|
|
|
# =============================================================================
|
|
# COUNTRY IP RANGES DOWNLOAD (ipdeny.com)
|
|
# =============================================================================
|
|
COUNTRY_CODES = [
|
|
'de', 'at', 'ch', 'us', 'gb', 'fr', 'nl', 'it', 'es', 'pl', 'be', 'se', 'no', 'dk', 'fi',
|
|
'ru', 'cn', 'jp', 'kr', 'in', 'br', 'au', 'ca', 'ua', 'cz', 'pt', 'ie', 'gr', 'hu', 'ro',
|
|
'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'lu', 'mt', 'cy', 'tr', 'il', 'za', 'mx', 'ar',
|
|
'cl', 'co', 'pe', 've', 'eg', 'ng', 'ke', 'ma', 'tn', 'pk', 'bd', 'vn', 'th', 'my', 'sg',
|
|
'id', 'ph', 'tw', 'hk', 'nz', 'ae', 'sa', 'qa', 'kw', 'bh', 'om', 'ir', 'iq'
|
|
]
|
|
|
|
|
|
def download_country_ranges(force: bool = False) -> int:
|
|
"""
|
|
Lädt IP-Ranges für alle Länder von ipdeny.com herunter.
|
|
Speichert NUR in den GLOBALEN Cache (world-readable für PHP via Symlink).
|
|
|
|
Args:
|
|
force: Cache ignorieren und neu laden
|
|
|
|
Returns:
|
|
Anzahl der erfolgreich geladenen Länder
|
|
"""
|
|
global _country_ranges_cache, _country_cache_loaded
|
|
|
|
# Globales Cache-Verzeichnis erstellen (world-readable)
|
|
os.makedirs(GLOBAL_COUNTRY_CACHE_DIR, exist_ok=True)
|
|
os.chmod(GLOBAL_COUNTRY_CACHE_DIR, 0o755)
|
|
|
|
downloaded = 0
|
|
|
|
for country in COUNTRY_CODES:
|
|
global_cache_file = os.path.join(GLOBAL_COUNTRY_CACHE_DIR, f"{country}.ranges")
|
|
|
|
# Prüfe ob globaler Cache existiert und aktuell ist (24h)
|
|
if not force and os.path.isfile(global_cache_file):
|
|
try:
|
|
if time.time() - os.path.getmtime(global_cache_file) < 86400:
|
|
downloaded += 1
|
|
continue
|
|
except:
|
|
pass
|
|
|
|
# Download von ipdeny.com
|
|
url = f"https://www.ipdeny.com/ipblocks/data/aggregated/{country}-aggregated.zone"
|
|
try:
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'JTL-WAFi/3.1'})
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
|
content = response.read().decode('utf-8')
|
|
|
|
ranges = []
|
|
for line in content.strip().split('\n'):
|
|
line = line.strip()
|
|
if line and '/' in line:
|
|
ranges.append(line)
|
|
|
|
if len(ranges) > 100: # Sanity check
|
|
# PHP serialize format (for PHP compatibility)
|
|
php_serialized = f'a:{len(ranges)}:{{' + ''.join(
|
|
f'i:{i};s:{len(r)}:"{r}";' for i, r in enumerate(ranges)
|
|
) + '}'
|
|
|
|
# In globalen Cache speichern (world-readable)
|
|
with open(global_cache_file, 'w') as f:
|
|
f.write(php_serialized)
|
|
os.chmod(global_cache_file, 0o644)
|
|
|
|
downloaded += 1
|
|
logger.debug(f"Country ranges geladen: {country.upper()} ({len(ranges)} ranges)")
|
|
except Exception as e:
|
|
logger.debug(f"Fehler beim Laden von {country}: {e}")
|
|
|
|
# Globalen Cache neu laden
|
|
_country_cache_loaded = False
|
|
_load_global_country_cache()
|
|
|
|
return downloaded
|
|
|
|
|
|
def download_country_ranges_async():
|
|
"""Lädt Country-Ranges im Hintergrund in den globalen Cache."""
|
|
def _download():
|
|
count = download_country_ranges(force=False)
|
|
logger.info(f"Country IP-Ranges geladen: {count}/{len(COUNTRY_CODES)} Länder")
|
|
|
|
thread = threading.Thread(target=_download, daemon=True)
|
|
thread.start()
|
|
|
|
|
|
def copy_country_cache_to_shop(ratelimit_path: str) -> int:
|
|
"""
|
|
Kopiert Country-Cache vom globalen Verzeichnis in den Shop.
|
|
(Symlinks funktionieren nicht wegen PHP open_basedir Restriction)
|
|
|
|
Args:
|
|
ratelimit_path: Pfad zum jtl-wafi_ratelimit Verzeichnis des Shops
|
|
|
|
Returns:
|
|
Anzahl der kopierten Dateien
|
|
"""
|
|
shop_cache_dir = os.path.join(ratelimit_path, 'country_cache')
|
|
|
|
try:
|
|
# Shop-Cache Verzeichnis erstellen
|
|
os.makedirs(shop_cache_dir, exist_ok=True)
|
|
|
|
# Falls Symlink existiert (von alter Version), entfernen
|
|
if os.path.islink(shop_cache_dir):
|
|
os.remove(shop_cache_dir)
|
|
os.makedirs(shop_cache_dir, exist_ok=True)
|
|
|
|
# Prüfe ob globaler Cache existiert
|
|
if not os.path.isdir(GLOBAL_COUNTRY_CACHE_DIR):
|
|
logger.debug(f"Globaler Country-Cache nicht vorhanden: {GLOBAL_COUNTRY_CACHE_DIR}")
|
|
return 0
|
|
|
|
copied = 0
|
|
for filename in os.listdir(GLOBAL_COUNTRY_CACHE_DIR):
|
|
if filename.endswith('.ranges'):
|
|
src = os.path.join(GLOBAL_COUNTRY_CACHE_DIR, filename)
|
|
dst = os.path.join(shop_cache_dir, filename)
|
|
|
|
# Nur kopieren wenn Quelle neuer ist oder Ziel nicht existiert
|
|
if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst):
|
|
shutil.copy2(src, dst)
|
|
copied += 1
|
|
|
|
if copied > 0:
|
|
logger.debug(f"Country-Cache kopiert: {copied} Dateien nach {shop_cache_dir}")
|
|
|
|
return copied
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Kopieren des Country-Cache: {e}")
|
|
return 0
|
|
|
|
|
|
def copy_country_cache_to_shop_async(ratelimit_path: str):
|
|
"""Kopiert Country-Cache im Hintergrund."""
|
|
def _copy():
|
|
# Erst sicherstellen, dass globaler Cache existiert
|
|
if not os.path.isdir(GLOBAL_COUNTRY_CACHE_DIR) or not os.listdir(GLOBAL_COUNTRY_CACHE_DIR):
|
|
download_country_ranges(force=False)
|
|
copy_country_cache_to_shop(ratelimit_path)
|
|
|
|
thread = threading.Thread(target=_copy, daemon=True)
|
|
thread.start()
|
|
|
|
|
|
# =============================================================================
|
|
# ACTIVATE / DEACTIVATE BLOCKING
|
|
# =============================================================================
|
|
def activate_blocking(shop: str, silent: bool = True,
|
|
bot_mode: bool = True,
|
|
bot_rate_limit: int = None,
|
|
bot_ban_duration: int = None,
|
|
country_mode: bool = False,
|
|
country_rate_limit: int = None,
|
|
country_ban_duration: int = None,
|
|
unlimited_countries: List[str] = None,
|
|
monitor_only: bool = False) -> bool:
|
|
"""
|
|
Aktiviert Blocking für einen Shop (v2.5).
|
|
|
|
Args:
|
|
shop: Domain des Shops
|
|
silent: Keine Konsolenausgabe
|
|
bot_mode: Bot-Rate-Limiting aktivieren
|
|
bot_rate_limit: Requests/min pro Bot-Type
|
|
bot_ban_duration: Ban-Dauer in Sekunden für Bots
|
|
country_mode: Country-Rate-Limiting aktivieren
|
|
country_rate_limit: Requests/min pro Land
|
|
country_ban_duration: Ban-Dauer in Sekunden für Länder
|
|
unlimited_countries: Liste der Länder ohne Rate-Limit
|
|
monitor_only: Nur Monitoring, kein Blocking
|
|
|
|
Returns:
|
|
True wenn erfolgreich, False sonst
|
|
"""
|
|
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)
|
|
|
|
# Defaults setzen
|
|
if bot_rate_limit is None:
|
|
bot_rate_limit = DEFAULT_RATE_LIMIT
|
|
if bot_ban_duration is None:
|
|
bot_ban_duration = DEFAULT_BAN_DURATION * 60
|
|
if country_rate_limit is None:
|
|
country_rate_limit = DEFAULT_COUNTRY_RATE_LIMIT
|
|
if country_ban_duration is None:
|
|
country_ban_duration = DEFAULT_COUNTRY_BAN_DURATION
|
|
if unlimited_countries is None:
|
|
unlimited_countries = []
|
|
|
|
# Prüfe ob bereits aktiv
|
|
if os.path.isfile(backup_php):
|
|
if not silent:
|
|
logger.warning(f"Blocking bereits aktiv für {shop}")
|
|
return False
|
|
|
|
if not os.path.isfile(index_php):
|
|
if not silent:
|
|
logger.error(f"index.php nicht gefunden für {shop}")
|
|
return False
|
|
|
|
# Ermittle Owner
|
|
uid, gid = get_most_common_owner(httpdocs)
|
|
|
|
# Log-Meldung erstellen
|
|
mode_parts = []
|
|
if monitor_only:
|
|
mode_parts.append("MONITOR")
|
|
else:
|
|
if bot_mode:
|
|
mode_parts.append(f"Bot({bot_rate_limit}/min)")
|
|
if country_mode:
|
|
mode_parts.append(f"Country({country_rate_limit}/min, {len(unlimited_countries)} unlimited)")
|
|
mode_str = " + ".join(mode_parts) if mode_parts else "None"
|
|
|
|
if not silent:
|
|
logger.info(f"Aktiviere {shop}: {mode_str}")
|
|
|
|
try:
|
|
# Step 1: Backup und PHP-Blocking aktivieren
|
|
shutil.copy2(index_php, backup_php)
|
|
set_owner(backup_php, uid, gid)
|
|
|
|
with open(index_php, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
lines = content.split('\n')
|
|
insert_line = 0
|
|
for i, line in enumerate(lines):
|
|
if 'declare(strict_types' in line:
|
|
insert_line = i + 1
|
|
break
|
|
elif '<?php' in line and insert_line == 0:
|
|
insert_line = i + 1
|
|
|
|
require_statement = f"require_once __DIR__ . '/{BLOCKING_FILE}';"
|
|
if require_statement not in content:
|
|
lines.insert(insert_line, require_statement)
|
|
with open(index_php, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(lines))
|
|
set_owner(index_php, uid, gid)
|
|
|
|
expiry = datetime.now() + timedelta(hours=72)
|
|
|
|
# Step 2: Verzeichnisstruktur erstellen
|
|
os.makedirs(ratelimit_path, mode=0o777, exist_ok=True)
|
|
os.chmod(ratelimit_path, 0o777)
|
|
|
|
# Step 3: Blocking-Script erstellen
|
|
if monitor_only:
|
|
# Monitor-Only Modus: Nur Logging, kein Blocking
|
|
script_content = MONITOR_TEMPLATE.format(
|
|
expiry_date=expiry.strftime('%Y-%m-%d %H:%M:%S CET'),
|
|
expiry_timestamp=expiry.strftime('%Y-%m-%d %H:%M:%S'),
|
|
log_file=SHOP_LOG_FILE,
|
|
ratelimit_dir=RATELIMIT_DIR,
|
|
bot_patterns=generate_php_bot_patterns(),
|
|
generic_patterns=generate_php_generic_patterns(),
|
|
bot_ip_ranges=generate_php_bot_ip_ranges()
|
|
)
|
|
if not silent:
|
|
logger.info(f"Monitor-Only Modus aktiviert")
|
|
else:
|
|
# Combined Script (Bot + Country Rate-Limiting)
|
|
script_content = COMBINED_SCRIPT_TEMPLATE.format(
|
|
expiry_date=expiry.strftime('%Y-%m-%d %H:%M:%S CET'),
|
|
expiry_timestamp=expiry.strftime('%Y-%m-%d %H:%M:%S'),
|
|
log_file=SHOP_LOG_FILE,
|
|
ratelimit_dir=RATELIMIT_DIR,
|
|
bot_patterns=generate_php_bot_patterns(),
|
|
generic_patterns=generate_php_generic_patterns(),
|
|
bot_ip_ranges=generate_php_bot_ip_ranges(),
|
|
bot_mode='true' if bot_mode else 'false',
|
|
bot_mode_enabled='Enabled' if bot_mode else 'Disabled',
|
|
bot_rate_limit=bot_rate_limit,
|
|
bot_ban_duration=bot_ban_duration,
|
|
bot_ban_duration_min=bot_ban_duration // 60,
|
|
country_mode='true' if country_mode else 'false',
|
|
country_mode_enabled='Enabled' if country_mode else 'Disabled',
|
|
country_rate_limit=country_rate_limit,
|
|
country_ban_duration=country_ban_duration,
|
|
country_ban_duration_min=country_ban_duration // 60,
|
|
unlimited_countries=generate_php_unlimited_countries(unlimited_countries),
|
|
unlimited_countries_list=', '.join(unlimited_countries) if unlimited_countries else 'None'
|
|
)
|
|
|
|
with open(blocking_file, 'w', encoding='utf-8') as f:
|
|
f.write(script_content)
|
|
set_owner(blocking_file, uid, gid)
|
|
set_owner(ratelimit_path, uid, gid, recursive=True)
|
|
|
|
# Step 4: Registrieren
|
|
add_shop_to_active(
|
|
shop=shop,
|
|
bot_mode=bot_mode,
|
|
bot_rate_limit=bot_rate_limit,
|
|
bot_ban_duration=bot_ban_duration,
|
|
country_mode=country_mode,
|
|
country_rate_limit=country_rate_limit,
|
|
country_ban_duration=country_ban_duration,
|
|
unlimited_countries=unlimited_countries,
|
|
monitor_only=monitor_only
|
|
)
|
|
|
|
# Step 5: Country-Cache vom globalen Cache kopieren (wegen PHP open_basedir)
|
|
copy_country_cache_to_shop_async(ratelimit_path)
|
|
|
|
if not silent:
|
|
logger.info(f"Blocking aktiviert für {shop}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Aktivieren von {shop}: {e}")
|
|
# Cleanup bei Fehler
|
|
if os.path.isfile(backup_php):
|
|
shutil.move(backup_php, index_php)
|
|
if os.path.isfile(blocking_file):
|
|
os.remove(blocking_file)
|
|
return False
|
|
|
|
|
|
def deactivate_blocking(shop: str, silent: bool = True) -> bool:
|
|
"""
|
|
Deaktiviert Blocking für einen Shop.
|
|
|
|
Args:
|
|
shop: Domain des Shops
|
|
silent: Keine Konsolenausgabe
|
|
|
|
Returns:
|
|
True wenn erfolgreich, False sonst
|
|
"""
|
|
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)
|
|
|
|
if not silent:
|
|
logger.info(f"Deaktiviere Blocking für: {shop}")
|
|
|
|
try:
|
|
# Step 1: Original wiederherstellen
|
|
if os.path.isfile(backup_php):
|
|
shutil.move(backup_php, index_php)
|
|
else:
|
|
# Manuell require entfernen
|
|
if os.path.isfile(index_php):
|
|
with open(index_php, 'r') as f:
|
|
content = f.read()
|
|
lines = [l for l in content.split('\n') if BLOCKING_FILE not in l]
|
|
with open(index_php, 'w') as f:
|
|
f.write('\n'.join(lines))
|
|
|
|
# Step 2: Dateien löschen
|
|
for f in [os.path.join(httpdocs, x) for x in [BLOCKING_FILE, CACHE_FILE, SHOP_LOG_FILE]]:
|
|
if os.path.isfile(f):
|
|
os.remove(f)
|
|
|
|
# Step 3: Rate-Limit Verzeichnis löschen
|
|
if os.path.isdir(ratelimit_path):
|
|
shutil.rmtree(ratelimit_path)
|
|
|
|
# Step 4: Deregistrieren
|
|
remove_shop_from_active(shop)
|
|
|
|
if not silent:
|
|
logger.info(f"✅ Blocking deaktiviert für {shop}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Deaktivieren von {shop}: {e}")
|
|
return False
|
|
|
|
|
|
# =============================================================================
|
|
# SHOP LOG STATS
|
|
# =============================================================================
|
|
def get_shop_log_stats(shop: str) -> Dict[str, Any]:
|
|
"""
|
|
Sammelt Statistiken aus dem Shop-Log (v2.5).
|
|
|
|
Returns:
|
|
Dict mit log_entries, bot_bans, country_bans, active_bot_bans,
|
|
active_country_bans, banned_bots, banned_countries, req_per_min,
|
|
unique_ips, unique_bots, unique_countries, top_bots, top_ips, top_countries
|
|
"""
|
|
httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs')
|
|
log_file = os.path.join(httpdocs, SHOP_LOG_FILE)
|
|
ratelimit_path = os.path.join(httpdocs, RATELIMIT_DIR)
|
|
|
|
stats = {
|
|
'log_entries': 0,
|
|
'bot_bans': 0,
|
|
'country_bans': 0,
|
|
'active_bot_bans': 0,
|
|
'active_country_bans': 0,
|
|
'banned_bots': [],
|
|
'banned_countries': [],
|
|
'req_per_min': 0.0,
|
|
'unique_ips': 0,
|
|
'unique_bots': 0,
|
|
'unique_countries': 0,
|
|
'top_bots': {},
|
|
'top_ips': {},
|
|
'top_countries': {},
|
|
'human_requests': 0,
|
|
'bot_requests': 0
|
|
}
|
|
|
|
ips = {}
|
|
bots = {}
|
|
countries = {}
|
|
|
|
# Log-Datei auswerten
|
|
if os.path.isfile(log_file):
|
|
try:
|
|
with open(log_file, 'r') as f:
|
|
for line in f:
|
|
stats['log_entries'] += 1
|
|
ip = None
|
|
detected_bot = None
|
|
country = None
|
|
|
|
# Bot-Bans erkennen
|
|
if 'BANNED_BOT: ' in line or 'BANNED: ' in line:
|
|
stats['bot_bans'] += 1
|
|
try:
|
|
if 'BANNED_BOT: ' in line:
|
|
detected_bot = line.split('BANNED_BOT: ')[1].split(' |')[0].strip()
|
|
else:
|
|
detected_bot = line.split('BANNED: ')[1].split(' |')[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Country-Bans erkennen
|
|
elif 'BANNED_COUNTRY: ' in line:
|
|
stats['country_bans'] += 1
|
|
try:
|
|
country = line.split('BANNED_COUNTRY: ')[1].split(' |')[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Bot-Requests erkennen
|
|
elif 'BOT: ' in line or 'BLOCKED_BOT: ' in line or 'MONITOR_BOT: ' in line:
|
|
stats['bot_requests'] += 1
|
|
try:
|
|
if 'BOT: ' in line:
|
|
detected_bot = line.split('BOT: ')[1].split(' |')[0].strip()
|
|
elif 'BLOCKED_BOT: ' in line:
|
|
detected_bot = line.split('BLOCKED_BOT: ')[1].split(' |')[0].strip()
|
|
elif 'MONITOR_BOT: ' in line:
|
|
detected_bot = line.split('MONITOR_BOT: ')[1].split(' |')[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Human-Requests erkennen
|
|
elif 'HUMAN' in line or 'BLOCKED_COUNTRY: ' in line or 'MONITOR_HUMAN' in line:
|
|
stats['human_requests'] += 1
|
|
|
|
# IP extrahieren
|
|
if 'IP: ' in line:
|
|
try:
|
|
ip = line.split('IP: ')[1].split(' |')[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Country extrahieren
|
|
if 'Country: ' in line:
|
|
try:
|
|
country = line.split('Country: ')[1].split(' |')[0].strip()
|
|
except:
|
|
pass
|
|
|
|
# Statistiken sammeln
|
|
if ip:
|
|
ips[ip] = ips.get(ip, 0) + 1
|
|
|
|
if detected_bot:
|
|
bots[detected_bot] = bots.get(detected_bot, 0) + 1
|
|
|
|
if country and country != 'XX':
|
|
countries[country] = countries.get(country, 0) + 1
|
|
|
|
except:
|
|
pass
|
|
|
|
# Aktive Bot-Bans zählen
|
|
bans_dir = os.path.join(ratelimit_path, 'bans')
|
|
if os.path.isdir(bans_dir):
|
|
now = time.time()
|
|
try:
|
|
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:
|
|
content = f.read().strip()
|
|
parts = content.split('|', 1)
|
|
ban_until = int(parts[0])
|
|
bot_name = parts[1] if len(parts) > 1 else "Unbekannt"
|
|
if now < ban_until:
|
|
stats['active_bot_bans'] += 1
|
|
stats['banned_bots'].append(bot_name)
|
|
except:
|
|
pass
|
|
except:
|
|
pass
|
|
|
|
# Aktive Country-Bans zählen
|
|
country_bans_dir = os.path.join(ratelimit_path, 'country_bans')
|
|
if os.path.isdir(country_bans_dir):
|
|
now = time.time()
|
|
try:
|
|
for ban_file in os.listdir(country_bans_dir):
|
|
if ban_file.endswith('.ban'):
|
|
try:
|
|
with open(os.path.join(country_bans_dir, ban_file), 'r') as f:
|
|
ban_until = int(f.read().strip())
|
|
if now < ban_until:
|
|
stats['active_country_bans'] += 1
|
|
country_code = ban_file.replace('.ban', '').upper()
|
|
stats['banned_countries'].append(country_code)
|
|
except:
|
|
pass
|
|
except:
|
|
pass
|
|
|
|
# Statistiken berechnen
|
|
stats['unique_ips'] = len(ips)
|
|
stats['unique_bots'] = len(bots)
|
|
stats['unique_countries'] = len(countries)
|
|
|
|
# Top Bots (max 10)
|
|
sorted_bots = sorted(bots.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
stats['top_bots'] = dict(sorted_bots)
|
|
|
|
# Top IPs (max 10)
|
|
sorted_ips = sorted(ips.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
stats['top_ips'] = dict(sorted_ips)
|
|
|
|
# Top Countries (max 10)
|
|
sorted_countries = sorted(countries.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
stats['top_countries'] = dict(sorted_countries)
|
|
|
|
# Req/min berechnen
|
|
activation_time = get_shop_activation_time(shop)
|
|
if activation_time and stats['log_entries'] > 0:
|
|
runtime_minutes = (datetime.now() - activation_time).total_seconds() / 60
|
|
if runtime_minutes > 0:
|
|
stats['req_per_min'] = round(stats['log_entries'] / runtime_minutes, 2)
|
|
stats['human_rpm'] = round(stats['human_requests'] / runtime_minutes, 2)
|
|
stats['bot_rpm'] = round(stats['bot_requests'] / runtime_minutes, 2)
|
|
else:
|
|
stats['human_rpm'] = 0.0
|
|
stats['bot_rpm'] = 0.0
|
|
else:
|
|
stats['human_rpm'] = 0.0
|
|
stats['bot_rpm'] = 0.0
|
|
|
|
return stats
|
|
|
|
|
|
# =============================================================================
|
|
# LOG WATCHER - On-Demand Live-Streaming
|
|
# =============================================================================
|
|
class LogWatcher:
|
|
"""
|
|
Überwacht Shop-Log-Dateien für Live-Streaming.
|
|
Nur aktiv wenn explizit angefordert.
|
|
"""
|
|
|
|
def __init__(self, callback: Callable[[str, str], None]):
|
|
"""
|
|
Args:
|
|
callback: Wird mit (shop, line) aufgerufen für neue Einträge
|
|
"""
|
|
self.callback = callback
|
|
self.watching: Set[str] = set()
|
|
self.file_positions: Dict[str, int] = {}
|
|
self.running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._lock = threading.Lock()
|
|
|
|
def start(self):
|
|
"""Startet den Watcher-Thread."""
|
|
if self.running:
|
|
return
|
|
|
|
self.running = True
|
|
self._thread = threading.Thread(target=self._watch_loop, daemon=True)
|
|
self._thread.start()
|
|
logger.debug("LogWatcher gestartet")
|
|
|
|
def stop(self):
|
|
"""Stoppt den Watcher-Thread."""
|
|
self.running = False
|
|
if self._thread:
|
|
self._thread.join(timeout=2)
|
|
logger.debug("LogWatcher gestoppt")
|
|
|
|
def start_watching(self, shop: str):
|
|
"""Startet Überwachung für einen Shop."""
|
|
with self._lock:
|
|
self.watching.add(shop)
|
|
log_file = os.path.join(VHOSTS_DIR, shop, 'httpdocs', SHOP_LOG_FILE)
|
|
if os.path.isfile(log_file):
|
|
# Start at end of file
|
|
self.file_positions[shop] = os.path.getsize(log_file)
|
|
else:
|
|
self.file_positions[shop] = 0
|
|
logger.debug(f"LogWatcher: Überwache {shop}")
|
|
|
|
def stop_watching(self, shop: str):
|
|
"""Stoppt Überwachung für einen Shop."""
|
|
with self._lock:
|
|
self.watching.discard(shop)
|
|
self.file_positions.pop(shop, None)
|
|
logger.debug(f"LogWatcher: Beende Überwachung von {shop}")
|
|
|
|
def _watch_loop(self):
|
|
"""Hauptschleife - prüft alle 500ms auf neue Einträge."""
|
|
while self.running:
|
|
with self._lock:
|
|
shops_to_watch = list(self.watching)
|
|
|
|
for shop in shops_to_watch:
|
|
try:
|
|
log_file = os.path.join(VHOSTS_DIR, shop, 'httpdocs', SHOP_LOG_FILE)
|
|
|
|
if not os.path.isfile(log_file):
|
|
continue
|
|
|
|
current_size = os.path.getsize(log_file)
|
|
last_pos = self.file_positions.get(shop, 0)
|
|
|
|
if current_size > last_pos:
|
|
# Neue Daten vorhanden
|
|
with open(log_file, 'r') as f:
|
|
f.seek(last_pos)
|
|
new_content = f.read()
|
|
self.file_positions[shop] = f.tell()
|
|
|
|
for line in new_content.strip().split('\n'):
|
|
if line:
|
|
try:
|
|
self.callback(shop, line)
|
|
except Exception as e:
|
|
logger.error(f"LogWatcher Callback Fehler: {e}")
|
|
|
|
elif current_size < last_pos:
|
|
# Log wurde rotiert
|
|
self.file_positions[shop] = 0
|
|
|
|
except Exception as e:
|
|
logger.error(f"LogWatcher Fehler für {shop}: {e}")
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
# =============================================================================
|
|
# LOG ROTATION
|
|
# =============================================================================
|
|
def rotate_shop_logs():
|
|
"""Rotiert alle Shop-Logs die > 10MB sind."""
|
|
for shop in get_active_shops():
|
|
try:
|
|
httpdocs = os.path.join(VHOSTS_DIR, shop, 'httpdocs')
|
|
log_file = os.path.join(httpdocs, SHOP_LOG_FILE)
|
|
|
|
if not os.path.isfile(log_file):
|
|
continue
|
|
|
|
size = os.path.getsize(log_file)
|
|
if size <= LOG_MAX_SIZE:
|
|
continue
|
|
|
|
logger.info(f"Rotiere Log für {shop} ({size / 1024 / 1024:.1f} MB)")
|
|
|
|
# Rotiere: log -> log.1 -> log.2 -> log.3 (delete)
|
|
for i in range(LOG_BACKUP_COUNT - 1, 0, -1):
|
|
src = f"{log_file}.{i}"
|
|
dst = f"{log_file}.{i + 1}"
|
|
if os.path.exists(src):
|
|
if i + 1 > LOG_BACKUP_COUNT:
|
|
os.remove(src)
|
|
else:
|
|
shutil.move(src, dst)
|
|
|
|
shutil.move(log_file, f"{log_file}.1")
|
|
|
|
# Neues Log mit korrekter Ownership
|
|
uid, gid = get_most_common_owner(httpdocs)
|
|
with open(log_file, 'w') as f:
|
|
f.write(f"# Log rotated at {utc_now_iso()}\n")
|
|
set_owner(log_file, uid, gid)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Log-Rotation Fehler für {shop}: {e}")
|
|
|
|
|
|
# =============================================================================
|
|
# GEOIP AGENT - WebSocket Client
|
|
# =============================================================================
|
|
class JTLWAFiAgent:
|
|
"""
|
|
WebSocket-basierter Agent für Echtzeit-Kommunikation mit dem Dashboard.
|
|
"""
|
|
|
|
def __init__(self, dashboard_url: str = DEFAULT_DASHBOARD_URL):
|
|
self.dashboard_url = dashboard_url
|
|
self.hostname = socket.getfqdn()
|
|
self.agent_id = hashlib.md5(self.hostname.encode()).hexdigest()[:16]
|
|
self.token: Optional[str] = None
|
|
self.approved = False
|
|
self.running = False
|
|
self.ws = None
|
|
self.reconnect_delay = RECONNECT_BASE_DELAY
|
|
|
|
# Log Watcher für Live-Streaming
|
|
self.log_watcher = LogWatcher(callback=self._on_log_entry)
|
|
|
|
# State Tracking
|
|
self.last_stats: Dict[str, Dict] = {}
|
|
self.last_stats_time = 0
|
|
self.last_heartbeat_time = 0
|
|
self.last_log_rotation_time = 0
|
|
|
|
# Token laden falls vorhanden
|
|
self._load_token()
|
|
|
|
def _load_token(self):
|
|
"""Lädt gespeicherten Token aus Datei."""
|
|
if os.path.isfile(TOKEN_FILE):
|
|
try:
|
|
with open(TOKEN_FILE, 'r') as f:
|
|
self.token = f.read().strip()
|
|
if self.token:
|
|
logger.info(f"Token geladen aus {TOKEN_FILE}")
|
|
except Exception as e:
|
|
logger.warning(f"Konnte Token nicht laden: {e}")
|
|
|
|
def _save_token(self, token: str):
|
|
"""Speichert Token in Datei."""
|
|
try:
|
|
os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True)
|
|
with open(TOKEN_FILE, 'w') as f:
|
|
f.write(token)
|
|
os.chmod(TOKEN_FILE, 0o600)
|
|
self.token = token
|
|
logger.info(f"Token gespeichert in {TOKEN_FILE}")
|
|
except Exception as e:
|
|
logger.error(f"Konnte Token nicht speichern: {e}")
|
|
|
|
def _get_os_info(self) -> Dict[str, str]:
|
|
"""Sammelt OS-Informationen."""
|
|
return {
|
|
"system": platform.system(),
|
|
"release": platform.release(),
|
|
"machine": platform.machine()
|
|
}
|
|
|
|
def _get_system_stats(self) -> Dict[str, Any]:
|
|
"""Sammelt System-Statistiken."""
|
|
stats = {}
|
|
|
|
try:
|
|
load = os.getloadavg()
|
|
stats["load_1m"] = round(load[0], 2)
|
|
stats["load_5m"] = round(load[1], 2)
|
|
stats["load_15m"] = round(load[2], 2)
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
with open('/proc/meminfo', 'r') as f:
|
|
meminfo = {}
|
|
for line in f:
|
|
parts = line.split(':')
|
|
if len(parts) == 2:
|
|
meminfo[parts[0].strip()] = int(parts[1].strip().split()[0])
|
|
|
|
total = meminfo.get('MemTotal', 1)
|
|
available = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
|
stats["memory_percent"] = round((1 - available / total) * 100, 1)
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
with open('/proc/uptime', 'r') as f:
|
|
stats["uptime_seconds"] = int(float(f.read().split()[0]))
|
|
except:
|
|
pass
|
|
|
|
return stats
|
|
|
|
def _get_shops_summary(self) -> Dict[str, int]:
|
|
"""Gibt Shop-Zusammenfassung zurück."""
|
|
available = get_available_shops()
|
|
active = get_active_shops()
|
|
return {"total": len(available), "active": len(active)}
|
|
|
|
def _get_all_shops_data(self) -> List[Dict[str, Any]]:
|
|
"""Sammelt Daten aller Shops (v2.5)."""
|
|
shops_data = []
|
|
available = get_available_shops()
|
|
active = get_active_shops()
|
|
|
|
for shop in available:
|
|
is_active = shop in active
|
|
link11_info = check_link11(shop)
|
|
|
|
shop_data = {
|
|
"domain": shop,
|
|
"status": "active" if is_active else "inactive",
|
|
"link11": link11_info['is_link11'],
|
|
"link11_ip": link11_info['ip']
|
|
}
|
|
|
|
if is_active:
|
|
config = get_shop_config(shop)
|
|
activation_time = get_shop_activation_time(shop)
|
|
|
|
# v2.5 Konfiguration
|
|
shop_data["bot_mode"] = config.get("bot_mode", False)
|
|
shop_data["bot_rate_limit"] = config.get("bot_rate_limit")
|
|
shop_data["bot_ban_duration"] = config.get("bot_ban_duration")
|
|
shop_data["country_mode"] = config.get("country_mode", False)
|
|
shop_data["country_rate_limit"] = config.get("country_rate_limit")
|
|
shop_data["country_ban_duration"] = config.get("country_ban_duration")
|
|
shop_data["unlimited_countries"] = config.get("unlimited_countries", [])
|
|
shop_data["monitor_only"] = config.get("monitor_only", False)
|
|
|
|
if activation_time:
|
|
shop_data["activated"] = activation_time.isoformat()
|
|
runtime = (datetime.now() - activation_time).total_seconds() / 60
|
|
shop_data["runtime_minutes"] = round(runtime, 1)
|
|
|
|
# Stats sammeln
|
|
shop_data["stats"] = get_shop_log_stats(shop)
|
|
|
|
shops_data.append(shop_data)
|
|
|
|
return shops_data
|
|
|
|
def _on_log_entry(self, shop: str, line: str):
|
|
"""Callback für neue Log-Einträge."""
|
|
if not self.ws or not self.approved:
|
|
return
|
|
|
|
# Event senden
|
|
asyncio.run_coroutine_threadsafe(
|
|
self._send_event('log.entry', {'shop': shop, 'line': line}),
|
|
self._loop
|
|
)
|
|
|
|
# Live Stats Tracker updaten
|
|
try:
|
|
tracker = get_shop_stats_tracker(shop)
|
|
|
|
# IP extrahieren
|
|
ip = None
|
|
if 'IP: ' in line:
|
|
ip = line.split('IP: ')[1].split(' |')[0].strip()
|
|
|
|
# Path extrahieren
|
|
path = '/'
|
|
if 'Path: ' in line:
|
|
path = line.split('Path: ')[1].split(' |')[0].strip()
|
|
|
|
# Bot/Human erkennen
|
|
is_bot = any(x in line for x in ['BOT: ', 'BOT:', 'BLOCKED_BOT:', 'MONITOR_BOT:', 'BANNED_BOT:'])
|
|
|
|
# Blocked erkennen
|
|
is_blocked = any(x in line for x in ['BANNED', 'BLOCKED'])
|
|
|
|
# 404 erkennen
|
|
is_404 = '404' in line
|
|
|
|
if ip:
|
|
tracker.record_request(ip, path, is_bot, is_blocked, is_404)
|
|
except Exception as e:
|
|
logger.debug(f"LiveStats record error: {e}")
|
|
|
|
# Prüfe auf Ban-Events
|
|
if 'BANNED: ' in line:
|
|
try:
|
|
bot_name = line.split('BANNED: ')[1].split(' |')[0].strip()
|
|
asyncio.run_coroutine_threadsafe(
|
|
self._send_event('bot.banned', {
|
|
'shop': shop,
|
|
'bot_name': bot_name,
|
|
'line': line
|
|
}),
|
|
self._loop
|
|
)
|
|
except:
|
|
pass
|
|
|
|
async def _send_event(self, event_type: str, data: Dict[str, Any]):
|
|
"""Sendet ein Event an das Dashboard."""
|
|
if not self.ws:
|
|
return
|
|
|
|
try:
|
|
message = json.dumps({
|
|
'type': event_type,
|
|
'data': data
|
|
})
|
|
await self.ws.send(message)
|
|
logger.debug(f"Gesendet: {event_type}")
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Senden von {event_type}: {e}")
|
|
|
|
async def _send_connect(self):
|
|
"""Sendet agent.connect Event."""
|
|
await self._send_event('agent.connect', {
|
|
'hostname': self.hostname,
|
|
'agent_id': self.agent_id,
|
|
'token': self.token,
|
|
'version': VERSION,
|
|
'os_info': self._get_os_info(),
|
|
'shops_summary': self._get_shops_summary()
|
|
})
|
|
|
|
async def _send_heartbeat(self):
|
|
"""Sendet agent.heartbeat Event."""
|
|
await self._send_event('agent.heartbeat', {
|
|
'agent_id': self.agent_id,
|
|
'system': self._get_system_stats(),
|
|
'shops_summary': self._get_shops_summary()
|
|
})
|
|
self.last_heartbeat_time = time.time()
|
|
|
|
async def _send_full_update(self):
|
|
"""Sendet shop.full_update Event."""
|
|
await self._send_event('shop.full_update', {
|
|
'agent_id': self.agent_id,
|
|
'hostname': self.hostname,
|
|
'shops': self._get_all_shops_data(),
|
|
'system': self._get_system_stats()
|
|
})
|
|
|
|
async def _send_stats_update(self):
|
|
"""Sendet Stats-Updates für aktive Shops."""
|
|
for shop in get_active_shops():
|
|
stats = get_shop_log_stats(shop)
|
|
|
|
# Nur senden wenn sich etwas geändert hat
|
|
last = self.last_stats.get(shop, {})
|
|
if stats != last:
|
|
await self._send_event('shop.stats', {
|
|
'domain': shop,
|
|
'stats': stats
|
|
})
|
|
self.last_stats[shop] = stats
|
|
|
|
self.last_stats_time = time.time()
|
|
|
|
async def _handle_message(self, message: str):
|
|
"""Verarbeitet eingehende Nachrichten vom Dashboard."""
|
|
try:
|
|
data = json.loads(message)
|
|
event_type = data.get('type')
|
|
event_data = data.get('data', {})
|
|
|
|
logger.debug(f"Empfangen: {event_type}")
|
|
|
|
if event_type == 'auth.approved':
|
|
# Token speichern
|
|
token = event_data.get('token')
|
|
if token:
|
|
self._save_token(token)
|
|
self.approved = True
|
|
logger.info("✅ Agent wurde im Dashboard freigegeben!")
|
|
# Full Update senden
|
|
await self._send_full_update()
|
|
|
|
elif event_type == 'command.activate':
|
|
await self._handle_activate_command(event_data)
|
|
|
|
elif event_type == 'command.deactivate':
|
|
await self._handle_deactivate_command(event_data)
|
|
|
|
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:
|
|
self.log_watcher.start_watching(shop)
|
|
|
|
elif event_type == 'log.unsubscribe':
|
|
shop = event_data.get('shop')
|
|
if shop:
|
|
self.log_watcher.stop_watching(shop)
|
|
|
|
elif event_type == 'ping':
|
|
await self._send_event('pong', {'agent_id': self.agent_id})
|
|
|
|
except json.JSONDecodeError:
|
|
logger.error(f"Ungültiges JSON empfangen: {message[:100]}")
|
|
except Exception as e:
|
|
logger.error(f"Fehler bei Nachrichtenverarbeitung: {e}")
|
|
|
|
async def _handle_activate_command(self, data: Dict[str, Any]):
|
|
"""Verarbeitet activate-Command (v2.5)."""
|
|
command_id = data.get('command_id', 'unknown')
|
|
shop = data.get('shop')
|
|
|
|
# v2.5 Parameter
|
|
bot_mode = data.get('bot_mode', True)
|
|
bot_rate_limit = data.get('bot_rate_limit')
|
|
bot_ban_duration = data.get('bot_ban_duration')
|
|
country_mode = data.get('country_mode', False)
|
|
country_rate_limit = data.get('country_rate_limit')
|
|
country_ban_duration = data.get('country_ban_duration')
|
|
unlimited_countries = data.get('unlimited_countries', [])
|
|
monitor_only = data.get('monitor_only', False)
|
|
restart_fpm = data.get('restart_fpm', False)
|
|
|
|
# Log-Meldung erstellen
|
|
mode_parts = []
|
|
if monitor_only:
|
|
mode_parts.append("MONITOR")
|
|
else:
|
|
if bot_mode:
|
|
mode_parts.append(f"Bot({bot_rate_limit or 'default'}/min)")
|
|
if country_mode:
|
|
mode_parts.append(f"Country({country_rate_limit or 'default'}/min)")
|
|
mode_str = " + ".join(mode_parts) if mode_parts else "None"
|
|
logger.info(f"Aktiviere {shop}: {mode_str}")
|
|
|
|
try:
|
|
success = activate_blocking(
|
|
shop=shop,
|
|
silent=True,
|
|
bot_mode=bot_mode,
|
|
bot_rate_limit=bot_rate_limit,
|
|
bot_ban_duration=bot_ban_duration,
|
|
country_mode=country_mode,
|
|
country_rate_limit=country_rate_limit,
|
|
country_ban_duration=country_ban_duration,
|
|
unlimited_countries=unlimited_countries,
|
|
monitor_only=monitor_only
|
|
)
|
|
|
|
if success:
|
|
# PHP-FPM Restart wenn gewünscht
|
|
fpm_result = None
|
|
if restart_fpm:
|
|
fpm_result = restart_php_fpm(shop)
|
|
if fpm_result['success']:
|
|
logger.info(f"PHP-FPM Restart erfolgreich: {fpm_result['message']}")
|
|
else:
|
|
logger.warning(f"PHP-FPM Restart fehlgeschlagen: {fpm_result['message']}")
|
|
|
|
message = f'Shop {shop} aktiviert ({mode_str})'
|
|
if fpm_result:
|
|
message += f' | FPM: {fpm_result["message"]}'
|
|
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'success',
|
|
'message': message,
|
|
'shop': shop,
|
|
'fpm_restart': fpm_result
|
|
})
|
|
# Full Update senden
|
|
await self._send_full_update()
|
|
else:
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': f'Aktivierung fehlgeschlagen',
|
|
'shop': shop
|
|
})
|
|
except Exception as e:
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': str(e),
|
|
'shop': shop
|
|
})
|
|
|
|
async def _handle_deactivate_command(self, data: Dict[str, Any]):
|
|
"""Verarbeitet deactivate-Command."""
|
|
command_id = data.get('command_id', 'unknown')
|
|
shop = data.get('shop')
|
|
restart_fpm = data.get('restart_fpm', False)
|
|
|
|
logger.info(f"Deaktiviere {shop}")
|
|
|
|
try:
|
|
success = deactivate_blocking(shop, silent=True)
|
|
|
|
if success:
|
|
# PHP-FPM Restart wenn gewünscht
|
|
fpm_result = None
|
|
if restart_fpm:
|
|
fpm_result = restart_php_fpm(shop)
|
|
if fpm_result['success']:
|
|
logger.info(f"PHP-FPM Restart erfolgreich: {fpm_result['message']}")
|
|
else:
|
|
logger.warning(f"PHP-FPM Restart fehlgeschlagen: {fpm_result['message']}")
|
|
|
|
message = f'Shop {shop} deaktiviert'
|
|
if fpm_result:
|
|
message += f' | FPM: {fpm_result["message"]}'
|
|
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'success',
|
|
'message': message,
|
|
'shop': shop,
|
|
'fpm_restart': fpm_result
|
|
})
|
|
# Full Update senden
|
|
await self._send_full_update()
|
|
else:
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': f'Deaktivierung fehlgeschlagen',
|
|
'shop': shop
|
|
})
|
|
except Exception as e:
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': str(e),
|
|
'shop': shop
|
|
})
|
|
|
|
async def _handle_update_command(self, data: Dict[str, Any]):
|
|
"""Verarbeitet update-Command - Agent selbst updaten."""
|
|
command_id = data.get('command_id', 'unknown')
|
|
|
|
logger.info("Update-Command empfangen - starte Self-Update...")
|
|
|
|
try:
|
|
import urllib.request
|
|
|
|
# 1. Download neue Version
|
|
logger.info(f"Lade neue Version von {AGENT_UPDATE_URL}...")
|
|
request = urllib.request.Request(
|
|
AGENT_UPDATE_URL,
|
|
headers={'User-Agent': f'JTL-WAFi-Agent/{VERSION}'}
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
new_content = response.read().decode('utf-8')
|
|
|
|
# 2. Syntax-Check
|
|
logger.info("Prüfe Syntax der neuen Version...")
|
|
compile(new_content, '<update>', 'exec')
|
|
|
|
# 3. Version extrahieren (optional, für Log)
|
|
new_version = "unknown"
|
|
for line in new_content.split('\n')[:50]:
|
|
if 'VERSION = ' in line:
|
|
new_version = line.split('=')[1].strip().strip('"\'')
|
|
break
|
|
|
|
logger.info(f"Neue Version: {new_version} (aktuell: {VERSION})")
|
|
|
|
# 4. Script-Pfad ermitteln
|
|
script_path = os.path.abspath(__file__)
|
|
backup_path = script_path + '.backup'
|
|
|
|
# 5. Backup erstellen
|
|
logger.info(f"Erstelle Backup: {backup_path}")
|
|
shutil.copy(script_path, backup_path)
|
|
|
|
# 6. Neue Version schreiben
|
|
logger.info(f"Schreibe neue Version nach {script_path}...")
|
|
with open(script_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
|
|
# 7. Erfolg melden
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'success',
|
|
'message': f'Update erfolgreich ({VERSION} -> {new_version}). Agent wird neugestartet...'
|
|
})
|
|
|
|
# Kurz warten damit Nachricht gesendet wird
|
|
await asyncio.sleep(1)
|
|
|
|
# 8. Neustart via os.execv (ersetzt den aktuellen Prozess)
|
|
logger.info("Starte Agent neu via os.execv...")
|
|
os.execv(sys.executable, [sys.executable, script_path])
|
|
|
|
except urllib.error.URLError as e:
|
|
error_msg = f'Download fehlgeschlagen: {str(e)}'
|
|
logger.error(error_msg)
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': error_msg
|
|
})
|
|
except SyntaxError as e:
|
|
error_msg = f'Syntax-Fehler in neuer Version: {str(e)}'
|
|
logger.error(error_msg)
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'message': error_msg
|
|
})
|
|
except Exception as e:
|
|
error_msg = f'Update fehlgeschlagen: {str(e)}'
|
|
logger.error(error_msg)
|
|
await self._send_event('command.result', {
|
|
'command_id': command_id,
|
|
'status': 'error',
|
|
'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:
|
|
try:
|
|
now = time.time()
|
|
|
|
# Heartbeat (alle 60 Sekunden)
|
|
if now - self.last_heartbeat_time >= HEARTBEAT_INTERVAL:
|
|
if self.approved:
|
|
await self._send_heartbeat()
|
|
|
|
# Stats Update (alle 10 Sekunden)
|
|
if now - self.last_stats_time >= STATS_UPDATE_INTERVAL:
|
|
if self.approved:
|
|
await self._send_stats_update()
|
|
|
|
# Log Rotation (alle 5 Minuten prüfen)
|
|
if now - self.last_log_rotation_time >= 300:
|
|
rotate_shop_logs()
|
|
self.last_log_rotation_time = now
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Fehler in periodic_tasks: {e}")
|
|
await asyncio.sleep(5)
|
|
|
|
async def connect(self):
|
|
"""Stellt WebSocket-Verbindung her."""
|
|
# SSL Context der Self-Signed Certificates akzeptiert
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
|
|
try:
|
|
# websockets importieren
|
|
import websockets
|
|
|
|
logger.info(f"Verbinde zu {self.dashboard_url}...")
|
|
|
|
async with websockets.connect(
|
|
self.dashboard_url,
|
|
ssl=ssl_context,
|
|
ping_interval=30,
|
|
ping_timeout=10,
|
|
close_timeout=5
|
|
) as websocket:
|
|
self.ws = websocket
|
|
self.reconnect_delay = RECONNECT_BASE_DELAY
|
|
|
|
logger.info("✅ WebSocket verbunden")
|
|
|
|
# Connect-Event senden
|
|
await self._send_connect()
|
|
|
|
# Periodic Tasks starten
|
|
periodic_task = asyncio.create_task(self._periodic_tasks())
|
|
|
|
try:
|
|
# Nachrichten empfangen
|
|
async for message in websocket:
|
|
await self._handle_message(message)
|
|
finally:
|
|
periodic_task.cancel()
|
|
try:
|
|
await periodic_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
except ImportError:
|
|
logger.error("websockets-Modul nicht installiert! Installiere mit: pip install websockets")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"WebSocket Fehler: {e}")
|
|
raise
|
|
finally:
|
|
self.ws = None
|
|
self.approved = False
|
|
|
|
async def run_async(self):
|
|
"""Hauptschleife mit Auto-Reconnect."""
|
|
self._loop = asyncio.get_running_loop()
|
|
self.running = True
|
|
self._shutdown_event = asyncio.Event()
|
|
|
|
# Signal Handler für asyncio
|
|
def signal_handler():
|
|
logger.info("Beende Agent (Signal empfangen)...")
|
|
self.running = False
|
|
self._shutdown_event.set()
|
|
# WebSocket schließen falls verbunden
|
|
if self.ws:
|
|
asyncio.create_task(self._close_websocket())
|
|
|
|
# Signale registrieren
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
self._loop.add_signal_handler(sig, signal_handler)
|
|
|
|
# LogWatcher starten
|
|
self.log_watcher.start()
|
|
|
|
try:
|
|
while self.running:
|
|
try:
|
|
await self.connect()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
if not self.running:
|
|
break
|
|
logger.warning(f"Verbindung getrennt: {e}")
|
|
|
|
if self.running:
|
|
logger.info(f"Reconnect in {self.reconnect_delay}s...")
|
|
try:
|
|
# Warte mit Timeout, damit Signal schnell reagiert
|
|
await asyncio.wait_for(
|
|
self._shutdown_event.wait(),
|
|
timeout=self.reconnect_delay
|
|
)
|
|
break # Shutdown signal received
|
|
except asyncio.TimeoutError:
|
|
pass # Normal timeout, reconnect
|
|
|
|
# Exponential Backoff
|
|
self.reconnect_delay = min(
|
|
self.reconnect_delay * 2,
|
|
RECONNECT_MAX_DELAY
|
|
)
|
|
finally:
|
|
# LogWatcher stoppen
|
|
self.log_watcher.stop()
|
|
# Signal Handler entfernen
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
self._loop.remove_signal_handler(sig)
|
|
|
|
async def _close_websocket(self):
|
|
"""Schließt WebSocket-Verbindung."""
|
|
if self.ws:
|
|
try:
|
|
await self.ws.close()
|
|
except:
|
|
pass
|
|
|
|
def run(self):
|
|
"""Startet den Agent."""
|
|
logger.info("=" * 60)
|
|
logger.info(f"JTL-WAFi Agent v{VERSION} (WebSocket Real-Time)")
|
|
logger.info(f"Hostname: {self.hostname}")
|
|
logger.info(f"Agent-ID: {self.agent_id}")
|
|
logger.info(f"Dashboard: {self.dashboard_url}")
|
|
logger.info(f"Token: {'vorhanden' if self.token else 'nicht vorhanden'}")
|
|
logger.info("=" * 60)
|
|
|
|
# Asyncio Loop starten
|
|
try:
|
|
asyncio.run(self.run_async())
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
logger.info("Agent beendet.")
|
|
|
|
|
|
# =============================================================================
|
|
# CLI INTERFACE
|
|
# =============================================================================
|
|
def create_systemd_service():
|
|
"""Erstellt systemd Service-Datei."""
|
|
service = """[Unit]
|
|
Description=JTL-WAFi Agent v3.0 (WebSocket)
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=/usr/bin/python3 /opt/jtl-wafi/jtl_wafi_agent.py
|
|
Restart=always
|
|
RestartSec=10
|
|
User=root
|
|
Environment=PYTHONUNBUFFERED=1
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
"""
|
|
service_path = "/etc/systemd/system/jtl-wafi-agent.service"
|
|
|
|
try:
|
|
with open(service_path, 'w') as f:
|
|
f.write(service)
|
|
print(f"✅ Service erstellt: {service_path}")
|
|
print(" Aktivieren mit: systemctl daemon-reload && systemctl enable --now jtl-wafi-agent")
|
|
except PermissionError:
|
|
print("❌ Root-Rechte erforderlich!")
|
|
sys.exit(1)
|
|
|
|
|
|
def check_dependencies():
|
|
"""Prüft ob alle Abhängigkeiten installiert sind."""
|
|
missing = []
|
|
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
missing.append("websockets")
|
|
|
|
if missing:
|
|
print("❌ Fehlende Abhängigkeiten:")
|
|
for dep in missing:
|
|
print(f" - {dep}")
|
|
print(f"\nInstallieren mit: pip install {' '.join(missing)}")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def main():
|
|
"""Hauptfunktion mit CLI-Argumenten."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description=f"JTL-WAFi Agent v{VERSION} - WebSocket Real-Time Agent"
|
|
)
|
|
parser.add_argument(
|
|
"--url",
|
|
default=DEFAULT_DASHBOARD_URL,
|
|
help=f"Dashboard WebSocket URL (default: {DEFAULT_DASHBOARD_URL})"
|
|
)
|
|
parser.add_argument(
|
|
"--debug",
|
|
action="store_true",
|
|
help="Debug-Logging aktivieren"
|
|
)
|
|
parser.add_argument(
|
|
"--install-service",
|
|
action="store_true",
|
|
help="Systemd Service installieren"
|
|
)
|
|
parser.add_argument(
|
|
"--check-deps",
|
|
action="store_true",
|
|
help="Abhängigkeiten prüfen"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Logging initialisieren
|
|
global logger
|
|
logger = setup_logging(debug=args.debug)
|
|
|
|
if args.install_service:
|
|
create_systemd_service()
|
|
return
|
|
|
|
if args.check_deps:
|
|
if check_dependencies():
|
|
print("✅ Alle Abhängigkeiten sind installiert")
|
|
return
|
|
|
|
# Abhängigkeiten prüfen
|
|
if not check_dependencies():
|
|
sys.exit(1)
|
|
|
|
# Root-Check
|
|
if os.geteuid() != 0:
|
|
print("❌ Root-Rechte erforderlich!")
|
|
sys.exit(1)
|
|
|
|
# Country IP-Ranges laden/initialisieren (im Hintergrund)
|
|
logger.info("Initialisiere Country IP-Ranges Cache...")
|
|
download_country_ranges_async()
|
|
|
|
# Agent starten
|
|
agent = JTLWAFiAgent(dashboard_url=args.url)
|
|
agent.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|