Upload via GUI (24 Dateien)

This commit is contained in:
2026-11-01 11:16:47 +00:00
parent 257fbc1e55
commit e1c6f8dfca
12 changed files with 255 additions and 11 deletions
+8 -3
View File
@@ -13,13 +13,18 @@ Ein Server, der abstürzt, kann nicht mehr selbst "Hilfe" rufen. Deshalb gibt es
Gegen Ticket-Fluten: ein einzelner Aussetzer macht kein Ticket, pro Ausfall höchstens eines, und pro Server frühestens nach der eingestellten Wartezeit (30 Minuten) wieder eines. Server, die seit dem Start des Proxys nie online waren (z. B. absichtlich abgeschaltet), werden ignoriert.
**Automatisch lösen:** Das Plugin setzt das Ausfall-Ticket in DeskHub auf „Gelöst“ und hinterlässt eine Notiz mit der Ausfalldauer. Die Ticketnummer merkt es sich in `offene-tickets.properties`, auch ein Neustart währenddessen schadet nicht. Abschalten mit `ticket.resolve-on-recovery: false`. Voraussetzung: DeskHub **1.0.48 oder neuer** (API-Aktion „resolve“) und der API-Schlüssel mit dem Recht „Tickets anlegen“; gelöst werden nur Tickets, die dieser Schlüssel über die API angelegt hat.
- **BungeeCord:** Sobald der Server wieder auf Pings antwortet.
- **Spigot/Paper:** Beim nächsten Start. Ein Absturz-Ticket wird dort gleich nach dem Anlegen gelöst (der Server läuft ja wieder, die Notiz nennt die Ausfalldauer). Ein Herunterfahren-Ticket (falls eingeschaltet) wird beim nächsten Start gelöst.
Normales Herunterfahren (`/stop`, geplanter Neustart) erzeugt **kein** Ticket (auf Wunsch einstellbar).
## Installation
1. **API-Schlüssel in DeskHub:** Adminportal → Verwalten → **API-Schlüssel** → neuen Schlüssel mit dem Recht **„Tickets anlegen“** erzeugen (er wird nur einmal angezeigt). Trage unter „IP-Beschränkung“ die IP-Adresse deines Minecraft-Servers ein, dann ist der Schlüssel nur von dort nutzbar.
2. **Abteilung und Priorität:** Öffne im Adminportal die Abteilung bzw. Priorität. Die Zahl am Ende der Adresse (`…?id=4`) ist die ID für die config.
3. **Jar kopieren:** `DeskHub-Minecraft-1.0.0.jar` in den Ordner `plugins` deines Servers bzw. Proxys. Server einmal starten, damit `plugins/DeskHub-Minecraft/config.yml` entsteht.
3. **Jar kopieren:** `DeskHub-Minecraft-1.1.0.jar` in den Ordner `plugins` deines Servers bzw. Proxys. Server einmal starten, damit `plugins/DeskHub-Minecraft/config.yml` entsteht.
4. **config.yml ausfüllen** (mindestens `url`, `api-key`, `requester-email`; optional `department-id`, `priority-id`), dann `/deskhubmc reload`.
5. **Testen:** `/deskhubmc test` legt ein Testticket an und zeigt die Ticketnummer oder den Grund für einen Fehler.
@@ -70,7 +75,7 @@ Voraussetzungen: JDK 17 oder neuer, Maven.
```bash
cd minecraft-plugin
mvn package # Tests und Jar: target/DeskHub-Minecraft-1.0.0.jar
mvn package # Tests und Jar: target/DeskHub-Minecraft-1.1.0.jar
```
Zusätzlicher Test des Spigot/Paper-Teils gegen einen simulierten Paper-Server (braucht JDK 21):
@@ -83,6 +88,6 @@ mvn test
## Grenzen
- Auf einem **einzelnen** Spigot/Paper-Server ohne Proxy kommt das Ticket erst, wenn der Server wieder startet. Wer eine Meldung in Echtzeit braucht, lässt zusätzlich das Plugin auf dem Proxy laufen oder überwacht den Server von außen.
- Auf einem **einzelnen** Spigot/Paper-Server ohne Proxy kommt das Ticket erst, wenn der Server wieder startet (und wird dann gleich gelöst). Wer eine Meldung in Echtzeit braucht, lässt zusätzlich das Plugin auf dem Proxy laufen oder überwacht den Server von außen.
- **Velocity** wird nicht unterstützt (eigenes Plugin-System). Auf Waterfall läuft die BungeeCord-Variante.
- Das Plugin ist kompiliert für Java 8 und die Spigot-API 1.16.5. Es sollte auf älteren und neueren Versionen laufen, geprüft sind Paper 1.21.11 (simuliert) und BungeeCord (echt).
Binary file not shown.
+1 -1
View File
@@ -34,7 +34,7 @@
<dependency>
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
@@ -91,12 +91,35 @@ class PaperPluginTest {
configure("");
writeHeartbeat(true, false, System.currentTimeMillis() - 5 * 60_000L);
enable();
assertEquals(1, bodies.size(), "genau ein Ticket");
assertEquals(2, bodies.size(), "ein Ticket und gleich danach seine Lösung (der Server läuft ja wieder)");
String b = bodies.get(0);
assertEquals(KEY, keys.get(0));
assertTrue(b.contains("\"departmentId\":4") && b.contains("\"priorityId\":3"), b);
assertTrue(b.contains("war nicht erreichbar"), b);
assertTrue(b.contains("Ausfalldauer: ca. 5 Minuten"), b);
String r = bodies.get(1);
assertTrue(r.contains("\"action\":\"resolve\"") && r.contains("\"number\":\"T-1\"") && r.contains("\"email\":\"mc@example.de\""), r);
assertTrue(r.contains("wieder erreichbar"), r);
}
@Test
void crashTicketStaysOpenWhenResolvingIsOff() throws Exception {
configure(" resolve-on-recovery: false\n");
writeHeartbeat(true, false, System.currentTimeMillis() - 60_000L);
enable();
assertEquals(1, bodies.size(), "nur das Ticket, keine Lösung");
}
@Test
void shutdownTicketIsResolvedOnNextStart() throws Exception {
configure("backend:\n ticket-on-shutdown: true\n");
enable();
server.getPluginManager().disablePlugin(plugin);
assertEquals(1, bodies.size());
assertTrue(new File(plugin.getDataFolder(), "offene-tickets.properties").isFile(), "Ticketnummer wird gemerkt");
enable();
assertEquals(2, bodies.size(), "beim Start wird das Herunterfahren-Ticket gelöst");
assertTrue(bodies.get(1).contains("\"action\":\"resolve\"") && bodies.get(1).contains("\"number\":\"T-1\""), bodies.get(1));
}
@Test
+1 -1
View File
@@ -5,7 +5,7 @@
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<packaging>jar</packaging>
<name>DeskHub-Minecraft</name>
<description>Legt in DeskHub ein Ticket an, wenn ein Minecraft-Server offline geht. Eine Jar für Spigot, Paper und BungeeCord.</description>
@@ -1,6 +1,7 @@
package de.deskhub.minecraft.bukkit;
import de.deskhub.minecraft.core.HeartbeatFile;
import de.deskhub.minecraft.core.OpenTickets;
import de.deskhub.minecraft.core.Settings;
import de.deskhub.minecraft.core.Template;
import de.deskhub.minecraft.core.TicketClient;
@@ -28,6 +29,7 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor {
private Settings settings;
private TicketClient client;
private HeartbeatFile heartbeat;
private OpenTickets openTickets;
private BukkitTask beatTask;
@Override
@@ -37,7 +39,9 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor {
if (getCommand("deskhubmc") != null) getCommand("deskhubmc").setExecutor(this);
heartbeat = new HeartbeatFile(new File(getDataFolder(), "lebenszeichen.properties"));
HeartbeatFile.Previous previous = heartbeat.readPrevious();
openTickets = new OpenTickets(new File(getDataFolder(), "offene-tickets.properties"));
long now = System.currentTimeMillis();
resolveOpenOnStart(now);
try {
heartbeat.start(now);
} catch (IOException e) {
@@ -70,6 +74,7 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor {
// 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));
if (r.ok) openTickets.put(serverName(), r.number, now);
getLogger().info(r.ok ? "Ticket " + r.number + " angelegt (Herunterfahren)." : "Ticket nicht angelegt: " + r.error);
}
}
@@ -104,12 +109,42 @@ public class BukkitPlugin extends JavaPlugin implements CommandExecutor {
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);
if (r.ok) {
getLogger().info("Ticket " + r.number + " angelegt.");
// Der Server läuft wieder (sonst würde dieser Code nicht laufen): Das Ausfall-Ticket gleich lösen
resolveNow(r.number, previous.lastBeat > 0 ? previous.lastBeat : 0, now);
} else getLogger().warning("Ticket nicht angelegt: " + r.error);
}
});
}
/** Beim Start: Ein beim letzten Herunterfahren angelegtes Ticket ist erledigt, der Server läuft wieder. */
private void resolveOpenOnStart(final long now) {
final String number = openTickets.get(serverName());
if (number == null || !settings.resolveOnRecovery || !settings.problems().isEmpty()) return;
final long since = openTickets.since(serverName());
Bukkit.getScheduler().runTaskAsynchronously(this, new Runnable() {
@Override
public void run() {
resolveNow(number, since, now);
}
});
}
/** Blockierend (nur in einem Hintergrund-Thread aufrufen): Ticket auf Gelöst setzen und aus der Merkliste nehmen. */
private void resolveNow(String number, long since, long now) {
if (!settings.resolveOnRecovery || !settings.problems().isEmpty()) return;
Map<String, String> v = Template.vars(serverName(), "Plugin auf dem Server", now);
v.put("downtime", since > 0 ? Template.formatDuration(now - since) : "unbekannt");
TicketResult r = client.resolve(number, Template.apply(settings.messageRecovered, v));
if (r.ok || r.httpCode == 404) {
openTickets.remove(serverName());
getLogger().info(r.ok ? "Ticket " + number + " gelöst." : "Ticket " + number + " gibt es in DeskHub nicht mehr, wird nicht weiter verfolgt.");
} else {
getLogger().warning("Ticket " + number + " konnte nicht gelöst werden: " + r.error + " (bitte von Hand lösen)");
}
}
@Override
public boolean onCommand(final CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("deskhubmc.admin")) {
@@ -1,5 +1,6 @@
package de.deskhub.minecraft.bungee;
import de.deskhub.minecraft.core.OpenTickets;
import de.deskhub.minecraft.core.OutageTracker;
import de.deskhub.minecraft.core.Settings;
import de.deskhub.minecraft.core.Template;
@@ -36,6 +37,7 @@ public final class BungeePlugin extends Plugin {
private Settings settings;
private TicketClient client;
private OutageTracker tracker;
private OpenTickets openTickets;
private ScheduledTask watchdog;
@Override
@@ -69,6 +71,7 @@ public final class BungeePlugin extends Plugin {
settings = new Settings(new BungeeConfig(new Configuration()));
}
client = new TicketClient(settings, getDescription().getVersion());
openTickets = new OpenTickets(new File(getDataFolder(), "offene-tickets.properties"));
tracker = new OutageTracker(settings.monitorFailures, settings.cooldownMinutes, settings.ignoreNeverOnline);
for (String w : settings.warnings()) getLogger().warning(w);
List<String> problems = settings.problems();
@@ -115,6 +118,7 @@ public final class BungeePlugin extends Plugin {
OutageTracker.Decision d = tracker.report(server, online, now);
if (d == OutageTracker.Decision.RECOVERED) {
getLogger().info("Server \"" + server + "\" ist wieder erreichbar.");
resolveTicket(server, now);
} 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.");
@@ -127,13 +131,37 @@ public final class BungeePlugin extends Plugin {
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.");
if (r.ok) {
openTickets.put(server, r.number, since > 0 ? since : now);
getLogger().info("Ticket " + r.number + " angelegt.");
}
else getLogger().warning("Ticket nicht angelegt: " + r.error);
}
});
}
}
/** Server wieder erreichbar: das beim Ausfall angelegte Ticket in DeskHub auf "Gelöst" setzen. */
private void resolveTicket(final String server, final long now) {
final String number = openTickets.get(server);
if (number == null || !settings.resolveOnRecovery || !settings.problems().isEmpty()) return;
final long since = openTickets.since(server);
getProxy().getScheduler().runAsync(this, new Runnable() {
@Override
public void run() {
Map<String, String> v = Template.vars(server, "BungeeCord-Proxy", now);
v.put("downtime", since > 0 ? Template.formatDuration(now - since) : "unbekannt");
TicketResult r = client.resolve(number, Template.apply(settings.messageRecovered, v));
if (r.ok || r.httpCode == 404) {
openTickets.remove(server);
getLogger().info(r.ok ? "Ticket " + number + " gelöst." : "Ticket " + number + " gibt es in DeskHub nicht mehr, wird nicht weiter verfolgt.");
} else {
getLogger().warning("Ticket " + number + " konnte nicht gelöst werden: " + r.error + " (bitte von Hand lösen)");
}
}
});
}
private final class McCommand extends Command {
McCommand() {
super("deskhubmc", "deskhubmc.admin", "dhmc");
@@ -0,0 +1,78 @@
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.util.Properties;
/**
* Merkt sich je Server die Nummer des Tickets, das für den aktuellen Ausfall angelegt wurde, damit es bei "wieder erreichbar" gelöst
* werden kann. Wird in einer kleinen Datei gespeichert, damit ein Neustart des Proxys während des Ausfalls das Ticket nicht vergisst.
*/
public final class OpenTickets {
private final File file;
private final Properties props = new Properties();
public OpenTickets(File file) {
this.file = file;
if (file.isFile()) {
try {
InputStream in = new FileInputStream(file);
try {
props.load(in);
} finally {
in.close();
}
} catch (IOException e) {
props.clear();
}
}
}
/** Ticketnummer des laufenden Ausfalls oder null. */
public synchronized String get(String server) {
String v = props.getProperty(server);
if (v == null) return null;
int i = v.indexOf(';');
return i < 0 ? v : v.substring(0, i);
}
/** Beginn des Ausfalls in Millisekunden (0 = unbekannt). */
public synchronized long since(String server) {
String v = props.getProperty(server);
int i = v == null ? -1 : v.indexOf(';');
if (i < 0) return 0;
try {
return Long.parseLong(v.substring(i + 1));
} catch (NumberFormatException e) {
return 0;
}
}
public synchronized void put(String server, String number, long sinceMillis) {
props.setProperty(server, number + ";" + sinceMillis);
save();
}
public synchronized void remove(String server) {
if (props.remove(server) != null) save();
}
private void save() {
try {
File dir = file.getParentFile();
if (dir != null && !dir.isDirectory() && !dir.mkdirs()) return;
OutputStream out = new FileOutputStream(file);
try {
props.store(out, "DeskHub-Minecraft: offene Ausfall-Tickets (Server = Ticketnummer;Beginn)");
} finally {
out.close();
}
} catch (IOException e) {
// Speichern ist nachrangig: Das Ticket wird dann nur nach einem Proxy-Neustart nicht automatisch gelöst
}
}
}
@@ -28,6 +28,8 @@ public final class Settings {
public final String messageShutdown;
public final String subjectTest;
public final String messageTest;
public final boolean resolveOnRecovery;
public final String messageRecovered;
public final int monitorIntervalSeconds;
public final int monitorFailures;
@@ -65,6 +67,9 @@ public final class Settings {
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.resolveOnRecovery = c.getBoolean("ticket.resolve-on-recovery", true);
this.messageRecovered = c.getString("ticket.recovered.message", "Der Minecraft-Server \"{server}\" ist seit {time} wieder erreichbar (Ausfall: {downtime}). Das Ticket wurde automatisch gelöst.");
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);
@@ -8,7 +8,7 @@ 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. */
/** Legt über die öffentliche Ticket-API von DeskHub (POST /api/public/v1/tickets.php) ein Ticket an oder löst es wieder. Blockierend: nie im Haupt-Thread aufrufen. */
public final class TicketClient {
private static final int MAX_RESPONSE = 65536;
private final Settings settings;
@@ -32,7 +32,21 @@ public final class TicketClient {
return b.append('}').toString();
}
/** Baut den JSON-Körper für "Ticket lösen" (öffentlich für Tests). */
public String buildResolveBody(String number, String message) {
return "{\"action\":\"resolve\",\"number\":" + Json.quote(number) + ",\"email\":" + Json.quote(settings.requesterEmail) + ",\"message\":" + Json.quote(clip(message, 2000)) + "}";
}
public TicketResult create(String subject, String message) {
return send(buildBody(subject, message));
}
/** Setzt ein vom Plugin angelegtes Ticket auf "Gelöst" (Server wieder erreichbar). */
public TicketResult resolve(String number, String message) {
return send(buildResolveBody(number, message));
}
private TicketResult send(String json) {
if (!settings.problems().isEmpty()) {
return TicketResult.failure(0, "Einstellungen unvollständig: " + settings.problems().get(0));
}
@@ -49,7 +63,7 @@ public final class TicketClient {
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);
byte[] data = json.getBytes(StandardCharsets.UTF_8);
c.setFixedLengthStreamingMode(data.length);
OutputStream out = c.getOutputStream();
try {
@@ -47,6 +47,11 @@ ticket:
Ausfalldauer: ca. {downtime}
Bitte die Logdateien prüfen.
# Ist der Server wieder erreichbar (BungeeCord) bzw. neu gestartet (Spigot/Paper), wird das Ticket automatisch auf "Gelöst" gesetzt und bekommt diese Notiz.
# Ausschalten mit false (dann bleibt das Ticket offen, bis ihr es selbst löst).
resolve-on-recovery: true
recovered:
message: 'Der Minecraft-Server "{server}" ist seit {time} wieder erreichbar (Ausfall: {downtime}). Das Ticket wurde automatisch gelöst.'
shutdown:
subject: "[Minecraft] {server} wurde heruntergefahren"
message: 'Der Minecraft-Server "{server}" wurde um {time} heruntergefahren.'
@@ -149,6 +149,57 @@ class CoreTest {
assertDoesNotThrow(() -> new HeartbeatFile(f).readPrevious());
}
@Test
void ticketIsResolvedWhenServerIsBack() throws Exception {
final List<String> bodies = new ArrayList<String>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/public/v1/tickets.php", ex -> {
bodies.add(readAll(ex.getRequestBody()));
byte[] out = "{\"number\":\"T-4711\",\"status\":\"resolved\"}".getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, out.length);
ex.getResponseBody().write(out);
ex.close();
});
server.createContext("/weg/api/public/v1/tickets.php", ex -> {
byte[] out = "{\"error\":{\"code\":\"NOT_FOUND\",\"message\":\"Ticket nicht gefunden.\"}}".getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(404, out.length);
ex.getResponseBody().write(out);
ex.close();
});
server.start();
try {
String base = "http://127.0.0.1:" + server.getAddress().getPort();
TicketResult r = new TicketClient(new Settings(cfg(valid(base))), "1").resolve("T-4711", "Server \"Lobby\" wieder erreichbar");
assertTrue(r.ok, r.toString());
String b = bodies.get(0);
assertTrue(b.contains("\"action\":\"resolve\"") && b.contains("\"number\":\"T-4711\"") && b.contains("\"email\":\"mc@example.de\""), b);
assertTrue(b.contains("\"message\":\"Server \\\"Lobby\\\" wieder erreichbar\""), b);
TicketResult gone = new TicketClient(new Settings(cfg(valid(base + "/weg"))), "1").resolve("T-1", "x");
assertFalse(gone.ok);
assertEquals(404, gone.httpCode);
} finally {
server.stop(0);
}
Map<String, Object> off = valid("https://x.de");
assertTrue(new Settings(cfg(off)).resolveOnRecovery);
off.put("ticket.resolve-on-recovery", false);
assertFalse(new Settings(cfg(off)).resolveOnRecovery);
}
@Test
void openTicketsSurviveRestart() throws Exception {
java.io.File f = java.io.File.createTempFile("offene-tickets", ".properties");
f.deleteOnExit();
OpenTickets a = new OpenTickets(f);
a.put("lobby", "T-9", 123456L);
OpenTickets b = new OpenTickets(f);
assertEquals("T-9", b.get("lobby"));
assertEquals(123456L, b.since("lobby"));
assertEquals(null, b.get("andere"));
b.remove("lobby");
assertEquals(null, new OpenTickets(f).get("lobby"));
}
@Test
void ticketIsSentToDeskHub() throws Exception {
final List<String> bodies = new ArrayList<String>();