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
@@ -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);