Upload via GUI (67 Dateien)

This commit is contained in:
2026-10-21 16:45:36 +00:00
parent dc19180a77
commit 8385fdabf2
50 changed files with 1948 additions and 0 deletions
@@ -0,0 +1,35 @@
package de.deskhub.minecraft.bukkit;
import de.deskhub.minecraft.core.ConfigSource;
import org.bukkit.configuration.file.FileConfiguration;
import java.util.List;
/** Die config.yml von Spigot/Paper für den gemeinsamen Teil des Plugins. */
final class BukkitConfig implements ConfigSource {
private final FileConfiguration cfg;
BukkitConfig(FileConfiguration cfg) {
this.cfg = cfg;
}
@Override
public String getString(String path, String def) {
return cfg.getString(path, def);
}
@Override
public int getInt(String path, int def) {
return cfg.getInt(path, def);
}
@Override
public boolean getBoolean(String path, boolean def) {
return cfg.getBoolean(path, def);
}
@Override
public List<String> getStringList(String path) {
return cfg.getStringList(path);
}
}
@@ -0,0 +1,159 @@
package de.deskhub.minecraft.bukkit;
import de.deskhub.minecraft.core.HeartbeatFile;
import de.deskhub.minecraft.core.Settings;
import de.deskhub.minecraft.core.Template;
import de.deskhub.minecraft.core.TicketClient;
import de.deskhub.minecraft.core.TicketResult;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
/**
* Spigot/Paper: Der Server meldet sich selbst. Ein Server, der abstürzt, kann das nicht mehr selbst melden. Deshalb schreibt das Plugin
* regelmäßig ein Lebenszeichen und markiert es beim ordnungsgemäßen Beenden. Findet es beim nächsten Start ein Lebenszeichen ohne diese
* Markierung, legt es ein Ticket "Server war nicht erreichbar" an. Für Meldungen in Echtzeit läuft das Plugin zusätzlich auf dem Proxy
* (BungeeCord), der die Server von außen prüft.
*/
public class BukkitPlugin extends JavaPlugin implements CommandExecutor {
private Settings settings;
private TicketClient client;
private HeartbeatFile heartbeat;
private BukkitTask beatTask;
@Override
public void onEnable() {
saveDefaultConfig();
load();
if (getCommand("deskhubmc") != null) getCommand("deskhubmc").setExecutor(this);
heartbeat = new HeartbeatFile(new File(getDataFolder(), "lebenszeichen.properties"));
HeartbeatFile.Previous previous = heartbeat.readPrevious();
long now = System.currentTimeMillis();
try {
heartbeat.start(now);
} catch (IOException e) {
getLogger().log(Level.WARNING, "Lebenszeichen-Datei nicht schreibbar: " + e.getMessage() + " (Abstürze werden nicht erkannt)");
}
if (previous.crashed() && settings.ticketOnCrash) reportCrash(previous, now);
long period = settings.heartbeatSeconds * 20L;
beatTask = Bukkit.getScheduler().runTaskTimerAsynchronously(this, new Runnable() {
@Override
public void run() {
try {
heartbeat.beat(System.currentTimeMillis());
} catch (IOException ignored) {
// schon beim Start gemeldet
}
}
}, period, period);
}
@Override
public void onDisable() {
if (beatTask != null) beatTask.cancel();
long now = System.currentTimeMillis();
try {
if (heartbeat != null) heartbeat.markClean(now);
} catch (IOException e) {
getLogger().log(Level.WARNING, "Lebenszeichen-Datei nicht schreibbar: " + e.getMessage());
}
if (settings != null && settings.ticketOnShutdown && settings.problems().isEmpty()) {
// Beim Beenden darf gewartet werden: der Server wartet auf das Plugin, solange es nicht länger als das Zeitlimit dauert
Map<String, String> v = Template.vars(serverName(), "Plugin auf dem Server", now);
TicketResult r = client.create(Template.apply(settings.subjectShutdown, v), Template.apply(settings.messageShutdown, v));
getLogger().info(r.ok ? "Ticket " + r.number + " angelegt (Herunterfahren)." : "Ticket nicht angelegt: " + r.error);
}
}
private void load() {
reloadConfig();
settings = new Settings(new BukkitConfig(getConfig()));
client = new TicketClient(settings, getDescription().getVersion());
for (String w : settings.warnings()) getLogger().warning(w);
List<String> problems = settings.problems();
if (!problems.isEmpty()) {
getLogger().warning("Es werden keine Tickets angelegt, bis die config.yml stimmt:");
for (String p : problems) getLogger().warning(" - " + p);
}
}
private String serverName() {
if (!settings.serverName.isEmpty()) return settings.serverName;
return "Minecraft-Server (Port " + Bukkit.getPort() + ")";
}
private void reportCrash(final HeartbeatFile.Previous previous, final long now) {
if (!settings.problems().isEmpty()) {
getLogger().warning("Der Server war zuvor nicht ordnungsgemäß beendet worden, aber es kann kein Ticket angelegt werden (config.yml prüfen).");
return;
}
getLogger().warning("Der Server wurde zuvor nicht ordnungsgemäß beendet. Es wird ein Ticket angelegt.");
Bukkit.getScheduler().runTaskAsynchronously(this, new Runnable() {
@Override
public void run() {
Map<String, String> v = Template.vars(serverName(), "Plugin auf dem Server", now);
v.put("lastSeen", previous.lastBeat > 0 ? Template.formatTime(previous.lastBeat) : "unbekannt");
v.put("downtime", previous.lastBeat > 0 ? Template.formatDuration(now - previous.lastBeat) : "unbekannt");
TicketResult r = client.create(Template.apply(settings.subjectCrash, v), Template.apply(settings.messageCrash, v));
if (r.ok) getLogger().info("Ticket " + r.number + " angelegt.");
else getLogger().warning("Ticket nicht angelegt: " + r.error);
}
});
}
@Override
public boolean onCommand(final CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("deskhubmc.admin")) {
sender.sendMessage("§cDafür fehlt dir die Berechtigung (deskhubmc.admin).");
return true;
}
String sub = args.length > 0 ? args[0].toLowerCase() : "help";
if (sub.equals("reload")) {
load();
sender.sendMessage(settings.problems().isEmpty() ? "§aEinstellungen neu geladen." : "§eEinstellungen neu geladen, aber unvollständig: " + settings.problems().get(0));
} else if (sub.equals("status")) {
sender.sendMessage("§6DeskHub-Minecraft §7(Spigot/Paper)");
sender.sendMessage("§7DeskHub: §f" + (settings.url.isEmpty() ? "nicht eingestellt" : settings.url));
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"));
} else if (sub.equals("test")) {
if (!settings.problems().isEmpty()) {
sender.sendMessage("§cKein Test möglich: " + settings.problems().get(0));
return true;
}
sender.sendMessage("§7Testticket wird angelegt …");
Bukkit.getScheduler().runTaskAsynchronously(this, new Runnable() {
@Override
public void run() {
Map<String, String> v = Template.vars(serverName(), "Befehl /deskhubmc test", System.currentTimeMillis());
final TicketResult r = client.create(Template.apply(settings.subjectTest, v), Template.apply(settings.messageTest, v));
Bukkit.getScheduler().runTask(BukkitPlugin.this, new Runnable() {
@Override
public void run() {
sender.sendMessage(r.ok ? "§aTicket " + r.number + " wurde angelegt." : "§cFehlgeschlagen: " + r.error);
}
});
}
});
} else {
sender.sendMessage("§6/deskhubmc test §7- Testticket anlegen");
sender.sendMessage("§6/deskhubmc status §7- Einstellungen anzeigen");
sender.sendMessage("§6/deskhubmc reload §7- config.yml neu laden");
}
return true;
}
private static String idText(int id) {
return id > 0 ? String.valueOf(id) : "Standard";
}
}
@@ -0,0 +1,35 @@
package de.deskhub.minecraft.bungee;
import de.deskhub.minecraft.core.ConfigSource;
import net.md_5.bungee.config.Configuration;
import java.util.List;
/** Die config.yml von BungeeCord für den gemeinsamen Teil des Plugins. */
final class BungeeConfig implements ConfigSource {
private final Configuration cfg;
BungeeConfig(Configuration cfg) {
this.cfg = cfg;
}
@Override
public String getString(String path, String def) {
return cfg.getString(path, def);
}
@Override
public int getInt(String path, int def) {
return cfg.getInt(path, def);
}
@Override
public boolean getBoolean(String path, boolean def) {
return cfg.getBoolean(path, def);
}
@Override
public List<String> getStringList(String path) {
return cfg.getStringList(path);
}
}
@@ -0,0 +1,186 @@
package de.deskhub.minecraft.bungee;
import de.deskhub.minecraft.core.OutageTracker;
import de.deskhub.minecraft.core.Settings;
import de.deskhub.minecraft.core.Template;
import de.deskhub.minecraft.core.TicketClient;
import de.deskhub.minecraft.core.TicketResult;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.Callback;
import net.md_5.bungee.api.ServerPing;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.config.ServerInfo;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.Plugin;
import net.md_5.bungee.api.scheduler.ScheduledTask;
import net.md_5.bungee.config.Configuration;
import net.md_5.bungee.config.ConfigurationProvider;
import net.md_5.bungee.config.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
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.logging.Level;
/**
* BungeeCord: Der Proxy prüft die Server von außen (Ping) und meldet einen Ausfall in Echtzeit, auch wenn der Server abgestürzt ist
* und sich selbst nicht mehr melden könnte. Ein Ticket entsteht erst nach mehreren Fehlschlägen in Folge, höchstens einmal je Ausfall
* und je Wartezeit.
*/
public final class BungeePlugin extends Plugin {
private Settings settings;
private TicketClient client;
private OutageTracker tracker;
private ScheduledTask watchdog;
@Override
public void onEnable() {
load();
getProxy().getPluginManager().registerCommand(this, new McCommand());
}
@Override
public void onDisable() {
if (watchdog != null) watchdog.cancel();
}
private void load() {
if (watchdog != null) watchdog.cancel();
try {
File file = new File(getDataFolder(), "config.yml");
if (!file.exists()) {
if (!getDataFolder().isDirectory() && !getDataFolder().mkdirs()) throw new IOException("Ordner nicht anlegbar: " + getDataFolder());
InputStream in = getResourceAsStream("config.yml");
try {
Files.copy(in, file.toPath());
} finally {
in.close();
}
}
Configuration cfg = ConfigurationProvider.getProvider(YamlConfiguration.class).load(file);
settings = new Settings(new BungeeConfig(cfg));
} catch (IOException e) {
getLogger().log(Level.SEVERE, "config.yml konnte nicht gelesen werden: " + e.getMessage());
settings = new Settings(new BungeeConfig(new Configuration()));
}
client = new TicketClient(settings, getDescription().getVersion());
tracker = new OutageTracker(settings.monitorFailures, settings.cooldownMinutes, settings.ignoreNeverOnline);
for (String w : settings.warnings()) getLogger().warning(w);
List<String> problems = settings.problems();
if (!problems.isEmpty()) {
getLogger().warning("Es werden keine Tickets angelegt, bis die config.yml stimmt:");
for (String p : problems) getLogger().warning(" - " + p);
}
watchdog = getProxy().getScheduler().schedule(this, new Runnable() {
@Override
public void run() {
checkServers();
}
}, 10, settings.monitorIntervalSeconds, TimeUnit.SECONDS);
}
private List<ServerInfo> monitored() {
List<ServerInfo> list = new ArrayList<ServerInfo>();
Map<String, ServerInfo> all = getProxy().getServers();
if (settings.monitorServers.isEmpty()) {
list.addAll(all.values());
} else {
for (String name : settings.monitorServers) {
ServerInfo info = all.get(name);
if (info != null) list.add(info);
else getLogger().warning("monitor.servers: Server \"" + name + "\" ist in der Proxy-Konfiguration nicht vorhanden.");
}
}
return list;
}
private void checkServers() {
for (final ServerInfo info : monitored()) {
info.ping(new Callback<ServerPing>() {
@Override
public void done(ServerPing result, Throwable error) {
handle(info.getName(), error == null && result != null);
}
});
}
}
private void handle(final String server, boolean online) {
final long now = System.currentTimeMillis();
OutageTracker.Decision d = tracker.report(server, online, now);
if (d == OutageTracker.Decision.RECOVERED) {
getLogger().info("Server \"" + server + "\" ist wieder erreichbar.");
} else if (d == OutageTracker.Decision.OPEN_TICKET) {
final long since = tracker.offlineSince(server);
getLogger().warning("Server \"" + server + "\" ist nicht erreichbar. Es wird ein Ticket angelegt.");
if (!settings.problems().isEmpty()) {
getLogger().warning("Kein Ticket möglich: " + settings.problems().get(0));
return;
}
getProxy().getScheduler().runAsync(this, new Runnable() {
@Override
public void run() {
Map<String, String> v = Template.vars(server, "BungeeCord-Proxy", since > 0 ? since : now);
TicketResult r = client.create(Template.apply(settings.subjectOffline, v), Template.apply(settings.messageOffline, v));
if (r.ok) getLogger().info("Ticket " + r.number + " angelegt.");
else getLogger().warning("Ticket nicht angelegt: " + r.error);
}
});
}
}
private final class McCommand extends Command {
McCommand() {
super("deskhubmc", "deskhubmc.admin", "dhmc");
}
@Override
public void execute(final CommandSender sender, String[] args) {
String sub = args.length > 0 ? args[0].toLowerCase() : "help";
if (sub.equals("reload")) {
load();
send(sender, settings.problems().isEmpty() ? "§aEinstellungen neu geladen." : "§eEinstellungen neu geladen, aber unvollständig: " + settings.problems().get(0));
} else if (sub.equals("status")) {
send(sender, "§6DeskHub-Minecraft §7(BungeeCord)");
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.");
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"));
} else if (sub.equals("test")) {
if (!settings.problems().isEmpty()) {
send(sender, "§cKein Test möglich: " + settings.problems().get(0));
return;
}
send(sender, "§7Testticket wird angelegt …");
getProxy().getScheduler().runAsync(BungeePlugin.this, new Runnable() {
@Override
public void run() {
Map<String, String> v = Template.vars("BungeeCord-Proxy", "Befehl /deskhubmc test", System.currentTimeMillis());
TicketResult r = client.create(Template.apply(settings.subjectTest, v), Template.apply(settings.messageTest, v));
send(sender, r.ok ? "§aTicket " + r.number + " wurde angelegt." : "§cFehlgeschlagen: " + r.error);
}
});
} else {
send(sender, "§6/deskhubmc test §7- Testticket anlegen");
send(sender, "§6/deskhubmc status §7- Einstellungen und Serverzustand");
send(sender, "§6/deskhubmc reload §7- config.yml neu laden");
}
}
}
private static void send(CommandSender sender, String text) {
sender.sendMessage(TextComponent.fromLegacyText(text));
}
private static String idText(int id) {
return id > 0 ? String.valueOf(id) : "Standard";
}
}
@@ -0,0 +1,14 @@
package de.deskhub.minecraft.core;
import java.util.List;
/** Zugriff auf die config.yml, unabhängig davon, ob Bukkit (Spigot/Paper) oder BungeeCord sie geladen hat. Pfade sind durch Punkte getrennt. */
public interface ConfigSource {
String getString(String path, String def);
int getInt(String path, int def);
boolean getBoolean(String path, boolean def);
List<String> getStringList(String path);
}
@@ -0,0 +1,102 @@
package de.deskhub.minecraft.core;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
/**
* Lebenszeichen-Datei des Servers: Solange der Server läuft, wird sie regelmäßig aktualisiert; beim ordnungsgemäßen Beenden wird sie als
* "sauber beendet" markiert. Findet das Plugin beim nächsten Start eine Datei ohne diese Markierung, ist der Server abgestürzt (oder sein
* Prozess wurde beendet, oder der Strom fiel aus) und das Plugin legt ein Ticket an.
*/
public final class HeartbeatFile {
/** Zustand der Datei beim Start. */
public static final class Previous {
public final boolean existed;
public final boolean clean;
public final long lastBeat;
Previous(boolean existed, boolean clean, long lastBeat) {
this.existed = existed;
this.clean = clean;
this.lastBeat = lastBeat;
}
/** Ob der letzte Lauf abgestürzt ist. */
public boolean crashed() {
return existed && !clean;
}
}
private final File file;
private long startedAt;
public HeartbeatFile(File file) {
this.file = file;
}
/** Liest den Zustand des letzten Laufs. Eine fehlende oder unlesbare Datei gilt als "kein früherer Lauf". */
public Previous readPrevious() {
if (!file.isFile()) return new Previous(false, true, 0);
Properties p = new Properties();
try {
InputStream in = new FileInputStream(file);
try {
p.load(in);
} finally {
in.close();
}
} catch (IOException e) {
return new Previous(false, true, 0);
}
boolean clean = "true".equals(p.getProperty("clean")) || !"true".equals(p.getProperty("running"));
long last = 0;
try {
last = Long.parseLong(p.getProperty("lastBeat", "0"));
} catch (NumberFormatException ignored) {
// bleibt 0
}
return new Previous(true, clean, last);
}
/** Beim Start: Lauf beginnt. */
public void start(long nowMillis) throws IOException {
startedAt = nowMillis;
write(true, false, nowMillis);
}
/** Regelmäßig: Server läuft noch. */
public void beat(long nowMillis) throws IOException {
write(true, false, nowMillis);
}
/** Beim ordnungsgemäßen Beenden. */
public void markClean(long nowMillis) throws IOException {
write(false, true, nowMillis);
}
private void write(boolean running, boolean clean, long now) throws IOException {
File dir = file.getParentFile();
if (dir != null && !dir.isDirectory() && !dir.mkdirs()) throw new IOException("Ordner nicht anlegbar: " + dir);
Properties p = new Properties();
p.setProperty("running", Boolean.toString(running));
p.setProperty("clean", Boolean.toString(clean));
p.setProperty("lastBeat", Long.toString(now));
p.setProperty("startedAt", Long.toString(startedAt));
File tmp = new File(file.getPath() + ".tmp");
OutputStream out = new FileOutputStream(tmp);
try {
p.store(out, "DeskHub-Minecraft Lebenszeichen, nicht bearbeiten");
} finally {
out.close();
}
// Erst vollständig schreiben, dann ersetzen: ein Absturz mitten im Schreiben darf die Datei nicht zerstören
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
@@ -0,0 +1,74 @@
package de.deskhub.minecraft.core;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Minimaler JSON-Baustein: Texte maskieren und zwei Werte aus der Antwort von DeskHub lesen (keine Fremdbibliothek nötig). */
public final class Json {
private static final Pattern NUMBER = Pattern.compile("\"number\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
private static final Pattern MESSAGE = Pattern.compile("\"message\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
private Json() {
}
/** Maskiert einen Text für JSON (mit Anführungszeichen drumherum). */
public static String quote(String s) {
StringBuilder b = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
switch (ch) {
case '"': b.append("\\\""); break;
case '\\': b.append("\\\\"); break;
case '\n': b.append("\\n"); break;
case '\r': b.append("\\r"); break;
case '\t': b.append("\\t"); break;
case '\b': b.append("\\b"); break;
case '\f': b.append("\\f"); break;
default:
if (ch < 0x20) b.append(String.format("\\u%04x", (int) ch));
else b.append(ch);
}
}
return b.append('"').toString();
}
/** Ticketnummer aus {"number":"T-123","status":"open"} oder null. */
public static String ticketNumber(String body) {
return first(NUMBER, body);
}
/** Fehlertext aus {"error":{"code":"..","message":".."}} oder null. */
public static String errorMessage(String body) {
return first(MESSAGE, body);
}
private static String first(Pattern p, String body) {
if (body == null) return null;
Matcher m = p.matcher(body);
return m.find() ? unescape(m.group(1)) : null;
}
private static String unescape(String s) {
StringBuilder b = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch != '\\' || i + 1 >= s.length()) { b.append(ch); continue; }
char n = s.charAt(++i);
switch (n) {
case 'n': b.append('\n'); break;
case 'r': b.append('\r'); break;
case 't': b.append('\t'); break;
case 'b': b.append('\b'); break;
case 'f': b.append('\f'); break;
case 'u':
if (i + 5 <= s.length()) {
try { b.append((char) Integer.parseInt(s.substring(i + 1, i + 5), 16)); i += 4; break; } catch (RuntimeException e) { /* unten als Zeichen übernehmen */ }
}
b.append('u');
break;
default: b.append(n);
}
}
return b.toString();
}
}
@@ -0,0 +1,75 @@
package de.deskhub.minecraft.core;
import java.util.HashMap;
import java.util.LinkedHashMap;
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.
*/
public final class OutageTracker {
public enum Decision { NONE, OPEN_TICKET, RECOVERED }
private static final class State {
int failures;
boolean seenOnline;
boolean outageOpen;
long lastTicketAt = -1;
long offlineSince;
boolean online;
}
private final int failuresNeeded;
private final long cooldownMillis;
private final boolean ignoreNeverOnline;
private final Map<String, State> states = new HashMap<String, State>();
public OutageTracker(int failuresNeeded, int cooldownMinutes, boolean ignoreNeverOnline) {
this.failuresNeeded = Math.max(1, failuresNeeded);
this.cooldownMillis = Math.max(0, cooldownMinutes) * 60000L;
this.ignoreNeverOnline = ignoreNeverOnline;
}
public synchronized Decision report(String server, boolean online, long nowMillis) {
State s = states.get(server);
if (s == null) {
s = new State();
states.put(server, s);
}
s.online = online;
if (online) {
s.seenOnline = true;
boolean recovered = s.outageOpen;
s.failures = 0;
s.outageOpen = false;
s.offlineSince = 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) {
s.outageOpen = true;
if (s.lastTicketAt < 0 || nowMillis - s.lastTicketAt >= cooldownMillis) {
s.lastTicketAt = nowMillis;
return Decision.OPEN_TICKET;
}
}
return Decision.NONE;
}
/** Zeitpunkt des ersten fehlgeschlagenen Prüfens des aktuellen Ausfalls (0 = Server ist online). */
public synchronized long offlineSince(String server) {
State s = states.get(server);
return s == null ? 0 : s.offlineSince;
}
/** Server => online? (für den Befehl "status") */
public synchronized Map<String, Boolean> snapshot() {
Map<String, Boolean> m = new LinkedHashMap<String, Boolean>();
for (Map.Entry<String, State> e : states.entrySet()) m.put(e.getKey(), e.getValue().online);
return m;
}
}
@@ -0,0 +1,107 @@
package de.deskhub.minecraft.core;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
/** Alle Einstellungen aus der config.yml, geprüft und mit Vorgabewerten. Ändert sich nach dem Laden nicht mehr. */
public final class Settings {
private static final Pattern API_KEY = Pattern.compile("^dh_[a-f0-9]{48}$");
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
public final String url;
public final String apiKey;
public final String requesterName;
public final String requesterEmail;
public final int timeoutSeconds;
public final int departmentId;
public final int priorityId;
public final int helpTopicId;
public final String subjectOffline;
public final String messageOffline;
public final String subjectCrash;
public final String messageCrash;
public final String subjectShutdown;
public final String messageShutdown;
public final String subjectTest;
public final String messageTest;
public final int monitorIntervalSeconds;
public final int monitorFailures;
public final int cooldownMinutes;
public final boolean ignoreNeverOnline;
public final List<String> monitorServers;
public final int heartbeatSeconds;
public final boolean ticketOnCrash;
public final boolean ticketOnShutdown;
public final String serverName;
public Settings(ConfigSource c) {
String u = c.getString("deskhub.url", "").trim();
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
this.url = u;
this.apiKey = c.getString("deskhub.api-key", "").trim();
this.requesterName = clip(c.getString("deskhub.requester-name", "Minecraft-Überwachung").trim(), 120);
this.requesterEmail = c.getString("deskhub.requester-email", "").trim();
this.timeoutSeconds = clamp(c.getInt("deskhub.timeout-seconds", 10), 2, 60);
this.departmentId = Math.max(0, c.getInt("ticket.department-id", 0));
this.priorityId = Math.max(0, c.getInt("ticket.priority-id", 0));
this.helpTopicId = Math.max(0, c.getInt("ticket.help-topic-id", 0));
this.subjectOffline = c.getString("ticket.offline.subject", "[Minecraft] {server} ist offline");
this.messageOffline = c.getString("ticket.offline.message",
"Der Minecraft-Server \"{server}\" ist seit {time} nicht mehr erreichbar.\n\nErkannt von: {source}\nBitte den Server prüfen und neu starten.");
this.subjectCrash = c.getString("ticket.crash.subject", "[Minecraft] {server} war nicht erreichbar (Absturz oder Ausfall)");
this.messageCrash = c.getString("ticket.crash.message",
"Der Minecraft-Server \"{server}\" wurde nicht ordnungsgemäß beendet (Absturz, Prozess beendet oder Stromausfall).\n\n"
+ "Letztes Lebenszeichen: {lastSeen}\nNeu gestartet: {time}\nAusfalldauer: ca. {downtime}\n\nBitte die Logdateien prüfen.");
this.subjectShutdown = c.getString("ticket.shutdown.subject", "[Minecraft] {server} wurde heruntergefahren");
this.messageShutdown = c.getString("ticket.shutdown.message", "Der Minecraft-Server \"{server}\" wurde um {time} heruntergefahren.");
this.subjectTest = c.getString("ticket.test.subject", "[Minecraft] Testticket von {server}");
this.messageTest = c.getString("ticket.test.message", "Das ist ein Testticket des DeskHub-Minecraft-Plugins. Die Verbindung zu DeskHub funktioniert.\n\nServer: {server}\nZeit: {time}");
this.monitorIntervalSeconds = clamp(c.getInt("monitor.interval-seconds", 30), 5, 3600);
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);
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);
this.heartbeatSeconds = clamp(c.getInt("backend.heartbeat-seconds", 10), 2, 600);
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);
}
/** Probleme, die das Anlegen von Tickets verhindern (leer = alles in Ordnung). */
public List<String> problems() {
List<String> p = new ArrayList<String>();
if (url.isEmpty()) p.add("deskhub.url fehlt (z. B. https://support.example.de)");
else if (!url.startsWith("https://") && !url.startsWith("http://")) p.add("deskhub.url muss mit https:// beginnen");
if (apiKey.isEmpty()) p.add("deskhub.api-key fehlt (Adminportal, Verwalten, API-Schlüssel, Recht \"Tickets anlegen\")");
else if (!API_KEY.matcher(apiKey).matches()) p.add("deskhub.api-key sieht nicht wie ein DeskHub-Schlüssel aus (dh_ und 48 Zeichen)");
if (!EMAIL.matcher(requesterEmail).matches()) p.add("deskhub.requester-email fehlt oder ist ungültig");
return p;
}
/** Hinweise, die das Anlegen nicht verhindern. */
public List<String> warnings() {
List<String> w = new ArrayList<String>();
if (url.startsWith("http://")) w.add("deskhub.url verwendet http: der API-Schlüssel würde unverschlüsselt übertragen. Bitte https:// verwenden.");
return w;
}
private static int clamp(int v, int min, int max) {
return Math.max(min, Math.min(max, v));
}
private static String clip(String s, int max) {
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -0,0 +1,48 @@
package de.deskhub.minecraft.core;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
/** Ersetzt Platzhalter wie {server} oder {time} in den Texten der config.yml. Unbekannte Platzhalter bleiben stehen. */
public final class Template {
private Template() {
}
public static String apply(String template, Map<String, String> vars) {
String out = template == null ? "" : template;
for (Map.Entry<String, String> e : vars.entrySet()) {
out = out.replace("{" + e.getKey() + "}", e.getValue() == null ? "" : e.getValue());
}
return out;
}
/** Standard-Platzhalter für ein Ereignis. */
public static Map<String, String> vars(String server, String source, long timeMillis) {
Map<String, String> v = new LinkedHashMap<String, String>();
v.put("server", server);
v.put("source", source);
v.put("time", formatTime(timeMillis));
return v;
}
public static String formatTime(long millis) {
SimpleDateFormat f = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY);
f.setTimeZone(TimeZone.getDefault());
return f.format(new Date(millis));
}
/** "5 Minuten", "2 Std. 10 Min." usw. */
public static String formatDuration(long millis) {
long s = Math.max(0, millis) / 1000;
if (s < 90) return s + " Sekunden";
long m = s / 60;
if (m < 90) return m + " Minuten";
long h = m / 60;
if (h < 48) return h + " Std. " + (m % 60) + " Min.";
return (h / 24) + " Tage " + (h % 24) + " Std.";
}
}
@@ -0,0 +1,93 @@
package de.deskhub.minecraft.core;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/** Legt über die öffentliche Ticket-API von DeskHub (POST /api/public/v1/tickets.php) ein Ticket an. Blockierend: nie im Haupt-Thread aufrufen. */
public final class TicketClient {
private static final int MAX_RESPONSE = 65536;
private final Settings settings;
private final String userAgent;
public TicketClient(Settings settings, String pluginVersion) {
this.settings = settings;
this.userAgent = "DeskHub-Minecraft/" + pluginVersion;
}
/** Baut den JSON-Körper der Anfrage (öffentlich für Tests). */
public String buildBody(String subject, String message) {
StringBuilder b = new StringBuilder("{");
b.append("\"name\":").append(Json.quote(settings.requesterName));
b.append(",\"email\":").append(Json.quote(settings.requesterEmail));
b.append(",\"subject\":").append(Json.quote(clip(subject, 200)));
b.append(",\"message\":").append(Json.quote(clip(message, 20000)));
if (settings.departmentId > 0) b.append(",\"departmentId\":").append(settings.departmentId);
if (settings.priorityId > 0) b.append(",\"priorityId\":").append(settings.priorityId);
if (settings.helpTopicId > 0) b.append(",\"helpTopicId\":").append(settings.helpTopicId);
return b.append('}').toString();
}
public TicketResult create(String subject, String message) {
if (!settings.problems().isEmpty()) {
return TicketResult.failure(0, "Einstellungen unvollständig: " + settings.problems().get(0));
}
HttpURLConnection c = null;
try {
URL url = new URL(settings.url + "/api/public/v1/tickets.php");
c = (HttpURLConnection) url.openConnection();
c.setRequestMethod("POST");
c.setInstanceFollowRedirects(false); // ein Redirect würde den API-Schlüssel an einen anderen Ort schicken
c.setConnectTimeout(settings.timeoutSeconds * 1000);
c.setReadTimeout(settings.timeoutSeconds * 1000);
c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json; charset=utf-8");
c.setRequestProperty("Accept", "application/json");
c.setRequestProperty("X-API-Key", settings.apiKey);
c.setRequestProperty("User-Agent", userAgent);
byte[] data = buildBody(subject, message).getBytes(StandardCharsets.UTF_8);
c.setFixedLengthStreamingMode(data.length);
OutputStream out = c.getOutputStream();
try {
out.write(data);
} finally {
out.close();
}
int code = c.getResponseCode();
String body = read(code >= 400 ? c.getErrorStream() : c.getInputStream());
if (code == 201 || code == 200) {
String number = Json.ticketNumber(body);
return number != null ? TicketResult.success(number, code) : TicketResult.failure(code, "Antwort ohne Ticketnummer");
}
String why = Json.errorMessage(body);
if (code >= 300 && code < 400) why = "Weiterleitung nicht erlaubt, bitte die genaue https-Adresse von DeskHub eintragen";
if (code == 401) why = "API-Schlüssel ungültig oder IP nicht freigegeben";
return TicketResult.failure(code, why != null ? why : "unbekannter Fehler");
} catch (IOException e) {
return TicketResult.failure(0, "keine Verbindung zu DeskHub: " + e.getClass().getSimpleName() + (e.getMessage() != null ? " (" + e.getMessage() + ")" : ""));
} finally {
if (c != null) c.disconnect();
}
}
private static String read(InputStream in) throws IOException {
if (in == null) return "";
try {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[2048];
int n;
while ((n = in.read(chunk)) > 0 && buf.size() < MAX_RESPONSE) buf.write(chunk, 0, n);
return new String(buf.toByteArray(), StandardCharsets.UTF_8);
} finally {
in.close();
}
}
private static String clip(String s, int max) {
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -0,0 +1,29 @@
package de.deskhub.minecraft.core;
/** Ergebnis eines Versuchs, in DeskHub ein Ticket anzulegen. */
public final class TicketResult {
public final boolean ok;
public final String number;
public final int httpCode;
public final String error;
private TicketResult(boolean ok, String number, int httpCode, String error) {
this.ok = ok;
this.number = number;
this.httpCode = httpCode;
this.error = error;
}
public static TicketResult success(String number, int httpCode) {
return new TicketResult(true, number, httpCode, null);
}
public static TicketResult failure(int httpCode, String error) {
return new TicketResult(false, null, httpCode, error);
}
@Override
public String toString() {
return ok ? "Ticket " + number : "Fehler (HTTP " + httpCode + "): " + error;
}
}
@@ -0,0 +1,5 @@
name: DeskHub-Minecraft
main: de.deskhub.minecraft.bungee.BungeePlugin
version: ${project.version}
author: M_Viper
description: Legt in DeskHub ein Ticket an, wenn ein Minecraft-Server offline geht.
@@ -0,0 +1,83 @@
# ──────────────────────────────────────────────────────────────────────────────
# DeskHub-Minecraft: legt in DeskHub ein Ticket an, wenn ein Minecraft-Server offline geht.
# Eine Jar für Spigot, Paper und BungeeCord.
#
# • Auf dem BungeeCord-Proxy: prüft die Server von außen und meldet einen Ausfall in Echtzeit (empfohlen).
# • Auf Spigot/Paper: erkennt beim nächsten Start, dass der Server abgestürzt oder abgeschaltet worden war.
# Am besten beides einsetzen: Proxy meldet sofort, der Server liefert die Gewissheit nach dem Neustart.
#
# Nach Änderungen: /deskhubmc reload Testen: /deskhubmc test
# ──────────────────────────────────────────────────────────────────────────────
deskhub:
# Adresse eurer DeskHub-Installation, ohne Pfad. Bitte https verwenden (der Schlüssel wird mitgeschickt).
url: "https://support.example.de"
# API-Schlüssel: DeskHub-Adminportal, Verwalten, API-Schlüssel, neuer Schlüssel mit dem Recht "Tickets anlegen".
# Tipp: Im Schlüssel die IP-Adresse des Minecraft-Servers eintragen, dann ist er nur von dort nutzbar.
api-key: ""
# Absender der Tickets (es kommt keine Mail dorthin, aber DeskHub verlangt eine gültige Adresse)
requester-name: "Minecraft-Überwachung"
requester-email: "minecraft@example.de"
# Wartezeit auf DeskHub in Sekunden
timeout-seconds: 10
ticket:
# Zahlen aus DeskHub. 0 = Standard von DeskHub.
# Zu finden im Adminportal: Abteilungen / Prioritäten öffnen, die Zahl steht am Ende der Adresse (…?id=3).
department-id: 0
priority-id: 0
# Optional: Hilfethema (legt Abteilung und Priorität automatisch fest, wenn die beiden oben 0 sind)
help-topic-id: 0
# Texte. Platzhalter: {server} {time} {source} {lastSeen} {downtime}
offline:
subject: "[Minecraft] {server} ist offline"
message: |
Der Minecraft-Server "{server}" ist seit {time} nicht mehr erreichbar.
Erkannt von: {source}
Bitte den Server prüfen und neu starten.
crash:
subject: "[Minecraft] {server} war nicht erreichbar (Absturz oder Ausfall)"
message: |
Der Minecraft-Server "{server}" wurde nicht ordnungsgemäß beendet (Absturz, Prozess beendet oder Stromausfall).
Letztes Lebenszeichen: {lastSeen}
Neu gestartet: {time}
Ausfalldauer: ca. {downtime}
Bitte die Logdateien prüfen.
shutdown:
subject: "[Minecraft] {server} wurde heruntergefahren"
message: 'Der Minecraft-Server "{server}" wurde um {time} heruntergefahren.'
test:
subject: "[Minecraft] Testticket von {server}"
message: |
Das ist ein Testticket des DeskHub-Minecraft-Plugins. Die Verbindung zu DeskHub funktioniert.
Server: {server}
Zeit: {time}
# Nur BungeeCord: Der Proxy prüft die Server von außen.
monitor:
# Alle wie viele Sekunden geprüft wird
interval-seconds: 30
# Wie oft die Prüfung hintereinander fehlschlagen muss, bevor ein Ticket entsteht (verhindert Tickets bei kurzen Aussetzern)
failures-before-ticket: 3
# 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)
ignore-never-online: true
# Welche Server überwacht werden (Namen aus der Proxy-Konfiguration). Leer = alle.
servers: []
# Nur Spigot/Paper: Der Server meldet sich selbst.
backend:
# Wie oft das Lebenszeichen geschrieben wird (Sekunden)
heartbeat-seconds: 10
# 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.
ticket-on-shutdown: false
# Name des Servers in den Tickets. Leer = "Minecraft-Server (Port 25565)"
server-name: ""
@@ -0,0 +1,16 @@
name: DeskHub-Minecraft
main: de.deskhub.minecraft.bukkit.BukkitPlugin
version: ${project.version}
api-version: '1.13'
author: M_Viper
description: Legt in DeskHub ein Ticket an, wenn der Minecraft-Server abgestuerzt oder offline war.
commands:
deskhubmc:
description: DeskHub-Minecraft verwalten (test, status, reload)
usage: /deskhubmc <test|status|reload>
aliases: [dhmc]
permission: deskhubmc.admin
permissions:
deskhubmc.admin:
description: DeskHub-Minecraft verwalten
default: op
@@ -0,0 +1,248 @@
package de.deskhub.minecraft.core;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class CoreTest {
private static final String KEY = "dh_" + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4";
/** Einstellungen aus einer einfachen Map (wie eine config.yml). */
private static ConfigSource cfg(final Map<String, Object> m) {
return new ConfigSource() {
public String getString(String p, String d) { return m.containsKey(p) ? String.valueOf(m.get(p)) : d; }
public int getInt(String p, int d) { return m.containsKey(p) ? ((Number) m.get(p)).intValue() : d; }
public boolean getBoolean(String p, boolean d) { return m.containsKey(p) ? (Boolean) m.get(p) : d; }
@SuppressWarnings("unchecked")
public List<String> getStringList(String p) { return m.containsKey(p) ? (List<String>) m.get(p) : Collections.<String>emptyList(); }
};
}
private static Map<String, Object> valid(String url) {
Map<String, Object> m = new HashMap<String, Object>();
m.put("deskhub.url", url);
m.put("deskhub.api-key", KEY);
m.put("deskhub.requester-email", "mc@example.de");
return m;
}
@Test
void settingsValidation() {
assertTrue(new Settings(cfg(valid("https://support.example.de/"))).problems().isEmpty());
assertEquals("https://support.example.de", new Settings(cfg(valid("https://support.example.de///"))).url);
assertFalse(new Settings(cfg(new HashMap<String, Object>())).problems().isEmpty());
Map<String, Object> bad = valid("ftp://x");
assertTrue(new Settings(cfg(bad)).problems().get(0).contains("https://"));
bad = valid("https://x.de");
bad.put("deskhub.api-key", "falsch");
assertTrue(new Settings(cfg(bad)).problems().get(0).contains("api-key"));
bad = valid("https://x.de");
bad.put("deskhub.requester-email", "keine-mail");
assertTrue(new Settings(cfg(bad)).problems().get(0).contains("requester-email"));
assertFalse(new Settings(cfg(valid("http://x.de"))).warnings().isEmpty(), "http wird bemängelt");
assertTrue(new Settings(cfg(valid("https://x.de"))).warnings().isEmpty());
}
@Test
void settingsLimits() {
Map<String, Object> m = valid("https://x.de");
m.put("monitor.interval-seconds", 1);
m.put("monitor.failures-before-ticket", 0);
m.put("ticket.department-id", -4);
m.put("backend.heartbeat-seconds", 100000);
Settings s = new Settings(cfg(m));
assertEquals(5, s.monitorIntervalSeconds);
assertEquals(1, s.monitorFailures);
assertEquals(0, s.departmentId);
assertEquals(600, s.heartbeatSeconds);
}
@Test
void jsonQuotingAndParsing() {
assertEquals("\"a\\\"b\\\\c\\nd\\u0001\"", Json.quote("a\"b\\c\nd\u0001"));
assertEquals("T-123", Json.ticketNumber("{\"number\":\"T-123\",\"status\":\"open\"}"));
assertNull(Json.ticketNumber("{}"));
assertEquals("Fehler \"x\"", Json.errorMessage("{\"error\":{\"code\":\"X\",\"message\":\"Fehler \\\"x\\\"\"}}"));
assertEquals("\u00e4", Json.errorMessage("{\"error\":{\"message\":\"\\u00e4\"}}"));
}
@Test
void templates() {
Map<String, String> v = Template.vars("Lobby", "Proxy", 0);
assertEquals("Lobby ist offline {unbekannt}", Template.apply("{server} ist offline {unbekannt}", v));
assertEquals("30 Sekunden", Template.formatDuration(30000));
assertEquals("5 Minuten", Template.formatDuration(5 * 60000));
assertEquals("3 Std. 5 Min.", Template.formatDuration((3 * 60 + 5) * 60000L));
assertEquals("3 Tage 2 Std.", Template.formatDuration((3 * 24 + 2) * 3600000L));
}
@Test
void outageTrackerRules() {
OutageTracker t = new OutageTracker(3, 30, true);
long now = 1_000_000;
// Server war nie online: ignoriert
for (int i = 0; i < 5; i++) assertEquals(OutageTracker.Decision.NONE, t.report("neu", false, now += 1000));
// normal online, dann Ausfall: erst nach 3 Fehlschlägen ein Ticket, nur einmal
assertEquals(OutageTracker.Decision.NONE, t.report("lobby", true, now));
assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 1000));
long since = now;
assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 1000));
assertEquals(OutageTracker.Decision.OPEN_TICKET, t.report("lobby", false, now += 1000));
assertEquals(since, t.offlineSince("lobby"));
assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 1000));
// Aussetzer: kurz online und wieder weg -> kein zweites Ticket innerhalb der Wartezeit
assertEquals(OutageTracker.Decision.RECOVERED, t.report("lobby", true, now += 1000));
for (int i = 0; i < 3; i++) assertEquals(OutageTracker.Decision.NONE, t.report("lobby", false, now += 1000));
// nach der Wartezeit gibt es wieder ein Ticket
assertEquals(OutageTracker.Decision.RECOVERED, t.report("lobby", true, now += 1000)); // der unterdrückte Ausfall endet
now += 31 * 60000L;
t.report("lobby", false, now);
t.report("lobby", false, now += 1000);
assertEquals(OutageTracker.Decision.OPEN_TICKET, t.report("lobby", false, now += 1000));
// ein einzelner Aussetzer reicht nicht
OutageTracker t2 = new OutageTracker(3, 0, true);
t2.report("a", true, 1);
assertEquals(OutageTracker.Decision.NONE, t2.report("a", false, 2));
assertEquals(OutageTracker.Decision.NONE, t2.report("a", true, 3));
assertEquals(1, t2.snapshot().size());
// Server, die nie online waren, gelten mit ignore=false auch
OutageTracker t3 = new OutageTracker(1, 0, false);
assertEquals(OutageTracker.Decision.OPEN_TICKET, t3.report("x", false, 1));
}
@Test
void heartbeatDetectsCrash() throws IOException {
File dir = Files.createTempDirectory("mc").toFile();
File f = new File(dir, "sub/lebenszeichen.properties");
HeartbeatFile h = new HeartbeatFile(f);
assertFalse(h.readPrevious().existed, "kein früherer Lauf");
h.start(1000);
h.beat(2000);
// Server stürzt ab: kein markClean -> nächster Start erkennt es
HeartbeatFile h2 = new HeartbeatFile(f);
HeartbeatFile.Previous p = h2.readPrevious();
assertTrue(p.crashed());
assertEquals(2000, p.lastBeat);
h2.start(5000);
h2.markClean(6000);
// ordentlich beendet: kein Absturz
HeartbeatFile h3 = new HeartbeatFile(f);
assertFalse(h3.readPrevious().crashed());
assertTrue(h3.readPrevious().existed);
// kaputte Datei bricht nichts
Files.write(f.toPath(), new byte[]{0, 1, 2});
assertDoesNotThrow(() -> new HeartbeatFile(f).readPrevious());
}
@Test
void ticketIsSentToDeskHub() throws Exception {
final List<String> bodies = new ArrayList<String>();
final List<String> keys = new ArrayList<String>();
final List<String> agents = new ArrayList<String>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/public/v1/tickets.php", ex -> {
keys.add(ex.getRequestHeaders().getFirst("X-API-Key"));
agents.add(ex.getRequestHeaders().getFirst("User-Agent"));
bodies.add(readAll(ex.getRequestBody()));
byte[] out = "{\"number\":\"T-4711\",\"status\":\"open\"}".getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(201, out.length);
ex.getResponseBody().write(out);
ex.close();
});
server.createContext("/fehler/api/public/v1/tickets.php", ex -> {
byte[] out = "{\"error\":{\"code\":\"INVALID_PRIORITY\",\"message\":\"Die Priorität ist ungültig.\"}}".getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(422, out.length);
ex.getResponseBody().write(out);
ex.close();
});
server.createContext("/weiter/api/public/v1/tickets.php", ex -> {
ex.getResponseHeaders().add("Location", "http://evil.example/");
ex.sendResponseHeaders(302, -1);
ex.close();
});
server.createContext("/auth/api/public/v1/tickets.php", ex -> {
byte[] out = "{\"error\":{\"code\":\"UNAUTHENTICATED\",\"message\":\"x\"}}".getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(401, out.length);
ex.getResponseBody().write(out);
ex.close();
});
server.start();
try {
String base = "http://127.0.0.1:" + server.getAddress().getPort();
Map<String, Object> m = valid(base);
m.put("ticket.department-id", 3);
m.put("ticket.priority-id", 2);
TicketClient c = new TicketClient(new Settings(cfg(m)), "1.0.0");
TicketResult r = c.create("[Minecraft] Lobby \"offline\"", "Zeile 1\nZeile 2 ä ö ü");
assertTrue(r.ok, r.toString());
assertEquals("T-4711", r.number);
assertEquals(KEY, keys.get(0));
assertEquals("DeskHub-Minecraft/1.0.0", agents.get(0));
String b = bodies.get(0);
assertTrue(b.contains("\"departmentId\":3") && b.contains("\"priorityId\":2") && !b.contains("helpTopicId"), b);
assertTrue(b.contains("\"subject\":\"[Minecraft] Lobby \\\"offline\\\"\""), b);
assertTrue(b.contains("\"message\":\"Zeile 1\\nZeile 2 ä ö ü\""), b);
assertTrue(b.contains("\"email\":\"mc@example.de\""), b);
// ohne Abteilung/Priorität werden sie nicht mitgeschickt (DeskHub nimmt den Standard)
TicketClient plain = new TicketClient(new Settings(cfg(valid(base))), "1.0.0");
assertTrue(plain.create("a", "b").ok);
assertFalse(bodies.get(1).contains("departmentId") || bodies.get(1).contains("priorityId"));
// Fehlermeldung von DeskHub wird weitergegeben
r = new TicketClient(new Settings(cfg(valid(base + "/fehler"))), "1").create("a", "b");
assertFalse(r.ok);
assertEquals(422, r.httpCode);
assertEquals("Die Priorität ist ungültig.", r.error);
// Weiterleitungen werden nicht verfolgt (sonst ginge der Schlüssel woandershin)
r = new TicketClient(new Settings(cfg(valid(base + "/weiter"))), "1").create("a", "b");
assertFalse(r.ok);
assertEquals(302, r.httpCode);
r = new TicketClient(new Settings(cfg(valid(base + "/auth"))), "1").create("a", "b");
assertEquals(401, r.httpCode);
assertTrue(r.error.contains("API-Schlüssel"));
} finally {
server.stop(0);
}
// Server nicht erreichbar
Map<String, Object> m = valid("http://127.0.0.1:1");
m.put("deskhub.timeout-seconds", 2);
TicketResult down = new TicketClient(new Settings(cfg(m)), "1").create("a", "b");
assertFalse(down.ok);
assertEquals(0, down.httpCode);
assertTrue(down.error.contains("keine Verbindung"));
// unvollständige Einstellungen: keine Anfrage
TicketResult incomplete = new TicketClient(new Settings(cfg(new HashMap<String, Object>())), "1").create("a", "b");
assertFalse(incomplete.ok);
assertTrue(incomplete.error.contains("unvollständig"));
// lange Texte werden gekürzt
char[] longText = new char[30000];
Arrays.fill(longText, 'x');
String body = new TicketClient(new Settings(cfg(valid("https://x.de"))), "1").buildBody(new String(longText), new String(longText));
assertTrue(body.length() < 20300 + 400);
}
private static String readAll(InputStream in) throws IOException {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int n;
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
return new String(b.toByteArray(), StandardCharsets.UTF_8);
}
}