Waechter: meldet Ausfaelle, statt sie unbemerkt zu lassen

Der Befund war besser als der Aufgabentitel: Alle Dienste haben
Restart=always und stehen nach einem Absturz von selbst wieder auf. Die
Luecke liegt woanders:

  - Dauerschleife: Startet ein Dienst und stuerzt sofort wieder ab, gibt
    systemd nach wenigen Versuchen auf. Dann bleibt er unten.
  - "active" heisst nicht "antwortet". Ein haengender Dienst gilt
    systemd als gesund.
  - Zertifikatsablauf und volle Platte machen keinen Dienst inaktiv,
    legen aber alles lahm.

Geprueft werden 18 Punkte: sechs Dienste, neun Adressen, Plattenplatz,
zwei Zertifikatslaufzeiten.

WARUM ALS ROOT PER CRON

Ein Waechter, der ueber den internen Dienst meldet, hat einen
Konstruktionsfehler mit Ansage: Ausgerechnet wenn DIESER Dienst das
Problem ist, kaeme keine Meldung durch. Also liest er .env und Datenbank
selbst und verschickt selbst -- unabhaengig davon, ob noch irgendetwas
laeuft. Ohne Fremdpakete, nur Node-Bordmittel, sqlite3 und systemctl.

NUR BEI ZUSTANDSWECHSEL

Gemeldet wird, wenn etwas kippt -- in beide Richtungen. Nicht alle fuenf
Minuten dasselbe. Wer staendig Meldungen bekommt, sieht irgendwann keine
mehr an und uebersieht die eine, auf die es ankam.

ZWEI EIGENE FEHLER, DIE DER TEST GEFUNDEN HAT

1. Zuerst stand je Adresse eine handgepflegte Liste erlaubter
   Antwortcodes. Der erste Lauf 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 Kommentar an derselben Stelle warnte.

2. Danach galt "unter 400" als heil. Jetzt meldete das Postfach einen
   Ausfall, weil die geprueften Adresse 404 lieferte -- der Dienst lief
   einwandfrei, ich hatte die Adresse falsch gewaehlt.

Beide Male dieselbe Lehre: Eine Ueberwachung, die bei einer falsch
getippten Adresse "Ausfall" ruft, erzieht einen dazu, ihre Meldungen zu
ignorieren. Die Regel lautet jetzt "unter 500", denn der Waechter fragt
"lebt der Dienst?", nicht "ist der Inhalt richtig?". 401, 403 und 404
BEWEISEN, dass jemand da ist und zuhoert. Nur 5xx und Schweigen heissen,
dass dahinter nichts mehr laeuft. Ausnahme sind die beiden Pflichtseiten
Impressum und Widerruf -- dort ist alles ausser 200 bereits ein Mangel.

GEPRUEFT AM SERVER

Ausfall eingebaut: erkannt und gemeldet. Ausfall dauert an: still, keine
Wiederholung. Wieder erreichbar: Entwarnung. 18 Punkte, 0 Fehlalarme
ueber mehrere Laeufe.

⚠️ GRENZE, DIE BLEIBT

Ist der Server als Ganzes weg -- Netz, Strom, Hardware --, meldet auch
dieser Waechter nichts. Dagegen hilft nur eine Ueberwachung ausserhalb
der Maschine. Steht so im Kopf der Datei, damit sich niemand in falscher
Sicherheit wiegt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-26 14:17:12 +02:00
co-authored by Claude Opus 5
parent 03a533f143
commit 3a5805bfad
+372
View File
@@ -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:[email protected]",
};
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);