Beim ersten echten Meldetest lief das Skript ohne -Webhook. Es hat den Alarm
angezeigt, NICHTS gesendet -- und den Zustand trotzdem als "gemeldet"
abgehakt. Der naechste Lauf MIT Webhook waere still geblieben.
Ein Merker bedeutet "dieser Zustand wurde berichtet". Wenn nichts berichtet
wurde, darf er nicht gesetzt werden -- sonst verschluckt ausgerechnet der
Fehlerfall die Meldung, fuer die das Ganze da ist.
Jetzt vier Faelle, sauber getrennt:
alles gut -> Merker leeren, keine Nachricht noetig
schon gemeldet -> still bleiben
kein Webhook -> sagen, dass NICHTS gemeldet wurde, Merker nicht setzen
Webhook da -> senden; NUR bei HTTP 200/204 den Merker setzen
Der letzte Punkt ist der zweite Teil derselben Sache: schlaegt das Senden fehl,
bleibt der Merker leer und der naechste Lauf versucht es erneut. Eine Meldung,
die im Netz haengengeblieben ist, ist keine Meldung.
Nachgemessen, beide Richtungen:
Alarm ohne Webhook -> "NICHTS gemeldet", Merker vorhanden: False
derselbe mit Webhook -> "Gemeldet (HTTP 204)", Merker gesetzt: True
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
14 KiB
PowerShell
289 lines
14 KiB
PowerShell
# 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 { '' }
|
|
|
|
# DER MERKER WIRD ERST GESETZT, WENN DIE MELDUNG WIRKLICH RAUSGING.
|
|
#
|
|
# Vorher stand er ganz oben und wurde bei jedem Zustandswechsel geschrieben --
|
|
# auch wenn gar kein Webhook konfiguriert war oder das Senden fehlschlug. Beim
|
|
# ersten echten Test ist genau das passiert: ein Lauf OHNE -Webhook hat den
|
|
# Alarm als "gemeldet" abgehakt, und der naechste Lauf MIT Webhook waere still
|
|
# geblieben.
|
|
#
|
|
# Ein Merker bedeutet "dieser Zustand wurde berichtet". Wenn nichts berichtet
|
|
# wurde, darf er nicht gesetzt werden -- sonst verschluckt genau der Fehlerfall
|
|
# die Meldung, fuer die das Ganze da ist.
|
|
|
|
if (-not $jetzt) {
|
|
# Alles in Ordnung: Entwarnung braucht keine Nachricht, aber der Merker
|
|
# muss weg, damit derselbe Zustand spaeter wieder gemeldet werden kann.
|
|
if ($zuletzt) { Set-Content $merker '' -Encoding UTF8; Gut 'Wieder in Ordnung.' }
|
|
} elseif ($jetzt -eq $zuletzt) {
|
|
Warn 'Schon gemeldet -- bleibt still, bis sich etwas aendert.'
|
|
} elseif (-not $Webhook) {
|
|
Write-Host ''
|
|
Warn 'Kein -Webhook angegeben -- es wurde NICHTS gemeldet.'
|
|
Warn 'Der Merker bleibt deshalb leer: beim naechsten Lauf mit Webhook'
|
|
Warn 'geht die Meldung dann wirklich raus.'
|
|
} else {
|
|
$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)."
|
|
Set-Content $merker $jetzt -Encoding UTF8
|
|
} else {
|
|
# NICHT merken: beim naechsten Lauf noch einmal versuchen. Eine
|
|
# Meldung, die im Netz haengengeblieben ist, ist keine Meldung.
|
|
Schlecht "Melden fehlgeschlagen: HTTP $code -- wird beim naechsten Lauf erneut versucht."
|
|
}
|
|
}
|