Files
dogfather-universe/server-internal/waechter.mjs
T
DogFatherGitandClaude Opus 5 46563b02fc Waechter merkt jetzt, wenn die naechtliche Sicherung ausfaellt
Beim Nachweis des ersten automatischen Laufs gefunden: Der Cron-Eintrag
endet auf >/dev/null 2>&1 -- jede Fehlermeldung wird verworfen. Das
Sicherungsskript fuehrt zwar ein eigenes Protokoll, aber alles, was VOR
der ersten Protokollzeile schiefgeht (Skript geloescht, sqlite3 weg,
Platte voll, Cron gestoppt), passiert spurlos. Niemand haette es
gemerkt -- ausser in dem Moment, in dem man die Sicherung braucht.

Der Waechter prueft ab sofort die Datei selbst, nicht das Protokoll:
Ein Protokoll kann "erfolgreich" melden, waehrend die Datei fehlt.

In lib/ ausgelagert, weil waechter.mjs beim Import sofort seinen ganzen
Durchlauf startet -- testbar war die Funktion dort nicht.

Beim Testentwurf einen eigenen Fehler gefunden: readdirSync wirft
sowohl bei fehlendem Ordner (ENOENT) als auch bei fehlenden Rechten
(EACCES). Die erste Fassung behandelte beides als "kein Urteil" und
haette damit einen geloeschten Sicherungsordner verschwiegen. Jetzt
getrennt: ENOENT meldet, EACCES schweigt.

Die 26-Stunden-Grenze ist bewusst nicht enger: Kurz vor dem naechsten
Lauf ist die juengste Sicherung regulaer 24 Stunden alt. Genau dort
entstehen die Fehlalarme, nach denen man die Meldungen abschaltet --
und dann geht der eine echte mit unter. Als eigener Testfall abgesichert.

14 von 14 Pruefungen bestanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 03:02:55 +02:00

463 lines
18 KiB
JavaScript

/* =====================================================================
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, chmodSync } 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";
import { sicherungZustand } from "./lib/sicherung-pruefen.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" };
}
/* ⚠️ Hier standen zwei sehr verschiedene Ursachen unter derselben
Meldung: eine leere Geraeteliste und eine fehlende Tabelle.
dbLesen gibt in beiden Faellen "" zurueck -- einmal, weil niemand
angemeldet ist (harmlos, ein Klick behebt es), einmal, weil die
Migration nicht gelaufen ist (ein Fehler, der Suchen an ganz
anderer Stelle ausloest). Wer "kein Geraet angemeldet" liest, klickt
auf Einschalten; wenn dann nichts passiert, sucht er beim Browser
statt bei der Datenbank.
Deshalb wird jetzt zuerst gefragt, ob es die Tabelle ueberhaupt
gibt. Aufgefallen am 26.08.2026 beim ersten echten Probelauf. */
const tabelleDa = dbLesen(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='wd_push_geraete';"
);
if (tabelleDa !== "1") {
return { gesendet: 0, grund: "Tabelle wd_push_geraete fehlt — Migration 0019 nicht gelaufen" };
}
const zeilen = dbLesen("SELECT endpunkt, p256dh, auth FROM wd_push_geraete;");
if (!zeilen) {
return {
gesendet: 0,
grund: "kein Geraet angemeldet — in der Verwaltung unter Uebersicht einschalten",
};
}
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
--------------------------------------------------------------------- */
/* 700/600 statt der Vorgabe 755/644. Hier stehen keine
personenbezogenen Daten, aber eine vollstaendige Liste aller Dienste,
Adressen und ihres Zustands -- fuer jemanden, der einen Angriff
vorbereitet, eine bequeme Landkarte. Sie kostet nichts, also gibt es
auch keinen Grund, sie herzugeben. */
/* ⚠️ mode: 0o700 beim Anlegen REICHT NICHT (nachgemessen 26.08.2026).
Zwei Gruende, und beide sind still:
1. "mode" gilt nur, wenn das Verzeichnis dabei NEU entsteht. Existiert
es schon -- und beim zweiten Lauf existiert es immer --, laesst
mkdirSync die Rechte unberuehrt. Das Verzeichnis war beim allerersten
Lauf entstanden, bevor diese Zeile ueberhaupt eingebaut war, und
blieb deshalb auf 755.
2. Selbst beim Neuanlegen zieht die umask des Prozesses noch Bits ab.
Gemessen: /var/backups/dogfather steht auf 700, weil dort ein
ausdrueckliches chmod im Skript steht. /var/lib/dogfather-waechter
stand auf 755 -- weltweit lesbar, mit einer vollstaendigen Liste
aller Dienste, Adressen und ihres Zustands darin.
Die Absicht stand also im Code, die Wirkung fehlte. Deshalb jetzt
ausdruecklich chmod, bei jedem Lauf, fuer Verzeichnis UND Datei. */
function verzeichnisSichern(pfad) {
const ordner = dirname(pfad);
mkdirSync(ordner, { recursive: true, mode: 0o700 });
try { chmodSync(ordner, 0o700); } catch { /* nicht Eigentuemer: dann eben nicht */ }
}
function dateiSichern(pfad) {
try { chmodSync(pfad, 0o600); } catch { /* Datei noch nicht da */ }
}
function protokoll(text) {
try {
verzeichnisSichern(PROTOKOLL);
writeFileSync(PROTOKOLL,
`${new Date().toISOString()} ${text}\n`, { flag: "a", mode: 0o600 });
} 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) {
verzeichnisSichern(ZUSTAND_DATEI);
writeFileSync(ZUSTAND_DATEI, JSON.stringify(z, null, 1), { encoding: "utf8", mode: 0o600 });
dateiSichern(ZUSTAND_DATEI);
}
/* ---------------------------------------------------------------------
PROBELAUF: node waechter.mjs --probe
Verschickt eine Meldung, ohne dass etwas kaputt sein muss.
Das ist nicht Spielerei, sondern die einzige Moeglichkeit, den
MELDEWEG zu pruefen, ohne auf eine echte Stoerung zu warten. Eine
Ueberwachung, deren Zustellung stillschweigend nicht funktioniert,
ist schlimmer als gar keine: Man haelt Stille faelschlich fuer
"alles in Ordnung" -- dabei ist sie nur Stille.
--------------------------------------------------------------------- */
if (process.argv.includes("--probe")) {
const r = await meldung(
"Probe vom Waechter",
"Wenn du das liest, kommen Stoerungsmeldungen an."
);
protokoll(`Probelauf: ${r.gesendet ? r.gesendet + " Geraet(e) erreicht" : "NICHT zugestellt (" + r.grund + ")"}`);
process.exit(r.gesendet ? 0 : 1);
}
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`;
/* Kein Urteil (null) heisst: alten Zustand stehen lassen, nicht melden --
dasselbe Vorgehen wie beim Zertifikat weiter unten. */
const sich = sicherungZustand();
if (sich) {
jetzt["sicherung"] = sich.heil;
beschreibung["sicherung"] = sich.heil
? `Sicherung laeuft wieder (${sich.grund})`
: `SICHERUNG FEHLT: ${sich.grund}`;
}
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);