[CmdletBinding()] param( [string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres', [string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\postgres-backup-key.dpapi.json", [string]$GpgPath = 'gpg.exe', [string]$SshHost = 'docker', [switch]$VerifyRemoteRestore ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Get-RecoveryPassphrase([string]$Path) { $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json if ($record.version -ne 1 -or [string]::IsNullOrWhiteSpace($record.protected_value)) { throw 'Invalid DPAPI recovery key record' } $secure = ConvertTo-SecureString -String $record.protected_value $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } $manifestFile = Get-ChildItem -LiteralPath $NasBackupRoot -Filter '*.json' -File | Sort-Object BaseName | Select-Object -Last 1 if (-not $manifestFile -or $manifestFile.BaseName -notmatch '^\d{8}T\d{6}Z$') { throw 'No valid NAS backup manifest found' } $manifest = Get-Content -LiteralPath $manifestFile.FullName -Raw | ConvertFrom-Json $latest = $manifestFile.BaseName $cipherPath = Join-Path $NasBackupRoot "$latest.tar.gz.gpg" if ((Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPath).Hash.ToLowerInvariant() -ne $manifest.ciphertext_sha256) { throw 'NAS ciphertext hash mismatch' } $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) $work = Join-Path $tempRoot ("domovoy-backup-test-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $work | Out-Null $passphrase = $null $remoteCheck = $null try { & icacls.exe $work /inheritance:r /grant:r "$([Security.Principal.WindowsIdentity]::GetCurrent().Name):(OI)(CI)F" | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Temporary ACL hardening failed' } $passphrase = Get-RecoveryPassphrase $KeyRecordPath $passphraseFile = Join-Path $work 'passphrase.txt' [IO.File]::WriteAllText($passphraseFile, $passphrase, [Text.UTF8Encoding]::new($false)) $archive = Join-Path $work "$latest.tar.gz" & $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --decrypt --output $archive $cipherPath if ($LASTEXITCODE -ne 0) { throw 'NAS backup decryption failed' } if ((Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() -ne $manifest.archive_sha256) { throw 'Decrypted archive hash mismatch' } & tar.exe -xzf $archive -C $work if ($LASTEXITCODE -ne 0) { throw 'Decrypted archive extraction failed' } $sourceDirectory = Join-Path $work $latest Push-Location $sourceDirectory try { foreach ($line in Get-Content -LiteralPath 'SHA256SUMS') { if ($line -notmatch '^([0-9a-f]{64}) ([A-Za-z0-9.]+)$') { throw 'Invalid decrypted SHA256SUMS' } if ((Get-FileHash -Algorithm SHA256 -LiteralPath $matches[2]).Hash.ToLowerInvariant() -ne $matches[1]) { throw "Decrypted file hash mismatch: $($matches[2])" } } } finally { Pop-Location } if ($VerifyRemoteRestore) { $remoteCheck = "/home/m/assistant-backups/offhost-restore-check/$latest-$([guid]::NewGuid().ToString('N'))" if ($remoteCheck -notmatch '^/home/m/assistant-backups/offhost-restore-check/\d{8}T\d{6}Z-[0-9a-f]{32}$') { throw 'Unsafe remote restore path' } & ssh.exe $SshHost "mkdir -p '$remoteCheck'" if ($LASTEXITCODE -ne 0) { throw 'Remote restore-check directory creation failed' } & scp.exe -q -r "$sourceDirectory\*" "${SshHost}:${remoteCheck}/" if ($LASTEXITCODE -ne 0) { throw 'Remote restore-check copy failed' } & ssh.exe $SshHost "/home/m/assistant-ops/verify-postgres-restore.sh '$remoteCheck'" if ($LASTEXITCODE -ne 0) { throw 'Remote PostgreSQL restore check failed' } } Write-Output "Off-host backup verified: $latest" } finally { if ($remoteCheck -and $remoteCheck -match '^/home/m/assistant-backups/offhost-restore-check/\d{8}T\d{6}Z-[0-9a-f]{32}$') { & ssh.exe $SshHost "rm -rf -- '$remoteCheck'" | Out-Null } $passphrase = $null if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } }