Files
Assistant/deploy/windows/Test-PostgresBackupFromNas.ps1
m 0a188ddafe
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 11s
ops: add encrypted off-host PostgreSQL backups
2026-08-16 18:13:03 +03:00

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 }
}