Upload via GUI (67 Dateien)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user