From 0f3f63749c4e25dae2094fcb1da668d366f93959 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 13 Aug 2026 09:49:06 +0200 Subject: [PATCH] feat: tools/heim-abholen.ps1 -- die Auswaertskopie, die der Server nicht loeschen kann Der Weg nach draussen fuehrt jetzt ueber die Nextcloud, die ohnehin jede Nacht ein geprueftes Archiv bekommt: der Desktop-Client zieht den Ordner auf die Platte zu Hause. Serverseitig ist dafuer nichts noetig -- kein Konto, kein Schluessel, kein Skript. WARUM DAS ALLEIN NICHT REICHT: der Client gleicht in BEIDE Richtungen ab. Verschwinden die Archive auf dem Server -- Trojaner, Fehlgriff, kaputter Auftrag --, loescht er sie auch zu Hause. Der synchronisierte Ordner ist eine Kopie, aber keine Sicherung. Dieses Skript kopiert daraus in einen zweiten Ordner, den der Client nicht kennt, und bricht damit die Kette: was einmal dort liegt, bleibt liegen, bis die Aufbewahrungsfrist es wegraeumt. Der Server kann es nicht mehr erreichen. robocopy OHNE /MIR, und das ist der ganze Punkt -- /MIR wuerde das Ziel dem Quellordner angleichen und dort Geloeschtes mitloeschen. Es weigert sich ausserdem, wenn das Ziel IM synchronisierten Ordner liegt: dann wuerde der Client die Kopien wieder hochladen und alles waere umsonst. UND ES MELDET SICH, WENN NICHTS MEHR ANKOMMT. Ein Kopierauftrag, der still aufhoert, ist genau der Schaden, gegen den er gebaut ist: man glaubt, man haette eine Sicherung, und merkt es an dem Tag, an dem man sie braucht. Gemeldet wird nur bei Zustandswechsel -- eine taegliche "alles in Ordnung"-Meldung liest nach einer Woche niemand mehr, und dann auch die eine nicht, auf die es ankommt. An Wegwerf-Ordnern durchgespielt, alle Faelle einzeln: drei Archive holen -> 3 neu Datei in der QUELLE geloescht -> bleibt im Ziel <- der Punkt Aufbewahrung auf 2 -> aeltestes entfernt seit 8 Tagen nichts Neues -> Alarm zweiter Lauf -> bleibt still neues Archiv kommt an -> Entwarnung Ziel im Quellordner -> abgelehnt Der vierte Test hat beim ersten Anlauf nichts gemessen: ich hatte die KOPIEN gealtert statt der Quelle, und robocopy hat sie korrekt neu geholt. Ein Test, der das Falsche misst, meldet "in Ordnung" und wird geglaubt. Co-Authored-By: Claude Opus 5 --- tools/heim-abholen.ps1 | 187 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tools/heim-abholen.ps1 diff --git a/tools/heim-abholen.ps1 b/tools/heim-abholen.ps1 new file mode 100644 index 0000000..36bd92f --- /dev/null +++ b/tools/heim-abholen.ps1 @@ -0,0 +1,187 @@ +# Holt die Sicherungen aus dem synchronisierten Nextcloud-Ordner in eine +# EIGENE Ablage — und bricht damit die Rückkopplung. +# +# LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server. +# +# pwsh -File heim-abholen.ps1 -Quelle "D:\Nextcloud\Sicherungen\d4rk_media" -Ziel "D:\Archiv\d4rk_media" +# pwsh -File heim-abholen.ps1 ... -Einrichten <- als tägliche Aufgabe eintragen +# pwsh -File heim-abholen.ps1 ... -Webhook "https://discord.com/api/webhooks/..." +# +# WOZU, wo der Nextcloud-Client doch schon synchronisiert. +# +# WEIL ER IN BEIDE RICHTUNGEN ABGLEICHT. Verschwinden die Archive auf dem +# Server — durch einen Verschlüsselungstrojaner, einen Fehlgriff, einen +# kaputten Auftrag —, löscht der Client sie auch hier. Der synchronisierte +# Ordner ist deshalb eine Kopie, aber keine Sicherung. +# +# Dieses Skript kopiert daraus in einen zweiten Ordner, den der Client NICHT +# kennt. Was einmal hier liegt, bleibt liegen, bis die Aufbewahrungsfrist es +# wegräumt. Damit ist die Kette gebrochen: der Server kann diese Kopien nicht +# mehr erreichen. +# +# UND ES MELDET SICH, WENN NICHTS MEHR ANKOMMT. Ein Kopierauftrag, der still +# aufhört zu laufen, ist genau der Schaden, gegen den er gebaut wurde: man +# glaubt, man hätte eine Sicherung, und merkt es an dem Tag, an dem man sie +# braucht. Deshalb prüft er das Alter des neuesten Archivs und schlägt Alarm. + +[CmdletBinding()] +param( + # Der Ordner, den der Nextcloud-Client synchronisiert. + [Parameter(Mandatory = $true)][string]$Quelle, + # Die eigene Ablage. MUSS ausserhalb des synchronisierten Ordners liegen -- + # sonst schiebt der Client die Kopien wieder zum Server hoch, und die + # Rueckkopplung, die wir brechen wollen, ist wieder da. + [Parameter(Mandatory = $true)][string]$Ziel, + [int]$Behalten = 30, + # Ab wann "es kommt nichts mehr" gemeldet wird. Die Sicherung laeuft + # taeglich; zwei Tage lassen Raum fuer einen ausgefallenen Lauf, drei waeren + # schon zwei verpasste. + [int]$AlarmNachTagen = 2, + [string]$Webhook = '', + [switch]$Einrichten +) + +$ErrorActionPreference = 'Stop' +function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } +function Gut($t) { Write-Host " $t" -ForegroundColor Green } +function Warn($t) { Write-Host " $t" -ForegroundColor Yellow } +function Schlecht($t) { Write-Host " $t" -ForegroundColor Red } + +$MUSTER = @('*.tar.gz', '*.db.gz') + +# -- Als Aufgabe eintragen ------------------------------------------------------ + +if ($Einrichten) { + $pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source + if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source } + $arg = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$PSCommandPath`"" + + " -Quelle `"$Quelle`" -Ziel `"$Ziel`" -Behalten $Behalten -AlarmNachTagen $AlarmNachTagen" + if ($Webhook) { $arg += " -Webhook `"$Webhook`"" } + + # Ueber einen VBS-Starter, damit kein Fenster aufgeht -- wie beim Tunnel. + # Ein Fenster, das man zumachen kann, wird irgendwann zugemacht. + $ordner = Join-Path $env:LOCALAPPDATA 'Sicherungs-Abholung' + New-Item -ItemType Directory -Force -Path $ordner | Out-Null + $starter = Join-Path $ordner 'start.vbs' + # Die Anfuehrungszeichen im Argumenttext muessen fuer VBS verdoppelt werden. + # Als Here-String, weil die verschachtelte Variante beim ersten Anlauf ein + # unlesbares Knaeuel war -- und prompt nicht geparst hat. + $vbsArg = $arg -replace '"', '""' + @" +' Erzeugt von heim-abholen.ps1 -- startet die Abholung OHNE Fenster. +Set sh = CreateObject("WScript.Shell") +sh.Run """$pwsh"" $vbsArg", 0, False +"@ | Set-Content $starter -Encoding ASCII + + Unregister-ScheduledTask -TaskName 'Sicherungen abholen' -Confirm:$false -ErrorAction SilentlyContinue + $aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`"" + # ZWEI Ausloeser: taeglich mittags, UND beim Anmelden. Der zweite ist der + # wichtige -- ein Rechner, der nachts aus ist, holt sonst nie etwas. + $t1 = New-ScheduledTaskTrigger -Daily -At 12:00 + $t2 = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" + $prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited + $optionen = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + Register-ScheduledTask -TaskName 'Sicherungen abholen' -Action $aktion -Trigger @($t1, $t2) ` + -Principal $prinzipal -Settings $optionen ` + -Description "Kopiert Sicherungen aus $Quelle nach $Ziel und haelt $Behalten Staende." | Out-Null + Gut 'Als Aufgabe eingetragen: taeglich 12:00 und bei jeder Anmeldung.' +} + +# -- Prüfen --------------------------------------------------------------------- + +Schritt 'Nachsehen' +if (-not (Test-Path $Quelle)) { + Schlecht "Die Quelle gibt es nicht: $Quelle" + Schlecht 'Synchronisiert der Nextcloud-Client diesen Ordner wirklich?' + exit 1 +} +# Das Ziel darf NICHT im synchronisierten Ordner liegen -- sonst laedt der +# Client die Kopien wieder hoch und die Rueckkopplung ist zurueck. +$qVoll = (Resolve-Path $Quelle).Path.TrimEnd('\') +New-Item -ItemType Directory -Force -Path $Ziel | Out-Null +$zVoll = (Resolve-Path $Ziel).Path.TrimEnd('\') +if ($zVoll.StartsWith($qVoll, [StringComparison]::OrdinalIgnoreCase)) { + Schlecht 'Das Ziel liegt IM synchronisierten Ordner. Dann waere alles umsonst:' + Schlecht 'der Client wuerde die Kopien wieder hochladen und mitloeschen.' + exit 1 +} +Gut "aus $qVoll" +Gut "nach $zVoll" + +# -- Holen ---------------------------------------------------------------------- +# +# robocopy OHNE /MIR, und das ist der ganze Punkt: /MIR wuerde das Ziel dem +# Quellordner angleichen und dort geloeschte Dateien auch hier loeschen. Genau +# das soll nicht passieren. + +Schritt 'Kopieren (nur neue, nichts loeschen)' +$zaehlen = { @(Get-ChildItem $zVoll -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count } +$vorher = & $zaehlen +robocopy $qVoll $zVoll @MUSTER /XO /R:2 /W:5 /NP /NJH /NJS /NDL /NC /NS | Out-Null +# robocopy meldet ueber den Rueckgabewert: alles unter 8 ist Erfolg. +if ($LASTEXITCODE -ge 8) { Schlecht "robocopy meldet Fehler ($LASTEXITCODE)"; exit 1 } +$nachher = & $zaehlen +Gut "$($nachher - $vorher) neu, $nachher insgesamt" + +# -- Ausdünnen ------------------------------------------------------------------ + +$alle = @(Get-ChildItem $zVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' } | + Sort-Object LastWriteTime -Descending) +if ($alle.Count -gt $Behalten) { + Schritt "Alte Staende entfernen (mehr als $Behalten)" + $alle | Select-Object -Skip $Behalten | ForEach-Object { + Remove-Item $_.FullName -Force + Gut "entfernt: $($_.Name)" + } +} + +# -- Kommt überhaupt noch etwas an? --------------------------------------------- + +Schritt 'Alter der neuesten Sicherung' +$neuestes = $alle | Select-Object -First 1 +if (-not $neuestes) { + $text = 'In der Ablage liegt KEINE einzige Sicherung.' + Schlecht $text +} else { + $tage = [math]::Round(((Get-Date) - $neuestes.LastWriteTime).TotalDays, 1) + if ($tage -gt $AlarmNachTagen) { + $text = "Die neueste Sicherung ist $tage Tage alt ($($neuestes.Name)). Es kommt nichts mehr an." + Schlecht $text + } else { + $text = $null + Gut "$($neuestes.Name), $tage Tage alt, $([math]::Round($neuestes.Length/1MB)) MB" + } +} + +# -- Melden, falls gewünscht ---------------------------------------------------- +# +# Nur im Alarmfall und nur bei ZUSTANDSWECHSEL. Eine tägliche Meldung "alles in +# Ordnung" liest nach einer Woche niemand mehr, und dann auch die eine nicht, +# auf die es ankommt. + +$merker = Join-Path $zVoll '.letzter-alarm.txt' +$zuletzt = if (Test-Path $merker) { (Get-Content $merker -Raw).Trim() } else { '' } +$jetzt = if ($text) { $text } else { '' } + +if ($jetzt -ne $zuletzt) { + Set-Content $merker $jetzt -Encoding UTF8 + if ($Webhook -and $jetzt) { + $rumpf = @{ + username = 'Sicherungs-Abholung' + embeds = @(@{ + title = 'Bei dir zu Hause kommt keine Sicherung mehr an' + description = $jetzt + color = 15158332 + fields = @(@{ name = 'Ablage'; value = "``$zVoll``"; inline = $false }) + footer = @{ text = 'D4RKST3R // ABHOLUNG' } + }) + } | ConvertTo-Json -Depth 6 + $code = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST ` + -H 'content-type: application/json' --data-binary '@-' $Webhook + Write-Host '' + if ($code -in '200', '204') { Gut "Gemeldet (HTTP $code)." } else { Schlecht "Melden fehlgeschlagen: HTTP $code" } + } +} elseif ($jetzt) { + Warn 'Schon gemeldet -- bleibt still, bis sich etwas aendert.' +}