Nachgemessen: /var/lib/dogfather-waechter stand auf 755 -- weltweit lesbar, mit einer vollstaendigen Liste aller Dienste, Adressen und ihres Zustands darin. Also genau das, was der Commit von heute Nachmittag verhindern sollte. Die Absicht stand im Code, die Wirkung fehlte. Zwei Gruende, beide still: 1. "mode" bei mkdirSync gilt nur, wenn das Verzeichnis dabei NEU entsteht. Beim zweiten Lauf existiert es immer -- dann laesst mkdirSync die Rechte unberuehrt. Hier war es sogar schon vor dem Einbau der Zeile angelegt worden. 2. Selbst beim Neuanlegen zieht die umask des Prozesses Bits ab. Der Unterschied zur Sicherung ist lehrreich: /var/backups/dogfather steht korrekt auf 700, weil dort ein ausdrueckliches chmod im Skript steht. Dieselbe Ueberlegung, einmal umgesetzt und einmal nur gemeint. Jetzt chmod bei jedem Lauf, fuer Verzeichnis UND Dateien. Aufgefallen ist es nur, weil die Rechte nachgesehen wurden statt angenommen -- der Code sah richtig aus. Co-Authored-By: Claude Opus 5 <[email protected]>
452 lines
17 KiB
JavaScript
452 lines
17 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";
|
|
|
|
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`;
|
|
|
|
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);
|