Files
Assistant/deploy/windows/Sync-PostgresBackupToNas.ps1

116 lines
6.7 KiB
PowerShell

[CmdletBinding()]
param(
[string]$SshHost = 'docker',
[string]$RemoteBackupRoot = '/home/m/assistant-backups/postgres',
[string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres',
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\postgres-backup-key.dpapi.json",
[string]$GpgPath = 'gpg.exe'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Assert-SourceHashes([string]$Directory) {
$allowed = @('assistant.dump', 'globals.sql', 'database.name', 'owner.name')
$expected = @{}
foreach ($line in Get-Content -LiteralPath (Join-Path $Directory 'SHA256SUMS')) {
if ($line -notmatch '^([0-9a-f]{64}) ([A-Za-z0-9.]+)$' -or $matches[2] -notin $allowed) {
throw "Invalid SHA256SUMS entry"
}
$expected[$matches[2]] = $matches[1]
}
foreach ($name in $allowed) {
if (-not $expected.ContainsKey($name)) { throw "Missing SHA256SUMS entry: $name" }
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $Directory $name)).Hash.ToLowerInvariant()
if ($actual -ne $expected[$name]) { throw "Source hash mismatch: $name" }
}
}
function Get-RecoveryPassphrase([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "DPAPI recovery key is missing" }
$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) }
}
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$work = Join-Path $tempRoot ("domovoy-backup-sync-" + [guid]::NewGuid().ToString('N'))
if (-not ([IO.Path]::GetFullPath($work)).StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe temporary path' }
New-Item -ItemType Directory -Path $work | Out-Null
$passphrase = $null
$cipherPartial = $null
$manifestPartial = $null
try {
$latest = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort | tail -n1") -join '').Trim()
if ($LASTEXITCODE -ne 0 -or $latest -notmatch '^\d{8}T\d{6}Z$') { throw 'No valid completed production backup found' }
New-Item -ItemType Directory -Force -Path $NasBackupRoot | Out-Null
$cipherPath = Join-Path $NasBackupRoot "$latest.tar.gz.gpg"
$manifestPath = Join-Path $NasBackupRoot "$latest.json"
if ((Test-Path -LiteralPath $cipherPath) -xor (Test-Path -LiteralPath $manifestPath)) {
# Neither lone file is a completed backup. Remove only this exact incomplete pair before retry.
Remove-Item -LiteralPath $cipherPath, $manifestPath -Force -ErrorAction SilentlyContinue
}
if ((Test-Path -LiteralPath $cipherPath) -or (Test-Path -LiteralPath $manifestPath)) {
if (-not ((Test-Path -LiteralPath $cipherPath -PathType Leaf) -and (Test-Path -LiteralPath $manifestPath -PathType Leaf))) { throw 'Incomplete existing NAS artifact' }
$existing = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$actualCipherHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPath).Hash.ToLowerInvariant()
if ($existing.source_backup -ne $latest -or $existing.ciphertext_sha256 -ne $actualCipherHash) { throw 'Existing NAS artifact does not match its manifest' }
Write-Output "Off-host backup already current: $latest"
return
}
& scp.exe -q -r "${SshHost}:${RemoteBackupRoot}/${latest}" $work
if ($LASTEXITCODE -ne 0) { throw 'SCP backup copy failed' }
$sourceDirectory = Join-Path $work $latest
Assert-SourceHashes $sourceDirectory
$archive = Join-Path $work "$latest.tar.gz"
& tar.exe -czf $archive -C $work $latest
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw 'Archive creation failed' }
$archiveHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
$passphrase = Get-RecoveryPassphrase $KeyRecordPath
$passphraseFile = Join-Path $work 'passphrase.txt'
[IO.File]::WriteAllText($passphraseFile, $passphrase, [Text.UTF8Encoding]::new($false))
& 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' }
$cipherPartial = Join-Path $NasBackupRoot (".$latest.$PID.gpg.partial")
$manifestPartial = Join-Path $NasBackupRoot (".$latest.$PID.json.partial")
& $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --symmetric --cipher-algo AES256 --s2k-digest-algo SHA512 --s2k-cipher-algo AES256 --s2k-count 65011712 --compress-algo none --output $cipherPartial $archive
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $cipherPartial -PathType Leaf)) { throw 'GnuPG encryption failed' }
$roundTrip = Join-Path $work 'roundtrip.tar.gz'
& $GpgPath --batch --yes --pinentry-mode loopback --passphrase-file $passphraseFile --decrypt --output $roundTrip $cipherPartial
if ($LASTEXITCODE -ne 0) { throw 'GnuPG round-trip decryption failed' }
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $roundTrip).Hash.ToLowerInvariant() -ne $archiveHash) { throw 'Encrypted round-trip archive hash mismatch' }
$manifest = [ordered]@{
version = 1
project = 'domovoy'
source_host = 'docker.cin.su'
source_backup = $latest
source_sha256sums_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $sourceDirectory 'SHA256SUMS')).Hash.ToLowerInvariant()
archive_sha256 = $archiveHash
ciphertext_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $cipherPartial).Hash.ToLowerInvariant()
encryption = 'GnuPG symmetric AES256, SHA512 S2K'
created_at_utc = [DateTime]::UtcNow.ToString('o')
}
[IO.File]::WriteAllText($manifestPartial, ($manifest | ConvertTo-Json) + "`n", [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $cipherPartial -Destination $cipherPath
Move-Item -LiteralPath $manifestPartial -Destination $manifestPath
Write-Output "Off-host backup created: $latest"
}
finally {
$passphrase = $null
foreach ($partial in @($cipherPartial, $manifestPartial)) {
if ($partial -and (Test-Path -LiteralPath $partial)) { Remove-Item -LiteralPath $partial -Force }
}
if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force }
}