Upload via GUI (26 Dateien)

This commit is contained in:
2026-11-05 21:23:14 +00:00
parent 2e783e018b
commit 871bfbd8a2
10 changed files with 466 additions and 7 deletions
+13 -3
View File
@@ -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):
Binary file not shown.
+1 -1
View File
@@ -5,7 +5,7 @@
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft</artifactId>
<version>1.1.0</version>
<version>1.2.0</version>
<packaging>jar</packaging>
<name>DeskHub-Minecraft</name>
<description>Legt in DeskHub ein Ticket an, wenn ein Minecraft-Server offline geht. Eine Jar für Spigot, Paper und BungeeCord.</description>
@@ -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));
@@ -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<String> 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<String, Boolean> snap = tracker.snapshot();
if (snap.isEmpty()) send(sender, "§7Noch keine Prüfung durchgeführt.");
for (Map.Entry<String, Boolean> e : snap.entrySet()) send(sender, " §7- §f" + e.getKey() + ": " + (e.getValue() ? "§aonline" : "§coffline"));
@@ -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<String, State> states = new HashMap<String, State>();
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);
@@ -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 <Servername> <Zeit in Millisekunden> <HMAC-SHA256 als Hex>}. 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;
}
}
}
@@ -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<String> 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<String> servers = new ArrayList<String>();
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<String> warnings() {
List<String> w = new ArrayList<String>();
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;
}
@@ -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: ""
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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);