Files
DogFatherGitandClaude Opus 5 8a2d2b802a Wächter-Protokoll hält sich selbst klein (keine unbegrenzte Log-Datei)
waechter.log wuchs unbegrenzt: alle 5 Minuten eine Zeile, ~288/Tag. Ohne
Grenze irgendwann zu groß zum Durchsehen -- dann verliert das Protokoll
seinen Zweck.

Der Wächter kürzt jetzt selbst, statt logrotate: Das bräuchte eine Datei
in /etc (kein Schreibrecht) und einen Extra-Dienst. Vor dem Anhängen wird
nur die Größe abgefragt (billig); erst über 1 MB (~45 Tage) wird die Datei
einmal gelesen und auf die jüngsten 2000 Zeilen (~1 Woche) gestutzt.

Beim Bauen einen eigenen Fehler gefangen: statSync war in waechter.mjs
nicht importiert (beim Auslagern der Sicherungsprüfung mit entfernt worden).
node --check meldet das nicht -- es hätte erst zur Laufzeit im nächsten
Cron-Lauf gekracht. Import ergänzt.

Test pruef-protokoll-kuerzen.mjs: klein bleibt unangetastet, groß wird auf
die JÜNGSTEN Zeilen gestutzt (älteste fallen weg), Grenzfall und fehlende
Datei sauber. 8/8.

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

496 lines
19 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, statSync } 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 */ }
}
/* Das Protokoll hält sich selbst klein, statt unbegrenzt zu wachsen.
Der Wächter schreibt alle fünf Minuten eine Zeile -- rund 288 am Tag.
Ohne Grenze wüchse die Datei still vor sich hin, bis sie irgendwann zu
groß zum Durchsehen ist und damit ihren Zweck verliert.
Bewusst kein logrotate: Das bräuchte eine Datei in /etc (kein
Schreibrecht) und einen zusätzlichen Dienst. Der Wächter kürzt lieber
selbst -- er schreibt die Datei ohnehin.
Der Schnitt kostet fast nie etwas: Erst wird nur die Größe abgefragt
(billig). Nur wenn sie die Grenze übersteigt, wird die Datei einmal
gelesen und auf die jüngsten Zeilen gestutzt. Bei ~80 Byte pro Zeile
sind 1 MB rund 45 Tage; danach bleiben die letzten 2000 Zeilen (gut
eine Woche Verlauf) stehen. */
const PROTOKOLL_MAX_BYTE = 1024 * 1024;
const PROTOKOLL_BEHALTEN = 2000;
function protokollKuerzenFallsNoetig() {
let groesse;
try { groesse = statSync(PROTOKOLL).size; } catch { return; } // noch keine Datei
if (groesse <= PROTOKOLL_MAX_BYTE) return;
try {
const zeilen = readFileSync(PROTOKOLL, "utf8").split("\n");
/* Die letzte, meist leere Zeile nach dem abschließenden \n fällt beim
Zusammensetzen ohnehin wieder an -- deshalb slice auf die Nutzzeilen. */
const jung = zeilen.filter((z) => z.length).slice(-PROTOKOLL_BEHALTEN);
writeFileSync(PROTOKOLL, jung.join("\n") + "\n", { mode: 0o600 });
dateiSichern(PROTOKOLL);
} catch { /* Misslingt das Kürzen, wird eben weiter angehängt -- kein Drama. */ }
}
function protokoll(text) {
try {
verzeichnisSichern(PROTOKOLL);
protokollKuerzenFallsNoetig();
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);