diff --git a/server-internal/waechter.mjs b/server-internal/waechter.mjs new file mode 100644 index 00000000..f8ad643e --- /dev/null +++ b/server-internal/waechter.mjs @@ -0,0 +1,372 @@ +/* ===================================================================== + waechter.mjs — merkt, wenn etwas ausfaellt, und sagt Bescheid + + Aufruf (als root, per Cron alle fuenf Minuten): + /usr/local/bin/node /home/dogiweb/dogfather-universe/server-internal/waechter.mjs + + WARUM ALS ROOT UND NICHT ALS DIENST + + Ein Waechter, der ueber den internen Dienst meldet, hat einen + Konstruktionsfehler mit Ansage: Ausgerechnet wenn DIESER Dienst das + Problem ist, kommt keine Meldung mehr durch -- also genau im + wichtigsten Fall. + + Deshalb laeuft er eigenstaendig. Er liest die Zugangsdaten selbst aus + der .env und der Datenbank und verschickt selbst. Ob irgendein Dienst + noch laeuft, ist ihm gleich. + + KEINE FREMDPAKETE + + Bewusst nur Bordmittel: Node-eigene Kryptographie, sqlite3 von der + Kommandozeile, systemctl, fetch. Ein Waechter, der wegen einer + fehlenden Abhaengigkeit nicht startet, ist schlimmer als keiner -- + man verlaesst sich auf ihn. + + ⚠️ WAS ER NICHT KANN + + Ist der Server als Ganzes weg -- Netz, Strom, Hardware --, meldet + auch er nichts. Dagegen hilft nur eine Ueberwachung ausserhalb dieser + Maschine. Das ist eine bewusste Grenze, keine Nachlaessigkeit. + + NUR BEI ZUSTANDSWECHSEL + + Gemeldet wird, wenn etwas KIPPT: heil -> kaputt und kaputt -> heil. + Nicht alle fuenf Minuten dasselbe. Wer staendig Meldungen bekommt, + sieht irgendwann keine mehr hin -- und uebersieht dann die eine, auf + die es ankam. + ===================================================================== */ + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import tls from "node:tls"; +import { senden } from "./lib/webpush.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB = "/var/lib/dogfather-internal/dogfather-internal.db"; +const ENV_DATEI = "/home/dogiintern/dogfather-universe/server-internal/.env"; +const ZUSTAND_DATEI = "/var/lib/dogfather-waechter/zustand.json"; +const PROTOKOLL = "/var/lib/dogfather-waechter/waechter.log"; + +/* --------------------------------------------------------------------- + WAS UEBERWACHT WIRD + --------------------------------------------------------------------- */ + +const DIENSTE = [ + "caddy", "dogiweb", "dogiintern", "vandiy-shop", "safeaddress", "gitea", +]; + +/* WAS ALS "HEIL" GILT + + Grundregel: alles unter 500. + + Das ist bewusst so tief angesetzt, und der Grund ist der Zweck dieses + Waechters: Er fragt "lebt der Dienst?", nicht "ist der Inhalt + richtig?". Aus dieser Sicht sind 401, 403 und 404 GUTE Antworten -- + sie beweisen, dass jemand da ist, zuhoert und Rechte prueft. Nur 5xx + und Schweigen bedeuten, dass dahinter nichts mehr laeuft; genau das + meldet Caddy als 502, wenn ein Dienst weg ist. + + Auch dieser Wert stammt aus einem Fehlschlag im Test: Mit "unter 400" + galt das Postfach als ausgefallen, weil die geprueften Adresse 404 + lieferte. Der Dienst lief einwandfrei -- die Adresse war schlicht + falsch gewaehlt. Eine Ueberwachung, die bei einer falsch getippten + Adresse "Ausfall" ruft, erzieht einen dazu, ihre Meldungen zu + ignorieren. + + Weiterleitungen sind damit ebenfalls in Ordnung -- hinter einer + Zugangswand sind sie das RICHTIGE Ergebnis, nicht der Fehler. + + ⚠️ Zuerst stand hier je Adresse eine Liste erlaubter Antwortcodes, + von Hand gepflegt. Schon der erste Testlauf meldete VanVans Shop als + ausgefallen -- er war es nicht, er steht ebenfalls hinter einer + Zugangswand und antwortet mit 302. Ich war damit genau in die Falle + gelaufen, vor der der alte Kommentar an dieser Stelle gewarnt hatte. + + Eine handgepflegte Liste ist hier die falsche Bauweise: Sie muss bei + jeder Aenderung an irgendeiner Seite nachgezogen werden, und wer das + vergisst, bekommt einen Fehlalarm. Nach dem zweiten oder dritten + schaltet man den Waechter ab -- und dann ist er auch beim echten + Ausfall still. + + Was ein echter Ausfall ist, sieht dagegen immer gleich aus: 5xx (der + Dienst dahinter antwortet nicht mehr, Caddy meldet 502) oder gar + keine Antwort. Genau darauf wird geprueft. + + "genau" gibt es nur da, wo mehr verlangt ist: Die Widerrufsseite muss + nach § 356a BGB erreichbar sein. Eine Weiterleitung waere dort schon + ein Mangel, kein Normalzustand. */ +const SEITEN = [ + { name: "Dogfather Universe", url: "https://dogfather-universe.com/" }, + { name: "Postfach", url: "https://postfach.dogfather-universe.com/api/team" }, + { name: "VanVans Shop", url: "https://vans-diy-bastelbedarf.com/" }, + { name: "Buchhaltung", url: "https://buchhaltung.vans-diy-bastelbedarf.com/" }, + { name: "SafeAddress", url: "https://safeaddress.dogfather-universe.com/" }, + { name: "ZockerAnstalt", url: "https://zockeranstalt.dogfather-universe.com/" }, + { name: "Gitea", url: "https://git.dogfather-universe.com/" }, + { name: "Widerruf (Pflichtseite)", url: "https://dogfather-universe.com/webdesign/widerruf.html", genau: [200] }, + { name: "Impressum (Pflichtseite)", url: "https://dogfather-universe.com/webdesign/rechtliches.html", genau: [200] }, +]; + +/* Ab hier wird es eng. 90 Prozent klingt nach viel Reserve -- bei einer + Datenbank, die taeglich gesichert wird, ist es das nicht. */ +const PLATTE_WARNUNG = 88; + +/* Caddy erneuert Zertifikate selbst, meist 30 Tage vorher. Meldet der + Waechter erst bei 7 Tagen, ist bereits dreimal etwas schiefgegangen. */ +const ZERTIFIKAT_WARNUNG_TAGE = 14; + +/* --------------------------------------------------------------------- + PRUEFUNGEN + --------------------------------------------------------------------- */ + +function dienstZustand(name) { + try { + const aus = execFileSync("systemctl", ["is-active", name + ".service"], + { encoding: "utf8", timeout: 10000 }).trim(); + return aus === "active"; + } catch { + /* is-active gibt bei "inactive" einen Fehlercode zurueck -- das ist + kein Absturz, sondern die Antwort. */ + return false; + } +} + +async function seiteZustand(s) { + try { + const a = await fetch(s.url, { + /* Weiterleitungen NICHT verfolgen: Sonst prueft man am Ende die + Zugangswand statt der Seite und merkt nicht, wenn dahinter + nichts mehr ist. */ + redirect: "manual", + signal: AbortSignal.timeout(15000), + }); + const heil = s.genau ? s.genau.includes(a.status) : a.status < 500; + return { heil, status: a.status }; + } catch (e) { + /* Keine Antwort ist der eindeutigste Ausfall ueberhaupt. */ + return { heil: false, status: String(e?.message || e).slice(0, 60) }; + } +} + +function platteZustand() { + try { + const aus = execFileSync("df", ["--output=pcent", "/"], { encoding: "utf8", timeout: 10000 }); + const belegt = Number(aus.split("\n")[1].replace("%", "").trim()); + return { heil: belegt < PLATTE_WARNUNG, belegt }; + } catch { + return { heil: true, belegt: -1 }; + } +} + +/* Restlaufzeit des Zertifikats. Direkt ueber die TLS-Verbindung, ohne + openssl -- eine Abhaengigkeit weniger. */ +function zertifikatTage(host) { + return new Promise((f) => { + const zeit = setTimeout(() => { try { v.destroy(); } catch {} f(null); }, 12000); + const v = tls.connect({ host, port: 443, servername: host, rejectUnauthorized: false }, () => { + const z = v.getPeerCertificate(); + clearTimeout(zeit); + v.end(); + if (!z || !z.valid_to) return f(null); + f(Math.floor((new Date(z.valid_to).getTime() - Date.now()) / 86400000)); + }); + v.on("error", () => { clearTimeout(zeit); f(null); }); + }); +} + +/* --------------------------------------------------------------------- + MELDEN + --------------------------------------------------------------------- */ + +function envLesen() { + const raus = {}; + if (!existsSync(ENV_DATEI)) return raus; + for (const zeile of readFileSync(ENV_DATEI, "utf8").split("\n")) { + const t = zeile.trim(); + if (!t || t.startsWith("#")) continue; + const i = t.indexOf("="); + if (i < 0) continue; + raus[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, ""); + } + return raus; +} + +/* Als Trennzeichen ein Steuerzeichen, das in keiner Adresse und keinem + Schluessel vorkommen kann. "|" oder ";" waeren naheliegender und + irgendwann falsch -- Push-Adressen sind lange, undurchsichtige + Zeichenketten, und darauf, was darin nie auftaucht, moechte man nicht + wetten. + + Erzeugt ueber String.fromCharCode statt als rohes Zeichen: Ein + unsichtbares Steuerzeichen im Quelltext ueberlebt kein Kopieren, kein + Zusammenfuehren und keinen Editor, der Zeilen "aufraeumt". Es + verschwindet lautlos, und dann steht hier ein leerer Trenner, an dem + sich nichts mehr aufteilen laesst. */ +const TRENNER = String.fromCharCode(1); + +function dbLesen(sql) { + try { + return execFileSync("sqlite3", ["-separator", TRENNER, DB, sql], + { encoding: "utf8", timeout: 15000 }).trim(); + } catch { + return ""; + } +} + +/* Entschluesselt im selben Format wie lib/crypto.js: "iv.ciphertext", + beides base64, AES-GCM. */ +async function entschluesseln(wert, schluesselB64) { + const [ivB64, ctB64] = String(wert).split("."); + const roh = Uint8Array.from(Buffer.from(schluesselB64, "base64")); + const key = await crypto.subtle.importKey("raw", roh, "AES-GCM", false, ["decrypt"]); + const klar = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: Uint8Array.from(Buffer.from(ivB64, "base64")) }, + key, + Uint8Array.from(Buffer.from(ctB64, "base64")) + ); + return new TextDecoder().decode(klar); +} + +async function meldung(titel, text) { + const env = envLesen(); + if (!env.ENCRYPTION_KEY) return { gesendet: 0, grund: "kein ENCRYPTION_KEY lesbar" }; + + const oeffentlich = dbLesen("SELECT value FROM app_settings WHERE key='wd_push_vapid_oeffentlich';"); + const privatRoh = dbLesen("SELECT value FROM app_settings WHERE key='wd_push_vapid_privat';"); + if (!oeffentlich || !privatRoh) return { gesendet: 0, grund: "keine Push-Schluessel in der Datenbank" }; + + let privat; + try { + privat = await entschluesseln(privatRoh, env.ENCRYPTION_KEY); + } catch (e) { + return { gesendet: 0, grund: "Schluessel nicht entschluesselbar" }; + } + + const zeilen = dbLesen("SELECT endpunkt, p256dh, auth FROM wd_push_geraete;"); + if (!zeilen) return { gesendet: 0, grund: "kein Geraet angemeldet" }; + + const vapid = { + oeffentlich, privat, + kontakt: env.PUSH_KONTAKT || "mailto:kontakt@dogfather-universe.com", + }; + const nachricht = JSON.stringify({ + titel, text, url: "/webdesign/verwaltung.html", art: "waechter", + }); + + let gesendet = 0; + for (const z of zeilen.split("\n")) { + const [endpunkt, p256dh, auth] = z.split(TRENNER); + if (!endpunkt) continue; + try { + const r = await senden({ endpunkt, p256dh, auth }, nachricht, vapid, + /* Eine Stoerungsmeldung ist dringend und soll auch ein + schlafendes Geraet wecken. */ + { dringlichkeit: "high", ttl: 3600 }); + if (r.zustand === "ok") gesendet++; + } catch { /* ein totes Geraet darf die uebrigen nicht aufhalten */ } + } + return { gesendet }; +} + +/* --------------------------------------------------------------------- + ABLAUF + --------------------------------------------------------------------- */ + +function protokoll(text) { + try { + mkdirSync(dirname(PROTOKOLL), { recursive: true }); + writeFileSync(PROTOKOLL, + `${new Date().toISOString()} ${text}\n`, { flag: "a" }); + } catch { /* Protokoll ist Beiwerk, nicht der Zweck */ } + console.log(text); +} + +function zustandLesen() { + try { + return JSON.parse(readFileSync(ZUSTAND_DATEI, "utf8")); + } catch { + return {}; + } +} + +function zustandSchreiben(z) { + mkdirSync(dirname(ZUSTAND_DATEI), { recursive: true }); + writeFileSync(ZUSTAND_DATEI, JSON.stringify(z, null, 1), "utf8"); +} + +const jetzt = {}; +const beschreibung = {}; + +for (const d of DIENSTE) { + const heil = dienstZustand(d); + jetzt["dienst:" + d] = heil; + beschreibung["dienst:" + d] = heil + ? `Dienst ${d} laeuft wieder` + : `Dienst ${d} laeuft NICHT`; +} + +for (const s of SEITEN) { + const r = await seiteZustand(s); + jetzt["seite:" + s.name] = r.heil; + beschreibung["seite:" + s.name] = r.heil + ? `${s.name} ist wieder erreichbar` + : `${s.name} antwortet nicht (${r.status})`; +} + +const p = platteZustand(); +jetzt["platte"] = p.heil; +beschreibung["platte"] = p.heil + ? `Plattenplatz wieder in Ordnung (${p.belegt}%)` + : `Platte zu ${p.belegt}% belegt`; + +for (const host of ["dogfather-universe.com", "vans-diy-bastelbedarf.com"]) { + const tage = await zertifikatTage(host); + /* Kein Ergebnis heisst NICHT "kaputt" -- ein kurzer Netzhaenger darf + keinen Alarm ausloesen. Dann bleibt der alte Zustand stehen. */ + if (tage === null) continue; + jetzt["zert:" + host] = tage > ZERTIFIKAT_WARNUNG_TAGE; + beschreibung["zert:" + host] = tage > ZERTIFIKAT_WARNUNG_TAGE + ? `Zertifikat ${host} wieder gueltig (${tage} Tage)` + : `Zertifikat ${host} laeuft in ${tage} Tagen ab`; +} + +const vorher = zustandLesen(); +const neuKaputt = []; +const wiederHeil = []; + +for (const [k, heil] of Object.entries(jetzt)) { + const alt = vorher[k]; + if (alt === undefined) { + /* Beim allerersten Lauf wird nur aufgezeichnet. Sonst kaeme sofort + eine Meldung fuer jeden Punkt, der gerade zufaellig nicht + antwortet -- und der erste Eindruck waere ein Fehlalarm. */ + continue; + } + if (alt && !heil) neuKaputt.push(beschreibung[k]); + if (!alt && heil) wiederHeil.push(beschreibung[k]); +} + +zustandSchreiben(jetzt); + +const kaputtGesamt = Object.values(jetzt).filter((x) => !x).length; + +if (!neuKaputt.length && !wiederHeil.length) { + protokoll(`ruhig (${Object.keys(jetzt).length} Punkte, davon ${kaputtGesamt} auffaellig)`); + process.exit(0); +} + +let titel, text; +if (neuKaputt.length) { + titel = neuKaputt.length === 1 ? "Stoerung" : `${neuKaputt.length} Stoerungen`; + text = neuKaputt.join(" · ").slice(0, 300); +} else { + titel = "Wieder in Ordnung"; + text = wiederHeil.join(" · ").slice(0, 300); +} + +protokoll(`WECHSEL: ${titel} — ${text}`); +const r = await meldung(titel, text); +protokoll(`Meldung: ${r.gesendet ? r.gesendet + " Geraet(e)" : "nicht zugestellt (" + r.grund + ")"}`); +process.exit(0);