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 00000000..1ba3dd3b
Binary files /dev/null and b/minecraft-plugin/dist/DeskHub-Minecraft-1.2.0.jar differ
diff --git a/minecraft-plugin/pom.xml b/minecraft-plugin/pom.xml
index 8387f67f..534c23ab 100644
--- a/minecraft-plugin/pom.xml
+++ b/minecraft-plugin/pom.xml
@@ -5,7 +5,7 @@
de.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);