Files
SDR-Rover/tools/install_rf001_rx_autostart.ps1
2026-09-30 11:42:19 +03:00

995 lines
39 KiB
PowerShell

$ErrorActionPreference = "Stop"
$taskName = "SDR Rover RX Agent"
$sourceRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
$installRoot = Join-Path $env:LOCALAPPDATA "SDR_Rover_RX_Agent"
$versionsRoot = Join-Path $installRoot "versions"
$launcherRoot = Join-Path $installRoot "launcher"
$sharedRoot = Join-Path $installRoot "shared"
$logsRoot = Join-Path $installRoot "logs"
$repository = Join-Path $env:USERPROFILE "Documents\Codex\SDR_Rover_Video"
$python = Join-Path $repository ".venv\Scripts\pythonw.exe"
$hackrfRoot = Join-Path $env:USERPROFILE "Documents\Codex\sdr-tools\envs\sdr\Library\bin"
$workRoot = Join-Path $env:USERPROFILE "Documents\SDR_Rover_Runtime"
$manifestPath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.json"
$signaturePath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.hmac"
$legacyToken = Join-Path $installRoot "app\.local\rf001_http_token.txt"
$sharedToken = Join-Path $sharedRoot "rf001_http_token.txt"
$expectedReferenceHash = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
function Get-LowerSha256([string]$Path) {
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
}
finally {
$sha256.Dispose()
$stream.Dispose()
}
}
function Test-FixedTimeHex([string]$Left, [string]$Right) {
if ($Left.Length -ne $Right.Length) {
return $false
}
$difference = 0
for ($index = 0; $index -lt $Left.Length; $index++) {
$difference = $difference -bor ([int][char]$Left[$index] -bxor [int][char]$Right[$index])
}
return $difference -eq 0
}
function Assert-ExactReleasePath(
[string]$InstallRoot,
[string]$VersionsRoot,
[string]$ReleaseId,
[string]$TargetRelease
) {
$expectedVersions = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions"))
$actualVersions = [IO.Path]::GetFullPath($VersionsRoot)
if (-not $expectedVersions.Equals($actualVersions, [StringComparison]::OrdinalIgnoreCase)) {
throw "Release versions root is outside the fixed install root"
}
$expectedTarget = [IO.Path]::GetFullPath((Join-Path $actualVersions $ReleaseId))
$actualTarget = [IO.Path]::GetFullPath($TargetRelease)
if (-not $expectedTarget.Equals($actualTarget, [StringComparison]::OrdinalIgnoreCase)) {
throw "Release target path is not the installer-defined canonical path"
}
}
function Get-ReleaseDirectoryState(
[string]$TargetRelease,
[object[]]$ReleaseRows,
[string]$ReleasePrefix
) {
if (-not (Test-Path -LiteralPath $TargetRelease)) {
return [pscustomobject]@{
state = "TARGET_ABSENT"
mismatches = @()
conflicting_files = @()
}
}
if (-not (Test-Path -LiteralPath $TargetRelease -PathType Container)) {
$item = Get-Item -LiteralPath $TargetRelease -Force
$digest = $null
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
try {
$digest = Get-LowerSha256 $item.FullName
}
catch {
$digest = $null
}
}
return [pscustomobject]@{
state = "TARGET_CONFLICT"
mismatches = @([ordered]@{
kind = "TARGET_NOT_DIRECTORY"
path = "."
expected_bytes = $null
actual_bytes = [int64]$item.Length
expected_sha256 = $null
actual_sha256 = $digest
})
conflicting_files = @([ordered]@{
path = "."
bytes = [int64]$item.Length
sha256 = $digest
reparse_point = (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
})
}
}
$expected = @{}
$normalizedRows = @($ReleaseRows | ForEach-Object { $_ })
foreach ($row in $normalizedRows) {
$pathValues = @($row.path)
$byteValues = @($row.bytes)
$hashValues = @($row.sha256)
if ($pathValues.Count -ne 1 -or $byteValues.Count -ne 1 -or $hashValues.Count -ne 1) {
throw "Release manifest row fields are absent or ambiguous"
}
$manifestPath = [string]$pathValues[0]
if (-not $manifestPath.StartsWith($ReleasePrefix, [StringComparison]::Ordinal)) {
throw "Release manifest row is outside the expected prefix: $manifestPath"
}
$relative = $manifestPath.Substring($ReleasePrefix.Length)
if (
[string]::IsNullOrWhiteSpace($relative) -or
$relative -match '(^/|\\|(^|/)\.\.(/|$)|:)'
) {
throw "Unsafe path inside release manifest: $relative"
}
$key = $relative.ToLowerInvariant()
if ($expected.ContainsKey($key)) {
throw "Duplicate path inside release manifest: $relative"
}
$expected[$key] = [pscustomobject]@{
path = $relative
bytes = [int64]($byteValues[0])
sha256 = ([string]$hashValues[0]).ToLowerInvariant()
}
}
if ($expected.Count -eq 0) {
throw "Release manifest does not contain any files"
}
$targetPrefix = [IO.Path]::GetFullPath($TargetRelease).TrimEnd('\') + '\'
$actual = @{}
$mismatches = @()
$conflictingFiles = @()
$entries = @(Get-ChildItem -LiteralPath $TargetRelease -Recurse -Force)
foreach ($entry in $entries) {
$relative = $entry.FullName.Substring($targetPrefix.Length).Replace('\', '/')
$isReparsePoint = ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0
if ($entry.PSIsContainer) {
if ($isReparsePoint) {
$mismatches += [ordered]@{
kind = "REPARSE_POINT"
path = $relative
expected_bytes = $null
actual_bytes = $null
expected_sha256 = $null
actual_sha256 = $null
}
}
continue
}
$digest = $null
$hashError = $null
if (-not $isReparsePoint) {
try {
$digest = Get-LowerSha256 $entry.FullName
}
catch {
$hashError = $_.Exception.Message
}
}
$metadata = [pscustomobject]@{
path = $relative
bytes = [int64]$entry.Length
sha256 = $digest
reparse_point = $isReparsePoint
hash_error = $hashError
}
$conflictingFiles += $metadata
$key = $relative.ToLowerInvariant()
if ($actual.ContainsKey($key)) {
$mismatches += [ordered]@{
kind = "DUPLICATE_PATH"
path = $relative
expected_bytes = $null
actual_bytes = [int64]$entry.Length
expected_sha256 = $null
actual_sha256 = $digest
}
continue
}
$actual[$key] = $metadata
if ($isReparsePoint) {
$mismatches += [ordered]@{
kind = "REPARSE_POINT"
path = $relative
expected_bytes = $null
actual_bytes = [int64]$entry.Length
expected_sha256 = $null
actual_sha256 = $null
}
}
elseif ($null -ne $hashError) {
$mismatches += [ordered]@{
kind = "HASH_UNAVAILABLE"
path = $relative
expected_bytes = $null
actual_bytes = [int64]$entry.Length
expected_sha256 = $null
actual_sha256 = $null
error = $hashError
}
}
}
foreach ($key in $expected.Keys) {
$wanted = $expected[$key]
if (-not $actual.ContainsKey($key)) {
$mismatches += [ordered]@{
kind = "MISSING"
path = $wanted.path
expected_bytes = $wanted.bytes
actual_bytes = $null
expected_sha256 = $wanted.sha256
actual_sha256 = $null
}
continue
}
$found = $actual[$key]
if ($found.reparse_point -or $null -ne $found.hash_error) {
continue
}
if ($found.bytes -ne $wanted.bytes) {
$mismatches += [ordered]@{
kind = "SIZE_MISMATCH"
path = $wanted.path
expected_bytes = $wanted.bytes
actual_bytes = $found.bytes
expected_sha256 = $wanted.sha256
actual_sha256 = $found.sha256
}
}
if ($found.sha256 -ne $wanted.sha256) {
$mismatches += [ordered]@{
kind = "SHA256_MISMATCH"
path = $wanted.path
expected_bytes = $wanted.bytes
actual_bytes = $found.bytes
expected_sha256 = $wanted.sha256
actual_sha256 = $found.sha256
}
}
}
foreach ($key in $actual.Keys) {
if (-not $expected.ContainsKey($key)) {
$found = $actual[$key]
$mismatches += [ordered]@{
kind = "UNEXPECTED"
path = $found.path
expected_bytes = $null
actual_bytes = $found.bytes
expected_sha256 = $null
actual_sha256 = $found.sha256
}
}
}
$state = if ($mismatches.Count -eq 0) { "TARGET_VERIFIED" } else { "TARGET_CONFLICT" }
return [pscustomobject]@{
state = $state
mismatches = @($mismatches)
conflicting_files = @($conflictingFiles)
}
}
function Assert-ReleaseMetadata(
[string]$ReleaseRoot,
[string]$ExpectedBootstrapId,
[string]$ExpectedReleaseId,
[string]$ExpectedAgentVersion,
[int]$ExpectedProtocolVersion
) {
$readyPath = Join-Path $ReleaseRoot "READY.json"
if (-not (Test-Path -LiteralPath $readyPath -PathType Leaf)) {
throw "Release READY metadata is absent: $readyPath"
}
$ready = Get-Content -LiteralPath $readyPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (
$ready.schema_version -ne 1 -or
$ready.status -ne "READY" -or
$ready.bootstrap_id -ne $ExpectedBootstrapId -or
$ready.release_id -ne $ExpectedReleaseId -or
$ready.agent_version -ne $ExpectedAgentVersion -or
$ready.protocol_version -ne $ExpectedProtocolVersion -or
$ready.hardware_accessed -ne $false -or
$ready.rf_tx -ne 0
) {
throw "Release READY metadata does not match the signed bootstrap manifest"
}
}
function New-UniqueQuarantineDestination(
[string]$InstallRoot,
[string]$ReleaseId,
[string]$Category
) {
if ($Category -notin @("conflict", "failed_staging", "failed_release")) {
throw "Unsupported quarantine category"
}
$quarantineRoot = Join-Path $InstallRoot "quarantine"
New-Item -ItemType Directory -Force -Path $quarantineRoot | Out-Null
for ($attempt = 0; $attempt -lt 16; $attempt++) {
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'")
$unique = [Guid]::NewGuid().ToString("N")
$name = "{0}_{1}_{2}_{3}" -f $ReleaseId, $Category, $stamp, $unique
$candidate = Join-Path $quarantineRoot $name
if (
-not (Test-Path -LiteralPath $candidate) -and
-not (Test-Path -LiteralPath ($candidate + ".diagnostic.json"))
) {
return $candidate
}
}
throw "Could not allocate a unique quarantine path"
}
function Write-CreateNewUtf8Json([string]$Path, [object]$Value) {
$encoding = New-Object Text.UTF8Encoding($false)
$bytes = $encoding.GetBytes((($Value | ConvertTo-Json -Depth 10) + "`n"))
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
}
finally {
$stream.Dispose()
}
}
function Move-ConflictingTargetToQuarantine(
[string]$InstallRoot,
[string]$VersionsRoot,
[string]$TargetRelease,
[string]$ReleaseId,
[string]$BootstrapId,
[object]$State
) {
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
if ($State.state -ne "TARGET_CONFLICT") {
throw "Only TARGET_CONFLICT may be moved by the conflict quarantine operation"
}
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId "conflict"
$diagnosticPath = $destination + ".diagnostic.json"
$diagnostic = [ordered]@{
schema_version = 1
timestamp_utc = [DateTime]::UtcNow.ToString("o")
source_path = [IO.Path]::GetFullPath($TargetRelease)
quarantine_path = [IO.Path]::GetFullPath($destination)
bootstrap_id = $BootstrapId
expected_release_id = $ReleaseId
mismatches = @($State.mismatches)
conflicting_files = @($State.conflicting_files)
}
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
Move-Item -LiteralPath $TargetRelease -Destination $destination
return [pscustomobject]@{
path = $destination
diagnostic_path = $diagnosticPath
}
}
function Move-FailedReleasePathToQuarantine(
[string]$InstallRoot,
[string]$Path,
[string]$ReleaseId,
[string]$BootstrapId,
[string]$Category,
[string]$Cause
) {
$versionsRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions")).TrimEnd('\') + '\'
$fullPath = [IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($versionsRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Failed release path is outside the fixed versions root"
}
$leaf = Split-Path -Leaf $fullPath
$validCanonical = $leaf.Equals($ReleaseId, [StringComparison]::OrdinalIgnoreCase)
$validStaging = $leaf.StartsWith(".$ReleaseId.staging_", [StringComparison]::OrdinalIgnoreCase)
if (
($Category -eq "failed_release" -and -not $validCanonical) -or
($Category -eq "failed_staging" -and -not $validStaging)
) {
throw "Failed release path is not an installer-defined release path"
}
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId $Category
$diagnosticPath = $destination + ".diagnostic.json"
$diagnostic = [ordered]@{
schema_version = 1
timestamp_utc = [DateTime]::UtcNow.ToString("o")
source_path = $fullPath
quarantine_path = [IO.Path]::GetFullPath($destination)
bootstrap_id = $BootstrapId
expected_release_id = $ReleaseId
category = $Category
cause = $Cause
}
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
Move-Item -LiteralPath $fullPath -Destination $destination
return $destination
}
function Install-CleanRelease(
[string]$InstallRoot,
[string]$VersionsRoot,
[string]$SourceRelease,
[string]$TargetRelease,
[string]$ReleaseId,
[string]$BootstrapId,
[string]$ReleaseBootstrapId,
[string]$AgentVersion,
[int]$ProtocolVersion,
[object[]]$ReleaseRows,
[string]$ReleasePrefix
) {
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
$ReleaseBootstrapId = $BootstrapId
}
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
if (Test-Path -LiteralPath $TargetRelease) {
throw "Canonical release path is not absent before clean deployment"
}
$staging = Join-Path $VersionsRoot (".{0}.staging_{1}" -f $ReleaseId, [Guid]::NewGuid().ToString("N"))
$canonicalCreated = $false
try {
New-Item -ItemType Directory -Path $staging | Out-Null
Copy-Item -LiteralPath (Join-Path $SourceRelease "app") -Destination $staging -Recurse
Copy-Item -LiteralPath (Join-Path $SourceRelease "READY.json") -Destination $staging
$stagingState = Get-ReleaseDirectoryState `
-TargetRelease $staging `
-ReleaseRows $ReleaseRows `
-ReleasePrefix $ReleasePrefix
if ($stagingState.state -ne "TARGET_VERIFIED") {
throw "Staged release failed exact file verification: $($stagingState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
}
Assert-ReleaseMetadata `
-ReleaseRoot $staging `
-ExpectedBootstrapId $ReleaseBootstrapId `
-ExpectedReleaseId $ReleaseId `
-ExpectedAgentVersion $AgentVersion `
-ExpectedProtocolVersion $ProtocolVersion
Move-Item -LiteralPath $staging -Destination $TargetRelease
$canonicalCreated = $true
$targetState = Get-ReleaseDirectoryState `
-TargetRelease $TargetRelease `
-ReleaseRows $ReleaseRows `
-ReleasePrefix $ReleasePrefix
if ($targetState.state -ne "TARGET_VERIFIED") {
throw "Canonical release failed exact file verification: $($targetState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
}
Assert-ReleaseMetadata `
-ReleaseRoot $TargetRelease `
-ExpectedBootstrapId $ReleaseBootstrapId `
-ExpectedReleaseId $ReleaseId `
-ExpectedAgentVersion $AgentVersion `
-ExpectedProtocolVersion $ProtocolVersion
return $targetState
}
catch {
$failure = $_.Exception.Message
$quarantineErrors = @()
if ($canonicalCreated -and (Test-Path -LiteralPath $TargetRelease)) {
try {
Move-FailedReleasePathToQuarantine $InstallRoot $TargetRelease $ReleaseId $BootstrapId "failed_release" $failure | Out-Null
}
catch {
$quarantineErrors += $_.Exception.Message
}
}
if (Test-Path -LiteralPath $staging) {
try {
Move-FailedReleasePathToQuarantine $InstallRoot $staging $ReleaseId $BootstrapId "failed_staging" $failure | Out-Null
}
catch {
$quarantineErrors += $_.Exception.Message
}
}
if ($quarantineErrors.Count -ne 0) {
throw "Clean release deployment failed: $failure; failed-path quarantine errors: $($quarantineErrors -join '; ')"
}
throw "Clean release deployment failed: $failure"
}
}
function Prepare-TargetRelease(
[string]$InstallRoot,
[string]$VersionsRoot,
[string]$SourceRelease,
[string]$TargetRelease,
[string]$ReleaseId,
[string]$BootstrapId,
[string]$ReleaseBootstrapId,
[string]$AgentVersion,
[int]$ProtocolVersion,
[object[]]$ReleaseRows,
[string]$ReleasePrefix
) {
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
$ReleaseBootstrapId = $BootstrapId
}
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
$sourceState = Get-ReleaseDirectoryState `
-TargetRelease $SourceRelease `
-ReleaseRows $ReleaseRows `
-ReleasePrefix $ReleasePrefix
if ($sourceState.state -ne "TARGET_VERIFIED") {
throw "Signed bootstrap release content is not exact: $($sourceState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
}
Assert-ReleaseMetadata `
-ReleaseRoot $SourceRelease `
-ExpectedBootstrapId $ReleaseBootstrapId `
-ExpectedReleaseId $ReleaseId `
-ExpectedAgentVersion $AgentVersion `
-ExpectedProtocolVersion $ProtocolVersion
$initial = Get-ReleaseDirectoryState `
-TargetRelease $TargetRelease `
-ReleaseRows $ReleaseRows `
-ReleasePrefix $ReleasePrefix
$quarantine = $null
if ($initial.state -eq "TARGET_CONFLICT") {
$quarantine = Move-ConflictingTargetToQuarantine `
-InstallRoot $InstallRoot `
-VersionsRoot $VersionsRoot `
-TargetRelease $TargetRelease `
-ReleaseId $ReleaseId `
-BootstrapId $BootstrapId `
-State $initial
}
if ($initial.state -ne "TARGET_VERIFIED") {
$verified = Install-CleanRelease `
-InstallRoot $InstallRoot `
-VersionsRoot $VersionsRoot `
-SourceRelease $SourceRelease `
-TargetRelease $TargetRelease `
-ReleaseId $ReleaseId `
-BootstrapId $BootstrapId `
-ReleaseBootstrapId $ReleaseBootstrapId `
-AgentVersion $AgentVersion `
-ProtocolVersion $ProtocolVersion `
-ReleaseRows $ReleaseRows `
-ReleasePrefix $ReleasePrefix
}
else {
Assert-ReleaseMetadata `
-ReleaseRoot $TargetRelease `
-ExpectedBootstrapId $ReleaseBootstrapId `
-ExpectedReleaseId $ReleaseId `
-ExpectedAgentVersion $AgentVersion `
-ExpectedProtocolVersion $ProtocolVersion
$verified = $initial
}
return [pscustomobject]@{
initial_state = $initial.state
final_state = $verified.state
reused = ($initial.state -eq "TARGET_VERIFIED")
quarantine_path = if ($null -eq $quarantine) { $null } else { $quarantine.path }
quarantine_diagnostic = if ($null -eq $quarantine) { $null } else { $quarantine.diagnostic_path }
}
}
function Invoke-SignedAgentSelfTest(
[string]$Python,
[string]$AgentScript,
[string]$ExpectedAgentVersion,
[string]$StandardOutputPath,
[string]$StandardErrorPath
) {
$process = Start-Process `
-FilePath $Python `
-ArgumentList @("-B", "-X", "utf8", $AgentScript, "--self-test") `
-RedirectStandardOutput $StandardOutputPath `
-RedirectStandardError $StandardErrorPath `
-Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0) {
throw "Agent self-test exited with code $($process.ExitCode)"
}
$output = if (Test-Path -LiteralPath $StandardOutputPath) {
Get-Content -LiteralPath $StandardOutputPath -Raw -Encoding UTF8
}
else {
""
}
if ($output -notmatch ("(?m)^AGENT VERSION = {0}\s*$" -f [regex]::Escape($ExpectedAgentVersion))) {
throw "Agent self-test did not confirm signed AGENT_VERSION"
}
if ($output -notmatch '(?m)^SELF TEST = PASS\s*$') {
throw "Agent self-test did not report PASS"
}
return $output
}
foreach ($required in @(
$manifestPath,
$signaturePath,
$python,
(Join-Path $repository ".git"),
(Join-Path $hackrfRoot "hackrf_info.exe"),
(Join-Path $hackrfRoot "hackrf_clock.exe"),
(Join-Path $hackrfRoot "hackrf_transfer.exe")
)) {
if (-not (Test-Path -LiteralPath $required)) {
throw "Required path is absent: $required"
}
}
if ((Test-Path -LiteralPath $sharedToken) -and (Test-Path -LiteralPath $legacyToken)) {
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $legacyToken)) {
throw "Installed signing-token copies differ; refusing to choose one"
}
}
if (Test-Path -LiteralPath $sharedToken) {
$tokenSource = $sharedToken
}
elseif (Test-Path -LiteralPath $legacyToken) {
$tokenSource = $legacyToken
}
else {
throw "The existing HTTP signing token is absent; it is intentionally not in this package"
}
$manifestBytes = [IO.File]::ReadAllBytes($manifestPath)
$tokenText = [IO.File]::ReadAllText($tokenSource, [Text.Encoding]::UTF8).Trim()
$tokenBytes = [Text.Encoding]::UTF8.GetBytes($tokenText)
if ($tokenBytes.Length -lt 32) {
throw "The existing HTTP signing token is invalid"
}
$domainBytes = [Text.Encoding]::UTF8.GetBytes("RF001-UPDATE-MANIFEST-V1`n")
$signedBytes = New-Object byte[] ($domainBytes.Length + $manifestBytes.Length)
[Buffer]::BlockCopy($domainBytes, 0, $signedBytes, 0, $domainBytes.Length)
[Buffer]::BlockCopy($manifestBytes, 0, $signedBytes, $domainBytes.Length, $manifestBytes.Length)
$hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (, $tokenBytes)
try {
$expectedSignature = ([BitConverter]::ToString($hmac.ComputeHash($signedBytes))).Replace("-", "").ToLowerInvariant()
}
finally {
$hmac.Dispose()
}
$suppliedSignature = [IO.File]::ReadAllText($signaturePath, [Text.Encoding]::ASCII).Trim().ToLowerInvariant()
if (-not (Test-FixedTimeHex $expectedSignature $suppliedSignature)) {
throw "Bootstrap manifest signature mismatch"
}
$manifest = [Text.Encoding]::UTF8.GetString($manifestBytes) | ConvertFrom-Json
if ($manifest.schema_version -ne 1 -or $manifest.package_kind -ne "BOOTSTRAP") {
throw "Unsupported bootstrap manifest"
}
if ($manifest.protocol_version -ne 1) {
throw "Unsupported agent protocol version"
}
if ($manifest.main_server -ne "http://192.168.255.185:8765") {
throw "Bootstrap MAIN address differs from the approved address"
}
if ($manifest.allowed_rx_ipv4 -ne "192.168.255.56") {
throw "Bootstrap RX address differs from the approved address"
}
if ($manifest.reference_sha256 -ne $expectedReferenceHash) {
throw "Bootstrap reference identity is invalid"
}
$releaseId = [string]$manifest.agent_release_id
$agentVersion = [string]$manifest.agent_version
$bootstrapId = [string]$manifest.bootstrap_id
$releaseBootstrapId = [string]$manifest.agent_release_bootstrap_id
if ([string]::IsNullOrWhiteSpace($releaseBootstrapId)) {
$releaseBootstrapId = $bootstrapId
}
if ($bootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
throw "Bootstrap ID is invalid"
}
if ($releaseBootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
throw "Agent release bootstrap ID is invalid"
}
if ($releaseId -notmatch '^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
throw "Bootstrap release_id is invalid"
}
if ($agentVersion -notmatch '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
throw "Bootstrap agent version is invalid"
}
if ($releaseId -ne "rf001-agent-$agentVersion") {
throw "Bootstrap release_id does not match agent_version"
}
$releasePrefix = "versions/$releaseId/"
$expectedPaths = @(
"INSTALL_RX_AGENT.cmd",
"README_RX_AGENT.txt",
"UNINSTALL_RX_AGENT.cmd",
"launcher/rf001_agent_launcher.py",
"reference/lab065_reference_staircase.cs8",
"tools/install_rf001_rx_autostart.ps1",
"tools/uninstall_rf001_rx_autostart.ps1",
"${releasePrefix}READY.json",
"${releasePrefix}app/tools/hackrf_process_check.py",
"${releasePrefix}app/tools/rf001_agent_update.py",
"${releasePrefix}app/tools/rf001_capture_runner.py",
"${releasePrefix}app/tools/rf001_http_control.py",
"${releasePrefix}app/tools/rf001_offline_decode.py",
"${releasePrefix}app/tools/rf001_rx_capture.py",
"${releasePrefix}app/tools/rf001_rx_http_agent.py",
"${releasePrefix}app/tools/rf001_staircase_job.py",
"${releasePrefix}app/tools/rf001_staircase_search.py"
)
$expectedSet = @{}
foreach ($path in $expectedPaths) {
$expectedSet[$path.ToLowerInvariant()] = $true
}
$manifestSet = @{}
$sourcePrefix = $sourceRoot.TrimEnd('\') + '\'
foreach ($row in @($manifest.files)) {
$relative = [string]$row.path
if ($relative -match '(^/|\\|(^|/)\.\.(/|$)|:)' -or [string]::IsNullOrWhiteSpace($relative)) {
throw "Unsafe bootstrap path: $relative"
}
$key = $relative.ToLowerInvariant()
if ($manifestSet.ContainsKey($key)) {
throw "Duplicate bootstrap path: $relative"
}
if (-not $expectedSet.ContainsKey($key)) {
throw "Bootstrap file is outside the fixed allowlist: $relative"
}
$manifestSet[$key] = $true
$source = [IO.Path]::GetFullPath((Join-Path $sourceRoot ($relative.Replace('/', '\'))))
if (-not $source.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Bootstrap path escaped source root: $relative"
}
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Bootstrap file is absent: $relative"
}
$item = Get-Item -LiteralPath $source
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Bootstrap reparse points are prohibited: $relative"
}
if ($item.Length -ne [int64]$row.bytes) {
throw "Bootstrap file size mismatch: $relative"
}
if ((Get-LowerSha256 $source) -ne ([string]$row.sha256).ToLowerInvariant()) {
throw "Bootstrap file SHA256 mismatch: $relative"
}
}
if ($manifestSet.Count -ne $expectedSet.Count) {
throw "Bootstrap manifest does not contain the complete fixed allowlist"
}
$actualFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | ForEach-Object {
$_.FullName.Substring($sourcePrefix.Length).Replace('\', '/')
}
$allowedPackageFiles = @{}
foreach ($path in $expectedPaths + @("BOOTSTRAP_MANIFEST.json", "BOOTSTRAP_MANIFEST.hmac")) {
$allowedPackageFiles[$path.ToLowerInvariant()] = $true
}
foreach ($relative in $actualFiles) {
if (-not $allowedPackageFiles.ContainsKey($relative.ToLowerInvariant())) {
throw "Unexpected file in the bootstrap package: $relative"
}
}
if ($actualFiles.Count -ne $allowedPackageFiles.Count) {
throw "Bootstrap package file set is incomplete or ambiguous"
}
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($null -eq $existingTask) {
throw "The existing scheduled task '$taskName' is absent"
}
$wasRunning = $existingTask.State -eq "Running"
$backupRoot = Join-Path $installRoot (
"backups\bootstrap_{0}_{1}" -f
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'"),
[Guid]::NewGuid().ToString("N").Substring(0, 12)
)
New-Item -ItemType Directory -Path $backupRoot | Out-Null
$taskXmlPath = Join-Path $backupRoot "scheduled_task.xml"
[IO.File]::WriteAllText($taskXmlPath, (Export-ScheduledTask -TaskName $taskName), [Text.Encoding]::Unicode)
$backupInventory = @()
foreach ($name in @("app", "launcher", "shared", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
$current = Join-Path $installRoot $name
$existed = Test-Path -LiteralPath $current
$backupInventory += [ordered]@{
name = $name
existed = $existed
container = ($existed -and (Test-Path -LiteralPath $current -PathType Container))
}
if ($existed) {
Copy-Item -LiteralPath $current -Destination (Join-Path $backupRoot $name) -Recurse -Force
}
}
Write-CreateNewUtf8Json (Join-Path $backupRoot "install_state_inventory.json") $backupInventory
New-Item -ItemType Directory -Force -Path $versionsRoot, $launcherRoot, $sharedRoot, $logsRoot | Out-Null
if (-not (Test-Path -LiteralPath $sharedToken)) {
Copy-Item -LiteralPath $tokenSource -Destination $sharedToken
}
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $tokenSource)) {
throw "The preserved signing token differs after copying"
}
$sourceRelease = Join-Path $sourceRoot ($releasePrefix.Replace('/', '\').TrimEnd('\'))
$targetRelease = Join-Path $versionsRoot $releaseId
$sourceAgentScript = Join-Path $sourceRelease "app\tools\rf001_rx_http_agent.py"
Invoke-SignedAgentSelfTest `
-Python $python `
-AgentScript $sourceAgentScript `
-ExpectedAgentVersion $agentVersion `
-StandardOutputPath (Join-Path $backupRoot "source_agent_self_test.stdout.txt") `
-StandardErrorPath (Join-Path $backupRoot "source_agent_self_test.stderr.txt") | Out-Null
$releaseRows = @($manifest.files | Where-Object { ([string]$_.path).StartsWith($releasePrefix) })
$targetPreparation = Prepare-TargetRelease `
-InstallRoot $installRoot `
-VersionsRoot $versionsRoot `
-SourceRelease $sourceRelease `
-TargetRelease $targetRelease `
-ReleaseId $releaseId `
-BootstrapId $bootstrapId `
-ReleaseBootstrapId $releaseBootstrapId `
-AgentVersion $agentVersion `
-ProtocolVersion ([int]$manifest.protocol_version) `
-ReleaseRows $releaseRows `
-ReleasePrefix $releasePrefix
Copy-Item -LiteralPath (Join-Path $sourceRoot "launcher\rf001_agent_launcher.py") -Destination $launcherRoot -Force
Copy-Item -LiteralPath (Join-Path $sourceRoot "tools\uninstall_rf001_rx_autostart.ps1") -Destination $installRoot -Force
Copy-Item -LiteralPath (Join-Path $sourceRoot "UNINSTALL_RX_AGENT.cmd") -Destination $installRoot -Force
$referenceRoot = Join-Path $workRoot "references"
$referenceDestination = Join-Path $referenceRoot "lab065_reference_staircase.cs8"
New-Item -ItemType Directory -Force -Path $referenceRoot | Out-Null
if (Test-Path -LiteralPath $referenceDestination) {
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
throw "The existing staircase reference differs; refusing to overwrite it"
}
}
else {
Copy-Item -LiteralPath (Join-Path $sourceRoot "reference\lab065_reference_staircase.cs8") -Destination $referenceDestination
}
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
throw "Installed staircase reference SHA256 is invalid"
}
$configPath = Join-Path $installRoot "agent_config.json"
$activeReleasePath = Join-Path $installRoot "active_release.json"
$launcherStatusPath = Join-Path $installRoot "launcher_status.json"
$launcherRequestPath = Join-Path $installRoot "launcher_request.json"
$healthPath = Join-Path $installRoot "agent_health.json"
$agentLogPath = Join-Path $logsRoot "agent.log"
$launcherLogPath = Join-Path $logsRoot "launcher.log"
$agentArguments = @(
"--server", "http://192.168.255.185:8765",
"--token-file", $sharedToken,
"--agent-id", "user-pc",
"--repository", $repository,
"--hackrf-info", (Join-Path $hackrfRoot "hackrf_info.exe"),
"--hackrf-clock", (Join-Path $hackrfRoot "hackrf_clock.exe"),
"--hackrf-transfer", (Join-Path $hackrfRoot "hackrf_transfer.exe"),
"--work-root", $workRoot,
"--poll-interval", "5",
"--quiet-idle"
)
$config = [ordered]@{
schema_version = 1
install_root = $installRoot
releases_root = $versionsRoot
active_release_file = $activeReleasePath
launcher_status = $launcherStatusPath
launcher_request = $launcherRequestPath
health_file = $healthPath
agent_log = $agentLogPath
startup_timeout_seconds = 30
restart_delay_seconds = 2
agent_arguments = $agentArguments
hardware_accessed = $false
rf_tx = 0
}
[IO.File]::WriteAllText($configPath, (($config | ConvertTo-Json -Depth 5 -Compress) + "`n"), [Text.Encoding]::UTF8)
$activeRelease = [ordered]@{
schema_version = 1
release_id = $releaseId
updated_at = [DateTime]::UtcNow.ToString("o")
}
[IO.File]::WriteAllText($activeReleasePath, (($activeRelease | ConvertTo-Json -Compress) + "`n"), [Text.Encoding]::UTF8)
$agentScript = Join-Path $targetRelease "app\tools\rf001_rx_http_agent.py"
$selfTestOutput = Join-Path $backupRoot "new_agent_self_test.stdout.txt"
$selfTestError = Join-Path $backupRoot "new_agent_self_test.stderr.txt"
Invoke-SignedAgentSelfTest `
-Python $python `
-AgentScript $agentScript `
-ExpectedAgentVersion $agentVersion `
-StandardOutputPath $selfTestOutput `
-StandardErrorPath $selfTestError | Out-Null
$launcherScript = Join-Path $launcherRoot "rf001_agent_launcher.py"
$launcherArguments = @(
"-u",
"-B",
"-X", "utf8",
('"{0}"' -f $launcherScript),
"--config", ('"{0}"' -f $configPath),
"--log-file", ('"{0}"' -f $launcherLogPath)
) -join " "
$action = New-ScheduledTaskAction -Execute $python -Argument $launcherArguments -WorkingDirectory $launcherRoot
$healthBefore = if (Test-Path -LiteralPath $healthPath) { (Get-Item -LiteralPath $healthPath).LastWriteTimeUtc } else { [DateTime]::MinValue }
$installStartedAt = [DateTime]::UtcNow
try {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Set-ScheduledTask -TaskName $taskName -Action $action | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = [DateTime]::UtcNow.AddSeconds(35)
$confirmed = $false
do {
Start-Sleep -Milliseconds 500
$currentTask = Get-ScheduledTask -TaskName $taskName
if ((Test-Path -LiteralPath $healthPath) -and (Test-Path -LiteralPath $launcherStatusPath)) {
$healthItem = Get-Item -LiteralPath $healthPath
$health = Get-Content -LiteralPath $healthPath -Raw -Encoding UTF8 | ConvertFrom-Json
$launcherStatus = Get-Content -LiteralPath $launcherStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (
$currentTask.State -eq "Running" -and
$healthItem.LastWriteTimeUtc -gt $healthBefore -and
$healthItem.LastWriteTimeUtc -ge $installStartedAt.AddSeconds(-2) -and
$health.state -eq "RUNNING" -and
$health.agent_version -eq $agentVersion -and
$health.release_id -eq $releaseId -and
$health.rf_tx -eq 0 -and
$launcherStatus.state -eq "RUNNING" -and
$launcherStatus.active_release_id -eq $releaseId -and
$launcherStatus.active_agent_version -eq $agentVersion -and
$launcherStatus.rf_tx -eq 0
) {
$confirmed = $true
}
}
} while (-not $confirmed -and [DateTime]::UtcNow -lt $deadline)
if (-not $confirmed) {
$currentInfo = Get-ScheduledTaskInfo -TaskName $taskName
throw "Versioned agent did not report healthy startup; LastTaskResult=$($currentInfo.LastTaskResult)"
}
}
catch {
$failure = $_.Exception.Message
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
$xml = [IO.File]::ReadAllText($taskXmlPath, [Text.Encoding]::Unicode)
Register-ScheduledTask -TaskName $taskName -Xml $xml -Force | Out-Null
foreach ($name in @("launcher", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
$inventoryRow = @($backupInventory | Where-Object { $_.name -eq $name })
if ($inventoryRow.Count -ne 1) {
throw "Bootstrap failed and rollback inventory is invalid. Original cause: $failure"
}
$current = Join-Path $installRoot $name
$saved = Join-Path $backupRoot $name
if ($inventoryRow[0].existed) {
if ($inventoryRow[0].container) {
if (-not (Test-Path -LiteralPath $current -PathType Container)) {
New-Item -ItemType Directory -Path $current -Force | Out-Null
}
Copy-Item -Path (Join-Path $saved '*') -Destination $current -Recurse -Force
}
else {
Copy-Item -LiteralPath $saved -Destination $current -Force
}
}
}
if ($wasRunning) {
Start-ScheduledTask -TaskName $taskName
}
throw "Bootstrap failed; previous scheduled task restored. Cause: $failure"
}
$taskAfter = Get-ScheduledTask -TaskName $taskName
$taskInfoAfter = Get-ScheduledTaskInfo -TaskName $taskName
"BOOTSTRAP_INSTALL=COMPLETE"
"INSTALL_ROOT=$installRoot"
"BACKUP_ROOT=$backupRoot"
"AUTOSTART_TASK=$taskName"
"AUTOSTART_ACCOUNT=$($taskAfter.Principal.UserId)"
"AUTOSTART_RUN_LEVEL=$($taskAfter.Principal.RunLevel)"
"TASK_STATE=$($taskAfter.State)"
"LAST_TASK_RESULT=$($taskInfoAfter.LastTaskResult)"
"BOOTSTRAP_ID=$bootstrapId"
"AGENT_RELEASE_ID=$releaseId"
"AGENT_VERSION=$agentVersion"
"TARGET_INITIAL_STATE=$($targetPreparation.initial_state)"
"TARGET_FINAL_STATE=$($targetPreparation.final_state)"
"TARGET_REUSED=$($targetPreparation.reused)"
"CONFLICT_QUARANTINE_PATH=$($targetPreparation.quarantine_path)"
"CONFLICT_DIAGNOSTIC_PATH=$($targetPreparation.quarantine_diagnostic)"
"CLEAN_RELEASE_HASH_VERIFICATION=PASS"
"REFERENCE_SHA256=$expectedReferenceHash"
"MANIFEST_AUTHENTICATION=HMAC-SHA256"
"ARBITRARY_SHELL=DISABLED"
"HARDWARE_ACCESSED=false"
"RF_TX=0"