diff --git a/pruef-sicherung-waechter.mjs b/pruef-sicherung-waechter.mjs new file mode 100644 index 00000000..86c35b99 --- /dev/null +++ b/pruef-sicherung-waechter.mjs @@ -0,0 +1,145 @@ +/* ===================================================================== + Merkt der Waechter, wenn die Sicherung ausfaellt? + + Die Prüfung selbst muss geprüft werden -- sonst hat man am Ende zwei + Dinge, auf die man sich verlässt und von denen keines funktioniert. + + Getestet wird mit echten Ordnern in einem temporären Verzeichnis, + nicht mit nachgebauten Attrappen: Die Funktion soll genau das tun, + was sie auf dem Server tut. + ===================================================================== */ +import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sicherungZustand, MAX_STUNDEN, MIN_BYTE } from "./server-internal/lib/sicherung-pruefen.js"; + +let ok = 0, fehl = 0; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } +}; + +const wurzel = mkdtempSync(join(tmpdir(), "sicherungstest-")); + +/** Legt eine Sicherung an: Alter in Stunden, Groesse in Byte. */ +function baue(name, altStunden, groesse) { + const basis = join(wurzel, name); + const tag = join(basis, "taeglich", "2026-08-27"); + mkdirSync(tag, { recursive: true }); + const datei = join(tag, "dogfather-internal.db"); + writeFileSync(datei, Buffer.alloc(groesse)); + const zeit = new Date(Date.now() - altStunden * 3600000); + utimesSync(datei, zeit, zeit); + return basis; +} + +console.log("\n=== Der gute Fall ==="); +{ + const r = sicherungZustand(baue("frisch", 2, 800 * 1024)); + pruefe("frische Sicherung gilt als heil", r && r.heil === true, r?.grund); +} + +console.log("\n=== Die Ausfaelle, die gemeldet werden muessen ==="); +{ + const r = sicherungZustand(join(wurzel, "gibtesnicht")); + pruefe("gar kein Sicherungsordner", r && r.heil === false, r?.grund); +} +{ + /* Ordner da, aber keine Laeufe darin. */ + const basis = join(wurzel, "leer"); + mkdirSync(join(basis, "taeglich"), { recursive: true }); + const r = sicherungZustand(basis); + pruefe("Ordner vorhanden, aber kein Lauf", r && r.heil === false, r?.grund); +} +{ + /* Der Unterordner "taeglich" fehlt ganz -- beim ersten Entwurf lieferte + das faelschlich "kein Urteil" statt eines Befunds. */ + const basis = join(wurzel, "ohnetaeglich"); + mkdirSync(basis, { recursive: true }); + const r = sicherungZustand(basis); + pruefe("Unterordner taeglich fehlt", r && r.heil === false, r?.grund); +} +{ + /* Lauf angelegt, aber die Datenbank fehlt darin. */ + const basis = join(wurzel, "ohnedb"); + mkdirSync(join(basis, "taeglich", "2026-08-27"), { recursive: true }); + const r = sicherungZustand(basis); + pruefe("Lauf ohne Datenbankdatei", r && r.heil === false, r?.grund); +} +{ + const r = sicherungZustand(baue("alt", MAX_STUNDEN + 3, 800 * 1024)); + pruefe("Sicherung zu alt", r && r.heil === false, r?.grund); +} +{ + const r = sicherungZustand(baue("winzig", 2, 1024)); + pruefe("Sicherung verdaechtig klein", r && r.heil === false, r?.grund); +} +{ + const r = sicherungZustand(baue("leerdatei", 2, 0)); + pruefe("Sicherung mit 0 Byte", r && r.heil === false, r?.grund); +} + +console.log("\n=== Die Grenze darf nicht zu eng sitzen ==="); +{ + /* Genau 24 Stunden: Das ist der Normalfall kurz vor dem naechsten Lauf + und darf auf keinen Fall Alarm ausloesen. Genau hier entstehen die + Fehlalarme, die dazu fuehren, dass man die Meldungen abschaltet. */ + const r = sicherungZustand(baue("grenzfall", 24, 800 * 1024)); + pruefe("24 Stunden alt gilt noch als heil", r && r.heil === true, r?.grund); +} +{ + /* Und knapp darueber muss es kippen. */ + const r = sicherungZustand(baue("knappdrueber", MAX_STUNDEN + 0.5, 800 * 1024)); + pruefe("knapp ueber der Grenze kippt es", r && r.heil === false, r?.grund); +} +{ + /* Knapp ueber der Mindestgroesse: darf nicht anschlagen. */ + const r = sicherungZustand(baue("knappgross", 2, MIN_BYTE + 1024)); + pruefe("knapp ueber der Mindestgroesse ist heil", r && r.heil === true, r?.grund); +} + +console.log("\n=== Der juengste Lauf zaehlt, nicht irgendeiner ==="); +{ + /* Ein alter Lauf daneben darf das Urteil nicht verfaelschen. */ + const basis = join(wurzel, "mehrere"); + for (const [tag, alt] of [["2026-08-20", 170], ["2026-08-27", 2]]) { + const o = join(basis, "taeglich", tag); + mkdirSync(o, { recursive: true }); + const d = join(o, "dogfather-internal.db"); + writeFileSync(d, Buffer.alloc(800 * 1024)); + const z = new Date(Date.now() - alt * 3600000); + utimesSync(d, z, z); + } + const r = sicherungZustand(basis); + pruefe("alter Lauf daneben stoert nicht", r && r.heil === true, r?.grund); +} +{ + /* Umgekehrt: Ist der juengste Lauf alt, hilft ein Ordner mit anderem + Namen daneben nicht -- geprueft wird der juengste, nicht der beste. */ + const basis = join(wurzel, "nurAlt"); + const o = join(basis, "taeglich", "2026-08-01"); + mkdirSync(o, { recursive: true }); + const d = join(o, "dogfather-internal.db"); + writeFileSync(d, Buffer.alloc(800 * 1024)); + const z = new Date(Date.now() - 600 * 3600000); + utimesSync(d, z, z); + const r = sicherungZustand(basis); + pruefe("nur ein alter Lauf wird gemeldet", r && r.heil === false, r?.grund); +} + +console.log("\n=== Fremde Ordner werden ignoriert ==="); +{ + /* Im Zielverzeichnis koennen andere Dinge liegen (das Protokoll, ein + Ordner "woechentlich"). Nur YYYY-MM-DD darf mitzaehlen. */ + const basis = join(wurzel, "fremdes"); + mkdirSync(join(basis, "taeglich", "zwischenablage"), { recursive: true }); + mkdirSync(join(basis, "taeglich", "2026-08-27"), { recursive: true }); + const d = join(basis, "taeglich", "2026-08-27", "dogfather-internal.db"); + writeFileSync(d, Buffer.alloc(800 * 1024)); + const r = sicherungZustand(basis); + pruefe("Ordner ohne Datumsnamen zaehlen nicht mit", r && r.heil === true, r?.grund); +} + +rmSync(wurzel, { recursive: true, force: true }); +console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); +process.exitCode = fehl ? 1 : 0; diff --git a/server-internal/lib/sicherung-pruefen.js b/server-internal/lib/sicherung-pruefen.js new file mode 100644 index 00000000..118d179b --- /dev/null +++ b/server-internal/lib/sicherung-pruefen.js @@ -0,0 +1,110 @@ +/* ===================================================================== + Ist die naechtliche Sicherung wirklich gelaufen? + + Eigene Datei, damit sie sich testen laesst: waechter.mjs startet beim + Import sofort seinen kompletten Durchlauf (Dienste, Seiten, Push) -- + ein Test koennte die Funktion dort nicht anfassen, ohne den ganzen + Waechter auszuloesen. + + ANLASS (27.08.2026) + + 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. + + Eine Sicherung, die still ausfaellt, ist schlimmer als gar keine: Man + verlaesst sich auf etwas, das es nicht mehr gibt. Auffallen wuerde es + genau in dem Moment, in dem man sie braucht. + + WAS GEPRUEFT WIRD + + Die Datei, auf die es im Ernstfall ankommt -- nicht das Protokoll. + Ein Protokoll kann "erfolgreich" melden, waehrend die Datei fehlt. + Die Datei selbst kann das nicht. + ===================================================================== */ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/* Die Sicherung laeuft um 03:15, die juengste Datei ist also zu jedem + Zeitpunkt hoechstens 24 Stunden alt. Der Aufschlag deckt die Laufzeit + und die Zeitumstellung ab (eine Stunde im Maerz und Oktober) und + meldet einen Ausfall trotzdem noch am selben Morgen. */ +export const MAX_STUNDEN = 26; + +/* Die Datenbank war am 26.08.2026 rund 778 KB gross. Die Grenze liegt + bewusst weit darunter: Sie soll den stillen Totalausfall fangen (eine + Datei mit 0 Byte, ein abgebrochener Lauf), nicht ueber die richtige + Groesse urteilen. Zu eng gesetzt haette sie an dem Tag angeschlagen, + an dem jemand alte Auftraege loescht. */ +export const MIN_BYTE = 50 * 1024; + +/** + * @returns {null | {heil: boolean, grund: string}} + * null bedeutet ausdruecklich "kein Urteil moeglich" und ist NICHT + * dasselbe wie "kaputt" -- siehe unten. + */ +export function sicherungZustand(basis = "/var/backups/dogfather", jetzt = Date.now()) { + if (!existsSync(basis)) { + return { heil: false, grund: "es gibt gar keinen Sicherungsordner" }; + } + + const taeglich = join(basis, "taeglich"); + let ordner; + try { + ordner = readdirSync(taeglich) + .filter((n) => /^\d{4}-\d{2}-\d{2}$/.test(n)) + .sort(); + } catch (e) { + /* Hier MUSS unterschieden werden, und beim ersten Entwurf tat ich es + nicht: readdirSync wirft aus zwei ganz verschiedenen Gruenden. + + ENOENT -- der Ordner ist nicht da. Das ist ein echter Befund: Es + gab nie einen Lauf, oder jemand hat ihn geloescht. Das gehoert + gemeldet. + + EACCES/EPERM -- der Ordner ist da, wir duerfen nur nicht hinein. + Das sagt ueber die Sicherung nichts aus. Wer hier Alarm schlaegt, + erzeugt bei jedem Rechtewechsel einen Fehlalarm -- und ein + Waechter, der regelmaessig grundlos ruft, wird weggeklickt. Dann + geht der eine echte Alarm mit unter. + + Beides in einen Topf zu werfen hiesse: entweder den geloeschten + Ordner verschweigen oder bei jedem Rechteproblem faelschlich + Alarm schlagen. Keins von beidem ist brauchbar. */ + if (e && (e.code === "EACCES" || e.code === "EPERM")) return null; + return { heil: false, grund: "der Ordner fuer die taeglichen Laeufe fehlt" }; + } + + if (!ordner.length) { + return { heil: false, grund: "kein einziger Sicherungslauf vorhanden" }; + } + + /* Sortiert wird ueber den Namen, nicht ueber die Aenderungszeit: Die + Ordner heissen YYYY-MM-DD, da ist die alphabetische Reihenfolge + zugleich die zeitliche. Aenderungszeiten koennen beim Kopieren + oder Wiederherstellen verrutschen, der Name nicht. */ + const neueste = ordner[ordner.length - 1]; + const datei = join(taeglich, neueste, "dogfather-internal.db"); + + let s; + try { + s = statSync(datei); + } catch { + return { heil: false, grund: `Lauf vom ${neueste} hat keine Datenbankdatei` }; + } + + const stunden = (jetzt - s.mtimeMs) / 3600000; + if (stunden > MAX_STUNDEN) { + return { heil: false, grund: `juengste Sicherung ist ${Math.round(stunden)} Stunden alt (${neueste})` }; + } + if (s.size < MIN_BYTE) { + return { heil: false, grund: `Sicherung vom ${neueste} ist nur ${Math.round(s.size / 1024)} KB gross` }; + } + + return { + heil: true, + grund: `Sicherung vom ${neueste}, ${Math.round(s.size / 1024)} KB, vor ${Math.round(stunden)} h`, + }; +} diff --git a/server-internal/waechter.mjs b/server-internal/waechter.mjs index e25ca3a5..26e6f69b 100644 --- a/server-internal/waechter.mjs +++ b/server-internal/waechter.mjs @@ -42,6 +42,7 @@ 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"; @@ -400,6 +401,16 @@ 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