Upload via GUI (60 Dateien)

This commit is contained in:
2026-11-13 12:09:32 +00:00
parent fd519f55f1
commit 3cb39dabb7
17 changed files with 787 additions and 301 deletions
@@ -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 -> {
// 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<Runnable>(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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<String> 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<Map<String, Object>> loadAntiBotSecurityEvents(int maxEntries) {
List<Map<String, Object>> out = new ArrayList<>();
File logFile = new File(getDataFolder(), "antibot-security.log");
if (!logFile.exists() || maxEntries <= 0) return out;
try {
List<String> lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8);
List<String> 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 ──────────────────────────────────────────────────
@@ -102,6 +102,16 @@ public class AntiBotModule implements Module, Listener {
private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L);
private final Set<String> 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<String> whitelistNames = ConcurrentHashMap.newKeySet();
private volatile List<String> whitelistIpRules = new ArrayList<>();
private final Map<String, Long> trustedIpsUntil = new ConcurrentHashMap<>();
private final Map<String, Long> lastBlockLogAt = new ConcurrentHashMap<>();
private volatile int whitelistTrustHours = 24;
private static final String BYPASS_PERMISSION = "statusapi.antibot.bypass";
private final Map<String, IpWindow> perIpWindows = new ConcurrentHashMap<>();
private final Map<String, Long> blockedIpsUntil = new ConcurrentHashMap<>();
private final Map<String, VpnCacheEntry> 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) {
// 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<String, String> 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<String> 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 <name>.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<String> 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 <list|add|remove> [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 <ip>");
sender.sendMessage(ChatColor.YELLOW + "/antibot profile <strict|high-traffic>");
sender.sendMessage(ChatColor.YELLOW + "/antibot whitelist <list|add|remove> [spieler|ip|cidr]");
sender.sendMessage(ChatColor.YELLOW + "/antibot reload");
}
}
@@ -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;
}
@@ -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)
@@ -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<UUID, Set<UUID>> e : blocked.entrySet()) {
if (e.getValue().isEmpty()) continue;
StringBuilder sb = new StringBuilder();
@@ -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<UUID, Long> e : mutes.entrySet()) {
// Nur aktive Mutes speichern
@@ -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) + "|" +
@@ -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)
@@ -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}";
}
@@ -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<ForumNotification> list : pending.values()) {
for (ForumNotification n : list) {
if (!n.isDelivered()) {
@@ -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,
@@ -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();
@@ -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);
}
}
@@ -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 "<datei>.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);
}
}
}