diff --git a/src/main/java/net/viper/status/StatusAPI.java b/src/main/java/net/viper/status/StatusAPI.java index 72f797e..e09f9ca 100644 --- a/src/main/java/net/viper/status/StatusAPI.java +++ b/src/main/java/net/viper/status/StatusAPI.java @@ -27,12 +27,18 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.io.StringReader; +import java.net.SocketTimeoutException; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import net.md_5.bungee.api.scheduler.ScheduledTask; /** * StatusAPI - zentraler Bungee HTTP-Status- und Broadcast-Endpunkt @@ -40,8 +46,13 @@ import java.util.concurrent.TimeUnit; public class StatusAPI extends Plugin implements Runnable { - private Thread thread; + private volatile Thread thread; + private volatile ServerSocket serverSocket; + private volatile boolean shuttingDown = false; private int port = 9191; + private ScheduledTask httpWatchdogTask; + private ExecutorService requestExecutor; + private final AtomicLong lastHttpRequestAt = new AtomicLong(0L); private ModuleManager moduleManager; private UpdateChecker updateChecker; @@ -92,8 +103,14 @@ public class StatusAPI extends Plugin implements Runnable { // WebServer starten getLogger().info("Starte Web-Server auf Port " + port + "..."); - thread = new Thread(this, "StatusAPI-HTTP-Server"); - thread.start(); + shuttingDown = false; + requestExecutor = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); + t.setDaemon(true); + return t; + }); + startHttpServerThread(); + httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule(this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS); // Update System String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; @@ -106,15 +123,68 @@ public class StatusAPI extends Plugin implements Runnable { @Override public void onDisable() { + shuttingDown = true; + getLogger().info("Stoppe Module..."); if (moduleManager != null) { moduleManager.disableAll(this); } getLogger().info("Stoppe Web-Server..."); - if (thread != null) { - thread.interrupt(); - try { thread.join(1000); } catch (InterruptedException ignored) {} + if (httpWatchdogTask != null) { + httpWatchdogTask.cancel(); + httpWatchdogTask = null; + } + + stopHttpServerThread(); + + if (requestExecutor != null) { + requestExecutor.shutdownNow(); + requestExecutor = null; + } + } + + private synchronized void startHttpServerThread() { + if (thread != null && thread.isAlive()) { + return; + } + thread = new Thread(this, "StatusAPI-HTTP-Server"); + thread.setDaemon(true); + thread.start(); + } + + private synchronized void stopHttpServerThread() { + Thread localThread = thread; + if (localThread != null) { + localThread.interrupt(); + } + + ServerSocket localServerSocket = serverSocket; + if (localServerSocket != null) { + try { + localServerSocket.close(); + } catch (IOException ignored) { + } + } + + if (localThread != null) { + try { + localThread.join(1500); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + thread = null; + } + + private void ensureHttpServerAlive() { + if (shuttingDown) { + return; + } + Thread t = thread; + if (t == null || !t.isAlive()) { + getLogger().warning("HTTP-Server-Thread war gestoppt und wird neu gestartet."); + startHttpServerThread(); } } @@ -227,35 +297,117 @@ public class StatusAPI extends Plugin implements Runnable { // --- WebServer & JSON --- @Override public void run() { - try (ServerSocket serverSocket = new ServerSocket(port)) { - serverSocket.setSoTimeout(1000); - while (!Thread.interrupted()) { - try { - Socket clientSocket = serverSocket.accept(); - handleConnection(clientSocket); - } catch (java.net.SocketTimeoutException e) {} - catch (IOException e) { - getLogger().warning("Fehler beim Akzeptieren einer Verbindung: " + e.getMessage()); + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + try (ServerSocket localServerSocket = new ServerSocket(port)) { + this.serverSocket = localServerSocket; + localServerSocket.setReuseAddress(true); + localServerSocket.setSoTimeout(1000); + + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + try { + Socket clientSocket = localServerSocket.accept(); + submitConnection(clientSocket); + } catch (SocketTimeoutException ignored) { + // Poll-Schleife fuer Interrupt/Shutdown. + } catch (IOException e) { + if (!shuttingDown) { + getLogger().warning("Fehler beim Akzeptieren einer Verbindung: " + e.getMessage()); + } + } catch (Throwable t) { + if (!shuttingDown) { + getLogger().severe("Unbehandelter Fehler im HTTP-Accept-Loop: " + t.getMessage()); + } + } } + } catch (IOException e) { + if (!shuttingDown) { + getLogger().severe("Konnte ServerSocket nicht starten: " + e.getMessage()); + try { + Thread.sleep(2000L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } finally { + serverSocket = null; + } + } + } + + private void submitConnection(Socket clientSocket) { + if (clientSocket == null) { + return; + } + + try { + clientSocket.setSoTimeout(5000); + clientSocket.setTcpNoDelay(true); + } catch (Exception ignored) { + } + + ExecutorService executor = requestExecutor; + if (executor == null || executor.isShutdown()) { + try { + clientSocket.close(); + } catch (IOException ignored) { + } + return; + } + + try { + executor.execute(() -> { + try { + handleConnection(clientSocket); + } finally { + try { + clientSocket.close(); + } catch (IOException ignored) { + } + } + }); + } catch (RejectedExecutionException ex) { + try { + clientSocket.close(); + } catch (IOException ignored) { } - } catch (IOException e) { - getLogger().severe("Konnte ServerSocket nicht starten: " + e.getMessage()); } } private void handleConnection(Socket clientSocket) { // (doppelter/fehlerhafter Block entfernt) - try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), "UTF-8")); + try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8)); OutputStream out = clientSocket.getOutputStream()) { String inputLine = in.readLine(); if (inputLine == null) return; + lastHttpRequestAt.set(System.currentTimeMillis()); String[] reqParts = inputLine.split(" "); if (reqParts.length < 2) return; String method = reqParts[0].trim(); String path = reqParts[1].trim(); + String pathOnly = path; + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) { + pathOnly = path.substring(0, queryIndex); + } + + if ("GET".equalsIgnoreCase(method) && "/health".equalsIgnoreCase(path)) { + long lastMs = lastHttpRequestAt.get(); + long age = lastMs <= 0L ? -1L : (System.currentTimeMillis() - lastMs); + String json = "{\"success\":true,\"online\":true,\"last_request_age_ms\":" + age + "}"; + sendHttpResponse(out, json, 200); + return; + } + + if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) { + Map payload = new LinkedHashMap(); + payload.put("success", true); + payload.put("events", loadAntiBotSecurityEvents(250)); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } Map headers = new HashMap<>(); String line; @@ -609,7 +761,7 @@ public class StatusAPI extends Plugin implements Runnable { response.append("Access-Control-Allow-Origin: *\r\n"); response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); response.append("Connection: close\r\n\r\n"); - out.write(response.toString().getBytes("UTF-8")); + out.write(response.toString().getBytes(StandardCharsets.UTF_8)); out.write(jsonBytes); out.flush(); } @@ -741,4 +893,84 @@ public class StatusAPI extends Plugin implements Runnable { if (s == null) return ""; return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); } + + private List> loadAntiBotSecurityEvents(int maxEntries) { + List> out = new ArrayList>(); + File logFile = new File(getDataFolder(), "antibot-security.log"); + if (!logFile.exists() || maxEntries <= 0) { + return out; + } + + try { + List lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8); + for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) { + String line = lines.get(i); + if (line == null || line.trim().isEmpty()) { + continue; + } + + Map parsed = parseSecurityLogLine(line); + String event = parsed.get("event"); + if (!isAttackSecurityEvent(event)) { + continue; + } + + String player = parsed.get("player"); + String uuid = parsed.get("uuid"); + if (player == null || player.trim().isEmpty() || "-".equals(player) || "unknown".equalsIgnoreCase(player)) { + continue; + } + if (uuid == null || uuid.trim().isEmpty()) { + uuid = "-"; + } + + Map row = new LinkedHashMap(); + row.put("datetime", parsed.getOrDefault("datetime", "")); + row.put("player", player); + row.put("uuid", uuid); + row.put("ip", parsed.getOrDefault("ip", "-")); + out.add(row); + } + } catch (Exception e) { + getLogger().warning("Konnte antibot-security.log nicht lesen: " + e.getMessage()); + } + + return out; + } + + private Map parseSecurityLogLine(String line) { + Map map = new LinkedHashMap(); + if (line == null) { + return map; + } + + String[] segments = line.split("\\\\s*\\\\|\\\\s*"); + if (segments.length > 0) { + map.put("datetime", segments[0].trim()); + } + + for (int i = 1; i < segments.length; i++) { + String seg = segments[i]; + int idx = seg.indexOf('='); + if (idx <= 0) { + continue; + } + String key = seg.substring(0, idx).trim().toLowerCase(Locale.ROOT); + String value = seg.substring(idx + 1).trim(); + map.put(key, value); + } + + return map; + } + + private boolean isAttackSecurityEvent(String event) { + if (event == null) { + return false; + } + String e = event.trim().toLowerCase(Locale.ROOT); + return e.contains("ip_rate") + || e.contains("vpn") + || e.contains("learning_threshold_block") + || e.contains("block"); + } } \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java index b58dcef..f7213da 100644 --- a/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java +++ b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java @@ -4,7 +4,9 @@ import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.CommandSender; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.connection.PendingConnection; +import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.event.PreLoginEvent; +import net.md_5.bungee.api.event.PostLoginEvent; import net.md_5.bungee.api.plugin.Command; import net.md_5.bungee.api.plugin.Listener; import net.md_5.bungee.api.plugin.Plugin; @@ -14,9 +16,11 @@ import net.viper.status.module.Module; import net.viper.status.modules.network.NetworkInfoModule; import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.FileWriter; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; @@ -24,13 +28,18 @@ import java.net.InetSocketAddress; import java.net.URL; import java.nio.file.Files; import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -60,6 +69,23 @@ public class AntiBotModule implements Module, Listener { private boolean vpnBlockHosting = true; private int vpnCacheMinutes = 30; private int vpnTimeoutMs = 2500; + private boolean securityLogEnabled = true; + private String securityLogFileName = "antibot-security.log"; + private File securityLogFile; + private final Object securityLogLock = new Object(); + + private boolean learningModeEnabled = true; + private int learningScoreThreshold = 100; + private int learningDecayPerSecond = 2; + private int learningStateWindowSeconds = 120; + private int learningRapidWindowMs = 1500; + private int learningRapidPoints = 12; + private int learningIpRateExceededPoints = 30; + private int learningVpnProxyPoints = 40; + private int learningVpnHostingPoints = 30; + private int learningAttackModePoints = 12; + private int learningHighCpsPoints = 10; + private int learningRecentEventLimit = 30; private final AtomicInteger currentSecondConnections = new AtomicInteger(0); private volatile long currentSecond = System.currentTimeMillis() / 1000L; @@ -75,6 +101,9 @@ public class AntiBotModule implements Module, Listener { private final Map perIpWindows = new ConcurrentHashMap(); private final Map blockedIpsUntil = new ConcurrentHashMap(); private final Map vpnCache = new ConcurrentHashMap(); + private final Map recentIdentityByIp = new ConcurrentHashMap(); + private final Map learningProfiles = new ConcurrentHashMap(); + private final Deque learningRecentEvents = new ArrayDeque(); @Override public String getName() { @@ -89,6 +118,7 @@ public class AntiBotModule implements Module, Listener { this.plugin = (StatusAPI) plugin; ensureModuleConfigExists(); loadConfig(); + ensureSecurityLogFile(); if (!enabled) { this.plugin.getLogger().info("[AntiBotModule] deaktiviert via " + CONFIG_FILE_NAME + " (antibot.enabled=false)"); @@ -107,6 +137,10 @@ public class AntiBotModule implements Module, Listener { perIpWindows.clear(); blockedIpsUntil.clear(); vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { + learningRecentEvents.clear(); + } blockedIpsCurrentAttack.clear(); attackMode = false; } @@ -119,6 +153,10 @@ public class AntiBotModule implements Module, Listener { perIpWindows.clear(); blockedIpsUntil.clear(); vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { + learningRecentEvents.clear(); + } blockedIpsCurrentAttack.clear(); attackMode = false; attackCalmSince = 0L; @@ -127,6 +165,7 @@ public class AntiBotModule implements Module, Listener { lastCps = 0; peakCps.set(0); loadConfig(); + ensureSecurityLogFile(); } public Map buildSnapshot() { @@ -145,6 +184,8 @@ public class AntiBotModule implements Module, Listener { out.put("blocked_ips_active", blockedIpsUntil.size()); out.put("blocked_connections_total", blockedConnectionsTotal.get()); out.put("vpn_check_enabled", vpnCheckEnabled); + out.put("learning_mode_enabled", learningModeEnabled); + out.put("learning_profiles", learningProfiles.size()); out.put("thresholds", buildThresholds()); return out; } @@ -157,6 +198,8 @@ public class AntiBotModule implements Module, Listener { m.put("attack_calm_seconds", attackCalmSeconds); m.put("ip_connections_per_minute", ipConnectionsPerMinute); m.put("ip_block_seconds", ipBlockSeconds); + m.put("learning_score_threshold", learningScoreThreshold); + m.put("learning_decay_per_second", learningDecayPerSecond); return m; } @@ -171,6 +214,8 @@ public class AntiBotModule implements Module, Listener { return; } + cacheRecentIdentity(ip, event.getConnection(), System.currentTimeMillis()); + recordConnection(); long now = System.currentTimeMillis(); @@ -178,14 +223,36 @@ public class AntiBotModule implements Module, Listener { Long blockedUntil = blockedIpsUntil.get(ip); if (blockedUntil != null && blockedUntil > now) { + logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); blockEvent(event); return; } - if (isIpRateExceeded(ip, now)) { - blockIp(ip, now); - blockEvent(event); - return; + if (learningModeEnabled) { + evaluateLearningBaseline(ip, now); + Long learningBlock = blockedIpsUntil.get(ip); + if (learningBlock != null && learningBlock > now) { + blockEvent(event); + return; + } + } + + boolean ipRateExceeded = isIpRateExceeded(ip, now); + if (ipRateExceeded) { + if (learningModeEnabled) { + int score = addLearningScore(ip, now, learningIpRateExceededPoints, "ip-rate-exceeded", true); + logSecurityEvent("ip_rate_exceeded_scored", ip, event.getConnection(), "score=" + score + ", threshold=" + learningScoreThreshold); + if (score >= learningScoreThreshold) { + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=ip-rate-exceeded, score=" + score); + blockEvent(event); + return; + } + } else { + blockIp(ip, now); + logSecurityEvent("ip_rate_limit_block", ip, event.getConnection(), "mode=direct"); + blockEvent(event); + return; + } } if (vpnCheckEnabled) { @@ -193,13 +260,47 @@ public class AntiBotModule implements Module, Listener { if (info != null) { boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting); if (shouldBlock) { - blockIp(ip, now); - blockEvent(event); + logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting); + if (learningModeEnabled) { + if (vpnBlockProxy && info.proxy) { + addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false); + } + if (vpnBlockHosting && info.hosting) { + addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false); + } + + int current = getLearningScore(ip, now); + if (current >= learningScoreThreshold) { + blockIp(ip, now); + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current); + recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current); + blockEvent(event); + } + } else { + blockIp(ip, now); + logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); + blockEvent(event); + } } } } } + @EventHandler + public void onPostLogin(PostLoginEvent event) { + if (!enabled || event == null || event.getPlayer() == null) { + return; + } + + ProxiedPlayer player = event.getPlayer(); + String ip = extractIpFromPlayer(player); + if (ip == null || ip.isEmpty()) { + return; + } + + cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis()); + } + private void blockEvent(PreLoginEvent event) { event.setCancelled(true); } @@ -218,6 +319,20 @@ public class AntiBotModule implements Module, Listener { return String.valueOf(conn.getAddress()); } + private String extractIpFromPlayer(ProxiedPlayer player) { + if (player == null || player.getAddress() == null) { + return null; + } + if (player.getAddress() instanceof InetSocketAddress) { + InetSocketAddress sa = (InetSocketAddress) player.getAddress(); + if (sa.getAddress() != null) { + return sa.getAddress().getHostAddress(); + } + return sa.getHostString(); + } + return String.valueOf(player.getAddress()); + } + private void recordConnection() { long sec = System.currentTimeMillis() / 1000L; if (sec != currentSecond) { @@ -266,6 +381,27 @@ public class AntiBotModule implements Module, Listener { vpnCache.remove(entry.getKey()); } } + + for (Map.Entry entry : recentIdentityByIp.entrySet()) { + RecentPlayerIdentity id = entry.getValue(); + if (id == null || (now - id.updatedAtMs) > 600_000L) { + recentIdentityByIp.remove(entry.getKey()); + } + } + + if (learningModeEnabled) { + long staleAfter = Math.max(60, learningStateWindowSeconds) * 1000L; + for (Map.Entry entry : learningProfiles.entrySet()) { + LearningProfile lp = entry.getValue(); + if (lp == null) { + learningProfiles.remove(entry.getKey()); + continue; + } + if ((now - lp.lastSeenAt) > staleAfter && lp.score <= 0) { + learningProfiles.remove(entry.getKey()); + } + } + } } private void tick() { @@ -356,6 +492,24 @@ public class AntiBotModule implements Module, Listener { vpnBlockHosting = parseBoolean(props.getProperty("antibot.vpn_check.block_hosting"), vpnBlockHosting); vpnCacheMinutes = parseInt(props.getProperty("antibot.vpn_check.cache_minutes"), vpnCacheMinutes); vpnTimeoutMs = parseInt(props.getProperty("antibot.vpn_check.timeout_ms"), vpnTimeoutMs); + securityLogEnabled = parseBoolean(props.getProperty("antibot.security_log.enabled"), securityLogEnabled); + securityLogFileName = props.getProperty("antibot.security_log.file", securityLogFileName).trim(); + if (securityLogFileName.isEmpty()) { + securityLogFileName = "antibot-security.log"; + } + + learningModeEnabled = parseBoolean(props.getProperty("antibot.learning.enabled"), learningModeEnabled); + learningScoreThreshold = parseInt(props.getProperty("antibot.learning.score_threshold"), learningScoreThreshold); + learningDecayPerSecond = parseInt(props.getProperty("antibot.learning.decay_per_second"), learningDecayPerSecond); + learningStateWindowSeconds = parseInt(props.getProperty("antibot.learning.state_window_seconds"), learningStateWindowSeconds); + learningRapidWindowMs = parseInt(props.getProperty("antibot.learning.rapid.window_ms"), learningRapidWindowMs); + learningRapidPoints = parseInt(props.getProperty("antibot.learning.rapid.points"), learningRapidPoints); + learningIpRateExceededPoints = parseInt(props.getProperty("antibot.learning.ip_rate_exceeded.points"), learningIpRateExceededPoints); + learningVpnProxyPoints = parseInt(props.getProperty("antibot.learning.vpn_proxy.points"), learningVpnProxyPoints); + learningVpnHostingPoints = parseInt(props.getProperty("antibot.learning.vpn_hosting.points"), learningVpnHostingPoints); + learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints); + learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints); + learningRecentEventLimit = parseInt(props.getProperty("antibot.learning.recent_events.limit"), learningRecentEventLimit); } catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); } @@ -496,6 +650,266 @@ public class AntiBotModule implements Module, Listener { } } + private void evaluateLearningBaseline(String ip, long now) { + LearningProfile profile = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + + synchronized (profile) { + decayLearningProfile(profile, now); + + long delta = now - profile.lastConnectionAt; + if (profile.lastConnectionAt > 0 && delta <= Math.max(250L, learningRapidWindowMs)) { + profile.rapidStreak++; + int points = learningRapidPoints + Math.min(profile.rapidStreak, 5); + profile.score += Math.max(1, points); + recordLearningEvent("IP=" + ip + " +" + points + " rapid-connect score=" + profile.score); + } else { + profile.rapidStreak = 0; + } + + if (attackMode) { + profile.score += Math.max(1, learningAttackModePoints); + recordLearningEvent("IP=" + ip + " +" + learningAttackModePoints + " attack-mode score=" + profile.score); + } + + if (lastCps >= Math.max(1, maxCps)) { + profile.score += Math.max(1, learningHighCpsPoints); + recordLearningEvent("IP=" + ip + " +" + learningHighCpsPoints + " high-cps score=" + profile.score); + } + + profile.lastConnectionAt = now; + profile.lastSeenAt = now; + + if (profile.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=learning-threshold score=" + profile.score); + } + } + } + + private int addLearningScore(String ip, long now, int points, String reason, boolean checkThreshold) { + LearningProfile profile = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + synchronized (profile) { + decayLearningProfile(profile, now); + int add = Math.max(1, points); + profile.score += add; + profile.lastSeenAt = now; + recordLearningEvent("IP=" + ip + " +" + add + " " + reason + " score=" + profile.score); + + if (checkThreshold && profile.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=" + reason + " score=" + profile.score); + } + + return profile.score; + } + } + + private int getLearningScore(String ip, long now) { + LearningProfile profile = learningProfiles.get(ip); + if (profile == null) { + return 0; + } + synchronized (profile) { + decayLearningProfile(profile, now); + return profile.score; + } + } + + private void decayLearningProfile(LearningProfile profile, long now) { + long elapsedMs = Math.max(0L, now - profile.lastScoreUpdateAt); + if (elapsedMs > 0L) { + long decay = (elapsedMs / 1000L) * Math.max(0, learningDecayPerSecond); + if (decay > 0L) { + profile.score = (int) Math.max(0L, profile.score - decay); + } + profile.lastScoreUpdateAt = now; + } + + long resetAfter = Math.max(30, learningStateWindowSeconds) * 1000L; + if (profile.lastSeenAt > 0L && now - profile.lastSeenAt > resetAfter) { + profile.score = 0; + profile.rapidStreak = 0; + } + } + + private void recordLearningEvent(String event) { + String line = new SimpleDateFormat("HH:mm:ss").format(new Date()) + " " + event; + synchronized (learningRecentEvents) { + learningRecentEvents.addLast(line); + while (learningRecentEvents.size() > Math.max(5, learningRecentEventLimit)) { + learningRecentEvents.pollFirst(); + } + } + } + + private void ensureSecurityLogFile() { + if (plugin == null) { + return; + } + if (!plugin.getDataFolder().exists()) { + plugin.getDataFolder().mkdirs(); + } + securityLogFile = new File(plugin.getDataFolder(), securityLogFileName); + try { + if (!securityLogFile.exists()) { + securityLogFile.createNewFile(); + } + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Konnte Sicherheitslog nicht erstellen: " + e.getMessage()); + } + } + + private void logSecurityEvent(String eventType, String ip, PendingConnection conn, String details) { + if (!securityLogEnabled || plugin == null) { + return; + } + if (securityLogFile == null) { + ensureSecurityLogFile(); + if (securityLogFile == null) { + return; + } + } + + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + + if ((name == null || name.isEmpty() || "unknown".equalsIgnoreCase(name)) && ip != null) { + RecentPlayerIdentity cached = recentIdentityByIp.get(ip); + if (cached != null) { + if (cached.playerName != null && !cached.playerName.trim().isEmpty()) { + name = cached.playerName; + } + if ((uuid == null || uuid.isEmpty() || "unknown".equalsIgnoreCase(uuid)) + && cached.playerUuid != null && !cached.playerUuid.trim().isEmpty()) { + uuid = cached.playerUuid; + } + } + } + + if (name == null || name.trim().isEmpty()) { + name = "unknown"; + } + if (uuid == null || uuid.trim().isEmpty()) { + uuid = "unknown"; + } + + String line = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + + " | event=" + safeLog(eventType) + + " | ip=" + safeLog(ip) + + " | player=" + safeLog(name) + + " | uuid=" + safeLog(uuid) + + " | details=" + safeLog(details); + + synchronized (securityLogLock) { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) { + bw.write(line); + bw.newLine(); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Sicherheitslog-Schreibfehler: " + e.getMessage()); + } + } + } + + private void cacheRecentIdentity(String ip, PendingConnection conn, long now) { + if (ip == null || ip.isEmpty() || conn == null) { + return; + } + + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + + if ((name == null || name.isEmpty()) && (uuid == null || uuid.isEmpty())) { + return; + } + + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (name != null && !name.trim().isEmpty()) { + identity.playerName = name.trim(); + } + if (uuid != null && !uuid.trim().isEmpty()) { + identity.playerUuid = uuid.trim(); + } + identity.updatedAtMs = now; + } + } + + private void cacheRecentIdentityDirect(String ip, String playerName, UUID playerUuid, long now) { + if (ip == null || ip.isEmpty()) { + return; + } + + String name = playerName == null ? "" : playerName.trim(); + String uuid = playerUuid == null ? "" : playerUuid.toString(); + if (name.isEmpty() && uuid.isEmpty()) { + return; + } + + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (!name.isEmpty()) { + identity.playerName = name; + } + if (!uuid.isEmpty()) { + identity.playerUuid = uuid; + } + identity.updatedAtMs = now; + } + } + + private String extractPlayerName(PendingConnection conn) { + if (conn == null) { + return ""; + } + try { + String raw = conn.getName(); + return raw == null ? "" : raw.trim(); + } catch (Exception ignored) { + return ""; + } + } + + private String extractPlayerUuid(PendingConnection conn, String playerName) { + if (conn != null) { + try { + UUID uuid = conn.getUniqueId(); + if (uuid != null) { + return uuid.toString(); + } + } catch (Exception ignored) { + } + } + + if (playerName != null && !playerName.trim().isEmpty()) { + UUID offlineUuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName.trim()).getBytes(StandardCharsets.UTF_8)); + return offlineUuid.toString(); + } + + return ""; + } + + private String safeLog(String input) { + if (input == null || input.isEmpty()) { + return "-"; + } + return input.replace("\n", " ").replace("\r", " ").trim(); + } + + private List getRecentLearningEvents(int max) { + List out = new ArrayList(); + synchronized (learningRecentEvents) { + int skip = Math.max(0, learningRecentEvents.size() - Math.max(1, max)); + int idx = 0; + for (String line : learningRecentEvents) { + if (idx++ < skip) { + continue; + } + out.add(line); + } + } + return out; + } + private boolean parseBoolean(String s, boolean fallback) { if (s == null) return fallback; return Boolean.parseBoolean(s.trim()); @@ -586,6 +1000,28 @@ public class AntiBotModule implements Module, Listener { boolean hosting; } + private static class LearningProfile { + long lastConnectionAt; + long lastScoreUpdateAt; + long lastSeenAt; + int rapidStreak; + int score; + + LearningProfile(long now) { + this.lastConnectionAt = now; + this.lastScoreUpdateAt = now; + this.lastSeenAt = now; + this.rapidStreak = 0; + this.score = 0; + } + } + + private static class RecentPlayerIdentity { + String playerName; + String playerUuid; + long updatedAtMs; + } + private class AntiBotCommand extends Command { AntiBotCommand() { super("antibot", "statusapi.antibot"); @@ -611,6 +1047,16 @@ public class AntiBotModule implements Module, Listener { sender.sendMessage(ChatColor.YELLOW + "Active IP Blocks: " + ChatColor.WHITE + blockedIpsUntil.size()); sender.sendMessage(ChatColor.YELLOW + "Total blocked connections: " + ChatColor.WHITE + blockedConnectionsTotal.get()); sender.sendMessage(ChatColor.YELLOW + "VPN Check: " + ChatColor.WHITE + vpnCheckEnabled); + sender.sendMessage(ChatColor.YELLOW + "Learning Mode: " + ChatColor.WHITE + learningModeEnabled + + ChatColor.GRAY + " (threshold=" + learningScoreThreshold + ")"); + + List recent = getRecentLearningEvents(3); + if (!recent.isEmpty()) { + sender.sendMessage(ChatColor.YELLOW + "Learning Events:"); + for (String line : recent) { + sender.sendMessage(ChatColor.GRAY + "- " + line); + } + } return; } diff --git a/src/main/java/net/viper/status/modules/chat/ChatConfig.java b/src/main/java/net/viper/status/modules/chat/ChatConfig.java index f4e4796..4bd1563 100644 --- a/src/main/java/net/viper/status/modules/chat/ChatConfig.java +++ b/src/main/java/net/viper/status/modules/chat/ChatConfig.java @@ -39,6 +39,11 @@ public class ChatConfig { private String pmFormatReceiver; private String pmFormatSpy; private String pmSpyPermission; + private boolean pmRateLimitEnabled; + private long pmRateLimitWindowMs; + private int pmRateLimitMaxActions; + private long pmRateLimitBlockMs; + private String pmRateLimitMessage; // Mute private int defaultMuteDuration; @@ -294,6 +299,9 @@ public class ChatConfig { filterConfig.spamCooldownMs = spam.getInt("cooldown-ms", 1500); filterConfig.spamMaxMessages = spam.getInt("max-messages", 3); filterConfig.spamMessage = spam.getString("message", "&cNicht so schnell!"); + filterConfig.globalRateLimitWindowMs = Math.max(500L, filterConfig.spamCooldownMs); + filterConfig.globalRateLimitMaxActions = Math.max(1, filterConfig.spamMaxMessages); + filterConfig.globalRateLimitBlockMs = Math.max(2000L, filterConfig.spamCooldownMs * 2L); } Configuration dup = cf.getSection("duplicate-check"); if (dup != null) { @@ -324,6 +332,34 @@ public class ChatConfig { } } + // --- Globales Rate-Limit-Framework --- + pmRateLimitEnabled = true; + pmRateLimitWindowMs = 5000L; + pmRateLimitMaxActions = 4; + pmRateLimitBlockMs = 10000L; + pmRateLimitMessage = "&cDu sendest zu viele private Nachrichten. Bitte warte kurz."; + + Configuration rl = config.getSection("rate-limit"); + if (rl != null) { + Configuration rlChat = rl.getSection("chat"); + if (rlChat != null) { + filterConfig.globalRateLimitEnabled = rlChat.getBoolean("enabled", true); + filterConfig.globalRateLimitWindowMs = rlChat.getLong("window-ms", filterConfig.globalRateLimitWindowMs); + filterConfig.globalRateLimitMaxActions = rlChat.getInt("max-actions", filterConfig.globalRateLimitMaxActions); + filterConfig.globalRateLimitBlockMs = rlChat.getLong("block-ms", filterConfig.globalRateLimitBlockMs); + filterConfig.spamMessage = rlChat.getString("message", filterConfig.spamMessage); + } + + Configuration rlPm = rl.getSection("private-messages"); + if (rlPm != null) { + pmRateLimitEnabled = rlPm.getBoolean("enabled", pmRateLimitEnabled); + pmRateLimitWindowMs = rlPm.getLong("window-ms", pmRateLimitWindowMs); + pmRateLimitMaxActions = rlPm.getInt("max-actions", pmRateLimitMaxActions); + pmRateLimitBlockMs = rlPm.getLong("block-ms", pmRateLimitBlockMs); + pmRateLimitMessage = rlPm.getString("message", pmRateLimitMessage); + } + } + // --- Mentions --- Configuration mn = config.getSection("mentions"); if (mn != null) { @@ -445,6 +481,11 @@ public class ChatConfig { public String getPmFormatReceiver() { return pmFormatReceiver; } public String getPmFormatSpy() { return pmFormatSpy; } public String getPmSpyPermission() { return pmSpyPermission; } + public boolean isPmRateLimitEnabled() { return pmRateLimitEnabled; } + public long getPmRateLimitWindowMs() { return pmRateLimitWindowMs; } + public int getPmRateLimitMaxActions() { return pmRateLimitMaxActions; } + public long getPmRateLimitBlockMs() { return pmRateLimitBlockMs; } + public String getPmRateLimitMessage() { return pmRateLimitMessage; } public int getDefaultMuteDuration() { return defaultMuteDuration; } public String getMutedMessage() { return mutedMessage; } diff --git a/src/main/java/net/viper/status/modules/chat/ChatFilter.java b/src/main/java/net/viper/status/modules/chat/ChatFilter.java index c44063f..81ab01a 100644 --- a/src/main/java/net/viper/status/modules/chat/ChatFilter.java +++ b/src/main/java/net/viper/status/modules/chat/ChatFilter.java @@ -1,5 +1,7 @@ package net.viper.status.modules.chat; +import net.viper.status.ratelimit.GlobalRateLimitFramework; + import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; @@ -17,13 +19,10 @@ import java.util.regex.Pattern; public class ChatFilter { private final ChatFilterConfig cfg; + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); - // UUID → letzter Nachricht-Zeitstempel (ms) - private final Map lastMessageTime = new ConcurrentHashMap<>(); // UUID → letzte Nachricht (für Duplikat-Check) private final Map lastMessageText = new ConcurrentHashMap<>(); - // UUID → Spam-Zähler (aufeinanderfolgende schnelle Nachrichten) - private final Map spamCount = new ConcurrentHashMap<>(); // Kompilierte Regex-Pattern für Blacklist-Wörter private final List blacklistPatterns = new ArrayList<>(); @@ -79,17 +78,21 @@ public class ChatFilter { // ── 1. Spam-Cooldown ── if (cfg.antiSpamEnabled && !isAdmin) { - long now = System.currentTimeMillis(); - Long last = lastMessageTime.get(uuid); - if (last != null && (now - last) < cfg.spamCooldownMs) { - int count = spamCount.merge(uuid, 1, Integer::sum); - if (count >= cfg.spamMaxMessages) { + if (cfg.globalRateLimitEnabled) { + GlobalRateLimitFramework.Result rl = rateLimiter.check( + "chat.message", + uuid.toString(), + new GlobalRateLimitFramework.Rule( + true, + cfg.globalRateLimitWindowMs, + cfg.globalRateLimitMaxActions, + cfg.globalRateLimitBlockMs + ) + ); + if (rl.isBlocked()) { return new FilterResponse(FilterResult.BLOCKED, message, cfg.spamMessage); } - } else { - spamCount.put(uuid, 0); } - lastMessageTime.put(uuid, now); } // ── 2. Duplikat-Check ── @@ -201,9 +204,8 @@ public class ChatFilter { // ===== Cleanup beim Logout ===== public void cleanup(UUID uuid) { - lastMessageTime.remove(uuid); lastMessageText.remove(uuid); - spamCount.remove(uuid); + rateLimiter.clearActor(uuid.toString()); } // ===== Konfigurationsklasse ===== @@ -211,10 +213,16 @@ public class ChatFilter { public static class ChatFilterConfig { // Anti-Spam public boolean antiSpamEnabled = true; - public long spamCooldownMs = 1500; // ms zwischen Nachrichten - public int spamMaxMessages = 3; // max. Nachrichten innerhalb Cooldown + public long spamCooldownMs = 1500; // Legacy-Feld fuer Kompatibilitaet + public int spamMaxMessages = 3; // Legacy-Feld fuer Kompatibilitaet public String spamMessage = "&cBitte nicht so schnell schreiben!"; + // Globales Rate-Limit-Framework + public boolean globalRateLimitEnabled = true; + public long globalRateLimitWindowMs = 2500; + public int globalRateLimitMaxActions = 3; + public long globalRateLimitBlockMs = 6000; + // Duplikat public boolean duplicateCheckEnabled = true; public String duplicateMessage = "&cBitte keine identischen Nachrichten senden."; diff --git a/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java b/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java index c40ff2f..ca907a3 100644 --- a/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java +++ b/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java @@ -4,6 +4,7 @@ import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.viper.status.ratelimit.GlobalRateLimitFramework; import java.util.Map; import java.util.UUID; @@ -15,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; public class PrivateMsgManager { private final BlockManager blockManager; + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); // UUID → letzte PM-Gesprächspartner UUID (für /r) private final Map lastPartner = new ConcurrentHashMap<>(); @@ -55,6 +57,24 @@ public class PrivateMsgManager { sender.sendMessage(color("&cDieser Spieler hat dich blockiert oder du hast ihn blockiert.")); return false; } + + if (config.isPmRateLimitEnabled()) { + GlobalRateLimitFramework.Result result = rateLimiter.check( + "chat.pm", + sender.getUniqueId().toString(), + new GlobalRateLimitFramework.Rule( + true, + config.getPmRateLimitWindowMs(), + config.getPmRateLimitMaxActions(), + config.getPmRateLimitBlockMs() + ) + ); + + if (result.isBlocked()) { + sender.sendMessage(color(config.getPmRateLimitMessage())); + return false; + } + } } // Formatierung diff --git a/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java b/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java index 26e9399..6e733bc 100644 --- a/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java +++ b/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java @@ -10,6 +10,7 @@ import net.md_5.bungee.api.plugin.Plugin; import net.md_5.bungee.event.EventHandler; import net.viper.status.StatusAPI; import net.viper.status.module.Module; +import net.viper.status.ratelimit.GlobalRateLimitFramework; import java.io.File; import java.io.FileInputStream; @@ -27,6 +28,13 @@ public class CommandBlockerModule implements Module, Listener { private File file; private Set blocked = new HashSet<>(); + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); + + private boolean commandRateLimitEnabled = true; + private long commandRateLimitWindowMs = 3000L; + private int commandRateLimitMaxActions = 8; + private long commandRateLimitBlockMs = 6000L; + private String commandRateLimitMessage = "&cZu viele Befehle in kurzer Zeit. Bitte warte kurz."; @Override public String getName() { @@ -75,6 +83,25 @@ public class CommandBlockerModule implements Module, Listener { String msg = event.getMessage(); if (msg == null || msg.length() <= 1) return; + if (commandRateLimitEnabled) { + GlobalRateLimitFramework.Result result = rateLimiter.check( + "chat.command", + player.getUniqueId().toString(), + new GlobalRateLimitFramework.Rule( + true, + commandRateLimitWindowMs, + commandRateLimitMaxActions, + commandRateLimitBlockMs + ) + ); + + if (result.isBlocked()) { + event.setCancelled(true); + player.sendMessage(ChatColor.translateAlternateColorCodes('&', commandRateLimitMessage)); + return; + } + } + String cmd = msg.substring(1).toLowerCase(Locale.ROOT); String base = cmd.split(" ")[0]; @@ -154,6 +181,21 @@ public class CommandBlockerModule implements Module, Listener { } } + if (data != null && data.containsKey("rate-limit")) { + Object rlObj = data.get("rate-limit"); + if (rlObj instanceof Map) { + Map rl = (Map) rlObj; + commandRateLimitEnabled = parseBoolean(rl.get("enabled"), commandRateLimitEnabled); + commandRateLimitWindowMs = parseLong(rl.get("window-ms"), commandRateLimitWindowMs); + commandRateLimitMaxActions = (int) parseLong(rl.get("max-actions"), commandRateLimitMaxActions); + commandRateLimitBlockMs = parseLong(rl.get("block-ms"), commandRateLimitBlockMs); + Object msgObj = rl.get("message"); + if (msgObj != null) { + commandRateLimitMessage = String.valueOf(msgObj); + } + } + } + } catch (Exception e) { if (plugin != null) plugin.getLogger().severe("[CommandBlocker] Fehler beim Laden: " + e.getMessage()); else System.err.println("[CommandBlocker] Fehler beim Laden: " + e.getMessage()); @@ -165,6 +207,15 @@ public class CommandBlockerModule implements Module, Listener { Yaml yaml = new Yaml(); Map out = new LinkedHashMap<>(); out.put("blocked", new ArrayList<>(blocked)); + + Map rl = new LinkedHashMap<>(); + rl.put("enabled", commandRateLimitEnabled); + rl.put("window-ms", commandRateLimitWindowMs); + rl.put("max-actions", commandRateLimitMaxActions); + rl.put("block-ms", commandRateLimitBlockMs); + rl.put("message", commandRateLimitMessage); + out.put("rate-limit", rl); + FileWriter fw = null; try { fw = new FileWriter(file); @@ -177,4 +228,18 @@ public class CommandBlockerModule implements Module, Listener { else System.err.println("[CommandBlocker] Fehler beim Speichern: " + e.getMessage()); } } + + private boolean parseBoolean(Object obj, boolean fallback) { + if (obj == null) return fallback; + return Boolean.parseBoolean(String.valueOf(obj)); + } + + private long parseLong(Object obj, long fallback) { + if (obj == null) return fallback; + try { + return Long.parseLong(String.valueOf(obj)); + } catch (Exception ignored) { + return fallback; + } + } } diff --git a/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java index 27a4199..1b80105 100644 --- a/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java +++ b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java @@ -8,6 +8,7 @@ import net.md_5.bungee.api.config.ServerInfo; import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.plugin.Command; import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.scheduler.ScheduledTask; import net.viper.status.module.Module; import java.io.File; @@ -55,11 +56,18 @@ public class NetworkInfoModule implements Module { private int alertMemoryPercent = 90; private int alertPlayerPercent = 95; private int alertCooldownSeconds = 300; + private boolean alertTpsEnabled = true; + private double alertTpsThreshold = 18.0D; private boolean attackNotificationsEnabled = true; private String attackApiKey = ""; private String attackDefaultSource = "BetterBungee"; private long lastMemoryAlertAt = 0L; private long lastPlayerAlertAt = 0L; + private long lastTpsAlertAt = 0L; + private volatile double currentProxyTps = 20.0D; + private long lastTpsSampleAtMs = 0L; + private ScheduledTask alertTask; + private ScheduledTask tpsSamplerTask; @Override public String getName() { @@ -82,6 +90,8 @@ public class NetworkInfoModule implements Module { ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new NetInfoCommand()); } + tpsSamplerTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::sampleProxyTps, 1, 1, TimeUnit.SECONDS); + if (webhookEnabled && !webhookUrl.isEmpty()) { if (webhookNotifyStartStop) { boolean delivered = sendLifecycleStartNotification(); @@ -96,7 +106,7 @@ public class NetworkInfoModule implements Module { } } int interval = Math.max(10, webhookCheckSeconds); - ProxyServer.getInstance().getScheduler().schedule(plugin, this::evaluateAndSendAlerts, interval, interval, TimeUnit.SECONDS); + alertTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::evaluateAndSendAlerts, interval, interval, TimeUnit.SECONDS); } plugin.getLogger().info("[NetworkInfoModule] aktiviert. commandEnabled=" + commandEnabled + ", includePlayerNames=" + includePlayerNames + ", webhookEnabled=" + webhookEnabled + ", notifyStartStop=" + webhookNotifyStartStop + ", webhookUrlPresent=" + !webhookUrl.isEmpty()); @@ -104,6 +114,15 @@ public class NetworkInfoModule implements Module { @Override public void onDisable(Plugin plugin) { + if (alertTask != null) { + alertTask.cancel(); + alertTask = null; + } + if (tpsSamplerTask != null) { + tpsSamplerTask.cancel(); + tpsSamplerTask = null; + } + if (enabled && webhookEnabled && webhookNotifyStartStop && webhookUrl != null && !webhookUrl.isEmpty()) { boolean delivered = sendLifecycleStopNotification(); if (!delivered) { @@ -262,6 +281,7 @@ public class NetworkInfoModule implements Module { system.put("os_arch", System.getProperty("os.arch")); system.put("available_processors", Runtime.getRuntime().availableProcessors()); system.put("system_load_percent", getSystemLoadPercent()); + system.put("proxy_tps", roundDouble(currentProxyTps, 2)); out.put("enabled", true); out.put("timestamp_unix", now / 1000L); @@ -405,6 +425,8 @@ public class NetworkInfoModule implements Module { alertMemoryPercent = 90; alertPlayerPercent = 95; alertCooldownSeconds = 300; + alertTpsEnabled = true; + alertTpsThreshold = 18.0D; attackNotificationsEnabled = true; attackApiKey = ""; attackDefaultSource = "BetterBungee"; @@ -428,6 +450,8 @@ public class NetworkInfoModule implements Module { alertMemoryPercent = parseInt(props.getProperty("networkinfo.alert.memory_percent", "90"), 90); alertPlayerPercent = parseInt(props.getProperty("networkinfo.alert.player_percent", "95"), 95); alertCooldownSeconds = parseInt(props.getProperty("networkinfo.alert.cooldown_seconds", "300"), 300); + alertTpsEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.alert.tps_enabled", "true")); + alertTpsThreshold = parseDouble(props.getProperty("networkinfo.alert.tps_threshold", "18.0"), 18.0D); attackNotificationsEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.attack.enabled", "true")); attackApiKey = props.getProperty("networkinfo.attack.api_key", "").trim(); attackDefaultSource = props.getProperty("networkinfo.attack.source", "BetterBungee").trim(); @@ -446,6 +470,8 @@ public class NetworkInfoModule implements Module { alertMemoryPercent = 90; alertPlayerPercent = 95; alertCooldownSeconds = 300; + alertTpsEnabled = true; + alertTpsThreshold = 18.0D; attackNotificationsEnabled = true; attackApiKey = ""; attackDefaultSource = "BetterBungee"; @@ -494,6 +520,7 @@ public class NetworkInfoModule implements Module { int memoryPercent = toInt(memory.get("usage_percent")); int playerPercent = toInt(players.get("occupancy_percent")); + double proxyTps = currentProxyTps; if (memoryPercent >= Math.max(1, alertMemoryPercent) && canSend(lastMemoryAlertAt, now)) { lastMemoryAlertAt = now; @@ -542,6 +569,53 @@ public class NetworkInfoModule implements Module { ); } } + + if (alertTpsEnabled && proxyTps > 0D && proxyTps < Math.max(1D, alertTpsThreshold) && canSend(lastTpsAlertAt, now)) { + lastTpsAlertAt = now; + String tpsText = String.format(Locale.ROOT, "%.2f", proxyTps); + String thresholdText = String.format(Locale.ROOT, "%.2f", Math.max(1D, alertTpsThreshold)); + + if (isCompactEmbedMode()) { + sendWebhookEmbed( + webhookUrl, + "🟥 Niedrige Proxy-TPS", + "Aktuell: **" + tpsText + " TPS**\nSchwelle: **" + thresholdText + " TPS**", + 0xE74C3C + ); + } else { + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Proxy TPS", tpsText, true); + appendEmbedField(fields, "Schwelle", thresholdText, true); + appendEmbedField(fields, "Check-Intervall", Math.max(10, webhookCheckSeconds) + "s", true); + sendWebhookEmbed( + webhookUrl, + "🟥 Niedrige Proxy-TPS", + "Die gemessene Proxy-TPS liegt unter der konfigurierten Schwelle.", + 0xE74C3C, + fields.toString() + ); + } + } + } + + private void sampleProxyTps() { + long now = System.currentTimeMillis(); + if (lastTpsSampleAtMs <= 0L) { + lastTpsSampleAtMs = now; + currentProxyTps = 20.0D; + return; + } + + long deltaMs = now - lastTpsSampleAtMs; + lastTpsSampleAtMs = now; + if (deltaMs <= 0L) { + return; + } + + // 1s Scheduler-Tick sollte etwa 20 TPS entsprechen. Abweichung zeigt Main-Thread-Lag. + double instantTps = (1000.0D / (double) deltaMs) * 20.0D; + instantTps = Math.max(0.1D, Math.min(20.0D, instantTps)); + currentProxyTps = (currentProxyTps * 0.7D) + (instantTps * 0.3D); } private boolean isCompactEmbedMode() { @@ -561,6 +635,14 @@ public class NetworkInfoModule implements Module { } } + private double parseDouble(String s, double fallback) { + try { + return Double.parseDouble(s == null ? "" : s.trim()); + } catch (Exception ignored) { + return fallback; + } + } + private int toInt(Object o) { if (o instanceof Number) { return ((Number) o).intValue(); @@ -572,6 +654,11 @@ public class NetworkInfoModule implements Module { } } + private double roundDouble(double value, int digits) { + double factor = Math.pow(10D, Math.max(0, digits)); + return Math.round(value * factor) / factor; + } + private boolean sendWebhookEmbed(String targetWebhookUrl, String title, String description, int color) { return sendWebhookEmbed(targetWebhookUrl, title, description, color, null, true); } @@ -733,6 +820,8 @@ public class NetworkInfoModule implements Module { @SuppressWarnings("unchecked") Map memory = (Map) snapshot.get("memory"); @SuppressWarnings("unchecked") + Map system = (Map) snapshot.get("system"); + @SuppressWarnings("unchecked") Map ping = (Map) players.get("ping"); sender.sendMessage(ChatColor.GOLD + "----- StatusAPI NetworkInfo -----"); @@ -740,6 +829,7 @@ public class NetworkInfoModule implements Module { sender.sendMessage(ChatColor.YELLOW + "Spieler: " + ChatColor.WHITE + players.get("online") + "/" + players.get("max") + ChatColor.GRAY + " (Bedrock: " + players.get("bedrock_online") + ")"); sender.sendMessage(ChatColor.YELLOW + "Ping: " + ChatColor.WHITE + "avg " + ping.get("avg_ms") + "ms, min " + ping.get("min_ms") + "ms, max " + ping.get("max_ms") + "ms"); sender.sendMessage(ChatColor.YELLOW + "RAM: " + ChatColor.WHITE + memory.get("used_mb") + "MB / " + memory.get("max_mb") + "MB" + ChatColor.GRAY + " (" + memory.get("usage_percent") + "%)"); + sender.sendMessage(ChatColor.YELLOW + "Proxy TPS: " + ChatColor.WHITE + system.get("proxy_tps") + ChatColor.GRAY + " (Alert < " + String.format(Locale.ROOT, "%.2f", alertTpsThreshold) + ")"); sender.sendMessage(ChatColor.YELLOW + "Backends: " + ChatColor.WHITE + ((List) snapshot.get("backend_servers")).size()); } } diff --git a/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java b/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java new file mode 100644 index 0000000..672c445 --- /dev/null +++ b/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java @@ -0,0 +1,119 @@ +package net.viper.status.ratelimit; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Gemeinsames Rate-Limit-Framework fuer mehrere Module. + */ +public final class GlobalRateLimitFramework { + + private static final GlobalRateLimitFramework INSTANCE = new GlobalRateLimitFramework(); + + private final Map buckets = new ConcurrentHashMap(); + + private GlobalRateLimitFramework() { + } + + public static GlobalRateLimitFramework getInstance() { + return INSTANCE; + } + + public Result check(String scope, String actorId, Rule rule) { + return check(scope, actorId, rule, System.currentTimeMillis()); + } + + public Result check(String scope, String actorId, Rule rule, long now) { + if (rule == null || !rule.enabled || scope == null || scope.isEmpty() || actorId == null || actorId.isEmpty()) { + return Result.allowed(0, 0L); + } + + String key = scope + ":" + actorId; + Bucket bucket = buckets.computeIfAbsent(key, k -> new Bucket()); + + synchronized (bucket) { + if (bucket.blockedUntil > now) { + return Result.blocked(Math.max(0L, bucket.blockedUntil - now), bucket.hits.size()); + } + + long minTs = now - Math.max(1L, rule.windowMs); + while (!bucket.hits.isEmpty() && bucket.hits.peekFirst() < minTs) { + bucket.hits.pollFirst(); + } + + bucket.hits.addLast(now); + + if (bucket.hits.size() > Math.max(1, rule.maxActions)) { + long blockMs = Math.max(1L, rule.blockMs); + bucket.blockedUntil = now + blockMs; + return Result.blocked(blockMs, bucket.hits.size()); + } + + return Result.allowed(bucket.hits.size(), 0L); + } + } + + public void clearActor(String actorId) { + if (actorId == null || actorId.isEmpty()) { + return; + } + for (String key : buckets.keySet()) { + if (key.endsWith(":" + actorId)) { + buckets.remove(key); + } + } + } + + public static final class Rule { + public final boolean enabled; + public final long windowMs; + public final int maxActions; + public final long blockMs; + + public Rule(boolean enabled, long windowMs, int maxActions, long blockMs) { + this.enabled = enabled; + this.windowMs = Math.max(1L, windowMs); + this.maxActions = Math.max(1, maxActions); + this.blockMs = Math.max(1L, blockMs); + } + } + + public static final class Result { + private final boolean blocked; + private final int currentHits; + private final long remainingBlockMs; + + private Result(boolean blocked, int currentHits, long remainingBlockMs) { + this.blocked = blocked; + this.currentHits = Math.max(0, currentHits); + this.remainingBlockMs = Math.max(0L, remainingBlockMs); + } + + public static Result blocked(long remainingBlockMs, int currentHits) { + return new Result(true, currentHits, remainingBlockMs); + } + + public static Result allowed(int currentHits, long remainingBlockMs) { + return new Result(false, currentHits, remainingBlockMs); + } + + public boolean isBlocked() { + return blocked; + } + + public int getCurrentHits() { + return currentHits; + } + + public long getRemainingBlockMs() { + return remainingBlockMs; + } + } + + private static final class Bucket { + final Deque hits = new ArrayDeque(); + long blockedUntil = 0L; + } +} \ No newline at end of file diff --git a/src/main/resources/chat.yml b/src/main/resources/chat.yml index a6b6885..0238c6d 100644 --- a/src/main/resources/chat.yml +++ b/src/main/resources/chat.yml @@ -135,6 +135,25 @@ private-messages: format-social-spy: "&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}" social-spy-permission: "chat.socialspy" +# ============================================================ +# GLOBALES RATE-LIMIT-FRAMEWORK +# Zentraler Schutz für Chat/PM/Command-Flood. +# ============================================================ +rate-limit: + chat: + enabled: true + window-ms: 2500 + max-actions: 3 + block-ms: 6000 + message: "&cBitte nicht so schnell schreiben!" + + private-messages: + enabled: true + window-ms: 5000 + max-actions: 4 + block-ms: 10000 + message: "&cDu sendest zu viele private Nachrichten. Bitte warte kurz." + # ============================================================ # MUTE # ============================================================ diff --git a/src/main/resources/network-guard.properties b/src/main/resources/network-guard.properties index be32267..a040f73 100644 --- a/src/main/resources/network-guard.properties +++ b/src/main/resources/network-guard.properties @@ -8,7 +8,7 @@ networkinfo.include_player_names=false # Discord Webhook fuer Status-, Warn- und Attack-Meldungen networkinfo.webhook.enabled=true -networkinfo.webhook.url= +networkinfo.webhook.url=https://discord.com/api/webhooks/1488630083164831844/o7L5Mhy5P_xE_n-2Dq9usIVX40o7fCpPHgaGQOVIQHjfK7SDrVJbdeZM-G6vVRVhvzT9 networkinfo.webhook.username=StatusAPI networkinfo.webhook.thumbnail_url= networkinfo.webhook.notify_start_stop=true @@ -20,6 +20,9 @@ networkinfo.webhook.check_seconds=30 networkinfo.alert.memory_percent=90 networkinfo.alert.player_percent=95 networkinfo.alert.cooldown_seconds=300 +# Proxy-TPS Alert (20.0 = perfekt, Werte < 20 zeigen Main-Thread-Lag am Proxy) +networkinfo.alert.tps_enabled=true +networkinfo.alert.tps_threshold=18.0 # Attack Meldungen (Detected/Stopped) networkinfo.attack.enabled=true @@ -61,6 +64,26 @@ antibot.vpn_check.block_hosting=true antibot.vpn_check.cache_minutes=30 antibot.vpn_check.timeout_ms=2500 +# Sicherheitslog fuer Angreifer/VPN/Proxy-Events (mit Name/UUID falls verfuegbar) +antibot.security_log.enabled=true +antibot.security_log.file=antibot-security.log + +# Lernmodus: Muster mitschreiben, Score bilden und erst ab Schwellwert blockieren. +antibot.learning.enabled=true +antibot.learning.score_threshold=100 +antibot.learning.decay_per_second=2 +antibot.learning.state_window_seconds=120 + +# Punktelogik pro Muster +antibot.learning.rapid.window_ms=1500 +antibot.learning.rapid.points=12 +antibot.learning.ip_rate_exceeded.points=30 +antibot.learning.vpn_proxy.points=40 +antibot.learning.vpn_hosting.points=30 +antibot.learning.attack_mode.points=12 +antibot.learning.high_cps.points=10 +antibot.learning.recent_events.limit=30 + # =========================== # BACKEND JOIN GUARD SYNC (optional) # ===========================