diff --git a/README.md b/README.md index 8f03c87..66d2bb9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | Funktion | Was sie macht | |---|---| | 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Name im Spiel, 🔒 bei Passwort oder Whitelist, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden, Mod-Download und frei eintragbare Links wie Regeln oder TeamSpeak, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer | -| 🖥️ **Panel-Anbindung** *(optional)* | Trägt der Betreiber unter **Config → Game-Server** die Adresse des [d4rk_gameserver](https://git.d4rkst3r.de/D4rkst3r/d4rk_media)-Panels und ein Lese-Zeichen ein, weiß der Monitor mehr als eine Abfrage hergibt. gamedig **fragt** einen Server — ein startendes Modpack antwortet nicht und sieht dadurch aus wie ein Ausfall (am ATM10 gemessen: 306 Sekunden bis zum ersten Spieler). Das Panel **sieht** den Container: 🟡 „Startet gerade" statt 🔴 „Offline", kein Fehlalarm während des Hochfahrens, kein Alarm für einen im Panel gestoppten Server — aber sehr wohl einer für Absturz, OOM-Kill oder fehlenden Container. Dazu RAM und CPU im Embed. Ohne Eintrag ändert sich nichts, und antwortet das Panel nicht, ebenfalls nicht | +| 🖥️ **Panel-Anbindung** *(optional)* | Trägt der Betreiber unter **Config → Game-Server** die Adresse des [d4rk_gameserver](https://git.d4rkst3r.de/D4rkst3r/d4rk_media)-Panels und ein Lese-Zeichen ein, weiß der Monitor mehr als eine Abfrage hergibt. gamedig **fragt** einen Server — ein startendes Modpack antwortet nicht und sieht dadurch aus wie ein Ausfall (am ATM10 gemessen: 306 Sekunden bis zum ersten Spieler). Das Panel **sieht** den Container: 🟡 „Startet gerade" statt 🔴 „Offline", kein Fehlalarm während des Hochfahrens, kein Alarm für einen im Panel gestoppten Server — aber sehr wohl einer für Absturz, OOM-Kill oder fehlenden Container. Dazu RAM und CPU im Embed. Ohne Eintrag ändert sich nichts, und antwortet das Panel nicht, ebenfalls nicht. **Umgekehrt gilt außerdem:** im Panel hat jeder Server einen Schalter *„Im Discord anzeigen"* — wird er umgelegt, legt der Bot den Monitor-Eintrag binnen einer halben Minute selbst an und räumt ihn beim Ausschalten wieder weg. Das Panel schreibt dafür nichts hierher; das Häkchen reist im Statusbericht mit, den der Bot ohnehin abholt. Bild, Links und Zugangs-Code trägt man weiterhin hier ein — der Abgleich legt nur an und löscht, er überschreibt nie | | 🚜 **LS — Höfe & Preise** | Für Farming-Simulator-Server: Besitzkarte auf dem Luftbild des Servers (Parzellen nach Hof eingefärbt, Fläche = Hektar, Fahrzeuge und Spieler live), die Preiskurven übers Jahr mit „jetzt verkaufen oder warten", dazu **je Hof ein Embed**, das sich selbst aktualisiert: Land, Fuhrpark mit Wert und Betriebsstunden, was in die Werkstatt muss, was gewaschen gehört und was geladen ist. Dazu die **Modliste**: der Bot meldet, was neu, aktualisiert oder entfernt wurde, und auf `/mods/` steht jeder Mod einzeln zum Laden — durch den Bot hindurch, damit der Zugangs-Code des Spielservers nicht in einem Link landet. **Nicht möglich:** welche Frucht auf welchem Feld steht, und Tierställe — beides gibt der Feed nicht her (siehe [docs/ls-feed.md](docs/ls-feed.md)) | ### Server & Technik diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index 4998e00..78bceb2 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -9,6 +9,7 @@ import { brandEmbed } from '../embeds.js'; import { moduleEnabled } from '../modules.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; import { panelStatus, panelEintrag, zustandText, lastText, startetNoch, gewolltAus } from '../panel.js'; +import { abgleichen, embedWegraeumen } from '../panel-abgleich.js'; const GREEN = 0x23a55a; const RED = 0xf23f43; @@ -376,6 +377,28 @@ async function handleAlerts(client, r) { /** Ein Poll-Durchlauf — exportiert für Tests und den Timer */ export async function monitorTick(client) { if (!moduleEnabled('server_monitor')) return; + + // DER PANEL-BERICHT WIRD VOR ALLEM ANDEREN GEHOLT, und der Abgleich läuft + // VOR der Prüfung auf einen leeren Monitor. + // + // Vorher stand hier zuerst `if (servers.length === 0) return`. Damit hätte + // der Schalter im Panel beim ALLERERSTEN Server nichts getan -- der + // Durchlauf wäre ausgestiegen, bevor er das Panel überhaupt fragt. Und + // zwar stillschweigend, was der schlechteste aller Fehlermodi ist: der + // Schalter steht auf an, im Kanal passiert nichts, und es gibt nirgends + // eine Meldung, an der man sich entlanghangeln könnte. + // + // Der Aufruf ist billig: `panelStatus` hält seine Antwort 20 Sekunden und + // gibt ohne eingetragene Adresse sofort null zurück. + const panelItems = await panelStatus(); + if (panelItems) { + const { angelegt, entfernt } = abgleichen(panelItems); + if (entfernt.length > 0) await embedWegraeumen(client, entfernt); + if (angelegt.length > 0 || entfernt.length > 0) { + console.log(`[panel] übernommen: ${angelegt.join(', ') || '—'} | entfernt: ${entfernt.map((e) => e.name).join(', ') || '—'}`); + } + } + const servers = listGameservers(); if (servers.length === 0) return; @@ -384,7 +407,6 @@ export async function monitorTick(client) { // Die Zusatzauskunft vom Panel: EINMAL je Durchlauf, nicht je Server. // Sie ist optional -- ist nichts eingetragen oder antwortet das Panel // nicht, bleibt `panel` schlicht null und alles läuft wie vorher. - const panelItems = await panelStatus(); for (const r of results) r.panel = panelEintrag(panelItems, r); // Verlauf für die Web-Seite festhalten + Cache fürs API diff --git a/src/db.js b/src/db.js index 9a1ed38..b43bbc9 100644 --- a/src/db.js +++ b/src/db.js @@ -934,6 +934,18 @@ db.exec(` // Wer darf drauf: 'auto' nimmt, was die Abfrage sagt. Nur die wenigsten // Spiele melden das — und eine Whitelist meldet gar keins. if (!cols.includes('zugang')) db.exec(`ALTER TABLE gameservers ADD COLUMN zugang TEXT NOT NULL DEFAULT 'auto'`); + // Migration v4: Wer hat diesen Eintrag angelegt? + // + // Leer = ein Mensch, über das Webinterface. Steht ein Name drin, kommt der + // Eintrag aus dem d4rk_gameserver-Panel und wird von dort auch wieder + // weggeräumt. + // + // DIESE SPALTE IST DIE GANZE SICHERUNG DAGEGEN, dass ein Häkchen im Panel + // einen von Hand gepflegten Server löscht. Ohne sie müsste der Abgleich + // raten, und die einzige verfügbare Grundlage wäre der Name — bei dem + // ersten Server, der in beiden Werkzeugen gleich heißt, wäre der + // handgepflegte Eintrag samt Bild, Links und Zugangs-Code weg. + if (!cols.includes('panel_name')) db.exec(`ALTER TABLE gameservers ADD COLUMN panel_name TEXT NOT NULL DEFAULT ''`); } // Die Auswahl im Panel bot mal Spiel-IDs an, die es bei gamedig gar nicht gibt. // Wer eine davon gewählt hat, hatte einen Server, der dauerhaft offline stand, @@ -947,8 +959,8 @@ db.exec(` for (const [alt, neu] of Object.entries(echteIds)) um.run(neu, alt); } const insertGameserver = db.prepare(` - INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links, zugang) - VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links, @zugang) + INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links, zugang, panel_name) + VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links, @zugang, @panel_name) `); const updateGameserverStmt = db.prepare(` UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address, @@ -966,6 +978,11 @@ const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?'); const gameserverFelder = { name: '', type: 'fivem', query_url: '', address: '', host: '', port: null, connect_url: '', image_url: '', token: '', mods_url: '', links: '[]', zugang: 'auto', + // Leer = von Hand angelegt. Die UPDATE-Anweisung oben fasst die Spalte + // absichtlich NICHT an: wer einen Panel-Server im Webinterface bearbeitet, + // soll ihm damit nicht die Herkunft nehmen. (Ein überzähliger benannter + // Parameter stört better-sqlite3 nicht -- nachgemessen.) + panel_name: '', }; // Links dürfen als Liste hereinkommen — so gibt getGameserver sie ja auch // heraus. better-sqlite3 kann kein Array binden und würde sonst werfen. diff --git a/src/panel-abgleich.js b/src/panel-abgleich.js new file mode 100644 index 0000000..0c320d8 --- /dev/null +++ b/src/panel-abgleich.js @@ -0,0 +1,91 @@ +// Server, die im Panel das Häkchen „Im Discord anzeigen" tragen, hier anlegen +// — und wieder wegräumen, wenn es fällt. +// +// WARUM DIESE RICHTUNG. Der naheliegende Weg wäre: das Panel ruft die +// Bot-API und legt selbst an. Dann müsste das Panel aber ein Zeichen halten, +// das SCHREIBEN darf — und damit läge das mächtigere der beiden Geheimnisse +// auf der Seite, die es nicht braucht. So bleibt es bei einem Zeichen, das nur +// lesen kann, und der Bot holt den Statusbericht ohnehin alle 30 Sekunden ab. +// Das Häkchen reist einfach mit. +// +// DER PREIS IST EINE HALBE MINUTE. Deshalb sagt das Panel beim Umlegen des +// Schalters dazu, wie lange es dauert. Ein Knopf, der scheinbar nichts tut, +// wird dreimal gedrückt. +// +// WAS HIER NICHT PASSIERT: ein bestehender Eintrag wird nicht angefasst. Wer +// im Webinterface ein Bild, einen Verbinden-Link oder Regeln ergänzt hat, soll +// das nicht beim nächsten Durchlauf verlieren. Angelegt und gelöscht wird, +// mehr nicht. + +import { listGameservers, createGameserver, deleteGameserver, getSetting } from './db.js'; + +/** + * Taugt dieser Panel-Eintrag für einen Monitor-Eintrag? + * + * Drei Bedingungen, und alle drei sind im Panel schon geprüft — hier stehen + * sie noch einmal, weil ein Bericht von einem älteren Panel kommen kann und + * ein halber Eintrag schlimmer ist als keiner: er stünde dauerhaft auf + * offline und schlüge Alarm. + */ +export function taugt(x) { + return Boolean(x?.discord && x.gamedig && x.adresse && x.port); +} + +/** + * Einmal abgleichen. + * + * @returns {{angelegt: string[], entfernt: Array<{id: number, name: string, message_id: string|null}>}} + * Die Entfernten kommen mit ihrer Nachrichten-Kennung zurück, damit + * der Aufrufer das Embed löschen kann — hier gibt es keinen Client. + */ +export function abgleichen(items) { + const ergebnis = { angelegt: [], entfernt: [] }; + if (!Array.isArray(items)) return ergebnis; + + const vorhanden = listGameservers(); + const ausPanel = new Map(vorhanden.filter((s) => s.panel_name).map((s) => [s.panel_name, s])); + const gewollt = new Set(items.filter(taugt).map((x) => x.name)); + + for (const x of items) { + if (!taugt(x) || ausPanel.has(x.name)) continue; + // Ein von Hand angelegter Server auf demselben Port ist schon da — + // dann NICHT noch einmal. Sonst stünde derselbe Server zweimal im + // Kanal, und niemand wüsste, welches Embed das richtige ist. + const dasselbe = vorhanden.find( + (s) => Number(s.port) === Number(x.port) && String(s.host) === String(x.adresse), + ); + if (dasselbe) continue; + + createGameserver({ + name: x.name, + type: x.gamedig, + host: x.adresse, + port: x.port, + address: `${x.adresse}:${x.port}`, + query_url: '', + // Die Herkunft. Nur was hier steht, wird später auch wieder + // weggeräumt — der handgepflegte ATM10 ist davon nicht berührt. + panel_name: x.name, + }); + ergebnis.angelegt.push(x.name); + } + + for (const [name, s] of ausPanel) { + if (gewollt.has(name)) continue; + deleteGameserver(s.id); + ergebnis.entfernt.push({ id: s.id, name, message_id: s.message_id ?? null }); + } + return ergebnis; +} + +/** Das Status-Embed eines entfernten Servers mit wegräumen. */ +export async function embedWegraeumen(client, weg) { + const channelId = getSetting('status_channel_id'); + if (!channelId) return; + for (const e of weg) { + if (!e.message_id) continue; + const channel = await client.channels.fetch(channelId).catch(() => null); + const message = await channel?.messages?.fetch(e.message_id).catch(() => null); + await message?.delete().catch(() => {}); + } +} diff --git a/tools/panel-abgleich-pruefen.mjs b/tools/panel-abgleich-pruefen.mjs new file mode 100644 index 0000000..3c34b6b --- /dev/null +++ b/tools/panel-abgleich-pruefen.mjs @@ -0,0 +1,107 @@ +// Der Abgleich mit dem Panel -- vor allem, was er NICHT anfassen darf. +// +// Diese Probe braucht better-sqlite3 und läuft deshalb im Container: +// +// docker exec d4rkbot node tools/panel-abgleich-pruefen.mjs +// +// Sie arbeitet auf einer EIGENEN Datenbank in /tmp, nicht auf der echten -- +// eine Probe, die löschen kann, darf nicht dort löschen, wo es zählt. + +// VOR dem Import setzen: db.js loest den Pfad beim Laden auf. +const PROBE = '/tmp/abgleich-probe.sqlite'; +process.env.DB_PATH = PROBE; + +import { unlinkSync } from 'node:fs'; +for (const e of ['', '-wal', '-shm']) { + try { unlinkSync(PROBE + e); } catch { /* gab es noch nicht */ } +} + +const db = await import('../src/db.js'); +const { abgleichen, taugt } = await import('../src/panel-abgleich.js'); + +let fehler = 0; +const zeig = (gut, was, dazu = '') => { + if (!gut) fehler++; + console.log(` ${gut ? 'ok ' : 'FEHL'} ${was}${dazu ? ' ' + dazu : ''}`); +}; + +// Die echte Datenbank waere hier fatal -- diese Probe LOESCHT Server. Also +// nachsehen, worauf sie wirklich zeigt, statt es zu glauben. +{ + const datei = db.db.prepare('PRAGMA database_list').all()[0]?.file ?? ''; + if (!datei.includes('abgleich-probe')) { + console.log(`ABBRUCH: laeuft auf ${datei}, nicht auf der Probedatenbank.`); + process.exit(1); + } + console.log(`(Probedatenbank: ${datei})`); + console.log(''); +} + +const panel = (o) => ({ + name: 'p1', discord: true, gamedig: 'minecraft', adresse: '1.2.3.4', port: 26000, ...o, +}); + +console.log('=== Was überhaupt taugt'); +zeig(taugt(panel()) === true, 'vollständiger Eintrag'); +zeig(taugt(panel({ discord: false })) === false, 'ohne Häkchen -> nein'); +zeig(taugt(panel({ gamedig: null })) === false, 'nicht abfragbares Spiel -> nein (sonst dauerhaft offline)'); +zeig(taugt(panel({ adresse: '' })) === false, 'ohne Adresse für Spieler -> nein'); +zeig(taugt(panel({ port: null })) === false, 'ohne Port -> nein'); +zeig(taugt(undefined) === false, 'gar nichts -> nein'); + +console.log(''); +console.log('=== Anlegen'); +let a = abgleichen([panel()]); +zeig(a.angelegt.length === 1, 'ein Server mit Häkchen wird angelegt', a.angelegt.join()); +const angelegt = db.listGameservers().find((s) => s.name === 'p1'); +zeig(angelegt?.type === 'minecraft', 'mit der gamedig-Kennung, nicht der Vorlagen-Kennung', angelegt?.type); +zeig(angelegt?.host === '1.2.3.4' && Number(angelegt?.port) === 26000, 'Adresse und Port stehen drin'); +zeig(angelegt?.address === '1.2.3.4:26000', 'und die Adresse zum Kopieren', angelegt?.address); +zeig(angelegt?.panel_name === 'p1', 'die Herkunft ist vermerkt -- daran hängt alles Weitere'); + +a = abgleichen([panel()]); +zeig(a.angelegt.length === 0, 'ein zweiter Durchlauf legt ihn NICHT noch einmal an'); + +console.log(''); +console.log('=== Was der Abgleich NICHT anfassen darf'); +// Von Hand angelegt: kein panel_name. +const handId = db.createGameserver({ + name: 'ATM10', type: 'minecraft', host: '9.9.9.9', port: 25565, + address: '9.9.9.9:25565', image_url: 'https://bild', mods_url: 'https://mods', + links: [{ label: 'Regeln', url: 'https://regeln' }], +}); +a = abgleichen([]); // Panel meldet gar nichts mehr +const hand = db.getGameserver(handId); +zeig(Boolean(hand), 'ein von Hand angelegter Server überlebt einen leeren Bericht'); +zeig(hand?.image_url === 'https://bild', 'samt Bild'); +zeig(hand?.links?.length === 1, 'samt Links'); +zeig(a.entfernt.length === 1 && a.entfernt[0].name === 'p1', 'entfernt wird nur, was aus dem Panel kam', a.entfernt.map((e) => e.name).join()); +zeig(!db.listGameservers().some((s) => s.name === 'p1'), 'und der ist wirklich weg'); + +console.log(''); +console.log('=== Ein bestehender Eintrag wird nicht verdoppelt'); +// Derselbe Server, den es von Hand schon gibt: gleiche Adresse, gleicher Port. +a = abgleichen([panel({ name: 'ATM10 im Panel', adresse: '9.9.9.9', port: 25565 })]); +zeig(a.angelegt.length === 0, 'gleiche Adresse und gleicher Port -> kein zweites Embed im Kanal'); + +console.log(''); +console.log('=== Bearbeiten im Webinterface nimmt die Herkunft nicht'); +abgleichen([panel()]); +const p1 = db.listGameservers().find((s) => s.name === 'p1'); +db.updateGameserver({ ...p1, image_url: 'https://neu' }); +const danach = db.getGameserver(p1.id); +zeig(danach.panel_name === 'p1', 'panel_name steht nach dem Speichern noch da', danach.panel_name); +zeig(danach.image_url === 'https://neu', 'und die Änderung ist drin'); +// Und der nächste Abgleich darf sie nicht überschreiben. +abgleichen([panel()]); +zeig(db.getGameserver(p1.id).image_url === 'https://neu', 'der nächste Durchlauf plättet sie nicht'); + +console.log(''); +console.log('=== Kaputte Berichte'); +zeig(abgleichen(null).angelegt.length === 0, 'null -> nichts passiert'); +zeig(abgleichen([]).entfernt.length >= 0, 'leere Liste -> kein Absturz'); +zeig(abgleichen([{}]).angelegt.length === 0, 'Eintrag ohne Felder -> nichts angelegt'); + +console.log(''); +console.log(fehler ? `${fehler} FEHLER` : 'alles grün'); +process.exitCode = fehler ? 1 : 0;