76 lines
4.2 KiB
PowerShell
76 lines
4.2 KiB
PowerShell
[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 }
|
|
}
|