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:
@@ -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);
|
||||
Reference in New Issue
Block a user