Upload via GUI (60 Dateien)
This commit is contained in:
@@ -110,6 +110,27 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
|
|
||||||
mergeVerifyConfig();
|
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
|
// Port aus verify.properties lesen
|
||||||
String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191";
|
String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191";
|
||||||
try {
|
try {
|
||||||
@@ -197,11 +218,20 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
|
|
||||||
// WebServer starten
|
// WebServer starten
|
||||||
shuttingDown = false;
|
shuttingDown = false;
|
||||||
requestExecutor = Executors.newFixedThreadPool(4, r -> {
|
// Begrenzte Warteschlange: bei Ueberlast werden Verbindungen sofort geschlossen
|
||||||
Thread t = new Thread(r, "StatusAPI-HTTP-Worker");
|
// (RejectedExecutionException in submitConnection), statt unbegrenzt zu stauen.
|
||||||
t.setDaemon(true);
|
requestExecutor = new java.util.concurrent.ThreadPoolExecutor(
|
||||||
return t;
|
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();
|
startHttpServerThread();
|
||||||
httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule(
|
httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule(
|
||||||
this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS);
|
this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS);
|
||||||
@@ -488,6 +518,11 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
if (inputLine == null) return;
|
if (inputLine == null) return;
|
||||||
lastHttpRequestAt.set(System.currentTimeMillis());
|
lastHttpRequestAt.set(System.currentTimeMillis());
|
||||||
|
|
||||||
|
if (inputLine.length() > 2048) {
|
||||||
|
sendHttpResponse(out, "{\"success\":false,\"error\":\"uri_too_long\"}", 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String[] reqParts = inputLine.split(" ");
|
String[] reqParts = inputLine.split(" ");
|
||||||
if (reqParts.length < 2) return;
|
if (reqParts.length < 2) return;
|
||||||
String method = reqParts[0].trim();
|
String method = reqParts[0].trim();
|
||||||
@@ -504,19 +539,15 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /antibot/security-log
|
// Headers lesen (max. 64 Header, je max. 8 KB)
|
||||||
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
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
String line;
|
String line;
|
||||||
|
int headerCount = 0;
|
||||||
while ((line = in.readLine()) != null && !line.isEmpty()) {
|
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(':');
|
int idx = line.indexOf(':');
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
headers.put(line.substring(0, idx).trim().toLowerCase(Locale.ROOT),
|
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
|
// GET /network/backendguard/config
|
||||||
if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) {
|
if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) {
|
||||||
Properties guardProps = loadNetworkGuardProperties();
|
Properties guardProps = loadNetworkGuardProperties();
|
||||||
String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim();
|
String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim();
|
||||||
String providedApiKey = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", ""));
|
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);
|
sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1324,30 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
return new String(bodyChars);
|
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) {
|
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;
|
if (json == null || key == null) return null;
|
||||||
String search = "\"" + key + "\"";
|
String search = "\"" + key + "\"";
|
||||||
int idx = json.indexOf(search);
|
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() {
|
private Properties loadNetworkGuardProperties() {
|
||||||
Properties props = new Properties();
|
Properties props = new Properties();
|
||||||
File file = new File(getDataFolder(), "network-guard.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 {
|
private void sendHttpResponse(OutputStream out, String json, int code) throws IOException {
|
||||||
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
|
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();
|
StringBuilder response = new StringBuilder();
|
||||||
response.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n");
|
response.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n");
|
||||||
response.append("Content-Type: application/json; charset=UTF-8\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");
|
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) {
|
private List<Map<String, Object>> loadAntiBotSecurityEvents(int maxEntries) {
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
File logFile = new File(getDataFolder(), "antibot-security.log");
|
File logFile = new File(getDataFolder(), "antibot-security.log");
|
||||||
if (!logFile.exists() || maxEntries <= 0) return out;
|
if (!logFile.exists() || maxEntries <= 0) return out;
|
||||||
try {
|
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--) {
|
for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) {
|
||||||
String line = lines.get(i);
|
String line = lines.get(i);
|
||||||
if (line == null || line.trim().isEmpty()) continue;
|
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("player", player);
|
||||||
row.put("uuid", uuid);
|
row.put("uuid", uuid);
|
||||||
row.put("ip", parsed.getOrDefault("ip", "-"));
|
row.put("ip", parsed.getOrDefault("ip", "-"));
|
||||||
|
row.put("event", event.trim());
|
||||||
out.add(row);
|
out.add(row);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -1455,7 +1629,9 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
|
|||||||
private boolean isAttackSecurityEvent(String event) {
|
private boolean isAttackSecurityEvent(String event) {
|
||||||
if (event == null) return false;
|
if (event == null) return false;
|
||||||
String e = event.trim().toLowerCase(Locale.ROOT);
|
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 ──────────────────────────────────────────────────
|
// ── PAPI-Token-Erkennung ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ public class AntiBotModule implements Module, Listener {
|
|||||||
private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L);
|
private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L);
|
||||||
private final Set<String> blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet();
|
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, IpWindow> perIpWindows = new ConcurrentHashMap<>();
|
||||||
private final Map<String, Long> blockedIpsUntil = new ConcurrentHashMap<>();
|
private final Map<String, Long> blockedIpsUntil = new ConcurrentHashMap<>();
|
||||||
private final Map<String, VpnCacheEntry> vpnCache = 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
|
// FIX #3: cleanupExpired verwendet removeIf statt Iteration+remove
|
||||||
cleanupExpired(now);
|
cleanupExpired(now);
|
||||||
|
|
||||||
|
// Whitelist: vertrauenswuerdige IPs werden nie gezaehlt, gescored oder geblockt
|
||||||
|
if (isWhitelistedIp(ip, now)) {
|
||||||
|
blockedIpsUntil.remove(ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Long blockedUntil = blockedIpsUntil.get(ip);
|
Long blockedUntil = blockedIpsUntil.get(ip);
|
||||||
if (blockedUntil != null && blockedUntil > now) {
|
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);
|
blockEvent(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -323,7 +344,71 @@ public class AntiBotModule implements Module, Listener {
|
|||||||
ProxiedPlayer player = event.getPlayer();
|
ProxiedPlayer player = event.getPlayer();
|
||||||
String ip = extractIpFromPlayer(player);
|
String ip = extractIpFromPlayer(player);
|
||||||
if (ip == null || ip.isEmpty()) return;
|
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) {
|
private void blockEvent(PreLoginEvent event) {
|
||||||
@@ -401,6 +486,8 @@ public class AntiBotModule implements Module, Listener {
|
|||||||
*/
|
*/
|
||||||
private void cleanupExpired(long now) {
|
private void cleanupExpired(long now) {
|
||||||
blockedIpsUntil.entrySet().removeIf(e -> e.getValue() <= 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);
|
vpnCache.entrySet().removeIf(e -> e.getValue().expiresAt <= now);
|
||||||
recentIdentityByIp.entrySet().removeIf(e -> {
|
recentIdentityByIp.entrySet().removeIf(e -> {
|
||||||
RecentPlayerIdentity id = e.getValue();
|
RecentPlayerIdentity id = e.getValue();
|
||||||
@@ -503,6 +590,17 @@ public class AntiBotModule implements Module, Listener {
|
|||||||
learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints);
|
learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints);
|
||||||
learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints);
|
learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints);
|
||||||
learningRecentEventLimit = parseInt(props.getProperty("antibot.learning.recent_events.limit"), learningRecentEventLimit);
|
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) {
|
} catch (Exception e) {
|
||||||
plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage());
|
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);
|
+ " | details=" + safeLog(details);
|
||||||
|
|
||||||
synchronized (securityLogLock) {
|
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))) {
|
try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) {
|
||||||
bw.write(line); bw.newLine();
|
bw.write(line); bw.newLine();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -868,6 +975,38 @@ public class AntiBotModule implements Module, Listener {
|
|||||||
return;
|
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])) {
|
if ("reload".equalsIgnoreCase(args[0])) {
|
||||||
reloadRuntimeState();
|
reloadRuntimeState();
|
||||||
sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen.");
|
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 clearblocks");
|
||||||
sender.sendMessage(ChatColor.YELLOW + "/antibot unblock <ip>");
|
sender.sendMessage(ChatColor.YELLOW + "/antibot unblock <ip>");
|
||||||
sender.sendMessage(ChatColor.YELLOW + "/antibot profile <strict|high-traffic>");
|
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");
|
sender.sendMessage(ChatColor.YELLOW + "/antibot reload");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ public class BroadcastModule implements Module, Listener {
|
|||||||
loadConfig();
|
loadConfig();
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
if (requiredApiKey != null && !requiredApiKey.isEmpty()) {
|
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.");
|
plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ public class BroadcastModule implements Module, Listener {
|
|||||||
props.setProperty(id + ".messageColor", sb.messageColor);
|
props.setProperty(id + ".messageColor", sb.messageColor);
|
||||||
props.setProperty(id + ".recur", sb.recur);
|
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");
|
props.store(out, "PulseCast Scheduled Broadcasts");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage());
|
plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage());
|
||||||
@@ -296,7 +296,7 @@ public class BroadcastModule implements Module, Listener {
|
|||||||
loadConfig();
|
loadConfig();
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
if (requiredApiKey != null && !requiredApiKey.isEmpty()) {
|
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.");
|
plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,9 +260,9 @@ public class AccountLinkManager {
|
|||||||
|
|
||||||
// ===== Persistenz =====
|
// ===== Persistenz =====
|
||||||
|
|
||||||
public void save() {
|
public synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(
|
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()) {
|
for (LinkedAccount a : links.values()) {
|
||||||
bw.write(a.minecraftUUID
|
bw.write(a.minecraftUUID
|
||||||
+ "|" + esc(a.minecraftName)
|
+ "|" + esc(a.minecraftName)
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ public class BlockManager {
|
|||||||
|
|
||||||
// ===== Persistenz =====
|
// ===== Persistenz =====
|
||||||
|
|
||||||
public void save() {
|
public synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
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()) {
|
for (Map.Entry<UUID, Set<UUID>> e : blocked.entrySet()) {
|
||||||
if (e.getValue().isEmpty()) continue;
|
if (e.getValue().isEmpty()) continue;
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ public class MuteManager {
|
|||||||
|
|
||||||
// ===== Persistenz =====
|
// ===== Persistenz =====
|
||||||
|
|
||||||
public void save() {
|
public synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||||
long now = System.currentTimeMillis() / 1000L;
|
long now = System.currentTimeMillis() / 1000L;
|
||||||
for (Map.Entry<UUID, Long> e : mutes.entrySet()) {
|
for (Map.Entry<UUID, Long> e : mutes.entrySet()) {
|
||||||
// Nur aktive Mutes speichern
|
// Nur aktive Mutes speichern
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ public class ReportManager {
|
|||||||
|
|
||||||
// ===== Persistenz =====
|
// ===== Persistenz =====
|
||||||
|
|
||||||
public void save() {
|
public synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(
|
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()) {
|
for (ChatReport r : reports.values()) {
|
||||||
bw.write(
|
bw.write(
|
||||||
esc(r.id) + "|" +
|
esc(r.id) + "|" +
|
||||||
|
|||||||
+20
-1
@@ -204,7 +204,10 @@ public class CustomCommandModule implements Module, Listener {
|
|||||||
target = targetPlayer;
|
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 finalArgs = argsString;
|
||||||
final String senderName = player.getName();
|
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) {
|
private void executeCommand(CommandSender sender, String rawCommand, String args, String playerName) {
|
||||||
String parsed = rawCommand
|
String parsed = rawCommand
|
||||||
.replace("%args%", args)
|
.replace("%args%", args)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public class ForumBridgeModule implements Module, Listener {
|
|||||||
// ===== HTTP HANDLER =====
|
// ===== HTTP HANDLER =====
|
||||||
|
|
||||||
public String handleNotify(String body, String apiKeyHeader) {
|
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\"}";
|
return "{\"success\":false,\"error\":\"unauthorized\"}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ public class ForumBridgeModule implements Module, Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String handleUnlink(String body, String apiKeyHeader) {
|
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}";
|
return "{\"success\":true}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ public class ForumNotifStorage {
|
|||||||
|
|
||||||
// ===== Datei-Operationen =====
|
// ===== Datei-Operationen =====
|
||||||
|
|
||||||
public void save() {
|
public synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) {
|
try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new net.viper.status.util.SafeFileOutputStream(file), "UTF-8"))) {
|
||||||
for (CopyOnWriteArrayList<ForumNotification> list : pending.values()) {
|
for (CopyOnWriteArrayList<ForumNotification> list : pending.values()) {
|
||||||
for (ForumNotification n : list) {
|
for (ForumNotification n : list) {
|
||||||
if (!n.isDelivered()) {
|
if (!n.isDelivered()) {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ public class NetworkInfoModule implements Module {
|
|||||||
if (attackApiKey == null || attackApiKey.isEmpty()) {
|
if (attackApiKey == null || attackApiKey.isEmpty()) {
|
||||||
return true;
|
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,
|
public boolean sendAttackNotification(String eventType,
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ public class VanishModule implements Module, Listener {
|
|||||||
// PERSISTENZ
|
// PERSISTENZ
|
||||||
// =========================================================
|
// =========================================================
|
||||||
|
|
||||||
private void save() {
|
private synchronized void save() {
|
||||||
try (BufferedWriter bw = new BufferedWriter(
|
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) {
|
for (UUID uuid : persistentVanished) {
|
||||||
bw.write(uuid.toString());
|
bw.write(uuid.toString());
|
||||||
bw.newLine();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user