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