# 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') # Das Datum aus dem NAMEN lesen, nicht aus dem Zeitstempel der Datei. # # WARUM: der Nextcloud-Client setzt LastWriteTime auf den Zeitpunkt des # HERUNTERLADENS. Ein Archiv vom 11. August, das heute ankommt, sieht damit # taufrisch aus -- und der Alarm "es kommt nichts mehr an" würde nie # auslösen, weil alles immer neu wirkt. Genau das ist beim ersten echten Lauf # passiert: "d4rk_media-2026-08-11-2307.tar.gz, 0 Tage alt". # # Der Name trägt das Datum der SICHERUNG, und der überlebt jedes Kopieren: # d4rk_media-2026-08-13-0929.tar.gz # d4rkbot-2026-08-13.db.gz function ArchivDatum($datei) { if ($datei.Name -match '(\d{4})-(\d{2})-(\d{2})(?:-(\d{2})(\d{2}))?') { $std = if ($Matches[4]) { [int]$Matches[4] } else { 0 } $min = if ($Matches[5]) { [int]$Matches[5] } else { 0 } try { return Get-Date -Year ([int]$Matches[1]) -Month ([int]$Matches[2]) -Day ([int]$Matches[3]) ` -Hour $std -Minute $min -Second 0 } catch { } } # Kein Datum im Namen -- dann bleibt nur der Zeitstempel, mit allen # Nachteilen. Besser als gar keine Reihenfolge. return $datei.LastWriteTime } # -- 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' # JEDER Schritt sagt VORHER, was er tut. Beim ersten Anlauf blieb das Skript # nach "Nachsehen" stehen, und von aussen war nicht zu sehen, WORAN -- # Test-Path, New-Item und Resolve-Path koennen alle drei minutenlang # blockieren, wenn ein Pfad auf eine Netzfreigabe oder eine schlafende Platte # zeigt. Eine Ausgabe, die erst NACH dem Blockieren kommt, hilft genau dann # nicht, wenn man sie braucht. Write-Host " Quelle pruefen: $Quelle ..." -NoNewline if (-not (Test-Path $Quelle)) { Write-Host '' Schlecht "Die Quelle gibt es nicht: $Quelle" Schlecht 'Synchronisiert der Nextcloud-Client diesen Ordner wirklich?' exit 1 } Write-Host ' da.' # 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('\') Write-Host " Ziel anlegen/pruefen: $Ziel ..." -NoNewline New-Item -ItemType Directory -Force -Path $Ziel | Out-Null $zVoll = (Resolve-Path $Ziel).Path.TrimEnd('\') Write-Host ' da.' 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. # -- Sind es ueberhaupt Dateien, oder nur Platzhalter? -------------------------- # # DAS HAT EINEN RECHNER LAHMGELEGT. Der Nextcloud-Client kann "virtuelle # Dateien" anlegen: im Ordner steht dann ein Verweis mit der richtigen GROESSE, # aber ohne Inhalt. Wer so eine Datei anfasst, loest einen Download aus -- und # robocopy fasst alle an. Bei fuenf Archiven waren das 1,7 GB auf einmal, # waehrend Windows blockierte. # # Erkennbar an zwei Attributen: # 0x001000 OFFLINE -- Inhalt liegt nicht hier # 0x400000 RECALL_ON_DATA_ACCESS -- wird beim Zugriff nachgeladen # # Lieber abbrechen und es sagen, als die Maschine in die Knie zwingen. Schritt 'Sind es echte Dateien?' $platzhalter = @(Get-ChildItem $qVoll -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '\.(tar|db)\.gz$' -and ($_.Attributes -band 0x401000) }) if ($platzhalter.Count) { Schlecht "$($platzhalter.Count) von $(@(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count) Archiven sind nur PLATZHALTER." Write-Host '' Warn 'Der Nextcloud-Client hat "virtuelle Dateien" aktiviert: im Ordner steht' Warn 'ein Verweis mit der richtigen Groesse, aber ohne Inhalt. Sie zu kopieren' Warn 'wuerde alles auf einmal herunterladen und den Rechner blockieren.' Write-Host '' Warn 'Im Explorer sieht man es am WOLKEN-Symbol neben der Datei; ein gruener' Warn 'Haken heisst "liegt wirklich hier". Windows 11 zeigt ausserdem an, wenn' Warn 'es gerade etwas nachlaedt.' Write-Host '' Warn 'Im Nextcloud-Client: Einstellungen -> Konto -> Drei-Punkte-Menue' Warn ' -> "Virtuelle Dateien deaktivieren"' Warn 'Danach laedt er im Hintergrund alles herunter -- mit Fortschritt und' Warn 'unterbrechbar. Erst wenn das durch ist, hat diese Abholung Sinn.' Write-Host '' Schlecht 'Abgebrochen. Es wurde nichts kopiert und nichts geloescht.' exit 1 } Gut 'ja, alle Archive liegen wirklich lokal' 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 $inQuelle = @(Get-ChildItem $qVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' }).Count Gut "$($nachher - $vorher) neu, $nachher in der Ablage, $inQuelle in der Quelle" # -- Ausdünnen ------------------------------------------------------------------ $alle = @(Get-ChildItem $zVoll -File | Where-Object { $_.Name -match '\.(tar|db)\.gz$' } | Sort-Object @{ Expression = { ArchivDatum $_ } } -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) - (ArchivDatum $neuestes)).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.' }