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]>
This commit is contained in:
@@ -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;
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import tls from "node:tls";
|
import tls from "node:tls";
|
||||||
import { senden } from "./lib/webpush.js";
|
import { senden } from "./lib/webpush.js";
|
||||||
|
import { sicherungZustand } from "./lib/sicherung-pruefen.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const DB = "/var/lib/dogfather-internal/dogfather-internal.db";
|
const DB = "/var/lib/dogfather-internal/dogfather-internal.db";
|
||||||
@@ -400,6 +401,16 @@ beschreibung["platte"] = p.heil
|
|||||||
? `Plattenplatz wieder in Ordnung (${p.belegt}%)`
|
? `Plattenplatz wieder in Ordnung (${p.belegt}%)`
|
||||||
: `Platte zu ${p.belegt}% 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"]) {
|
for (const host of ["dogfather-universe.com", "vans-diy-bastelbedarf.com"]) {
|
||||||
const tage = await zertifikatTage(host);
|
const tage = await zertifikatTage(host);
|
||||||
/* Kein Ergebnis heisst NICHT "kaputt" -- ein kurzer Netzhaenger darf
|
/* Kein Ergebnis heisst NICHT "kaputt" -- ein kurzer Netzhaenger darf
|
||||||
|
|||||||
Reference in New Issue
Block a user