From 3cb39dabb7beb5887853e27dbedea375ebc28df4 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 13 Nov 2026 12:09:32 +0000 Subject: [PATCH] Upload via GUI (60 Dateien) --- .../main/java/net/viper/status/StatusAPI.java | 214 ++++++++++++++-- .../status/modules/antibot/AntiBotModule.java | 144 ++++++++++- .../modules/broadcast/BroadcastModule.java | 6 +- .../modules/chat/AccountLinkManager.java | 4 +- .../status/modules/chat/BlockManager.java | 4 +- .../status/modules/chat/MuteManager.java | 4 +- .../status/modules/chat/ReportManager.java | 4 +- .../customcommands/CustomCommandModule.java | 21 +- .../modules/customcommands/ForwardSender.java | 218 ++++++++-------- .../modules/forum/ForumBridgeModule.java | 4 +- .../modules/forum/ForumNotifStorage.java | 4 +- .../modules/network/NetworkInfoModule.java | 2 +- .../status/modules/vanish/VanishModule.java | 4 +- .../ratelimit/GlobalRateLimitFramework.java | 236 +++++++++--------- .../net/viper/status/stats/StatsManager.java | 68 ++--- .../net/viper/status/util/ConfigMigrator.java | 85 +++++++ .../status/util/SafeFileOutputStream.java | 66 +++++ 17 files changed, 787 insertions(+), 301 deletions(-) create mode 100644 StatusAPI/src/main/java/net/viper/status/util/ConfigMigrator.java create mode 100644 StatusAPI/src/main/java/net/viper/status/util/SafeFileOutputStream.java diff --git a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java index 11ea4b5..f2de683 100644 --- a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java +++ b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java @@ -110,6 +110,27 @@ public class StatusAPI extends Plugin implements Runnable, Listener { mergeVerifyConfig(); + // Bestehende network-guard.properties um neue Optionen ergaenzen (vorhandene Werte bleiben unveraendert) + net.viper.status.util.ConfigMigrator.migrateProperties( + new File(getDataFolder(), "network-guard.properties"), + new String[][] { + {"api.write_key", "", + "API-Key fuer schreibende Endpunkte (/economy/update, /stats/update, /scoreboard/*, /player/* ...).\nWird als Header X-API-Key erwartet. Leer = KEINE Pruefung. Muss identisch zu api-key in den Bridges sein."}, + {"api.securitylog.key", "", + "Optionaler Key fuer GET /antibot/security-log (Namen, UUIDs, IPs). Leer = offen."}, + {"api.ratelimit.enabled", "true", "Rate-Limit pro Client-IP fuer den HTTP-Server"}, + {"api.ratelimit.max_per_minute", "300", null}, + {"api.ratelimit.block_seconds", "30", null}, + {"api.ratelimit.exempt_private", "true", + "Anfragen mit gueltigem api.write_key und Clients aus Loopback-/Privatnetzen sind nie rate-limitiert.\nHinter einem Reverse-Proxy (z. B. NPM) auf false setzen und api.write_key nutzen."}, + {"antibot.whitelist.ips", "", + "AntiBot-Whitelist (kommagetrennt, auch per /antibot whitelist add|remove|list). IPs/CIDR, z. B. 203.0.113.5 oder 10.0.0.0/24."}, + {"antibot.whitelist.players", "", + "Spielernamen: nach erfolgreichem Login wird die IP des Spielers fuer trust_hours vertraut."}, + {"antibot.whitelist.trust_hours", "24", null}, + }, + getLogger()); + // Port aus verify.properties lesen String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191"; try { @@ -197,11 +218,20 @@ public class StatusAPI extends Plugin implements Runnable, Listener { // WebServer starten shuttingDown = false; - requestExecutor = Executors.newFixedThreadPool(4, r -> { - Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); - t.setDaemon(true); - return t; - }); + // Begrenzte Warteschlange: bei Ueberlast werden Verbindungen sofort geschlossen + // (RejectedExecutionException in submitConnection), statt unbegrenzt zu stauen. + requestExecutor = new java.util.concurrent.ThreadPoolExecutor( + 8, 8, 0L, TimeUnit.MILLISECONDS, + new java.util.concurrent.LinkedBlockingQueue(64), + r -> { + Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); + t.setDaemon(true); + return t; + }, + new java.util.concurrent.ThreadPoolExecutor.AbortPolicy()); + if (getApiWriteKey().isEmpty()) { + getLogger().warning("[StatusAPI] api.write_key ist leer: schreibende Endpunkte (/economy/update, /stats/update, ...) sind OHNE Authentifizierung erreichbar! Bitte in network-guard.properties setzen."); + } startHttpServerThread(); httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule( this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS); @@ -488,6 +518,11 @@ public class StatusAPI extends Plugin implements Runnable, Listener { if (inputLine == null) return; lastHttpRequestAt.set(System.currentTimeMillis()); + if (inputLine.length() > 2048) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"uri_too_long\"}", 400); + return; + } + String[] reqParts = inputLine.split(" "); if (reqParts.length < 2) return; String method = reqParts[0].trim(); @@ -504,19 +539,15 @@ public class StatusAPI extends Plugin implements Runnable, Listener { return; } - // GET /antibot/security-log - 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; - } - - // Headers lesen + // Headers lesen (max. 64 Header, je max. 8 KB) Map headers = new HashMap<>(); String line; + int headerCount = 0; while ((line = in.readLine()) != null && !line.isEmpty()) { + if (++headerCount > 64 || line.length() > 8192) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"headers_too_large\"}", 400); + return; + } int idx = line.indexOf(':'); if (idx > 0) { headers.put(line.substring(0, idx).trim().toLowerCase(Locale.ROOT), @@ -524,12 +555,61 @@ public class StatusAPI extends Plugin implements Runnable, Listener { } } + // Rate-Limit pro Client-IP (schuetzt vor Flooding des HTTP-Servers). + // Ausgenommen: Anfragen mit gueltigem api.write_key sowie Loopback-/Privatnetz-Clients + // (Bridges der Unterserver senden sehr viele Updates pro Sekunde und duerfen nicht gedrosselt werden, + // sonst zeigen Scoreboard/Tablist veraltete Werte). + String clientIp = "unknown"; + boolean trustedClient = false; + if (clientSocket.getInetAddress() != null) { + java.net.InetAddress ca = clientSocket.getInetAddress(); + clientIp = ca.getHostAddress(); + boolean exemptPrivate = Boolean.parseBoolean( + getApiSettings().getProperty("api.ratelimit.exempt_private", "true").trim()); + trustedClient = exemptPrivate + && (ca.isLoopbackAddress() || ca.isSiteLocalAddress() || ca.isLinkLocalAddress()); + } + String rlWriteKey = getApiWriteKey(); + if (!rlWriteKey.isEmpty() && safeEquals(rlWriteKey, headerApiKey(headers))) trustedClient = true; + if (!trustedClient) { + net.viper.status.ratelimit.GlobalRateLimitFramework.Result rl = + net.viper.status.ratelimit.GlobalRateLimitFramework.getInstance() + .check("http-api", clientIp, getApiRateRule()); + if (rl.isBlocked()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"rate_limited\"}", 429); + return; + } + } + + // Schreibende Endpunkte ohne eigenen Key: zentrale Pruefung (api.write_key) + if ("POST".equalsIgnoreCase(method) && requiresWriteKey(pathOnly)) { + String writeKey = getApiWriteKey(); + if (!writeKey.isEmpty() && !safeEquals(writeKey, headerApiKey(headers))) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + } + + // GET /antibot/security-log (optional per api.securitylog.key geschuetzt) + if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) { + String logKey = getApiSettings().getProperty("api.securitylog.key", "").trim(); + if (!logKey.isEmpty() && !safeEquals(logKey, headerApiKey(headers))) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + payload.put("events", loadAntiBotSecurityEvents(250)); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + // GET /network/backendguard/config if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) { Properties guardProps = loadNetworkGuardProperties(); String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim(); String providedApiKey = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); - if (!requiredApiKey.isEmpty() && !requiredApiKey.equals(providedApiKey == null ? "" : providedApiKey.trim())) { + if (!requiredApiKey.isEmpty() && !safeEquals(requiredApiKey, providedApiKey == null ? "" : providedApiKey.trim())) { sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); return; } @@ -1244,7 +1324,30 @@ public class StatusAPI extends Plugin implements Runnable, Listener { return new String(bodyChars); } + /** + * Liest einen Top-Level-Wert aus einem JSON-Objekt (Gson). Schluessel in verschachtelten + * Objekten oder innerhalb von Strings werden nicht mehr faelschlich gefunden. + * Faellt bei ungueltigem JSON auf den alten Text-Parser zurueck (Kompatibilitaet). + */ private String extractJsonString(String json, String key) { + if (json == null || key == null) return null; + try { + com.google.gson.JsonElement root = com.google.gson.JsonParser.parseString(json); + if (root != null && root.isJsonObject()) { + com.google.gson.JsonObject obj = root.getAsJsonObject(); + if (!obj.has(key)) return null; + com.google.gson.JsonElement el = obj.get(key); + if (el == null || el.isJsonNull()) return null; + if (el.isJsonPrimitive()) return el.getAsString().trim(); + return el.toString(); + } + } catch (Exception | LinkageError ignored) { + // kein gueltiges JSON (oder aeltere Gson-Version) -> Legacy-Parser + } + return extractJsonStringLegacy(json, key); + } + + private String extractJsonStringLegacy(String json, String key) { if (json == null || key == null) return null; String search = "\"" + key + "\""; int idx = json.indexOf(search); @@ -1304,6 +1407,59 @@ public class StatusAPI extends Plugin implements Runnable, Listener { } } + // ── API-Auth / Rate-Limit Helfer ────────────────────────────────────────── + + private volatile Properties apiSettingsCache = null; + private volatile long apiSettingsLoadedAt = 0L; + + /** network-guard.properties mit 10s Cache (nicht bei jedem Request von Platte lesen). */ + private Properties getApiSettings() { + long now = System.currentTimeMillis(); + Properties cached = apiSettingsCache; + if (cached != null && now - apiSettingsLoadedAt < 10_000L) return cached; + Properties fresh = loadNetworkGuardProperties(); + apiSettingsCache = fresh; + apiSettingsLoadedAt = now; + return fresh; + } + + private String getApiWriteKey() { + return getApiSettings().getProperty("api.write_key", "").trim(); + } + + private net.viper.status.ratelimit.GlobalRateLimitFramework.Rule getApiRateRule() { + Properties p = getApiSettings(); + boolean enabled = Boolean.parseBoolean(p.getProperty("api.ratelimit.enabled", "true").trim()); + int max = 300; + long blockSec = 30; + try { max = Integer.parseInt(p.getProperty("api.ratelimit.max_per_minute", "300").trim()); } catch (NumberFormatException ignored) {} + try { blockSec = Long.parseLong(p.getProperty("api.ratelimit.block_seconds", "30").trim()); } catch (NumberFormatException ignored) {} + return new net.viper.status.ratelimit.GlobalRateLimitFramework.Rule(enabled, 60_000L, max, blockSec * 1000L); + } + + /** Endpunkte, die Daten veraendern und keinen eigenen API-Key haben. */ + private boolean requiresWriteKey(String pathOnly) { + if (pathOnly == null) return false; + String p = pathOnly.toLowerCase(Locale.ROOT); + return p.equals("/economy/update") || p.equals("/punishment/update") || p.equals("/stats/update") + || p.equals("/ticket/update") || p.equals("/broadcast/cancel") || p.equals("/cancel") + || p.startsWith("/scoreboard/") || p.startsWith("/player/"); + } + + private static String headerApiKey(Map headers) { + String v = headers.get("x-api-key"); + if (v == null) v = headers.get("x-apikey"); + return v == null ? "" : v.trim(); + } + + /** Konstantzeit-Vergleich (verhindert Timing-Angriffe auf API-Keys). */ + public static boolean safeEquals(String expected, String provided) { + if (expected == null || provided == null) return false; + return java.security.MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + provided.getBytes(StandardCharsets.UTF_8)); + } + private Properties loadNetworkGuardProperties() { Properties props = new Properties(); File file = new File(getDataFolder(), "network-guard.properties"); @@ -1358,7 +1514,7 @@ public class StatusAPI extends Plugin implements Runnable, Listener { private void sendHttpResponse(OutputStream out, String json, int code) throws IOException { byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); - String status = code == 200 ? "OK" : (code == 403 ? "Forbidden" : code == 404 ? "Not Found" : code == 400 ? "Bad Request" : "Error"); + String status = code == 200 ? "OK" : (code == 403 ? "Forbidden" : code == 404 ? "Not Found" : code == 400 ? "Bad Request" : code == 429 ? "Too Many Requests" : "Error"); StringBuilder response = new StringBuilder(); response.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n"); response.append("Content-Type: application/json; charset=UTF-8\r\n"); @@ -1409,12 +1565,29 @@ public class StatusAPI extends Plugin implements Runnable, Listener { return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); } + /** Liest nur die letzten maxBytes einer Datei (statt die ganze, ggf. riesige Datei zu laden). */ + private List readTailLines(File file, int maxBytes) throws IOException { + try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file, "r")) { + long len = raf.length(); + long start = Math.max(0L, len - maxBytes); + raf.seek(start); + byte[] buf = new byte[(int) (len - start)]; + raf.readFully(buf); + String text = new String(buf, StandardCharsets.UTF_8); + if (start > 0) { + int nl = text.indexOf('\n'); + text = nl >= 0 ? text.substring(nl + 1) : ""; + } + return new ArrayList<>(Arrays.asList(text.split("\\r?\\n"))); + } + } + 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); + List lines = readTailLines(logFile, 1024 * 1024); for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) { String line = lines.get(i); if (line == null || line.trim().isEmpty()) continue; @@ -1430,6 +1603,7 @@ public class StatusAPI extends Plugin implements Runnable, Listener { row.put("player", player); row.put("uuid", uuid); row.put("ip", parsed.getOrDefault("ip", "-")); + row.put("event", event.trim()); out.add(row); } } catch (Exception e) { @@ -1455,7 +1629,9 @@ public class StatusAPI extends Plugin implements Runnable, Listener { 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"); + return e.equals("ip_rate_limit_block") + || e.equals("vpn_block") + || e.equals("learning_threshold_block"); } // ── PAPI-Token-Erkennung ────────────────────────────────────────────────── diff --git a/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java b/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java index f7b6c55..680f13f 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java @@ -102,6 +102,16 @@ public class AntiBotModule implements Module, Listener { private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L); private final Set blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet(); + // Whitelist: Spielernamen (lowercase) und IP-Regeln (exakte IP oder CIDR). + // Namen werden NICHT beim PreLogin vertraut (dort ungeprueft/faelschbar), sondern erst nach + // erfolgreichem Login: dann wird die IP des Spielers temporaer als vertrauenswuerdig gemerkt. + private final Set whitelistNames = ConcurrentHashMap.newKeySet(); + private volatile List whitelistIpRules = new ArrayList<>(); + private final Map trustedIpsUntil = new ConcurrentHashMap<>(); + private final Map lastBlockLogAt = new ConcurrentHashMap<>(); + private volatile int whitelistTrustHours = 24; + private static final String BYPASS_PERMISSION = "statusapi.antibot.bypass"; + private final Map perIpWindows = new ConcurrentHashMap<>(); private final Map blockedIpsUntil = new ConcurrentHashMap<>(); private final Map vpnCache = new ConcurrentHashMap<>(); @@ -212,9 +222,20 @@ public class AntiBotModule implements Module, Listener { // FIX #3: cleanupExpired verwendet removeIf statt Iteration+remove cleanupExpired(now); + // Whitelist: vertrauenswuerdige IPs werden nie gezaehlt, gescored oder geblockt + if (isWhitelistedIp(ip, now)) { + blockedIpsUntil.remove(ip); + return; + } + Long blockedUntil = blockedIpsUntil.get(ip); if (blockedUntil != null && blockedUntil > now) { - logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); + // Nur 1x pro Minute und IP loggen (verhindert Log-Flut bei Reconnect-Schleifen) + Long lastLog = lastBlockLogAt.get(ip); + if (lastLog == null || now - lastLog > 60_000L) { + lastBlockLogAt.put(ip, now); + logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); + } blockEvent(event); return; } @@ -323,7 +344,71 @@ public class AntiBotModule implements Module, Listener { ProxiedPlayer player = event.getPlayer(); String ip = extractIpFromPlayer(player); if (ip == null || ip.isEmpty()) return; - cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis()); + long now = System.currentTimeMillis(); + cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), now); + + // Verifizierter Login von Whitelist-Spieler bzw. Bypass-Permission: IP temporaer vertrauen + boolean trusted = (player.getName() != null && whitelistNames.contains(player.getName().toLowerCase(Locale.ROOT))) + || player.hasPermission(BYPASS_PERMISSION); + if (trusted) { + trustedIpsUntil.put(ip, now + Math.max(1, whitelistTrustHours) * 3_600_000L); + blockedIpsUntil.remove(ip); + learningProfiles.remove(ip); + } + } + + // --- Whitelist --- + + private boolean isWhitelistedIp(String ip, long now) { + if (ip == null || ip.isEmpty()) return false; + Long trustedUntil = trustedIpsUntil.get(ip); + if (trustedUntil != null) { + if (trustedUntil > now) return true; + trustedIpsUntil.remove(ip); + } + for (String rule : whitelistIpRules) { + if (ipMatchesRule(ip, rule)) return true; + } + return false; + } + + private static boolean isIpLiteral(String s) { + return s != null && !s.isEmpty() && s.length() <= 45 && s.matches("[0-9a-fA-F:.]+"); + } + + /** Exakte IP oder CIDR (IPv4/IPv6). Nutzt nur IP-Literale, nie DNS. */ + private boolean ipMatchesRule(String ip, String rule) { + if (rule == null || rule.isEmpty()) return false; + if (rule.equalsIgnoreCase(ip)) return true; + int slash = rule.indexOf('/'); + if (slash < 0) return false; + try { + String net = rule.substring(0, slash); + int bits = Integer.parseInt(rule.substring(slash + 1).trim()); + if (!isIpLiteral(net) || !isIpLiteral(ip)) return false; + byte[] a = java.net.InetAddress.getByName(ip).getAddress(); + byte[] b = java.net.InetAddress.getByName(net).getAddress(); + if (a.length != b.length || bits < 0 || bits > a.length * 8) return false; + int full = bits / 8, rest = bits % 8; + for (int i = 0; i < full; i++) if (a[i] != b[i]) return false; + if (rest == 0) return true; + int mask = (0xFF << (8 - rest)) & 0xFF; + return (a[full] & mask) == (b[full] & mask); + } catch (Exception e) { + return false; + } + } + + /** Eintrag ist IP/CIDR, wenn er nur aus IP-Zeichen (plus optional /Bits) besteht und einen Punkt oder Doppelpunkt enthaelt. */ + private static boolean looksLikeIpRule(String s) { + return s.matches("[0-9a-fA-F:.]+(/\\d{1,3})?") && (s.indexOf('.') >= 0 || s.indexOf(':') >= 0); + } + + private void persistWhitelist() throws Exception { + Map values = new LinkedHashMap<>(); + values.put("antibot.whitelist.players", String.join(",", new java.util.TreeSet<>(whitelistNames))); + values.put("antibot.whitelist.ips", String.join(",", whitelistIpRules)); + updateConfigValues(values); } private void blockEvent(PreLoginEvent event) { @@ -401,6 +486,8 @@ public class AntiBotModule implements Module, Listener { */ private void cleanupExpired(long now) { blockedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now); + trustedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now); + lastBlockLogAt.entrySet().removeIf(e -> now - e.getValue() > 120_000L); vpnCache.entrySet().removeIf(e -> e.getValue().expiresAt <= now); recentIdentityByIp.entrySet().removeIf(e -> { RecentPlayerIdentity id = e.getValue(); @@ -503,6 +590,17 @@ public class AntiBotModule implements Module, Listener { 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); + + whitelistTrustHours = parseInt(props.getProperty("antibot.whitelist.trust_hours"), 24); + whitelistNames.clear(); + for (String n : props.getProperty("antibot.whitelist.players", "").split(",")) { + if (!n.trim().isEmpty()) whitelistNames.add(n.trim().toLowerCase(Locale.ROOT)); + } + List ipRules = new ArrayList<>(); + for (String r : props.getProperty("antibot.whitelist.ips", "").split(",")) { + if (!r.trim().isEmpty()) ipRules.add(r.trim()); + } + whitelistIpRules = ipRules; } catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); } @@ -692,6 +790,15 @@ public class AntiBotModule implements Module, Listener { + " | details=" + safeLog(details); synchronized (securityLogLock) { + // Rotation: ab 5 MB wird die Datei zu .1 (alte .1 wird ueberschrieben) + try { + if (securityLogFile.length() > 5L * 1024 * 1024) { + File rotated = new File(securityLogFile.getParentFile(), securityLogFile.getName() + ".1"); + if (rotated.exists()) rotated.delete(); + securityLogFile.renameTo(rotated); + securityLogFile.createNewFile(); + } + } catch (Exception ignored) {} try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) { bw.write(line); bw.newLine(); } catch (Exception e) { @@ -868,6 +975,38 @@ public class AntiBotModule implements Module, Listener { return; } + if ("whitelist".equalsIgnoreCase(args[0])) { + String sub = args.length >= 2 ? args[1].toLowerCase(Locale.ROOT) : "list"; + if ("list".equals(sub)) { + sender.sendMessage(ChatColor.YELLOW + "Spieler: " + ChatColor.WHITE + (whitelistNames.isEmpty() ? "-" : String.join(", ", new java.util.TreeSet<>(whitelistNames)))); + sender.sendMessage(ChatColor.YELLOW + "IPs/CIDR: " + ChatColor.WHITE + (whitelistIpRules.isEmpty() ? "-" : String.join(", ", whitelistIpRules))); + sender.sendMessage(ChatColor.GRAY + "Permission " + BYPASS_PERMISSION + " wirkt ebenfalls (nach Login)."); + return; + } + if (("add".equals(sub) || "remove".equals(sub)) && args.length >= 3) { + String entry = args[2].trim(); + boolean isIp = looksLikeIpRule(entry); + boolean add = "add".equals(sub); + boolean changed; + if (isIp) { + List copy = new ArrayList<>(whitelistIpRules); + changed = add ? (!copy.contains(entry) && copy.add(entry)) : copy.remove(entry); + whitelistIpRules = copy; + if (add && !entry.contains("/")) blockedIpsUntil.remove(entry); + } else { + String lower = entry.toLowerCase(Locale.ROOT); + changed = add ? whitelistNames.add(lower) : whitelistNames.remove(lower); + } + if (!changed) { sender.sendMessage(ChatColor.RED + (add ? "Bereits vorhanden: " : "Nicht gefunden: ") + entry); return; } + try { persistWhitelist(); } + catch (Exception e) { sender.sendMessage(ChatColor.RED + "Gespeichert nur bis Neustart: " + e.getMessage()); return; } + sender.sendMessage(ChatColor.GREEN + (add ? "Whitelist + " : "Whitelist - ") + entry); + return; + } + sender.sendMessage(ChatColor.YELLOW + "/antibot whitelist [spieler|ip|cidr]"); + return; + } + if ("reload".equalsIgnoreCase(args[0])) { reloadRuntimeState(); sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen."); @@ -879,6 +1018,7 @@ public class AntiBotModule implements Module, Listener { sender.sendMessage(ChatColor.YELLOW + "/antibot clearblocks"); sender.sendMessage(ChatColor.YELLOW + "/antibot unblock "); sender.sendMessage(ChatColor.YELLOW + "/antibot profile "); + sender.sendMessage(ChatColor.YELLOW + "/antibot whitelist [spieler|ip|cidr]"); sender.sendMessage(ChatColor.YELLOW + "/antibot reload"); } } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java b/StatusAPI/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java index 8c64d8a..15f23d0 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java @@ -100,7 +100,7 @@ public class BroadcastModule implements Module, Listener { loadConfig(); if (!enabled) return false; if (requiredApiKey != null && !requiredApiKey.isEmpty()) { - if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + if (apiKeyHeader == null || !net.viper.status.StatusAPI.safeEquals(requiredApiKey, apiKeyHeader)) { plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt."); return false; } @@ -229,7 +229,7 @@ public class BroadcastModule implements Module, Listener { props.setProperty(id + ".messageColor", sb.messageColor); props.setProperty(id + ".recur", sb.recur); } - try (OutputStream out = new FileOutputStream(schedulesFile)) { + try (OutputStream out = new net.viper.status.util.SafeFileOutputStream(schedulesFile)) { props.store(out, "PulseCast Scheduled Broadcasts"); } catch (IOException e) { plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage()); @@ -296,7 +296,7 @@ public class BroadcastModule implements Module, Listener { loadConfig(); if (!enabled) return false; if (requiredApiKey != null && !requiredApiKey.isEmpty()) { - if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + if (apiKeyHeader == null || !net.viper.status.StatusAPI.safeEquals(requiredApiKey, apiKeyHeader)) { plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt."); return false; } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java index 86deaa8..410f633 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java @@ -260,9 +260,9 @@ public class AccountLinkManager { // ===== Persistenz ===== - public void save() { + public synchronized void save() { try (BufferedWriter bw = new BufferedWriter( - new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) { for (LinkedAccount a : links.values()) { bw.write(a.minecraftUUID + "|" + esc(a.minecraftName) diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/BlockManager.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/BlockManager.java index 706e357..339d49e 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/BlockManager.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/BlockManager.java @@ -75,8 +75,8 @@ public class BlockManager { // ===== Persistenz ===== - public void save() { - try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + public synchronized void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) { for (Map.Entry> e : blocked.entrySet()) { if (e.getValue().isEmpty()) continue; StringBuilder sb = new StringBuilder(); diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/MuteManager.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/MuteManager.java index bd2c56e..7dfb651 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/MuteManager.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/MuteManager.java @@ -82,8 +82,8 @@ public class MuteManager { // ===== Persistenz ===== - public void save() { - try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + public synchronized void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) { long now = System.currentTimeMillis() / 1000L; for (Map.Entry e : mutes.entrySet()) { // Nur aktive Mutes speichern diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java index 8b31815..2112d4a 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java @@ -144,9 +144,9 @@ public class ReportManager { // ===== Persistenz ===== - public void save() { + public synchronized void save() { try (BufferedWriter bw = new BufferedWriter( - new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) { for (ChatReport r : reports.values()) { bw.write( esc(r.id) + "|" + diff --git a/StatusAPI/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java b/StatusAPI/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java index 52b5bc6..8c63a89 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java @@ -204,7 +204,10 @@ public class CustomCommandModule implements Module, Listener { target = targetPlayer; } - String argsString = args.size() >= 1 ? String.join(" ", args) : ""; + // %args% bereinigen: Bei erhoehten Rechten (console/admin/anderer Spieler) strenger, + // damit Spieler keine beliebigen Argumente in privilegierte Befehle einschleusen koennen. + boolean privileged = !sendertype.equals("default"); + String argsString = sanitizeArgs(args.size() >= 1 ? String.join(" ", args) : "", privileged); final String finalArgs = argsString; final String senderName = player.getName(); @@ -231,6 +234,22 @@ public class CustomCommandModule implements Module, Listener { } } + private static String sanitizeArgs(String raw, boolean privileged) { + if (raw == null) return ""; + // Steuerzeichen und Farbcode-Zeichen immer entfernen + String s = raw.replaceAll("[\\p{Cntrl}§]", ""); + if (privileged) { + // Nur harmlose Zeichen (Namen, Zahlen, einfache Woerter) zulassen + s = s.replaceAll("[^A-Za-z0-9_.:\\- ]", ""); + if (s.length() > 64) s = s.substring(0, 64); + } else if (s.length() > 256) { + s = s.substring(0, 256); + } + // Fuehrende Slashes verhindern, dass %args% selbst wie ein Befehl wirkt + s = s.replaceAll("^/+", "").trim(); + return s; + } + private void executeCommand(CommandSender sender, String rawCommand, String args, String playerName) { String parsed = rawCommand .replace("%args%", args) diff --git a/StatusAPI/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java b/StatusAPI/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java index b1598bc..f8994b1 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java @@ -1,77 +1,77 @@ -package net.viper.status.modules.customcommands; - -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.util.Collection; -import net.md_5.bungee.api.CommandSender; -import net.md_5.bungee.api.chat.BaseComponent; -import net.md_5.bungee.api.connection.Connection; -import net.md_5.bungee.api.connection.ProxiedPlayer; -import net.md_5.bungee.api.connection.Connection.Unsafe; - -public class ForwardSender implements CommandSender, Connection { - private ProxiedPlayer target; - private Boolean admin; - - public ForwardSender(ProxiedPlayer sender, Boolean admin) { - this.target = sender; - this.admin = admin; - } - - public ProxiedPlayer target() { - return this.target; - } - - @Override - public String getName() { - return this.target.getName(); - } - - @Override - public void sendMessage(String message) { - this.target.sendMessage(message); - } - - @Override - public void sendMessages(String... messages) { - this.target.sendMessages(messages); - } - - @Override - public void sendMessage(BaseComponent... message) { - this.target.sendMessage(message); - } - - @Override - public void sendMessage(BaseComponent message) { - this.target.sendMessage(message); - } - - @Override - public Collection getGroups() { - return this.target.getGroups(); - } - - @Override - public void addGroups(String... groups) { - this.target.addGroups(groups); - } - - @Override - public void removeGroups(String... groups) { - this.target.removeGroups(groups); - } - - @Override - public boolean hasPermission(String permission) { - return this.admin ? true : this.target.hasPermission(permission); - } - - @Override - public void setPermission(String permission, boolean value) { - this.target.setPermission(permission, value); - } - +package net.viper.status.modules.customcommands; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collection; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.connection.Connection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.connection.Connection.Unsafe; + +public class ForwardSender implements CommandSender, Connection { + private ProxiedPlayer target; + private Boolean admin; + + public ForwardSender(ProxiedPlayer sender, Boolean admin) { + this.target = sender; + this.admin = admin; + } + + public ProxiedPlayer target() { + return this.target; + } + + @Override + public String getName() { + return this.target.getName(); + } + + @Override + public void sendMessage(String message) { + this.target.sendMessage(message); + } + + @Override + public void sendMessages(String... messages) { + this.target.sendMessages(messages); + } + + @Override + public void sendMessage(BaseComponent... message) { + this.target.sendMessage(message); + } + + @Override + public void sendMessage(BaseComponent message) { + this.target.sendMessage(message); + } + + @Override + public Collection getGroups() { + return this.target.getGroups(); + } + + @Override + public void addGroups(String... groups) { + this.target.addGroups(groups); + } + + @Override + public void removeGroups(String... groups) { + this.target.removeGroups(groups); + } + + @Override + public boolean hasPermission(String permission) { + return this.admin ? true : this.target.hasPermission(permission); + } + + @Override + public void setPermission(String permission, boolean value) { + this.target.setPermission(permission, value); + } + @Override public Collection getPermissions() { Collection perms = new java.util.ArrayList<>(this.target.getPermissions()); @@ -80,39 +80,39 @@ public class ForwardSender implements CommandSender, Connection { } return perms; } - - @Override - public InetSocketAddress getAddress() { - return this.target.getAddress(); - } - - @Override - public SocketAddress getSocketAddress() { - return this.target.getSocketAddress(); - } - - @Override - public void disconnect(String reason) { - this.target.disconnect(reason); - } - - @Override - public void disconnect(BaseComponent... reason) { - this.target.disconnect(reason); - } - - @Override - public void disconnect(BaseComponent reason) { - this.target.disconnect(reason); - } - - @Override - public boolean isConnected() { - return this.target.isConnected(); - } - - @Override - public Unsafe unsafe() { - return this.target.unsafe(); - } + + @Override + public InetSocketAddress getAddress() { + return this.target.getAddress(); + } + + @Override + public SocketAddress getSocketAddress() { + return this.target.getSocketAddress(); + } + + @Override + public void disconnect(String reason) { + this.target.disconnect(reason); + } + + @Override + public void disconnect(BaseComponent... reason) { + this.target.disconnect(reason); + } + + @Override + public void disconnect(BaseComponent reason) { + this.target.disconnect(reason); + } + + @Override + public boolean isConnected() { + return this.target.isConnected(); + } + + @Override + public Unsafe unsafe() { + return this.target.unsafe(); + } } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java b/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java index 24723aa..ae3df5d 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java @@ -102,7 +102,7 @@ public class ForumBridgeModule implements Module, Listener { // ===== HTTP HANDLER ===== public String handleNotify(String body, String apiKeyHeader) { - if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) { + if (!apiSecret.isEmpty() && !net.viper.status.StatusAPI.safeEquals(apiSecret, apiKeyHeader)) { return "{\"success\":false,\"error\":\"unauthorized\"}"; } @@ -137,7 +137,7 @@ public class ForumBridgeModule implements Module, Listener { } public String handleUnlink(String body, String apiKeyHeader) { - if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}"; + if (!apiSecret.isEmpty() && !net.viper.status.StatusAPI.safeEquals(apiSecret, apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}"; return "{\"success\":true}"; } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java b/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java index e1210c1..096f377 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java @@ -93,8 +93,8 @@ public class ForumNotifStorage { // ===== Datei-Operationen ===== - public void save() { - try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + public synchronized void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) { for (CopyOnWriteArrayList list : pending.values()) { for (ForumNotification n : list) { if (!n.isDelivered()) { diff --git a/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java b/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java index 5695d03..4630ed5 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java @@ -198,7 +198,7 @@ public class NetworkInfoModule implements Module { if (attackApiKey == null || attackApiKey.isEmpty()) { return true; } - return providedKey != null && attackApiKey.equals(providedKey.trim()); + return providedKey != null && net.viper.status.StatusAPI.safeEquals(attackApiKey, providedKey.trim()); } public boolean sendAttackNotification(String eventType, diff --git a/StatusAPI/src/main/java/net/viper/status/modules/vanish/VanishModule.java b/StatusAPI/src/main/java/net/viper/status/modules/vanish/VanishModule.java index d26c92b..5c6f2df 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/vanish/VanishModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/vanish/VanishModule.java @@ -232,9 +232,9 @@ public class VanishModule implements Module, Listener { // PERSISTENZ // ========================================================= - private void save() { + private synchronized void save() { try (BufferedWriter bw = new BufferedWriter( - new OutputStreamWriter(new FileOutputStream(dataFile), "UTF-8"))) { + new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(dataFile), "UTF-8"))) { for (UUID uuid : persistentVanished) { bw.write(uuid.toString()); bw.newLine(); diff --git a/StatusAPI/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java b/StatusAPI/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java index 672c445..bcf773c 100644 --- a/StatusAPI/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java +++ b/StatusAPI/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java @@ -1,119 +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; - } +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/StatusAPI/src/main/java/net/viper/status/stats/StatsManager.java b/StatusAPI/src/main/java/net/viper/status/stats/StatsManager.java index 8c7e222..bdfeb30 100644 --- a/StatusAPI/src/main/java/net/viper/status/stats/StatsManager.java +++ b/StatusAPI/src/main/java/net/viper/status/stats/StatsManager.java @@ -1,35 +1,35 @@ -package net.viper.status.stats; - -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -public class StatsManager { - private final ConcurrentHashMap map = new ConcurrentHashMap<>(); - - public PlayerStats get(UUID uuid, String name) { - return map.compute(uuid, (k, v) -> { - if (v == null) { - return new PlayerStats(uuid, name != null ? name : ""); - } else { - if (name != null && !name.isEmpty()) v.name = name; - return v; - } - }); - } - - public PlayerStats getIfPresent(UUID uuid) { - return map.get(uuid); - } - - public Iterable all() { - return map.values(); - } - - public void put(PlayerStats ps) { - map.put(ps.uuid, ps); - } - - public void remove(UUID uuid) { - map.remove(uuid); - } +package net.viper.status.stats; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class StatsManager { + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + + public PlayerStats get(UUID uuid, String name) { + return map.compute(uuid, (k, v) -> { + if (v == null) { + return new PlayerStats(uuid, name != null ? name : ""); + } else { + if (name != null && !name.isEmpty()) v.name = name; + return v; + } + }); + } + + public PlayerStats getIfPresent(UUID uuid) { + return map.get(uuid); + } + + public Iterable all() { + return map.values(); + } + + public void put(PlayerStats ps) { + map.put(ps.uuid, ps); + } + + public void remove(UUID uuid) { + map.remove(uuid); + } } \ No newline at end of file diff --git a/StatusAPI/src/main/java/net/viper/status/util/ConfigMigrator.java b/StatusAPI/src/main/java/net/viper/status/util/ConfigMigrator.java new file mode 100644 index 0000000..1383b8e --- /dev/null +++ b/StatusAPI/src/main/java/net/viper/status/util/ConfigMigrator.java @@ -0,0 +1,85 @@ +package net.viper.status.util; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.FileInputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.logging.Logger; + +/** + * Ergaenzt fehlende Schluessel in bestehenden Konfigurationsdateien, ohne vorhandene Werte, + * Kommentare oder die Reihenfolge zu veraendern. Neue Optionen erscheinen so nach einem + * Plugin-Update automatisch (mit Kommentar) in der Datei. + * + * Existiert die Datei nicht, passiert nichts (sie wird vom Plugin mit allen Standardwerten erzeugt). + */ +public final class ConfigMigrator { + + private ConfigMigrator() {} + + /** + * .properties-Datei (UTF-8). entries: je {schluessel, standardwert, kommentar}; der Kommentar darf + * mehrere Zeilen ("\n") enthalten. Gibt die Anzahl ergaenzter Schluessel zurueck. + */ + public static int migrateProperties(File file, String[][] entries, Logger log) { + if (file == null || !file.isFile()) return 0; + try { + Properties existing = new Properties(); + try (Reader r = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + existing.load(r); + } + StringBuilder add = new StringBuilder(); + int count = 0; + for (String[] e : entries) { + if (existing.containsKey(e[0])) continue; + if (count == 0) add.append("\n# -- Automatisch ergaenzt (neue Optionen nach Update) --\n"); + if (e.length > 2 && e[2] != null && !e[2].isEmpty()) { + for (String line : e[2].split("\n")) add.append("# ").append(line).append('\n'); + } + add.append(e[0]).append('=').append(e[1]).append("\n\n"); + count++; + } + if (count > 0) { + appendWithNewline(file, add.toString()); + if (log != null) log.info("[Config] " + file.getName() + ": " + count + " neue Option(en) ergaenzt."); + } + return count; + } catch (IOException ex) { + if (log != null) log.warning("[Config] Konnte " + file.getName() + " nicht aktualisieren: " + ex.getMessage()); + return 0; + } + } + + /** + * YAML-Datei: haengt block an, wenn der Top-Level-Schluessel {@code key} fehlt. + * @return true wenn ergaenzt wurde + */ + public static boolean migrateYamlKey(File file, String key, String block, Logger log) { + if (file == null || !file.isFile()) return false; + try { + String text = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + if (java.util.regex.Pattern.compile("(?m)^" + java.util.regex.Pattern.quote(key) + "\\s*:").matcher(text).find()) { + return false; + } + appendWithNewline(file, "\n# -- Automatisch ergaenzt (neue Option nach Update) --\n" + block + "\n"); + if (log != null) log.info("[Config] " + file.getName() + ": Option '" + key + "' ergaenzt."); + return true; + } catch (IOException ex) { + if (log != null) log.warning("[Config] Konnte " + file.getName() + " nicht aktualisieren: " + ex.getMessage()); + return false; + } + } + + private static void appendWithNewline(File file, String text) throws IOException { + byte[] existing = Files.readAllBytes(file.toPath()); + StringBuilder sb = new StringBuilder(); + if (existing.length > 0 && existing[existing.length - 1] != '\n') sb.append('\n'); + sb.append(text); + Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND); + } +} diff --git a/StatusAPI/src/main/java/net/viper/status/util/SafeFileOutputStream.java b/StatusAPI/src/main/java/net/viper/status/util/SafeFileOutputStream.java new file mode 100644 index 0000000..403c285 --- /dev/null +++ b/StatusAPI/src/main/java/net/viper/status/util/SafeFileOutputStream.java @@ -0,0 +1,66 @@ +package net.viper.status.util; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/** + * Drop-in-Ersatz fuer {@code new FileOutputStream(file)} bei Datendateien. + * + * Schreibt zuerst in ".tmp" und ersetzt die Zieldatei erst beim erfolgreichen close() + * (atomar, wenn moeglich). Ein Absturz oder Schreibfehler mitten im Speichern kann so die + * bestehende Datei nicht mehr abschneiden oder halb ueberschreiben. + */ +public class SafeFileOutputStream extends FileOutputStream { + + private final File target; + private final File tmp; + private boolean failed = false; + private boolean closed = false; + + public SafeFileOutputStream(File target) throws FileNotFoundException { + this(target, new File(target.getPath() + ".tmp")); + } + + private SafeFileOutputStream(File target, File tmp) throws FileNotFoundException { + super(tmp); + this.target = target; + this.tmp = tmp; + } + + @Override + public void write(int b) throws IOException { + try { super.write(b); } catch (IOException e) { failed = true; throw e; } + } + + @Override + public void write(byte[] b) throws IOException { + try { super.write(b); } catch (IOException e) { failed = true; throw e; } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + try { super.write(b, off, len); } catch (IOException e) { failed = true; throw e; } + } + + @Override + public void close() throws IOException { + if (closed) return; + closed = true; + super.close(); + if (failed) { + tmp.delete(); + return; + } + try { + Files.move(tmp.toPath(), target.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } +}