diff --git a/backend-join-guard/pom.xml b/backend-join-guard/pom.xml
new file mode 100644
index 0000000..e4b3b30
--- /dev/null
+++ b/backend-join-guard/pom.xml
@@ -0,0 +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
+
+
+
+
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
new file mode 100644
index 0000000..f4d9c3f
--- /dev/null
+++ b/backend-join-guard/src/main/java/net/viper/backendguard/BackendJoinGuardPlugin.java
@@ -0,0 +1,509 @@
+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);
+ }
+ }
+}
diff --git a/backend-join-guard/src/main/resources/config.yml b/backend-join-guard/src/main/resources/config.yml
new file mode 100644
index 0000000..076d370
--- /dev/null
+++ b/backend-join-guard/src/main/resources/config.yml
@@ -0,0 +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
diff --git a/backend-join-guard/src/main/resources/plugin.yml b/backend-join-guard/src/main/resources/plugin.yml
new file mode 100644
index 0000000..b547e07
--- /dev/null
+++ b/backend-join-guard/src/main/resources/plugin.yml
@@ -0,0 +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