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
+2
View File
@@ -0,0 +1,2 @@
target/
integration-test/target/
+88
View File
@@ -0,0 +1,88 @@
# DeskHub-Minecraft
Legt in **DeskHub** automatisch ein Ticket an, wenn dein Minecraft-Server offline geht. **Eine Jar** für **Spigot, Paper und BungeeCord**. Abteilung und Priorität stellst du in der `config.yml` ein.
## Wie der Ausfall erkannt wird
Ein Server, der abstürzt, kann nicht mehr selbst "Hilfe" rufen. Deshalb gibt es zwei Wege, die du am besten kombinierst:
| Wo das Plugin liegt | Was es tut | Wann das Ticket kommt |
|---|---|---|
| **BungeeCord-Proxy** (empfohlen) | Prüft deine Server von außen (Server-Ping, alle 30 s) | Nach 3 Fehlschlägen in Folge, also nach rund 1,5 Minuten, in Echtzeit |
| **Spigot / Paper** (jeder Server) | Schreibt regelmäßig ein Lebenszeichen und markiert es beim normalen Beenden | Beim **nächsten Start**, wenn der Server zuvor abgestürzt, hart beendet oder vom Strom getrennt wurde |
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.
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.
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.
Der Absender der Tickets (`requester-email`) bekommt von DeskHub wie bei jedem Ticket eine Eingangsbestätigung. Nimm deshalb eine Adresse, die bei dir ankommt (z. B. eine Sammeladresse für Technik).
## Befehle
| Befehl | Wirkung |
|---|---|
| `/deskhubmc test` | Testticket anlegen |
| `/deskhubmc status` | Einstellungen und (auf BungeeCord) den Zustand der Server anzeigen |
| `/deskhubmc reload` | `config.yml` neu laden |
Alias: `/dhmc`. Berechtigung: `deskhubmc.admin` (Standard: nur Operatoren; in der Konsole immer erlaubt).
## Wichtige Einstellungen
```yaml
deskhub:
url: "https://support.example.de"
api-key: "dh_…"
requester-email: "minecraft@example.de"
ticket:
department-id: 4 # 0 = Standard von DeskHub
priority-id: 3
monitor: # nur BungeeCord
interval-seconds: 30
failures-before-ticket: 3
cooldown-minutes: 30
servers: [] # leer = alle Server des Proxys
backend: # nur Spigot/Paper
ticket-on-crash: true
ticket-on-shutdown: false
```
Betreff und Text der Tickets sind in der `config.yml` anpassbar (Platzhalter `{server}`, `{time}`, `{source}`, `{lastSeen}`, `{downtime}`).
## Sicherheit
- Der API-Schlüssel steht in der `config.yml`. Dateirechte so setzen, dass nur der Server-Benutzer sie lesen kann, und die Datei nie weitergeben. Bei Verdacht den Schlüssel in DeskHub löschen und einen neuen erzeugen.
- Nutze **https**. Bei `http://` warnt das Plugin, weil der Schlüssel dann unverschlüsselt übertragen würde.
- Das Plugin folgt keinen Weiterleitungen, damit der Schlüssel nie an eine andere Adresse geht.
- DeskHub begrenzt die API auf 60 Tickets pro Minute und wendet eure Ticket-Regeln an.
## Bauen
Voraussetzungen: JDK 17 oder neuer, Maven.
```bash
cd minecraft-plugin
mvn package # Tests und Jar: target/DeskHub-Minecraft-1.0.0.jar
```
Zusätzlicher Test des Spigot/Paper-Teils gegen einen simulierten Paper-Server (braucht JDK 21):
```bash
mvn install -DskipTests
cd integration-test
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.
- **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.
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Prüft das fertige Plugin (Spigot/Paper-Teil) gegen einen simulierten Server (MockBukkit). Braucht Java 21.
Ablauf: im Ordner minecraft-plugin "mvn install", danach hier "mvn test" (siehe BAUEN.md). -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft-integration-test</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.11-R0.1-SNAPSHOT</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockbukkit.mockbukkit</groupId>
<artifactId>mockbukkit-v1.21</artifactId>
<version>4.116.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft</artifactId>
<version>1.0.0</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,162 @@
package de.deskhub.minecraft;
import com.sun.net.httpserver.HttpServer;
import de.deskhub.minecraft.bukkit.BukkitPlugin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockbukkit.mockbukkit.MockBukkit;
import org.mockbukkit.mockbukkit.ServerMock;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.*;
/** Spigot/Paper-Teil des fertigen Plugins gegen einen simulierten Server und eine simulierte DeskHub-API. */
class PaperPluginTest {
private static final String KEY = "dh_" + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4";
private ServerMock server;
private BukkitPlugin plugin;
private HttpServer http;
private final List<String> bodies = new CopyOnWriteArrayList<>();
private final List<String> keys = new CopyOnWriteArrayList<>();
@BeforeEach
void setUp() throws Exception {
http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
http.createContext("/api/public/v1/tickets.php", ex -> {
keys.add(ex.getRequestHeaders().getFirst("X-API-Key"));
ByteArrayOutputStream b = new ByteArrayOutputStream();
try (InputStream in = ex.getRequestBody()) { in.transferTo(b); }
bodies.add(b.toString(StandardCharsets.UTF_8));
byte[] out = ("{\"number\":\"T-" + bodies.size() + "\",\"status\":\"open\"}").getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(201, out.length);
ex.getResponseBody().write(out);
ex.close();
});
http.start();
server = MockBukkit.mock();
plugin = MockBukkit.load(BukkitPlugin.class);
server.getPluginManager().disablePlugin(plugin);
}
@AfterEach
void tearDown() {
http.stop(0);
MockBukkit.unmock();
}
private void configure(String extra) throws Exception {
String cfg = "deskhub:\n url: \"http://127.0.0.1:" + http.getAddress().getPort() + "\"\n api-key: \"" + KEY + "\"\n requester-email: \"mc@example.de\"\n"
+ "ticket:\n department-id: 4\n priority-id: 3\n" + extra;
Files.writeString(new File(plugin.getDataFolder(), "config.yml").toPath(), cfg);
}
private File heartbeat() {
return new File(plugin.getDataFolder(), "lebenszeichen.properties");
}
private void writeHeartbeat(boolean running, boolean clean, long lastBeat) throws Exception {
Properties p = new Properties();
p.setProperty("running", String.valueOf(running));
p.setProperty("clean", String.valueOf(clean));
p.setProperty("lastBeat", String.valueOf(lastBeat));
try (var out = Files.newOutputStream(heartbeat().toPath())) { p.store(out, null); }
}
private void enable() {
server.getPluginManager().enablePlugin(plugin);
server.getScheduler().waitAsyncTasksFinished();
}
@Test
void startsWithoutConfigAndCreatesNoTicket() {
// Standard-config: URL/Schlüssel fehlen -> Plugin läuft, meldet das Problem, legt nichts an
enable();
assertTrue(plugin.isEnabled());
assertTrue(bodies.isEmpty());
assertTrue(heartbeat().isFile(), "Lebenszeichen wird geschrieben");
}
@Test
void crashOfPreviousRunCreatesTicketOnStart() throws Exception {
configure("");
writeHeartbeat(true, false, System.currentTimeMillis() - 5 * 60_000L);
enable();
assertEquals(1, bodies.size(), "genau ein Ticket");
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);
}
@Test
void cleanShutdownCreatesNoTicketOnNextStart() throws Exception {
configure("");
enable();
server.getPluginManager().disablePlugin(plugin); // ordnungsgemäß beenden
assertFalse(Files.readString(heartbeat().toPath()).contains("running=true"));
configure("");
enable();
assertTrue(bodies.isEmpty(), "nach sauberem Beenden kein Ticket");
}
@Test
void firstEverStartCreatesNoTicket() throws Exception {
configure("");
Files.deleteIfExists(heartbeat().toPath());
enable();
assertTrue(bodies.isEmpty());
}
@Test
void crashTicketCanBeSwitchedOff() throws Exception {
configure("backend:\n ticket-on-crash: false\n");
writeHeartbeat(true, false, System.currentTimeMillis() - 60_000L);
enable();
assertTrue(bodies.isEmpty());
}
@Test
void shutdownTicketOnlyWhenEnabled() throws Exception {
configure("backend:\n ticket-on-shutdown: true\n");
enable();
server.getPluginManager().disablePlugin(plugin);
assertEquals(1, bodies.size());
assertTrue(bodies.get(0).contains("heruntergefahren"), bodies.get(0));
}
@Test
void testCommandCreatesTicketAndNeedsPermission() throws Exception {
configure("");
enable();
var op = server.addPlayer();
op.setOp(false);
server.dispatchCommand(op, "deskhubmc test");
server.getScheduler().waitAsyncTasksFinished();
assertTrue(bodies.isEmpty(), "ohne Berechtigung kein Ticket");
server.dispatchCommand(server.getConsoleSender(), "deskhubmc test");
server.getScheduler().waitAsyncTasksFinished();
server.getScheduler().performOneTick();
assertEquals(1, bodies.size());
assertTrue(bodies.get(0).contains("Testticket"), bodies.get(0));
}
@Test
void crashWithBrokenConfigDoesNotCrashPlugin() throws Exception {
Files.writeString(new File(plugin.getDataFolder(), "config.yml").toPath(), "deskhub:\n url: \"\"\n");
writeHeartbeat(true, false, 1);
assertDoesNotThrow(this::enable);
assertTrue(plugin.isEnabled());
assertTrue(bodies.isEmpty());
}
}
@@ -0,0 +1 @@
de\deskhub\minecraft\PaperPluginTest.class
@@ -0,0 +1 @@
C:\Users\Viper\Desktop\deskhub\minecraft-plugin\integration-test\src\test\java\de\deskhub\minecraft\PaperPluginTest.java
@@ -0,0 +1,17 @@
# Created at 2026-10-21T13:17:32.125
java.lang.NoClassDefFoundError: org/junit/platform/engine/OutputDirectoryCreator
at org.apache.maven.surefire.junitplatform.TestPlanScannerFilter.accept(TestPlanScannerFilter.java:47)
at org.apache.maven.surefire.api.util.DefaultScanResult.applyFilter(DefaultScanResult.java:87)
at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.scanClasspath(JUnitPlatformProvider.java:142)
at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:122)
at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385)
at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162)
at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507)
at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495)
Caused by: java.lang.ClassNotFoundException: org.junit.platform.engine.OutputDirectoryCreator
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 8 more
@@ -0,0 +1,3 @@
# Created at 2026-10-21T13:17:32.130
org/junit/platform/engine/OutputDirectoryCreator
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: de.deskhub.minecraft.PaperPluginTest
-------------------------------------------------------------------------------
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.317 s -- in de.deskhub.minecraft.PaperPluginTest
+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.deskhub</groupId>
<artifactId>deskhub-minecraft</artifactId>
<version>1.0.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>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Java 8 als Ziel: läuft auf alten und neuen Servern (Minecraft 1.8 bis aktuell, Java 8 bis 21+) -->
<maven.compiler.release>8</maven.compiler.release>
</properties>
<repositories>
<repository>
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>sonatype-snapshots</id>
<url>https://oss.sonatype.org/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<!-- Server-APIs: nur zum Kompilieren, die Server bringen sie mit -->
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>1.16.5-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.md-5</groupId>
<artifactId>bungeecord-api</artifactId>
<version>1.16-R0.4</version>
<scope>provided</scope>
<exclusions>
<!-- nicht benötigt und nur als SNAPSHOT veröffentlicht -->
<exclusion>
<groupId>net.md-5</groupId>
<artifactId>brigadier</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>DeskHub-Minecraft-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>plugin.yml</include>
<include>bungee.yml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>plugin.yml</exclude>
<exclude>bungee.yml</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -0,0 +1,5 @@
name: DeskHub-Minecraft
main: de.deskhub.minecraft.bungee.BungeePlugin
version: 1.0.0
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: ""