diff --git a/backend-join-guard/README.md b/backend-join-guard/README.md new file mode 100644 index 0000000..d638637 --- /dev/null +++ b/backend-join-guard/README.md @@ -0,0 +1,192 @@ +# BackendJoinGuard + +![Plugin](https://img.shields.io/badge/Plugin-BackendJoinGuard-0B63CE?style=for-the-badge) +![Platform](https://img.shields.io/badge/Platform-Paper%20%2F%20Spigot-D48700?style=for-the-badge) +![Mode](https://img.shields.io/badge/Mode-Standalone%20or%20StatusAPI%20Sync-6F42C1?style=for-the-badge) + +BackendJoinGuard blockiert direkte Joins auf Backend-Servern und erlaubt nur Verbindungen vom Proxy. + +Wichtig: +- Dieses Plugin kommt auf jeden Backend-Server. +- Es kommt nicht auf den Proxy. +- Es kann komplett eigenständig genutzt werden. +- Optional kann es seine Regeln zentral von StatusAPI beziehen. + +## Zweck + +BackendJoinGuard verhindert, dass Spieler einen Unterserver direkt betreten und damit den Proxy-Schutz umgehen. + +Das Plugin prüft beim Login die Quell-IP des Spielers. +Nur erlaubte Proxy-IPs oder CIDR-Netze dürfen durchgelassen werden. +Alle anderen Verbindungen werden direkt vor dem Join geblockt. + +## Betriebsarten + +### Standalone + +BackendJoinGuard nutzt nur seine eigene config.yml. +Das ist sinnvoll, wenn du keinen zentralen Sync willst oder das Plugin unabhängig von StatusAPI betreiben möchtest. + +### StatusAPI Sync + +BackendJoinGuard lädt seine Schutzregeln regelmäßig von StatusAPI. +Wenn der Sync ausfällt, bleiben die lokalen Werte aus der config.yml als Fallback erhalten. + +## Installation + +1. Die JAR in den plugins-Ordner jedes Backend-Servers kopieren. +2. Server starten, damit die config.yml erzeugt wird. +3. Konfiguration anpassen. +4. Server neu starten oder /backendguard reload ausführen. + +## Konfiguration + +Datei: config.yml + +### Lokale Guard-Werte + +- enforcement-enabled + Aktiviert oder deaktiviert den Direktjoin-Schutz. + +- log-blocked-attempts + Loggt geblockte Direktjoin-Versuche in die Konsole. + +- kick-message + Nachricht für geblockte Spieler. + +- allowed-proxy-ips + Liste exakter Proxy-IPs. + +- allowed-proxy-cidrs + Liste erlaubter Netze im CIDR-Format. + +Beispiel: + +```yml +enforcement-enabled: true +log-blocked-attempts: true +kick-message: "&cBitte verbinde dich nur ueber den Proxy-Server." + +allowed-proxy-ips: + - "127.0.0.1" + - "::1" + - "185.123.45.67" + +allowed-proxy-cidrs: + - "10.0.0.0/24" +``` + +### StatusAPI Sync + +Konfigurationsblock: + +```yml +statusapi-sync: + enabled: true + base-url: "http://127.0.0.1:9191" + endpoint-path: "/network/backendguard/config" + api-key: "DEIN_SYNC_KEY" + interval-seconds: 60 + log-sync-errors: true +``` + +Bedeutung: + +- enabled + Aktiviert den zentralen Abruf über StatusAPI. + +- base-url + Basis-URL deiner StatusAPI auf dem Proxy. + +- endpoint-path + Standard-Endpunkt für den Guard-Sync. + +- api-key + Optionaler Schutz für den Sync-Endpunkt. + Muss identisch zu backendguard.sync.api_key in StatusAPI sein. + +- interval-seconds + Wie oft BackendJoinGuard die Regeln neu abruft. + +- log-sync-errors + Loggt Sync-Probleme in die Backend-Konsole. + +## Passende StatusAPI-Konfiguration + +Wenn du den Sync-Modus nutzen willst, pflegst du die zentralen Werte in StatusAPI über network-guard.properties. + +Relevante Schlüssel: + +```properties +backendguard.enforcement_enabled=true +backendguard.log_blocked_attempts=true +backendguard.kick_message=&cBitte verbinde dich nur ueber den Proxy-Server. +backendguard.allowed_proxy_ips=127.0.0.1,::1,185.123.45.67 +backendguard.allowed_proxy_cidrs=10.0.0.0/24 +backendguard.sync.api_key=DEIN_SYNC_KEY +``` + +## Beispiele + +### Alles auf einer Maschine + +Nutze in der Regel: + +- allowed-proxy-ips: 127.0.0.1, ::1 +- base-url: http://127.0.0.1:9191 + +### Proxy auf einem anderen Host + +Nutze die echte Proxy-IP: + +- allowed-proxy-ips: 185.123.45.67 +- base-url: http://185.123.45.67:9191 + +### Internes Netzwerk + +Nutze interne IP oder CIDR: + +- allowed-proxy-ips: 10.0.0.10 +- allowed-proxy-cidrs: 10.0.0.0/24 +- base-url: http://10.0.0.10:9191 + +## Befehl + +| Command | Beschreibung | +|---|---| +| /backendguard reload | Lädt die Config neu und startet bei aktivem Sync sofort einen neuen Abruf | + +## Permission + +| Permission | Beschreibung | +|---|---| +| backendguard.admin | Darf BackendJoinGuard verwalten | + +## Funktionsweise + +BackendJoinGuard prüft beim PreLogin die IP-Adresse des Clients. + +- Erlaubte Proxy-IP: Join erlaubt +- Erlaubtes CIDR-Netz: Join erlaubt +- Alles andere: Join wird direkt geblockt + +## Troubleshooting + +### Spieler werden trotz Proxy geblockt + +- Prüfe, ob wirklich die Proxy-IP in allowed-proxy-ips eingetragen ist. +- Prüfe bei mehreren Netzsegmenten, ob ein CIDR-Eintrag sinnvoller ist. +- Prüfe, ob Velocity/Bungee korrekt als vorgeschalteter Proxy arbeitet. + +### Sync mit StatusAPI funktioniert nicht + +- Prüfe base-url. +- Prüfe, ob StatusAPI auf dem konfigurierten Port erreichbar ist. +- Prüfe, ob api-key identisch zu backendguard.sync.api_key ist. +- Prüfe die Backend-Konsole bei aktiviertem log-sync-errors. + +### Standalone oder Sync? + +- Wenn du maximale Einfachheit willst: Standalone. +- Wenn du zentrale Verwaltung willst: StatusAPI Sync. +- Wenn StatusAPI ausfällt, bleiben im Sync-Modus die lokalen Werte als Absicherung bestehen. \ No newline at end of file diff --git a/backend-join-guard/pom.xml b/backend-join-guard/pom.xml index e4b3b30..e340f8d 100644 --- a/backend-join-guard/pom.xml +++ b/backend-join-guard/pom.xml @@ -1,46 +1,46 @@ - - - 4.0.0 - - net.viper.backend - backend-join-guard - 1.0.0 - jar - - BackendJoinGuard - Blocks direct joins to backend servers and only allows proxy IPs. - - - 17 - 17 - UTF-8 - - - - - papermc - https://repo.papermc.io/repository/maven-public/ - - - - - - io.papermc.paper - paper-api - 1.20.6-R0.1-SNAPSHOT - provided - - - - - BackendJoinGuard - - - src/main/resources - false - - - - + + + 4.0.0 + + net.viper.backend + backend-join-guard + 1.0.0 + jar + + BackendJoinGuard + Blocks direct joins to backend servers and only allows proxy IPs. + + + 17 + 17 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + + io.papermc.paper + paper-api + 1.20.6-R0.1-SNAPSHOT + provided + + + + + BackendJoinGuard + + + src/main/resources + false + + + + diff --git a/backend-join-guard/src/main/java/net/viper/backendguard/BackendJoinGuardPlugin.java b/backend-join-guard/src/main/java/net/viper/backendguard/BackendJoinGuardPlugin.java index f4d9c3f..b0737fd 100644 --- a/backend-join-guard/src/main/java/net/viper/backendguard/BackendJoinGuardPlugin.java +++ b/backend-join-guard/src/main/java/net/viper/backendguard/BackendJoinGuardPlugin.java @@ -1,509 +1,515 @@ -package net.viper.backendguard; - -import org.bukkit.ChatColor; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.player.AsyncPlayerPreLoginEvent; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.scheduler.BukkitTask; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -public class BackendJoinGuardPlugin extends JavaPlugin implements Listener { - - private boolean enforcementEnabled; - private boolean logBlockedAttempts; - private String kickMessage; - private final Set allowedExactIps = new HashSet(); - private final List allowedCidrs = new ArrayList(); - private boolean statusApiSyncEnabled; - private String statusApiBaseUrl; - private String statusApiEndpointPath; - private String statusApiApiKey; - private int statusApiIntervalSeconds; - private boolean statusApiLogSyncErrors; - private BukkitTask syncTask; - - @Override - public void onEnable() { - saveDefaultConfig(); - reloadGuardConfig(); - getServer().getPluginManager().registerEvents(this, this); - getLogger().info("BackendJoinGuard aktiviert. Erlaubte Proxy-IPs=" + allowedExactIps.size() + ", CIDRs=" + allowedCidrs.size() + ", StatusAPI-Sync=" + statusApiSyncEnabled); - } - - @Override - public void onDisable() { - cancelSyncTask(); - } - - @EventHandler(priority = EventPriority.HIGHEST) - public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) { - if (!enforcementEnabled) { - return; - } - - InetAddress address = event.getAddress(); - if (address == null) { - return; - } - - if (isAllowed(address)) { - return; - } - - event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, colorize(kickMessage)); - if (logBlockedAttempts) { - getLogger().warning("Direktjoin blockiert: player=" + event.getName() + ", ip=" + address.getHostAddress()); - } - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (!"backendguard".equalsIgnoreCase(command.getName())) { - return false; - } - - if (!sender.hasPermission("backendguard.admin")) { - sender.sendMessage(colorize("&cDafuer hast du keine Rechte.")); - return true; - } - - if (args.length == 1 && "reload".equalsIgnoreCase(args[0])) { - reloadConfig(); - reloadGuardConfig(); - if (statusApiSyncEnabled) { - syncFromStatusApi(true); - sender.sendMessage(colorize("&aBackendJoinGuard neu geladen. StatusAPI-Sync aktiv.")); - } else { - sender.sendMessage(colorize("&aBackendJoinGuard neu geladen. Standalone-Modus aktiv.")); - } - return true; - } - - sender.sendMessage(colorize("&e/backendguard reload")); - return true; - } - - private void reloadGuardConfig() { - FileConfiguration config = getConfig(); - enforcementEnabled = config.getBoolean("enforcement-enabled", true); - logBlockedAttempts = config.getBoolean("log-blocked-attempts", true); - kickMessage = config.getString("kick-message", "&cBitte verbinde dich nur ueber den Proxy."); - - allowedExactIps.clear(); - allowedCidrs.clear(); - - for (String entry : config.getStringList("allowed-proxy-ips")) { - String normalized = normalizeIp(entry); - if (normalized != null) { - allowedExactIps.add(normalized); - } - } - - for (String entry : config.getStringList("allowed-proxy-cidrs")) { - SubnetRule rule = parseSubnet(entry); - if (rule != null) { - allowedCidrs.add(rule); - } - } - - statusApiSyncEnabled = config.getBoolean("statusapi-sync.enabled", false); - statusApiBaseUrl = config.getString("statusapi-sync.base-url", "http://127.0.0.1:9191"); - statusApiEndpointPath = config.getString("statusapi-sync.endpoint-path", "/network/backendguard/config"); - statusApiApiKey = config.getString("statusapi-sync.api-key", ""); - statusApiIntervalSeconds = Math.max(10, config.getInt("statusapi-sync.interval-seconds", 60)); - statusApiLogSyncErrors = config.getBoolean("statusapi-sync.log-sync-errors", true); - - cancelSyncTask(); - if (statusApiSyncEnabled) { - // Erst lokale Werte laden, dann zentral ueberschreiben falls Sync klappt. - syncFromStatusApi(false); - syncTask = getServer().getScheduler().runTaskTimerAsynchronously( - this, - () -> syncFromStatusApi(false), - statusApiIntervalSeconds * 20L, - statusApiIntervalSeconds * 20L - ); - } - } - - private void cancelSyncTask() { - if (syncTask != null) { - syncTask.cancel(); - syncTask = null; - } - } - - private void syncFromStatusApi(boolean manualTrigger) { - if (!statusApiSyncEnabled) { - return; - } - - HttpURLConnection conn = null; - try { - URL url = new URL(buildSyncUrl()); - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(4000); - conn.setReadTimeout(6000); - conn.setRequestProperty("Accept", "application/json"); - if (statusApiApiKey != null && !statusApiApiKey.trim().isEmpty()) { - conn.setRequestProperty("x-api-key", statusApiApiKey.trim()); - } - - int code = conn.getResponseCode(); - if (code < 200 || code >= 300) { - if (statusApiLogSyncErrors || manualTrigger) { - getLogger().warning("StatusAPI-Sync fehlgeschlagen (HTTP " + code + "). Nutze lokale Guard-Werte."); - } - return; - } - - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = br.readLine()) != null) { - sb.append(line); - } - } - - String json = sb.toString(); - Boolean remoteEnforcement = extractJsonBoolean(json, "enforcement_enabled"); - Boolean remoteLogBlocked = extractJsonBoolean(json, "log_blocked_attempts"); - String remoteKickMessage = extractJsonString(json, "kick_message"); - List remoteIps = extractJsonStringArray(json, "allowed_proxy_ips"); - List remoteCidrs = extractJsonStringArray(json, "allowed_proxy_cidrs"); - - if (remoteEnforcement != null) { - enforcementEnabled = remoteEnforcement; - } - if (remoteLogBlocked != null) { - logBlockedAttempts = remoteLogBlocked; - } - if (remoteKickMessage != null && !remoteKickMessage.trim().isEmpty()) { - kickMessage = remoteKickMessage; - } - - if (remoteIps != null) { - allowedExactIps.clear(); - for (String ip : remoteIps) { - String normalized = normalizeIp(ip); - if (normalized != null) { - allowedExactIps.add(normalized); - } - } - } - - if (remoteCidrs != null) { - allowedCidrs.clear(); - for (String cidr : remoteCidrs) { - SubnetRule rule = parseSubnet(cidr); - if (rule != null) { - allowedCidrs.add(rule); - } - } - } - - if (manualTrigger) { - getLogger().info("StatusAPI-Sync erfolgreich. Erlaubte Proxy-IPs=" + allowedExactIps.size() + ", CIDRs=" + allowedCidrs.size()); - } - } catch (Exception e) { - if (statusApiLogSyncErrors || manualTrigger) { - getLogger().warning("StatusAPI-Sync Fehler: " + e.getMessage() + ". Nutze lokale Guard-Werte."); - } - } finally { - if (conn != null) { - conn.disconnect(); - } - } - } - - private String buildSyncUrl() { - String base = statusApiBaseUrl == null ? "" : statusApiBaseUrl.trim(); - String path = statusApiEndpointPath == null ? "" : statusApiEndpointPath.trim(); - - if (base.endsWith("/") && path.startsWith("/")) { - return base.substring(0, base.length() - 1) + path; - } - if (!base.endsWith("/") && !path.startsWith("/")) { - return base + "/" + path; - } - return base + path; - } - - private Boolean extractJsonBoolean(String json, String key) { - String token = extractJsonToken(json, key); - if (token == null) { - return null; - } - if ("true".equalsIgnoreCase(token)) { - return Boolean.TRUE; - } - if ("false".equalsIgnoreCase(token)) { - return Boolean.FALSE; - } - return null; - } - - private String extractJsonString(String json, String key) { - if (json == null || key == null) { - return null; - } - String search = "\"" + key + "\""; - int idx = json.indexOf(search); - if (idx < 0) { - return null; - } - int colon = json.indexOf(':', idx + search.length()); - if (colon < 0) { - return null; - } - int i = colon + 1; - while (i < json.length() && Character.isWhitespace(json.charAt(i))) { - i++; - } - if (i >= json.length() || json.charAt(i) != '"') { - return null; - } - - i++; - StringBuilder sb = new StringBuilder(); - boolean escape = false; - while (i < json.length()) { - char ch = json.charAt(i++); - if (escape) { - if (ch == 'n') { - sb.append('\n'); - } else if (ch == 'r') { - sb.append('\r'); - } else { - sb.append(ch); - } - escape = false; - continue; - } - if (ch == '\\') { - escape = true; - continue; - } - if (ch == '"') { - break; - } - sb.append(ch); - } - return sb.toString(); - } - - private String extractJsonToken(String json, String key) { - if (json == null || key == null) { - return null; - } - String search = "\"" + key + "\""; - int idx = json.indexOf(search); - if (idx < 0) { - return null; - } - int colon = json.indexOf(':', idx + search.length()); - if (colon < 0) { - return null; - } - int i = colon + 1; - while (i < json.length() && Character.isWhitespace(json.charAt(i))) { - i++; - } - if (i >= json.length()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - while (i < json.length()) { - char ch = json.charAt(i); - if (ch == ',' || ch == '}' || ch == ']' || Character.isWhitespace(ch)) { - break; - } - sb.append(ch); - i++; - } - return sb.toString().trim(); - } - - private List extractJsonStringArray(String json, String key) { - if (json == null || key == null) { - return null; - } - - String search = "\"" + key + "\""; - int idx = json.indexOf(search); - if (idx < 0) { - return null; - } - - int colon = json.indexOf(':', idx + search.length()); - if (colon < 0) { - return null; - } - - int start = json.indexOf('[', colon + 1); - if (start < 0) { - return null; - } - - int depth = 0; - int end = -1; - for (int i = start; i < json.length(); i++) { - char ch = json.charAt(i); - if (ch == '[') { - depth++; - } else if (ch == ']') { - depth--; - if (depth == 0) { - end = i; - break; - } - } - } - - if (end < 0 || end <= start) { - return null; - } - - String raw = json.substring(start + 1, end); - List out = new ArrayList(); - boolean inString = false; - boolean escape = false; - StringBuilder current = new StringBuilder(); - - for (int i = 0; i < raw.length(); i++) { - char ch = raw.charAt(i); - if (!inString) { - if (ch == '"') { - inString = true; - current.setLength(0); - } - continue; - } - - if (escape) { - if (ch == 'n') { - current.append('\n'); - } else if (ch == 'r') { - current.append('\r'); - } else { - current.append(ch); - } - escape = false; - continue; - } - - if (ch == '\\') { - escape = true; - continue; - } - - if (ch == '"') { - inString = false; - out.add(current.toString()); - continue; - } - - current.append(ch); - } - - return out; - } - - private boolean isAllowed(InetAddress address) { - String normalized = normalizeIp(address.getHostAddress()); - if (normalized != null && allowedExactIps.contains(normalized)) { - return true; - } - - for (SubnetRule rule : allowedCidrs) { - if (rule.matches(address)) { - return true; - } - } - return false; - } - - private String normalizeIp(String raw) { - if (raw == null || raw.trim().isEmpty()) { - return null; - } - try { - return InetAddress.getByName(raw.trim()).getHostAddress().toLowerCase(Locale.ROOT); - } catch (UnknownHostException e) { - getLogger().warning("Ungueltige IP in Config ignoriert: " + raw); - return null; - } - } - - private SubnetRule parseSubnet(String raw) { - if (raw == null || raw.trim().isEmpty() || !raw.contains("/")) { - return null; - } - - String[] parts = raw.trim().split("/", 2); - try { - InetAddress network = InetAddress.getByName(parts[0]); - int prefix = Integer.parseInt(parts[1]); - return new SubnetRule(network, prefix); - } catch (Exception e) { - getLogger().warning("Ungueltige CIDR in Config ignoriert: " + raw); - return null; - } - } - - private String colorize(String text) { - return ChatColor.translateAlternateColorCodes('&', text == null ? "" : text); - } - - private static final class SubnetRule { - private final byte[] networkBytes; - private final int prefixLength; - - private SubnetRule(InetAddress network, int prefixLength) { - this.networkBytes = network.getAddress(); - this.prefixLength = prefixLength; - } - - private boolean matches(InetAddress address) { - byte[] candidate = address.getAddress(); - if (candidate.length != networkBytes.length) { - return false; - } - - int fullBytes = prefixLength / 8; - int remainderBits = prefixLength % 8; - - for (int i = 0; i < fullBytes; i++) { - if (candidate[i] != networkBytes[i]) { - return false; - } - } - - if (remainderBits == 0) { - return true; - } - - int mask = (-1) << (8 - remainderBits); - return (candidate[fullBytes] & mask) == (networkBytes[fullBytes] & mask); - } - } -} +package net.viper.backendguard; + +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class BackendJoinGuardPlugin extends JavaPlugin implements Listener { + + private boolean enforcementEnabled; + private boolean logBlockedAttempts; + private String kickMessage; + private final Set allowedExactIps = new HashSet(); + private final List allowedCidrs = new ArrayList(); + private boolean statusApiSyncEnabled; + private String statusApiBaseUrl; + private String statusApiEndpointPath; + private String statusApiApiKey; + private int statusApiIntervalSeconds; + private boolean statusApiLogSyncErrors; + private BukkitTask syncTask; + + @Override + public void onEnable() { + saveDefaultConfig(); + reloadGuardConfig(); + getServer().getPluginManager().registerEvents(this, this); + getLogger().info("BackendJoinGuard aktiviert. Erlaubte Proxy-IPs=" + allowedExactIps.size() + ", CIDRs=" + allowedCidrs.size() + ", StatusAPI-Sync=" + statusApiSyncEnabled); + } + + @Override + public void onDisable() { + cancelSyncTask(); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) { + if (!enforcementEnabled) { + return; + } + + // WICHTIG: getRawAddress() liefert die echte Verbindungsadresse (= der Proxy). + // event.getAddress() liefert bei aktivem BungeeCord-IP-Forwarding (spigot.yml: bungeecord: true) + // die IP des SPIELERS - die steht nie in der Proxy-Liste, daher wurden alle Spieler gekickt. + InetAddress address = event.getRawAddress(); + if (address == null) { + address = event.getAddress(); + } + if (address == null) { + return; + } + + if (isAllowed(address)) { + return; + } + + event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, colorize(kickMessage)); + if (logBlockedAttempts) { + getLogger().warning("Direktjoin blockiert: player=" + event.getName() + ", ip=" + address.getHostAddress()); + } + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!"backendguard".equalsIgnoreCase(command.getName())) { + return false; + } + + if (!sender.hasPermission("backendguard.admin")) { + sender.sendMessage(colorize("&cDafuer hast du keine Rechte.")); + return true; + } + + if (args.length == 1 && "reload".equalsIgnoreCase(args[0])) { + reloadConfig(); + reloadGuardConfig(); + if (statusApiSyncEnabled) { + syncFromStatusApi(true); + sender.sendMessage(colorize("&aBackendJoinGuard neu geladen. StatusAPI-Sync aktiv.")); + } else { + sender.sendMessage(colorize("&aBackendJoinGuard neu geladen. Standalone-Modus aktiv.")); + } + return true; + } + + sender.sendMessage(colorize("&e/backendguard reload")); + return true; + } + + private void reloadGuardConfig() { + FileConfiguration config = getConfig(); + enforcementEnabled = config.getBoolean("enforcement-enabled", true); + logBlockedAttempts = config.getBoolean("log-blocked-attempts", true); + kickMessage = config.getString("kick-message", "&cBitte verbinde dich nur ueber den Proxy."); + + allowedExactIps.clear(); + allowedCidrs.clear(); + + for (String entry : config.getStringList("allowed-proxy-ips")) { + String normalized = normalizeIp(entry); + if (normalized != null) { + allowedExactIps.add(normalized); + } + } + + for (String entry : config.getStringList("allowed-proxy-cidrs")) { + SubnetRule rule = parseSubnet(entry); + if (rule != null) { + allowedCidrs.add(rule); + } + } + + statusApiSyncEnabled = config.getBoolean("statusapi-sync.enabled", false); + statusApiBaseUrl = config.getString("statusapi-sync.base-url", "http://127.0.0.1:9191"); + statusApiEndpointPath = config.getString("statusapi-sync.endpoint-path", "/network/backendguard/config"); + statusApiApiKey = config.getString("statusapi-sync.api-key", ""); + statusApiIntervalSeconds = Math.max(10, config.getInt("statusapi-sync.interval-seconds", 60)); + statusApiLogSyncErrors = config.getBoolean("statusapi-sync.log-sync-errors", true); + + cancelSyncTask(); + if (statusApiSyncEnabled) { + // Erst lokale Werte laden, dann zentral ueberschreiben falls Sync klappt. + syncFromStatusApi(false); + syncTask = getServer().getScheduler().runTaskTimerAsynchronously( + this, + () -> syncFromStatusApi(false), + statusApiIntervalSeconds * 20L, + statusApiIntervalSeconds * 20L + ); + } + } + + private void cancelSyncTask() { + if (syncTask != null) { + syncTask.cancel(); + syncTask = null; + } + } + + private void syncFromStatusApi(boolean manualTrigger) { + if (!statusApiSyncEnabled) { + return; + } + + HttpURLConnection conn = null; + try { + URL url = new URL(buildSyncUrl()); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(4000); + conn.setReadTimeout(6000); + conn.setRequestProperty("Accept", "application/json"); + if (statusApiApiKey != null && !statusApiApiKey.trim().isEmpty()) { + conn.setRequestProperty("x-api-key", statusApiApiKey.trim()); + } + + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + if (statusApiLogSyncErrors || manualTrigger) { + getLogger().warning("StatusAPI-Sync fehlgeschlagen (HTTP " + code + "). Nutze lokale Guard-Werte."); + } + return; + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + } + + String json = sb.toString(); + Boolean remoteEnforcement = extractJsonBoolean(json, "enforcement_enabled"); + Boolean remoteLogBlocked = extractJsonBoolean(json, "log_blocked_attempts"); + String remoteKickMessage = extractJsonString(json, "kick_message"); + List remoteIps = extractJsonStringArray(json, "allowed_proxy_ips"); + List remoteCidrs = extractJsonStringArray(json, "allowed_proxy_cidrs"); + + if (remoteEnforcement != null) { + enforcementEnabled = remoteEnforcement; + } + if (remoteLogBlocked != null) { + logBlockedAttempts = remoteLogBlocked; + } + if (remoteKickMessage != null && !remoteKickMessage.trim().isEmpty()) { + kickMessage = remoteKickMessage; + } + + if (remoteIps != null) { + allowedExactIps.clear(); + for (String ip : remoteIps) { + String normalized = normalizeIp(ip); + if (normalized != null) { + allowedExactIps.add(normalized); + } + } + } + + if (remoteCidrs != null) { + allowedCidrs.clear(); + for (String cidr : remoteCidrs) { + SubnetRule rule = parseSubnet(cidr); + if (rule != null) { + allowedCidrs.add(rule); + } + } + } + + if (manualTrigger) { + getLogger().info("StatusAPI-Sync erfolgreich. Erlaubte Proxy-IPs=" + allowedExactIps.size() + ", CIDRs=" + allowedCidrs.size()); + } + } catch (Exception e) { + if (statusApiLogSyncErrors || manualTrigger) { + getLogger().warning("StatusAPI-Sync Fehler: " + e.getMessage() + ". Nutze lokale Guard-Werte."); + } + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private String buildSyncUrl() { + String base = statusApiBaseUrl == null ? "" : statusApiBaseUrl.trim(); + String path = statusApiEndpointPath == null ? "" : statusApiEndpointPath.trim(); + + if (base.endsWith("/") && path.startsWith("/")) { + return base.substring(0, base.length() - 1) + path; + } + if (!base.endsWith("/") && !path.startsWith("/")) { + return base + "/" + path; + } + return base + path; + } + + private Boolean extractJsonBoolean(String json, String key) { + String token = extractJsonToken(json, key); + if (token == null) { + return null; + } + if ("true".equalsIgnoreCase(token)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(token)) { + return Boolean.FALSE; + } + return null; + } + + private String extractJsonString(String json, String key) { + if (json == null || key == null) { + return null; + } + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) { + return null; + } + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) { + return null; + } + int i = colon + 1; + while (i < json.length() && Character.isWhitespace(json.charAt(i))) { + i++; + } + if (i >= json.length() || json.charAt(i) != '"') { + return null; + } + + i++; + StringBuilder sb = new StringBuilder(); + boolean escape = false; + while (i < json.length()) { + char ch = json.charAt(i++); + if (escape) { + if (ch == 'n') { + sb.append('\n'); + } else if (ch == 'r') { + sb.append('\r'); + } else { + sb.append(ch); + } + escape = false; + continue; + } + if (ch == '\\') { + escape = true; + continue; + } + if (ch == '"') { + break; + } + sb.append(ch); + } + return sb.toString(); + } + + private String extractJsonToken(String json, String key) { + if (json == null || key == null) { + return null; + } + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) { + return null; + } + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) { + return null; + } + int i = colon + 1; + while (i < json.length() && Character.isWhitespace(json.charAt(i))) { + i++; + } + if (i >= json.length()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + while (i < json.length()) { + char ch = json.charAt(i); + if (ch == ',' || ch == '}' || ch == ']' || Character.isWhitespace(ch)) { + break; + } + sb.append(ch); + i++; + } + return sb.toString().trim(); + } + + private List extractJsonStringArray(String json, String key) { + if (json == null || key == null) { + return null; + } + + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) { + return null; + } + + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) { + return null; + } + + int start = json.indexOf('[', colon + 1); + if (start < 0) { + return null; + } + + int depth = 0; + int end = -1; + for (int i = start; i < json.length(); i++) { + char ch = json.charAt(i); + if (ch == '[') { + depth++; + } else if (ch == ']') { + depth--; + if (depth == 0) { + end = i; + break; + } + } + } + + if (end < 0 || end <= start) { + return null; + } + + String raw = json.substring(start + 1, end); + List out = new ArrayList(); + boolean inString = false; + boolean escape = false; + StringBuilder current = new StringBuilder(); + + for (int i = 0; i < raw.length(); i++) { + char ch = raw.charAt(i); + if (!inString) { + if (ch == '"') { + inString = true; + current.setLength(0); + } + continue; + } + + if (escape) { + if (ch == 'n') { + current.append('\n'); + } else if (ch == 'r') { + current.append('\r'); + } else { + current.append(ch); + } + escape = false; + continue; + } + + if (ch == '\\') { + escape = true; + continue; + } + + if (ch == '"') { + inString = false; + out.add(current.toString()); + continue; + } + + current.append(ch); + } + + return out; + } + + private boolean isAllowed(InetAddress address) { + String normalized = normalizeIp(address.getHostAddress()); + if (normalized != null && allowedExactIps.contains(normalized)) { + return true; + } + + for (SubnetRule rule : allowedCidrs) { + if (rule.matches(address)) { + return true; + } + } + return false; + } + + private String normalizeIp(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + try { + return InetAddress.getByName(raw.trim()).getHostAddress().toLowerCase(Locale.ROOT); + } catch (UnknownHostException e) { + getLogger().warning("Ungueltige IP in Config ignoriert: " + raw); + return null; + } + } + + private SubnetRule parseSubnet(String raw) { + if (raw == null || raw.trim().isEmpty() || !raw.contains("/")) { + return null; + } + + String[] parts = raw.trim().split("/", 2); + try { + InetAddress network = InetAddress.getByName(parts[0]); + int prefix = Integer.parseInt(parts[1]); + return new SubnetRule(network, prefix); + } catch (Exception e) { + getLogger().warning("Ungueltige CIDR in Config ignoriert: " + raw); + return null; + } + } + + private String colorize(String text) { + return ChatColor.translateAlternateColorCodes('&', text == null ? "" : text); + } + + private static final class SubnetRule { + private final byte[] networkBytes; + private final int prefixLength; + + private SubnetRule(InetAddress network, int prefixLength) { + this.networkBytes = network.getAddress(); + this.prefixLength = prefixLength; + } + + private boolean matches(InetAddress address) { + byte[] candidate = address.getAddress(); + if (candidate.length != networkBytes.length) { + return false; + } + + int fullBytes = prefixLength / 8; + int remainderBits = prefixLength % 8; + + for (int i = 0; i < fullBytes; i++) { + if (candidate[i] != networkBytes[i]) { + return false; + } + } + + if (remainderBits == 0) { + return true; + } + + int mask = (-1) << (8 - remainderBits); + return (candidate[fullBytes] & mask) == (networkBytes[fullBytes] & mask); + } + } +} diff --git a/backend-join-guard/src/main/resources/config.yml b/backend-join-guard/src/main/resources/config.yml index 076d370..f27584d 100644 --- a/backend-join-guard/src/main/resources/config.yml +++ b/backend-join-guard/src/main/resources/config.yml @@ -1,29 +1,29 @@ -# BackendJoinGuard -# Dieses Plugin kommt auf JEDEN Unterserver (Paper/Spigot), nicht auf den Proxy. -# Nur Verbindungen von diesen Proxy-IPs oder Netzbereichen duerfen joinen. - -enforcement-enabled: true -log-blocked-attempts: true -kick-message: "&cBitte verbinde dich nur ueber den Proxy-Server. Direkte Unterserver-Joins sind blockiert." - -# Exakte Proxy-IPs -allowed-proxy-ips: - - "127.0.0.1" - - "::1" - -# Optional ganze Netze im CIDR-Format -allowed-proxy-cidrs: [] -# Beispiel: -# allowed-proxy-cidrs: -# - "10.0.0.0/24" -# - "192.168.178.10/32" - -# Optional: zentrale Steuerung ueber StatusAPI -# Standard bleibt Standalone (enabled=false), damit BackendJoinGuard auch ohne StatusAPI nutzbar ist. -statusapi-sync: - enabled: true - base-url: "http://127.0.0.1:9191" - endpoint-path: "/network/backendguard/config" - api-key: "bgSync_7Rk9pQ2nLm5xV8cH4tW1yZ6" - interval-seconds: 60 - log-sync-errors: true +# BackendJoinGuard +# Dieses Plugin kommt auf JEDEN Unterserver (Paper/Spigot), nicht auf den Proxy. +# Nur Verbindungen von diesen Proxy-IPs oder Netzbereichen duerfen joinen. + +enforcement-enabled: true +log-blocked-attempts: true +kick-message: "&cBitte verbinde dich nur ueber den Proxy-Server. Direkte Unterserver-Joins sind blockiert." + +# Exakte Proxy-IPs +allowed-proxy-ips: + - "127.0.0.1" + - "::1" + +# Optional ganze Netze im CIDR-Format +allowed-proxy-cidrs: [] +# Beispiel: +# allowed-proxy-cidrs: +# - "10.0.0.0/24" +# - "192.168.178.10/32" + +# Optional: zentrale Steuerung ueber StatusAPI +# Standard bleibt Standalone (enabled=false), damit BackendJoinGuard auch ohne StatusAPI nutzbar ist. +statusapi-sync: + enabled: true + base-url: "http://127.0.0.1:9191" + endpoint-path: "/network/backendguard/config" + api-key: "bgSync_7Rk9pQ2nLm5xV8cH4tW1yZ6" + interval-seconds: 60 + log-sync-errors: true diff --git a/backend-join-guard/src/main/resources/plugin.yml b/backend-join-guard/src/main/resources/plugin.yml index b547e07..baf61cc 100644 --- a/backend-join-guard/src/main/resources/plugin.yml +++ b/backend-join-guard/src/main/resources/plugin.yml @@ -1,14 +1,14 @@ -name: BackendJoinGuard -main: net.viper.backendguard.BackendJoinGuardPlugin -version: 1.0.0 -author: M_Viper -description: Blockiert direkte Joins auf Backendserver und erlaubt nur Proxy-IPs. -api-version: '1.20' -commands: - backendguard: - description: BackendJoinGuard neu laden - usage: /backendguard reload -permissions: - backendguard.admin: - description: Darf BackendJoinGuard verwalten - default: op +name: BackendJoinGuard +main: net.viper.backendguard.BackendJoinGuardPlugin +version: 1.0.0 +author: M_Viper +description: Blockiert direkte Joins auf Backendserver und erlaubt nur Proxy-IPs. +api-version: '1.20' +commands: + backendguard: + description: BackendJoinGuard neu laden + usage: /backendguard reload +permissions: + backendguard.admin: + description: Darf BackendJoinGuard verwalten + default: op