diff --git a/src/backup.js b/src/backup.js index 6f71a53..4e6c94e 100644 --- a/src/backup.js +++ b/src/backup.js @@ -222,11 +222,62 @@ export async function pruefeSicherung(client) { // später erneut gemeldet werden kann. await dmAdminEinmalig(client, 'backup_warn', null); } + + await pruefeZweitziel(client); } catch (error) { console.error('[backup] Wachhund:', error.message); } } +/** + * Wacht ueber die ABHOLUNG auf die zweite Platte (tools/abholen.ps1). + * + * WOZU EIN ZWEITER WACHHUND. Die Sicherung des Bots liegt im selben Volume wie + * seine Datenbank; erst das Abholen bringt sie dort heraus. Ein Skript auf dem + * Wirt, das still aufhoert zu laufen -- geplante Aufgabe deaktiviert, Laufwerk + * nicht da, Pfad geaendert --, waere also genau derselbe Schaden noch einmal, + * nur eine Ebene weiter aussen. + * + * NUR WENN ES SCHON EINMAL LIEF. Fehlt der Eintrag ganz, ist die Abholung + * nicht eingerichtet, und daraus jeden Tag eine Meldung zu machen waere + * Naergelei -- wer sie nicht will, soll nicht belaestigt werden. + */ +async function pruefeZweitziel(client) { + const roh = getSetting('last_zweitziel'); + if (!roh) return; + + let letzte; + try { letzte = JSON.parse(roh); } catch { return; } + + const stunden = (Date.now() - letzte.at) / 3_600_000; + + if (letzte.ok === false) { + await dmAdminEinmalig(client, 'zweitziel_warn', `fehler:${letzte.at}`, () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('🚨 Die Sicherung wurde nicht abgeholt') + .setDescription(letzte.fehler || 'Kein Grund gemeldet.') + .addFields({ name: 'Ziel', value: `\`${letzte.ziel ?? '?'}\``, inline: true })); + } else if (stunden > UEBERFAELLIG_STUNDEN) { + await dmAdminEinmalig(client, 'zweitziel_warn', `alt:${String(letzte.at).slice(0, 8)}`, () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('⚠️ Die Sicherung wird nicht mehr abgeholt') + .setDescription( + 'Sie liegt damit nur noch **im selben Volume** wie die Datenbank. ' + + 'Läuft die geplante Aufgabe für `tools/abholen.ps1` noch?', + ) + .addFields( + { + name: 'Zuletzt geholt', + value: ``, + inline: true, + }, + { name: 'Ziel', value: `\`${letzte.ziel ?? '?'}\``, inline: true }, + )); + } else { + await dmAdminEinmalig(client, 'zweitziel_warn', null); + } +} + /** Scheduler: täglich ab 03:00 (lokale TZ), einmal pro Tag — und er HOLT NACH. * * Vorher stand hier `if (now.getHours() !== 3) return`. Wer während dieser diff --git a/tools/abholen.ps1 b/tools/abholen.ps1 new file mode 100644 index 0000000..d32a7d2 --- /dev/null +++ b/tools/abholen.ps1 @@ -0,0 +1,240 @@ +# Die Sicherung des Bots aus dem Volume HOLEN und auf eine zweite Platte legen. +# +# Aufruf: +# pwsh -File tools\abholen.ps1 +# pwsh -File tools\abholen.ps1 -Ziel E:\woanders -Behalten 30 +# +# WOZU, wo der Bot doch schon sichert. Er sichert in DASSELBE VOLUME, in dem +# auch die Datenbank liegt (`/app/data/ecobot.db` und `/app/data/backups/`). +# Geht das Volume verloren, geht beides zusammen verloren -- das ist keine +# Sicherung, das ist eine Kopie. Dieses Skript ist der Weg hinaus. +# +# WAS ES NICHT TUT: sichern. Das Archiv baut der Bot selbst, mit SQLites +# Online-Schnittstelle und seit dem 12.08.2026 mit eigener Gegenpruefung. Hier +# wird nur ABGEHOLT -- und zwar so, dass am Ende feststeht, dass die Kopie heil +# ist und nicht nur, dass ein Kopierbefehl keinen Fehler geworfen hat. +# +# Die Reihenfolge und warum sie so ist: +# +# 1. Das neueste Archiv im Container finden. +# 2. Herausholen. `docker cp` und kein zweiter Container: der Bot laeuft +# ohnehin, und ein Volume-Mount waere ein Umweg um eine offene Tuer. +# 3. Pruefsumme vergleichen -- IM CONTAINER gerechnet gegen die Kopie hier. +# Nicht die Dateigroesse: eine abgebrochene Kopie auf eine volle Platte hat +# oft genau die richtige Laenge und trotzdem Nullen am Ende. +# 4. Das Archiv AUSPACKEN und die Datenbank darin oeffnen -- mit dem +# Bot-Abbild, weil dort better-sqlite3 schon liegt. Ein Archiv, das +# niemand geoeffnet hat, ist eine Vermutung. +# 5. Melden: `last_zweitziel` in die Einstellungen des Bots. Sein Wachhund +# liest das und beschwert sich, wenn die Abholung ausbleibt -- sonst waere +# dieses Skript das naechste, das still aufhoert zu laufen. +# 6. Alte Kopien ausduennen. +# +# UND ES WARNT, WENN ES DIESELBE PLATTE IST. Ein zweiter Ordner auf C: sieht +# genauso gruen aus wie eine echte zweite Platte und hilft gegen gar nichts; +# zwei Partitionen derselben NVMe sterben zusammen. Verglichen wird deshalb die +# PHYSISCHE Platte und nicht der Laufwerksbuchstabe. + +[CmdletBinding()] +param( + [string]$Ziel = 'D:\backup\d4rkbot', + [string]$Container = 'd4rkbot', + [string]$Abbild = 'd4rkbot:latest', + [int]$Behalten = 14 +) + +$ErrorActionPreference = 'Stop' +function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } +function Gut($t) { Write-Host " $t" -ForegroundColor Green } +function Schlecht($t) { Write-Host " $t" -ForegroundColor Red } + +# Das Ergebnis zurueck in die Datenbank des Bots. Schlaegt DAS fehl, ist es kein +# Grund, die Abholung als gescheitert zu behandeln -- die Kopie liegt dann +# trotzdem richtig. +function Melde($ok, $name, $bytes, $fehler) { + $eintrag = @{ + at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + ok = [bool]$ok + datei = "$name" + bytes = [long]$bytes + ziel = "$Ziel" + } + if ($fehler) { $eintrag['fehler'] = "$fehler" } + + # Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt + # Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht. + $skript = @" +let roh = ''; +process.stdin.on('data', (d) => (roh += d)).on('end', () => { + const db = require('/app/node_modules/better-sqlite3')('/app/data/ecobot.db'); + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ' + + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .run('last_zweitziel', roh.trim()); +}); +"@ -replace "`r", '' + ($eintrag | ConvertTo-Json -Compress) | docker exec -i $Container node -e $skript 2>&1 | Out-Null +} + +# -- Vorbedingungen ------------------------------------------------------------ + +$laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}' +if ($laeuft -ne $Container) { + Schlecht "Der Container '$Container' laeuft nicht." + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Ziel | Out-Null + +# -- 1. Das neueste Archiv finden ---------------------------------------------- + +Schritt 'Das neueste Archiv im Container suchen' + +# `ls -1` und danach das letzte: die Namen tragen das Datum in ISO-Form, sind +# also alphabetisch schon in der richtigen Reihenfolge. +$name = (docker exec $Container sh -c 'ls -1 /app/data/backups/*.db.gz 2>/dev/null | tail -1') -join '' +$name = $name.Trim() +if (-not $name) { + Schlecht 'Im Container liegt kein Archiv. Hat der Bot schon einmal gesichert?' + Melde $false '' 0 'kein Archiv im Container' + exit 1 +} +$kurz = Split-Path $name -Leaf +Gut $kurz + +# Ist es ueberhaupt von heute? Ein Skript, das treu das Archiv von vorletzter +# Woche abholt und "fertig" sagt, ist schlimmer als eines, das gar nicht laeuft. +$alter = (docker exec $Container sh -c "find '$name' -mtime +1 | wc -l").Trim() +if ($alter -ne '0') { + Write-Host ' ACHTUNG: das neueste Archiv ist aelter als 24 Stunden.' -ForegroundColor Yellow + Write-Host ' Die Abholung laeuft trotzdem -- aber die Sicherung des Bots stockt.' -ForegroundColor Yellow +} + +# -- 2. Herausholen ------------------------------------------------------------ + +Schritt "Herausholen nach $Ziel" + +$dortHin = Join-Path $Ziel $kurz +docker cp "${Container}:$name" $dortHin +if ($LASTEXITCODE -ne 0) { + Schlecht 'docker cp fehlgeschlagen.' + Melde $false $kurz 0 'docker cp fehlgeschlagen' + exit 1 +} +$bytes = (Get-Item $dortHin).Length +Gut ("{0:N0} Bytes" -f $bytes) + +# Dieselbe Platte? Dann ist es keine Sicherung, sondern eine Kopie. +function PlattenNummer($pfad) { + # $null heisst "kein Urteil moeglich" (Netzfreigabe, ungewoehnliche + # Einhaengung) und ist ausdruecklich nicht dasselbe wie "andere Platte". + try { + $wurzel = [System.IO.Path]::GetPathRoot((Resolve-Path $pfad).Path) + $b = $wurzel.TrimEnd('\', ':') + if ($b.Length -ne 1) { return $null } + return (Get-Partition -DriveLetter $b -ErrorAction Stop).DiskNumber + } catch { return $null } +} +# Die Daten des Bots liegen im Docker-Volume, und das liegt auf der Platte, auf +# der Docker seine Volumes haelt -- unter Windows also im Systemlaufwerk. +$hier = PlattenNummer $env:SystemDrive +$dort = PlattenNummer $Ziel +if ($null -ne $hier -and $hier -eq $dort) { + Write-Host " ACHTUNG: $Ziel liegt auf DERSELBEN Platte (Nr. $hier) wie das Docker-Volume." -ForegroundColor Yellow + Write-Host ' Gegen einen Plattenausfall hilft das nicht -- ein anderes Laufwerk waehlen.' -ForegroundColor Yellow +} elseif ($null -ne $hier -and $null -ne $dort) { + Gut "andere Platte: Nr. $dort statt Nr. $hier" +} + +# -- 3. Pruefsumme ------------------------------------------------------------- + +Schritt 'Pruefsumme vergleichen' + +$drinnen = (docker exec $Container sh -c "sha256sum '$name' | cut -d' ' -f1").Trim() +$draussen = (Get-FileHash $dortHin -Algorithm SHA256).Hash.ToLower() + +if (-not $drinnen) { + Schlecht 'Im Container liess sich keine Pruefsumme bilden.' + Melde $false $kurz $bytes 'sha256sum im Container fehlgeschlagen' + exit 1 +} +if ($drinnen -ne $draussen) { + Schlecht 'Die Kopie ist NICHT identisch.' + Schlecht " im Container: $drinnen" + Schlecht " hier: $draussen" + Remove-Item $dortHin -Force -ErrorAction SilentlyContinue + Melde $false $kurz $bytes 'Pruefsumme weicht ab' + exit 1 +} +Gut "identisch ($($drinnen.Substring(0,12))...)" + +# -- 4. Auspacken und hineinsehen ---------------------------------------------- +# +# Mit dem Bot-Abbild, weil dort better-sqlite3 schon liegt. Der Ordner haengt +# NUR LESEND -- eine Pruefung, die schreiben kann, kann auch kaputtmachen. + +Schritt 'Auspacken und die Datenbank darin oeffnen' + +# Das Pruefskript geht als DATEI hinein und nicht als `node -e`-Argument. Ein +# mehrzeiliges Skript durch PowerShell, docker und die Shell des Containers zu +# reichen, ist eine Anfuehrungszeichen-Frage mit drei Beteiligten -- und die +# verliert man. +$werkbank = Join-Path $env:TEMP "d4rkbot-pruefer-$PID" +New-Item -ItemType Directory -Force -Path $werkbank | Out-Null +$pruefer = @' +const { createReadStream, createWriteStream } = require('node:fs'); +const { pipeline } = require('node:stream/promises'); +const { createGunzip } = require('node:zlib'); +const Database = require('/app/node_modules/better-sqlite3'); +(async () => { + await pipeline(createReadStream(process.argv[2]), createGunzip(), createWriteStream('/tmp/p.db')); + const db = new Database('/tmp/p.db', { readonly: true }); + const urteil = db.pragma('integrity_check', { simple: true }); + if (urteil !== 'ok') { console.error('integrity_check: ' + urteil); process.exit(1); } + const t = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table'").get().n; + const s = db.prepare('SELECT COUNT(*) AS n FROM settings').get().n; + console.log(t + ' ' + s); +})().catch((e) => { console.error(e.message); process.exit(1); }); +'@ -replace "`r", '' +# .cjs, weil das Bot-Abbild "type": "module" traegt -- eine .js waere dort ein +# ES-Modul, und require() gaebe es darin nicht. +Set-Content -Path (Join-Path $werkbank 'pruefer.cjs') -Value $pruefer -NoNewline -Encoding utf8 + +try { + $ausgabe = docker run --rm ` + -v "${Ziel}:/x:ro" -v "${werkbank}:/s:ro" ` + --entrypoint node $Abbild /s/pruefer.cjs "/x/$kurz" 2>&1 + $code = $LASTEXITCODE +} finally { + Remove-Item $werkbank -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($code -ne 0) { + Schlecht "Das Archiv liess sich nicht oeffnen: $ausgabe" + Remove-Item $dortHin -Force -ErrorAction SilentlyContinue + Melde $false $kurz $bytes "Archiv unlesbar: $ausgabe" + exit 1 +} +$zahlen = ($ausgabe -join ' ').Trim().Split(' ') +Gut "in der Sicherung: $($zahlen[0]) Tabellen, $($zahlen[1]) Einstellungen" + +# -- 5. Melden ----------------------------------------------------------------- + +Melde $true $kurz $bytes $null + +# -- 6. Ausduennen ------------------------------------------------------------- + +$alle = Get-ChildItem $Ziel -Filter 'd4rkbot-*.db.gz' | Sort-Object Name -Descending +if ($alle.Count -gt $Behalten) { + Schritt "Alte Kopien entfernen (mehr als $Behalten)" + $alle | Select-Object -Skip $Behalten | ForEach-Object { + Remove-Item $_.FullName -Force + Gut "entfernt: $($_.Name)" + } +} + +Write-Host '' +Gut "Fertig: $dortHin" +# NACH dem Ausduennen neu zaehlen -- $alle ist der Stand von davor, und eine +# Zahl, die das Ausduennen nicht mitbekommt, ist eine falsche. +$jetzt = @(Get-ChildItem $Ziel -Filter 'd4rkbot-*.db.gz') +Write-Host " $($jetzt.Count) Kopien in $Ziel" -ForegroundColor DarkGray