From 871bfbd8a2c12f0bc9a420f7bbde30f6fbc6aa35 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Nov 2026 21:23:14 +0000 Subject: [PATCH] Upload via GUI (26 Dateien) --- minecraft-plugin/README.md | 16 ++- .../dist/DeskHub-Minecraft-1.2.0.jar | Bin 0 -> 58994 bytes minecraft-plugin/pom.xml | 2 +- .../minecraft/bukkit/BukkitPlugin.java | 8 ++ .../minecraft/bungee/BungeePlugin.java | 109 ++++++++++++++- .../deskhub/minecraft/core/OutageTracker.java | 37 ++++- .../deskhub/minecraft/core/RestartNotice.java | 110 +++++++++++++++ .../de/deskhub/minecraft/core/Settings.java | 44 ++++++ .../src/main/resources/config.yml | 18 +++ .../de/deskhub/minecraft/core/CoreTest.java | 129 ++++++++++++++++++ 10 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 minecraft-plugin/dist/DeskHub-Minecraft-1.2.0.jar create mode 100644 minecraft-plugin/src/main/java/de/deskhub/minecraft/core/RestartNotice.java diff --git a/minecraft-plugin/README.md b/minecraft-plugin/README.md index 156b9917..a6175058 100644 --- a/minecraft-plugin/README.md +++ b/minecraft-plugin/README.md @@ -8,9 +8,16 @@ Ein Server, der abstürzt, kann nicht mehr selbst "Hilfe" rufen. Deshalb gibt es | Wo das Plugin liegt | Was es tut | Wann das Ticket kommt | |---|---|---| -| **BungeeCord-Proxy** (empfohlen) | Prüft deine Server von außen (Server-Ping, alle 30 s) | Nach 3 Fehlschlägen in Folge, also nach rund 1,5 Minuten, in Echtzeit | +| **BungeeCord-Proxy** (empfohlen) | Prüft deine Server von außen (Server-Ping, alle 30 s) | Nach 3 Fehlschlägen in Folge **und** erst, wenn der Server mindestens die Schonfrist (5 Minuten) nicht erreichbar war | | **Spigot / Paper** (jeder Server) | Schreibt regelmäßig ein Lebenszeichen und markiert es beim normalen Beenden | Beim **nächsten Start**, wenn der Server zuvor abgestürzt, hart beendet oder vom Strom getrennt wurde | +**Geplante Neustarts:** Der Proxy kann von außen nicht erkennen, ob ein Server neu startet oder abgestürzt ist. Deshalb gibt es die **Neustart-Schonfrist** (`monitor.restart-grace-minutes`, Standard 5 Minuten): Ist der Server vorher wieder erreichbar, entsteht kein Ticket. Stelle sie etwas höher ein, als dein Neustart dauert. Die Einstellung `backend.ticket-on-shutdown` gilt nur für das Plugin auf dem Spigot/Paper-Server, nicht für den Proxy. + +**Geplante Neustarts melden (ab 1.2.0):** Der Server kann dem Proxy beim Herunterfahren selbst sagen, dass der Neustart geplant ist. Der Proxy wartet dann die längere Frist `monitor.planned-restart-grace-minutes` (Standard 15 Minuten), bevor er ein Ticket anlegt; bleibt der Server länger weg, kommt das Ticket trotzdem. Einrichten: +1. Auf dem **Proxy** in der `config.yml`: `monitor.notify-port` (zum Beispiel `25590`) und `monitor.notify-secret` (mindestens 16 zufällige Zeichen) eintragen, `/deskhubmc reload`. Der Anschluss sollte nur aus dem Netz deiner Server erreichbar sein (Firewall); ohne richtige Signatur nimmt der Proxy nichts an. +2. Auf jedem **Server** (Spigot/Paper): `backend.proxy-notify` (`Adresse-des-Proxys:25590`), `backend.proxy-secret` (dasselbe Geheimnis) und `backend.proxy-server-name` (der Name dieses Servers in der Proxy-Konfiguration, zum Beispiel `lobby`) eintragen, `/deskhubmc reload`. +Die Meldung ist mit HMAC-SHA256 signiert und muss frisch sein (höchstens eine Minute alt), alte Meldungen lassen sich nicht nachspielen. Stürzt der Server ab, meldet er nichts: Dann gilt die normale Schonfrist. + Gegen Ticket-Fluten: ein einzelner Aussetzer macht kein Ticket, pro Ausfall höchstens eines, und pro Server frühestens nach der eingestellten Wartezeit (30 Minuten) wieder eines. Server, die seit dem Start des Proxys nie online waren (z. B. absichtlich abgeschaltet), werden ignoriert. **Automatisch lösen:** Das Plugin setzt das Ausfall-Ticket in DeskHub auf „Gelöst“ und hinterlässt eine Notiz mit der Ausfalldauer. Die Ticketnummer merkt es sich in `offene-tickets.properties`, auch ein Neustart währenddessen schadet nicht. Abschalten mit `ticket.resolve-on-recovery: false`. Voraussetzung: DeskHub **1.0.48 oder neuer** (API-Aktion „resolve“) und der API-Schlüssel mit dem Recht „Tickets anlegen“; gelöst werden nur Tickets, die dieser Schlüssel über die API angelegt hat. @@ -24,7 +31,7 @@ Normales Herunterfahren (`/stop`, geplanter Neustart) erzeugt **kein** Ticket (a 1. **API-Schlüssel in DeskHub:** Adminportal → Verwalten → **API-Schlüssel** → neuen Schlüssel mit dem Recht **„Tickets anlegen“** erzeugen (er wird nur einmal angezeigt). Trage unter „IP-Beschränkung“ die IP-Adresse deines Minecraft-Servers ein, dann ist der Schlüssel nur von dort nutzbar. 2. **Abteilung und Priorität:** Öffne im Adminportal die Abteilung bzw. Priorität. Die Zahl am Ende der Adresse (`…?id=4`) ist die ID für die config. -3. **Jar kopieren:** `DeskHub-Minecraft-1.1.0.jar` in den Ordner `plugins` deines Servers bzw. Proxys. Server einmal starten, damit `plugins/DeskHub-Minecraft/config.yml` entsteht. +3. **Jar kopieren:** `DeskHub-Minecraft-1.2.0.jar` in den Ordner `plugins` deines Servers bzw. Proxys. Server einmal starten, damit `plugins/DeskHub-Minecraft/config.yml` entsteht. 4. **config.yml ausfüllen** (mindestens `url`, `api-key`, `requester-email`; optional `department-id`, `priority-id`), dann `/deskhubmc reload`. 5. **Testen:** `/deskhubmc test` legt ein Testticket an und zeigt die Ticketnummer oder den Grund für einen Fehler. @@ -53,6 +60,9 @@ ticket: monitor: # nur BungeeCord interval-seconds: 30 failures-before-ticket: 3 + restart-grace-minutes: 5 # Ticket erst, wenn der Server so lange weg ist (Neustarts) + planned-restart-grace-minutes: 15 # wenn der Server den Neustart selbst gemeldet hat + notify-port: 0 # Meldungen der Server annehmen (0 = aus), dazu notify-secret cooldown-minutes: 30 servers: [] # leer = alle Server des Proxys backend: # nur Spigot/Paper @@ -75,7 +85,7 @@ Voraussetzungen: JDK 17 oder neuer, Maven. ```bash cd minecraft-plugin -mvn package # Tests und Jar: target/DeskHub-Minecraft-1.1.0.jar +mvn package # Tests und Jar: target/DeskHub-Minecraft-1.2.0.jar ``` Zusätzlicher Test des Spigot/Paper-Teils gegen einen simulierten Paper-Server (braucht JDK 21): diff --git a/minecraft-plugin/dist/DeskHub-Minecraft-1.2.0.jar b/minecraft-plugin/dist/DeskHub-Minecraft-1.2.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..1ba3dd3b098b74e99662544c83175f08d821a890 GIT binary patch literal 58994 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.deskhub deskhub-minecraft - 1.1.0 + 1.2.0 jar DeskHub-Minecraft Legt in DeskHub ein Ticket an, wenn ein Minecraft-Server offline geht. Eine Jar für Spigot, Paper und BungeeCord. diff --git a/minecraft-plugin/src/main/java/de/deskhub/minecraft/bukkit/BukkitPlugin.java b/minecraft-plugin/src/main/java/de/deskhub/minecraft/bukkit/BukkitPlugin.java index 4cd88709..5e6d8dd5 100644 --- a/minecraft-plugin/src/main/java/de/deskhub/minecraft/bukkit/BukkitPlugin.java +++ b/minecraft-plugin/src/main/java/de/deskhub/minecraft/bukkit/BukkitPlugin.java @@ -2,6 +2,7 @@ package de.deskhub.minecraft.bukkit; import de.deskhub.minecraft.core.HeartbeatFile; import de.deskhub.minecraft.core.OpenTickets; +import de.deskhub.minecraft.core.RestartNotice; import de.deskhub.minecraft.core.Settings; import de.deskhub.minecraft.core.Template; import de.deskhub.minecraft.core.TicketClient; @@ -65,6 +66,12 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor { public void onDisable() { if (beatTask != null) beatTask.cancel(); long now = System.currentTimeMillis(); + // Zuerst dem Proxy sagen, dass der Neustart geplant ist (er wartet dann länger, bevor er ein Ticket anlegt) + if (settings != null && settings.announcesRestarts()) { + String err = RestartNotice.send(settings.proxyNotifyHost, settings.proxyNotifyPort, RestartNotice.build(settings.proxyNotifySecret, settings.proxyNotifyName, now), 1500); + if (err == null) getLogger().info("Proxy über das geplante Herunterfahren informiert."); + else getLogger().warning("Proxy konnte nicht über das Herunterfahren informiert werden: " + err); + } try { if (heartbeat != null) heartbeat.markClean(now); } catch (IOException e) { @@ -161,6 +168,7 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor { sender.sendMessage("§7Abteilung: §f" + idText(settings.departmentId) + " §7Priorität: §f" + idText(settings.priorityId)); sender.sendMessage("§7Konfiguration: " + (settings.problems().isEmpty() ? "§aOK" : "§c" + settings.problems().get(0))); sender.sendMessage("§7Absturz-Ticket: §f" + (settings.ticketOnCrash ? "an" : "aus") + " §7Herunterfahren-Ticket: §f" + (settings.ticketOnShutdown ? "an" : "aus")); + sender.sendMessage("§7Meldung an den Proxy beim Herunterfahren: §f" + (settings.announcesRestarts() ? "an (" + settings.proxyNotifyHost + ":" + settings.proxyNotifyPort + ", Name " + settings.proxyNotifyName + ")" : "aus")); } else if (sub.equals("test")) { if (!settings.problems().isEmpty()) { sender.sendMessage("§cKein Test möglich: " + settings.problems().get(0)); diff --git a/minecraft-plugin/src/main/java/de/deskhub/minecraft/bungee/BungeePlugin.java b/minecraft-plugin/src/main/java/de/deskhub/minecraft/bungee/BungeePlugin.java index 26477c8f..ae6e322e 100644 --- a/minecraft-plugin/src/main/java/de/deskhub/minecraft/bungee/BungeePlugin.java +++ b/minecraft-plugin/src/main/java/de/deskhub/minecraft/bungee/BungeePlugin.java @@ -2,6 +2,7 @@ package de.deskhub.minecraft.bungee; import de.deskhub.minecraft.core.OpenTickets; import de.deskhub.minecraft.core.OutageTracker; +import de.deskhub.minecraft.core.RestartNotice; import de.deskhub.minecraft.core.Settings; import de.deskhub.minecraft.core.Template; import de.deskhub.minecraft.core.TicketClient; @@ -21,11 +22,16 @@ import net.md_5.bungee.config.YamlConfiguration; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; /** @@ -39,6 +45,9 @@ public final class BungeePlugin extends Plugin { private OutageTracker tracker; private OpenTickets openTickets; private ScheduledTask watchdog; + private ServerSocket notifySocket; + /** Gleichzeitig bearbeitete Meldungen: Mehr werden sofort abgewiesen (schützt vor Verbindungsfluten auf dem offenen Anschluss). */ + private final AtomicInteger activeNotices = new AtomicInteger(); @Override public void onEnable() { @@ -49,6 +58,101 @@ public final class BungeePlugin extends Plugin { @Override public void onDisable() { if (watchdog != null) watchdog.cancel(); + stopNotifyListener(); + } + + /** + * Lauscht auf Meldungen der Server über geplante Neustarts (nur wenn monitor.notify-port und monitor.notify-secret gesetzt sind). + * Jede Meldung ist mit dem gemeinsamen Geheimnis signiert und muss frisch sein; der Servername muss in der Proxy-Konfiguration stehen. + */ + private void startNotifyListener() { + stopNotifyListener(); + if (!settings.listensForRestartNotices() || settings.plannedRestartGraceMinutes <= 0) return; + final ServerSocket ss; + try { + ss = new ServerSocket(); + ss.setReuseAddress(true); + ss.bind(new InetSocketAddress(settings.notifyBind, settings.notifyPort), 16); + } catch (IOException e) { + getLogger().warning("Meldungen über geplante Neustarts sind nicht möglich (Port " + settings.notifyPort + "): " + e.getMessage()); + return; + } + notifySocket = ss; + getLogger().info("Nimmt Meldungen über geplante Neustarts an (" + settings.notifyBind + ":" + settings.notifyPort + ")."); + Thread t = new Thread(new Runnable() { + @Override + public void run() { + while (!ss.isClosed()) { + try { + final Socket client = ss.accept(); + if (activeNotices.incrementAndGet() > 8) { + activeNotices.decrementAndGet(); + try { + client.close(); + } catch (IOException ignored) { + // abgewiesen + } + continue; + } + getProxy().getScheduler().runAsync(BungeePlugin.this, new Runnable() { + @Override + public void run() { + handleNotice(client); + } + }); + } catch (IOException e) { + if (ss.isClosed()) return; + } + } + } + }, "DeskHub-Minecraft-Notify"); + t.setDaemon(true); + t.start(); + } + + private void stopNotifyListener() { + if (notifySocket != null) { + try { + notifySocket.close(); + } catch (IOException ignored) { + // wird ohnehin beendet + } + notifySocket = null; + } + } + + private void handleNotice(Socket client) { + String answer = "ERR\n"; + try { + client.setSoTimeout(2000); + // höchstens eine kurze Zeile lesen + java.io.InputStream in = client.getInputStream(); + byte[] buf = new byte[RestartNotice.MAX_LINE_BYTES]; + int n = 0; + int b; + while (n < buf.length && (b = in.read()) != -1 && b != '\n') buf[n++] = (byte) b; + String line = new String(buf, 0, n, "UTF-8"); + String server = RestartNotice.verify(settings.notifySecret, line, System.currentTimeMillis(), 60000L); + if (server != null && getProxy().getServers().containsKey(server)) { + tracker.markPlanned(server, System.currentTimeMillis()); + getLogger().info("Server \"" + server + "\" meldet einen geplanten Neustart: Ticket frühestens nach " + settings.plannedRestartGraceMinutes + " Minuten."); + answer = "OK\n"; + } else if (server != null) { + getLogger().warning("Meldung für den unbekannten Server \"" + server + "\" ignoriert (Name stimmt nicht mit der Proxy-Konfiguration überein)."); + } + OutputStream out = client.getOutputStream(); + out.write(answer.getBytes("UTF-8")); + out.flush(); + } catch (IOException ignored) { + // Verbindung abgebrochen: nichts zu tun + } finally { + activeNotices.decrementAndGet(); + try { + client.close(); + } catch (IOException ignored) { + // nichts zu tun + } + } } private void load() { @@ -72,7 +176,8 @@ public final class BungeePlugin extends Plugin { } client = new TicketClient(settings, getDescription().getVersion()); openTickets = new OpenTickets(new File(getDataFolder(), "offene-tickets.properties")); - tracker = new OutageTracker(settings.monitorFailures, settings.cooldownMinutes, settings.ignoreNeverOnline); + tracker = new OutageTracker(settings.monitorFailures, settings.cooldownMinutes, settings.ignoreNeverOnline, settings.restartGraceMinutes, settings.plannedRestartGraceMinutes); + startNotifyListener(); for (String w : settings.warnings()) getLogger().warning(w); List problems = settings.problems(); if (!problems.isEmpty()) { @@ -178,7 +283,7 @@ public final class BungeePlugin extends Plugin { send(sender, "§7DeskHub: §f" + (settings.url.isEmpty() ? "nicht eingestellt" : settings.url)); send(sender, "§7Abteilung: §f" + idText(settings.departmentId) + " §7Priorität: §f" + idText(settings.priorityId)); send(sender, "§7Konfiguration: " + (settings.problems().isEmpty() ? "§aOK" : "§c" + settings.problems().get(0))); - send(sender, "§7Prüfung alle §f" + settings.monitorIntervalSeconds + " s§7, Ticket nach §f" + settings.monitorFailures + " §7Fehlschlägen, Wartezeit §f" + settings.cooldownMinutes + " Min."); + send(sender, "§7Prüfung alle §f" + settings.monitorIntervalSeconds + " s§7, Ticket nach §f" + settings.monitorFailures + " §7Fehlschlägen, Wartezeit §f" + settings.cooldownMinutes + " Min.§7, Neustart-Schonfrist §f" + settings.restartGraceMinutes + " Min. §7(geplant: §f" + settings.plannedRestartGraceMinutes + " Min.§7, Meldungen: §f" + (notifySocket != null ? "an, Anschluss " + settings.notifyPort : "aus") + "§7)"); Map snap = tracker.snapshot(); if (snap.isEmpty()) send(sender, "§7Noch keine Prüfung durchgeführt."); for (Map.Entry e : snap.entrySet()) send(sender, " §7- §f" + e.getKey() + ": " + (e.getValue() ? "§aonline" : "§coffline")); diff --git a/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/OutageTracker.java b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/OutageTracker.java index 437b6f59..0b00ed15 100644 --- a/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/OutageTracker.java +++ b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/OutageTracker.java @@ -8,6 +8,10 @@ import java.util.Map; * Entscheidet anhand der Prüfergebnisse (online/offline) je Server, wann ein Ticket angelegt wird: erst nach mehreren Fehlschlägen in Folge * (kein Ticket bei einem einzelnen Aussetzer), höchstens einmal pro Ausfall und höchstens einmal je Wartezeit (kein Ticket-Gewitter, wenn * ein Server ständig an- und ausgeht). Server, die seit dem Start nie online waren, werden auf Wunsch ignoriert. + * + * Ein geplanter Neustart ist von außen nicht von einem Absturz zu unterscheiden. Deshalb gibt es eine Schonfrist: Ein Ticket entsteht erst, + * wenn der Server mindestens so lange nicht erreichbar ist (Neustarts dauern meist ein bis drei Minuten). Meldet der Server vorher selbst + * einen geplanten Neustart (RestartNotice), gilt statt dessen die längere Frist für geplante Neustarts. */ public final class OutageTracker { public enum Decision { NONE, OPEN_TICKET, RECOVERED } @@ -19,14 +23,27 @@ public final class OutageTracker { long lastTicketAt = -1; long offlineSince; boolean online; + long plannedUntil; } private final int failuresNeeded; private final long cooldownMillis; private final boolean ignoreNeverOnline; + private final long graceMillis; + private final long plannedGraceMillis; private final Map states = new HashMap(); public OutageTracker(int failuresNeeded, int cooldownMinutes, boolean ignoreNeverOnline) { + this(failuresNeeded, cooldownMinutes, ignoreNeverOnline, 0); + } + + public OutageTracker(int failuresNeeded, int cooldownMinutes, boolean ignoreNeverOnline, int graceMinutes) { + this(failuresNeeded, cooldownMinutes, ignoreNeverOnline, graceMinutes, 0); + } + + public OutageTracker(int failuresNeeded, int cooldownMinutes, boolean ignoreNeverOnline, int graceMinutes, int plannedGraceMinutes) { + this.plannedGraceMillis = Math.max(0, plannedGraceMinutes) * 60000L; + this.graceMillis = Math.max(0, graceMinutes) * 60000L; this.failuresNeeded = Math.max(1, failuresNeeded); this.cooldownMillis = Math.max(0, cooldownMinutes) * 60000L; this.ignoreNeverOnline = ignoreNeverOnline; @@ -45,12 +62,13 @@ public final class OutageTracker { s.failures = 0; s.outageOpen = false; s.offlineSince = 0; + s.plannedUntil = 0; return recovered ? Decision.RECOVERED : Decision.NONE; } if (ignoreNeverOnline && !s.seenOnline) return Decision.NONE; if (s.failures == 0) s.offlineSince = nowMillis; s.failures++; - if (s.failures >= failuresNeeded && !s.outageOpen) { + if (s.failures >= failuresNeeded && !s.outageOpen && nowMillis - s.offlineSince >= graceMillis && nowMillis >= s.plannedUntil) { s.outageOpen = true; if (s.lastTicketAt < 0 || nowMillis - s.lastTicketAt >= cooldownMillis) { s.lastTicketAt = nowMillis; @@ -60,6 +78,23 @@ public final class OutageTracker { return Decision.NONE; } + /** Der Server hat einen geplanten Neustart gemeldet: Bis zur Frist für geplante Neustarts gibt es kein Ticket. */ + public synchronized void markPlanned(String server, long nowMillis) { + if (plannedGraceMillis <= 0) return; + State s = states.get(server); + if (s == null) { + s = new State(); + states.put(server, s); + } + s.plannedUntil = nowMillis + plannedGraceMillis; + } + + /** Ob für den Server gerade ein geplanter Neustart gemeldet ist. */ + public synchronized boolean isPlanned(String server, long nowMillis) { + State s = states.get(server); + return s != null && nowMillis < s.plannedUntil; + } + /** Zeitpunkt des ersten fehlgeschlagenen Prüfens des aktuellen Ausfalls (0 = Server ist online). */ public synchronized long offlineSince(String server) { State s = states.get(server); diff --git a/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/RestartNotice.java b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/RestartNotice.java new file mode 100644 index 00000000..87421d18 --- /dev/null +++ b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/RestartNotice.java @@ -0,0 +1,110 @@ +package de.deskhub.minecraft.core; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.security.MessageDigest; + +/** + * Meldung "Dieser Server wird jetzt geplant heruntergefahren" vom Server (Spigot/Paper) an den BungeeCord-Proxy. Der Proxy kann von + * außen einen Neustart nicht von einem Absturz unterscheiden; mit dieser Meldung gibt er dem Server mehr Zeit, bevor er ein Ticket anlegt. + * + * Eine Zeile über TCP: {@code DHMC1 }. Der Servername steht URL-kodiert darin; + * signiert werden Servername und Zeit mit einem gemeinsamen Geheimnis. Der Proxy nimmt nur Meldungen mit richtiger Signatur und frischer + * Zeit an (kein Nachspielen alter Meldungen). + */ +public final class RestartNotice { + private static final Charset UTF8 = Charset.forName("UTF-8"); + public static final int MAX_LINE_BYTES = 512; + + private RestartNotice() { + } + + /** Die Zeile, die der Server sendet (mit Zeilenumbruch am Ende). */ + public static String build(String secret, String server, long nowMillis) { + String name = encode(server); + return "DHMC1 " + name + " " + nowMillis + " " + sign(secret, name, nowMillis) + "\n"; + } + + /** + * Prüft eine empfangene Zeile. + * + * @return der Servername, oder null, wenn die Zeile fehlerhaft, die Signatur falsch oder die Zeit zu alt bzw. zu neu ist + */ + public static String verify(String secret, String line, long nowMillis, long maxSkewMillis) { + if (secret == null || secret.isEmpty() || line == null) return null; + String[] p = line.trim().split(" "); + if (p.length != 4 || !"DHMC1".equals(p[0])) return null; + long ts; + try { + ts = Long.parseLong(p[2]); + } catch (NumberFormatException e) { + return null; + } + if (Math.abs(nowMillis - ts) > maxSkewMillis) return null; + byte[] expected = sign(secret, p[1], ts).getBytes(UTF8); + if (!MessageDigest.isEqual(expected, p[3].toLowerCase().getBytes(UTF8))) return null; + return decode(p[1]); + } + + /** Sendet die Meldung (blockierend, mit kurzer Zeitgrenze). @return null bei Erfolg, sonst der Grund des Fehlschlags */ + public static String send(String host, int port, String line, int timeoutMillis) { + Socket s = new Socket(); + try { + s.connect(new InetSocketAddress(host, port), timeoutMillis); + s.setSoTimeout(timeoutMillis); + OutputStream out = s.getOutputStream(); + out.write(line.getBytes(UTF8)); + out.flush(); + // Auf die Bestätigung des Proxys warten (eine Zeile), damit die Meldung sicher angekommen ist + BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), UTF8)); + String ack = in.readLine(); + return ack != null && ack.startsWith("OK") ? null : "Proxy hat die Meldung abgelehnt (" + (ack == null ? "keine Antwort" : ack) + ")"; + } catch (IOException e) { + return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + } finally { + try { + s.close(); + } catch (IOException ignored) { + // nichts mehr zu tun + } + } + } + + private static String sign(String secret, String encodedName, long ts) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(UTF8), "HmacSHA256")); + byte[] raw = mac.doFinal((encodedName + "|" + ts).getBytes(UTF8)); + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) sb.append(String.format("%02x", b & 0xff)); + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String encode(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private static String decode(String s) { + try { + return URLDecoder.decode(s, "UTF-8"); + } catch (Exception e) { + return s; + } + } +} diff --git a/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/Settings.java b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/Settings.java index c0d6bf15..12d027a8 100644 --- a/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/Settings.java +++ b/minecraft-plugin/src/main/java/de/deskhub/minecraft/core/Settings.java @@ -35,12 +35,21 @@ public final class Settings { public final int monitorFailures; public final int cooldownMinutes; public final boolean ignoreNeverOnline; + public final int restartGraceMinutes; + public final int plannedRestartGraceMinutes; + public final int notifyPort; + public final String notifyBind; + public final String notifySecret; public final List monitorServers; public final int heartbeatSeconds; public final boolean ticketOnCrash; public final boolean ticketOnShutdown; public final String serverName; + public final String proxyNotifyHost; + public final int proxyNotifyPort; + public final String proxyNotifyName; + public final String proxyNotifySecret; public Settings(ConfigSource c) { String u = c.getString("deskhub.url", "").trim(); @@ -74,6 +83,11 @@ public final class Settings { this.monitorFailures = clamp(c.getInt("monitor.failures-before-ticket", 3), 1, 100); this.cooldownMinutes = clamp(c.getInt("monitor.cooldown-minutes", 30), 0, 10080); this.ignoreNeverOnline = c.getBoolean("monitor.ignore-never-online", true); + this.restartGraceMinutes = clamp(c.getInt("monitor.restart-grace-minutes", 5), 0, 120); + this.plannedRestartGraceMinutes = clamp(c.getInt("monitor.planned-restart-grace-minutes", 15), 0, 240); + this.notifyPort = clamp(c.getInt("monitor.notify-port", 0), 0, 65535); + this.notifyBind = c.getString("monitor.notify-bind", "0.0.0.0").trim(); + this.notifySecret = c.getString("monitor.notify-secret", "").trim(); List servers = new ArrayList(); for (String s : c.getStringList("monitor.servers")) if (s != null && !s.trim().isEmpty()) servers.add(s.trim()); this.monitorServers = Collections.unmodifiableList(servers); @@ -82,6 +96,33 @@ public final class Settings { this.ticketOnCrash = c.getBoolean("backend.ticket-on-crash", true); this.ticketOnShutdown = c.getBoolean("backend.ticket-on-shutdown", false); this.serverName = clip(c.getString("backend.server-name", "").trim(), 60); + String target = c.getString("backend.proxy-notify", "").trim(); + String host = ""; + int port = 0; + int colon = target.lastIndexOf(':'); + if (colon > 0) { + host = target.substring(0, colon).trim(); + try { + port = Integer.parseInt(target.substring(colon + 1).trim()); + } catch (NumberFormatException e) { + host = ""; + } + } + boolean validTarget = !host.isEmpty() && port >= 1 && port <= 65535; + this.proxyNotifyHost = validTarget ? host : ""; + this.proxyNotifyPort = validTarget ? port : 0; + this.proxyNotifyName = clip(c.getString("backend.proxy-server-name", "").trim(), 60); + this.proxyNotifySecret = c.getString("backend.proxy-secret", "").trim(); + } + + /** Ob der Proxy auf Meldungen über geplante Neustarts lauscht (Anschluss und Geheimnis eingestellt). */ + public boolean listensForRestartNotices() { + return notifyPort > 0 && !notifySecret.isEmpty(); + } + + /** Ob dieser Server geplante Neustarts an den Proxy meldet. */ + public boolean announcesRestarts() { + return !proxyNotifyHost.isEmpty() && !proxyNotifySecret.isEmpty() && !proxyNotifyName.isEmpty(); } /** Probleme, die das Anlegen von Tickets verhindern (leer = alles in Ordnung). */ @@ -98,6 +139,9 @@ public final class Settings { /** Hinweise, die das Anlegen nicht verhindern. */ public List warnings() { List w = new ArrayList(); + if (notifyPort > 0 && notifySecret.isEmpty()) w.add("monitor.notify-port ist gesetzt, aber monitor.notify-secret fehlt: Meldungen über geplante Neustarts werden nicht angenommen."); + if (notifySecret.length() > 0 && notifySecret.length() < 16) w.add("monitor.notify-secret ist kurz: bitte mindestens 16 zufällige Zeichen verwenden."); + if (!proxyNotifyHost.isEmpty() && (proxyNotifySecret.isEmpty() || proxyNotifyName.isEmpty())) w.add("backend.proxy-notify ist gesetzt, aber backend.proxy-secret oder backend.proxy-server-name fehlt: Neustarts werden nicht gemeldet."); if (url.startsWith("http://")) w.add("deskhub.url verwendet http: der API-Schlüssel würde unverschlüsselt übertragen. Bitte https:// verwenden."); return w; } diff --git a/minecraft-plugin/src/main/resources/config.yml b/minecraft-plugin/src/main/resources/config.yml index 3b68918a..86b17ce3 100644 --- a/minecraft-plugin/src/main/resources/config.yml +++ b/minecraft-plugin/src/main/resources/config.yml @@ -69,6 +69,17 @@ monitor: interval-seconds: 30 # Wie oft die Prüfung hintereinander fehlschlagen muss, bevor ein Ticket entsteht (verhindert Tickets bei kurzen Aussetzern) failures-before-ticket: 3 + # Schonfrist für Neustarts in Minuten: Ein Ticket entsteht erst, wenn der Server mindestens so lange nicht erreichbar ist. + # Der Proxy kann einen geplanten Neustart nicht von einem Absturz unterscheiden. Stelle hier etwas mehr ein, als dein Server zum Neustarten braucht + # (meist 2 bis 5). 0 = keine Schonfrist (Ticket gleich nach den Fehlschlägen oben). + restart-grace-minutes: 5 + # Meldet ein Server selbst einen geplanten Neustart (siehe backend.proxy-notify), gilt statt dessen diese längere Frist in Minuten. + planned-restart-grace-minutes: 15 + # Meldungen der Server über geplante Neustarts annehmen: TCP-Anschluss und gemeinsames Geheimnis (mindestens 16 zufällige Zeichen). + # notify-port 0 = aus. Der Anschluss sollte nur aus dem Netz eurer Server erreichbar sein (Firewall); ohne richtige Signatur wird nichts angenommen. + notify-port: 0 + notify-bind: "0.0.0.0" + notify-secret: "" # Frühestens nach so vielen Minuten gibt es für denselben Server wieder ein Ticket (verhindert Ticket-Flut, wenn ein Server ständig ausfällt) cooldown-minutes: 30 # true: Server, die seit dem Start des Proxys noch nie online waren, werden ignoriert (z. B. absichtlich abgeschaltete Server) @@ -83,6 +94,13 @@ backend: # Ticket beim nächsten Start, wenn der Server zuvor abgestürzt oder hart beendet worden war ticket-on-crash: true # Ticket auch beim normalen Herunterfahren (/stop, Neustart). Meist unerwünscht, deshalb aus. + # Gilt nur für das Plugin auf dem Server (Spigot/Paper), NICHT für den BungeeCord-Proxy: Dort gilt die Schonfrist oben (monitor.restart-grace-minutes). ticket-on-shutdown: false # Name des Servers in den Tickets. Leer = "Minecraft-Server (Port 25565)" server-name: "" + # Dem BungeeCord-Proxy beim Herunterfahren melden, dass der Neustart geplant ist (dann wartet er länger, bevor er ein Ticket anlegt). + # proxy-notify: Adresse und notify-port des Proxys, z. B. "10.0.0.5:25590". proxy-secret: dasselbe Geheimnis wie monitor.notify-secret. + # proxy-server-name: Name dieses Servers in der config.yml des Proxys (Abschnitt servers), z. B. "lobby". + proxy-notify: "" + proxy-secret: "" + proxy-server-name: "" diff --git a/minecraft-plugin/src/test/java/de/deskhub/minecraft/core/CoreTest.java b/minecraft-plugin/src/test/java/de/deskhub/minecraft/core/CoreTest.java index 0f55e2d3..ba83fb63 100644 --- a/minecraft-plugin/src/test/java/de/deskhub/minecraft/core/CoreTest.java +++ b/minecraft-plugin/src/test/java/de/deskhub/minecraft/core/CoreTest.java @@ -91,6 +91,135 @@ class CoreTest { assertEquals("3 Tage 2 Std.", Template.formatDuration((3 * 24 + 2) * 3600000L)); } + @Test + void plannedRestartNoticeGivesLongerGrace() { + OutageTracker t = new OutageTracker(3, 30, true, 5, 15); + long now = 1_000_000; + assertEquals(OutageTracker.Decision.NONE, t.report("lobby", true, now)); + // Server meldet einen geplanten Neustart und ist dann 12 Minuten weg: kein Ticket (Frist für geplante Neustarts: 15 Minuten) + t.markPlanned("lobby", now); + assertTrue(t.isPlanned("lobby", now + 1000)); + for (int i = 0; i < 24; i++) assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 30000)); + assertEquals(OutageTracker.Decision.NONE, t.report("lobby", true, now += 30000)); + assertFalse(t.isPlanned("lobby", now + 1000), "nach der Rückkehr gilt die Meldung nicht mehr"); + // Bleibt der Server nach der geplanten Frist weg, gibt es das Ticket doch + t.markPlanned("lobby", now); + int tickets = 0; + for (int i = 0; i < 40; i++) if (t.report("lobby", false, now += 30000) == OutageTracker.Decision.OPEN_TICKET) tickets++; + assertEquals(1, tickets, "nach 15 Minuten ohne Rückkehr kommt das Ticket"); + // Ohne Meldung gilt nur die kurze Schonfrist + OutageTracker u = new OutageTracker(3, 30, true, 5, 15); + u.report("a", true, now); + int t2 = 0; + for (int i = 0; i < 12; i++) if (u.report("a", false, now += 30000) == OutageTracker.Decision.OPEN_TICKET) t2++; + assertEquals(1, t2); + // Frist für geplante Neustarts 0 = Meldungen wirken nicht + OutageTracker v = new OutageTracker(3, 30, true, 5, 0); + v.report("b", true, now); + v.markPlanned("b", now); + assertFalse(v.isPlanned("b", now + 1)); + } + + @Test + void restartNoticeIsSignedAndFresh() { + long now = 1_700_000_000_000L; + String secret = "ein-langes-gemeinsames-geheimnis"; + String line = RestartNotice.build(secret, "Lobby Süd", now); + assertTrue(line.startsWith("DHMC1 ") && line.endsWith("\n")); + assertEquals("Lobby Süd", RestartNotice.verify(secret, line, now + 5000, 60000L)); + assertNull(RestartNotice.verify("falsches-geheimnis-xxxxxxxx", line, now, 60000L), "falsches Geheimnis"); + assertNull(RestartNotice.verify(secret, line, now + 120000L, 60000L), "zu alt (Nachspielen)"); + assertNull(RestartNotice.verify(secret, line, now - 120000L, 60000L), "aus der Zukunft"); + assertNull(RestartNotice.verify(secret, line.replace("Lobby", "Hub"), now, 60000L), "veränderter Servername"); + assertNull(RestartNotice.verify(secret, "DHMC1 lobby abc def", now, 60000L), "fehlerhafte Zeit"); + assertNull(RestartNotice.verify(secret, "Hallo Welt", now, 60000L), "kein Protokoll"); + assertNull(RestartNotice.verify("", line, now, 60000L), "ohne Geheimnis wird nichts angenommen"); + assertNull(RestartNotice.verify(secret, null, now, 60000L)); + } + + @Test + void restartNoticeTravelsOverTcp() throws Exception { + final String secret = "ein-langes-gemeinsames-geheimnis"; + final java.net.ServerSocket ss = new java.net.ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")); + final String[] got = new String[1]; + Thread th = new Thread(new Runnable() { + public void run() { + try { + java.net.Socket c = ss.accept(); + java.io.BufferedReader r = new java.io.BufferedReader(new java.io.InputStreamReader(c.getInputStream(), "UTF-8")); + got[0] = RestartNotice.verify(secret, r.readLine(), System.currentTimeMillis(), 60000L); + c.getOutputStream().write((got[0] != null ? "OK\n" : "ERR\n").getBytes("UTF-8")); + c.getOutputStream().flush(); + c.close(); + } catch (Exception ignored) { + // Test schlägt über got[0] fehl + } + } + }); + th.start(); + String err = RestartNotice.send("127.0.0.1", ss.getLocalPort(), RestartNotice.build(secret, "lobby", System.currentTimeMillis()), 3000); + th.join(3000); + ss.close(); + assertNull(err, "Senden gelingt: " + err); + assertEquals("lobby", got[0]); + // Nicht erreichbarer Proxy: Fehlermeldung statt Absturz + assertNotNull(RestartNotice.send("127.0.0.1", 1, RestartNotice.build(secret, "lobby", System.currentTimeMillis()), 500)); + } + + @Test + void restartNoticeSettings() { + Map m = valid("https://support.example.de"); + m.put("monitor.notify-port", 25590); + m.put("monitor.notify-secret", "ein-langes-gemeinsames-geheimnis"); + m.put("backend.proxy-notify", "10.0.0.5:25590"); + m.put("backend.proxy-secret", "ein-langes-gemeinsames-geheimnis"); + m.put("backend.proxy-server-name", "lobby"); + Settings s = new Settings(cfg(m)); + assertTrue(s.listensForRestartNotices()); + assertTrue(s.announcesRestarts()); + assertEquals("10.0.0.5", s.proxyNotifyHost); + assertEquals(25590, s.proxyNotifyPort); + assertEquals(15, s.plannedRestartGraceMinutes); + assertTrue(s.warnings().isEmpty(), s.warnings().toString()); + // Standard: alles aus + Settings d = new Settings(cfg(valid("https://support.example.de"))); + assertFalse(d.listensForRestartNotices()); + assertFalse(d.announcesRestarts()); + // Unvollständig: Warnung + Map bad = valid("https://support.example.de"); + bad.put("backend.proxy-notify", "10.0.0.5:25590"); + assertFalse(new Settings(cfg(bad)).announcesRestarts()); + assertFalse(new Settings(cfg(bad)).warnings().isEmpty()); + Map badPort = valid("https://support.example.de"); + badPort.put("backend.proxy-notify", "ohne-port"); + assertEquals("", new Settings(cfg(badPort)).proxyNotifyHost); + } + + @Test + void restartGracePreventsTicketForPlannedRestart() { + OutageTracker t = new OutageTracker(3, 30, true, 5); + long now = 1_000_000; + assertEquals(OutageTracker.Decision.NONE, t.report("lobby", true, now)); + // Neustart: 4 Minuten nicht erreichbar (alle 30 s geprüft), dann wieder online: kein Ticket, auch keine "Wiederhergestellt"-Meldung + for (int i = 0; i < 8; i++) assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 30000)); + assertEquals(OutageTracker.Decision.NONE, t.report("lobby", true, now += 30000)); + // echter Ausfall: nach 5 Minuten gibt es das Ticket, nur einmal + OutageTracker.Decision last = OutageTracker.Decision.NONE; + int tickets = 0; + for (int i = 0; i < 14; i++) { + last = t.report("lobby", false, now += 30000); + if (last == OutageTracker.Decision.OPEN_TICKET) tickets++; + } + assertEquals(1, tickets); + assertEquals(OutageTracker.Decision.RECOVERED, t.report("lobby", true, now += 30000)); + // ohne Schonfrist wie bisher nach den Fehlschlägen + OutageTracker none = new OutageTracker(3, 30, true, 0); + none.report("a", true, now); + none.report("a", false, now += 1000); + none.report("a", false, now += 1000); + assertEquals(OutageTracker.Decision.OPEN_TICKET, none.report("a", false, now += 1000)); + } + @Test void outageTrackerRules() { OutageTracker t = new OutageTracker(3, 30, true);