Harden RF node control and agent updates
This commit is contained in:
69
tools/install_rf001_main_autostart.ps1
Normal file
69
tools/install_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,69 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover HTTP Control Server"
|
||||
$repository = Split-Path -Parent $PSScriptRoot
|
||||
$pythonCommand = Get-Command python.exe -ErrorAction Stop
|
||||
$python = $pythonCommand.Source
|
||||
$serverScript = Join-Path $repository "tools\rf001_http_control.py"
|
||||
$tokenFile = Join-Path $repository ".local\rf001_http_token.txt"
|
||||
$stateFile = Join-Path $repository ".local\rf001_http_server_state.json"
|
||||
$logFile = Join-Path $repository ".local\logs\rf001_http_service.log"
|
||||
|
||||
foreach ($required in @($python, $serverScript, $tokenFile)) {
|
||||
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||
throw "Required file is absent: $required"
|
||||
}
|
||||
}
|
||||
|
||||
$arguments = @(
|
||||
"-u",
|
||||
"-B",
|
||||
('"{0}"' -f $serverScript),
|
||||
"serve",
|
||||
"--bind", "192.168.255.185",
|
||||
"--port", "8765",
|
||||
"--allowed-agent-ip", "192.168.255.56",
|
||||
"--state-file", ('"{0}"' -f $stateFile),
|
||||
"--token-file", ('"{0}"' -f $tokenFile),
|
||||
"--log-file", ('"{0}"' -f $logFile),
|
||||
"--bind-retry-seconds", "300"
|
||||
) -join " "
|
||||
|
||||
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $repository
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $account
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-RestartCount 10 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $existing) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $taskName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Settings $settings `
|
||||
-Description "Authenticated RF-001 control server. No network RF TX operation." `
|
||||
-Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Start-Sleep -Seconds 2
|
||||
$task = Get-ScheduledTask -TaskName $taskName
|
||||
$taskInfo = Get-ScheduledTaskInfo -TaskName $taskName
|
||||
|
||||
"AUTOSTART_TASK=$taskName"
|
||||
"AUTOSTART_ACCOUNT=$account"
|
||||
"AUTOSTART_MODE=USER_LOGON"
|
||||
"TASK_STATE=$($task.State)"
|
||||
"LAST_TASK_RESULT=$($taskInfo.LastTaskResult)"
|
||||
"RF_TX=0"
|
||||
994
tools/install_rf001_rx_autostart.ps1
Normal file
994
tools/install_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,994 @@
|
||||
$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"
|
||||
1009
tools/rf001_agent_launcher.py
Normal file
1009
tools/rf001_agent_launcher.py
Normal file
File diff suppressed because it is too large
Load Diff
422
tools/rf001_agent_update.py
Normal file
422
tools/rf001_agent_update.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Fixed release packaging and verification for the RF-001 RX agent.
|
||||
|
||||
The module deliberately has no command execution, SDR access, or generic
|
||||
archive extraction. A release contains one exact allowlist of Python files
|
||||
and a content manifest. Approval metadata is authenticated separately with
|
||||
the already provisioned HTTP-channel HMAC secret.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
import zipfile
|
||||
|
||||
|
||||
UPDATE_MANIFEST_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||
CONTENT_MANIFEST_NAME = "RELEASE_CONTENT.json"
|
||||
RELEASE_ID_PREFIX = "rf001-agent-"
|
||||
RELEASE_ID_PATTERN = re.compile(r"^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||
AGENT_VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
MAX_PACKAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
|
||||
MAX_ARCHIVE_ENTRIES = 32
|
||||
MAX_CONTENT_MANIFEST_BYTES = 64 * 1024
|
||||
|
||||
RELEASE_SOURCE_FILES: dict[str, str] = {
|
||||
"app/tools/hackrf_process_check.py": "tools/hackrf_process_check.py",
|
||||
"app/tools/rf001_agent_update.py": "tools/rf001_agent_update.py",
|
||||
"app/tools/rf001_capture_runner.py": "tools/rf001_capture_runner.py",
|
||||
"app/tools/rf001_http_control.py": "tools/rf001_http_control.py",
|
||||
"app/tools/rf001_offline_decode.py": "tools/rf001_offline_decode.py",
|
||||
"app/tools/rf001_rx_capture.py": "tools/rf001_rx_capture.py",
|
||||
"app/tools/rf001_rx_http_agent.py": "tools/rf001_rx_http_agent.py",
|
||||
"app/tools/rf001_staircase_job.py": "tools/rf001_staircase_job.py",
|
||||
"app/tools/rf001_staircase_search.py": "tools/rf001_staircase_search.py",
|
||||
}
|
||||
REQUIRED_RELEASE_FILES = frozenset(RELEASE_SOURCE_FILES)
|
||||
|
||||
|
||||
class ReleaseValidationError(ValueError):
|
||||
"""A release is malformed, unauthenticated, or outside its allowlist."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInspection:
|
||||
release_id: str
|
||||
agent_version: str
|
||||
protocol_version: int
|
||||
package_sha256: str
|
||||
package_bytes: int
|
||||
content_manifest_sha256: str
|
||||
uncompressed_bytes: int
|
||||
file_count: int
|
||||
|
||||
def descriptor(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"release_id": self.release_id,
|
||||
"agent_version": self.agent_version,
|
||||
"protocol_version": self.protocol_version,
|
||||
"package_sha256": self.package_sha256,
|
||||
"package_bytes": self.package_bytes,
|
||||
"content_manifest_sha256": self.content_manifest_sha256,
|
||||
"uncompressed_bytes": self.uncompressed_bytes,
|
||||
"file_count": self.file_count,
|
||||
}
|
||||
|
||||
|
||||
def source_agent_version(agent_source: Path) -> str:
|
||||
"""Read the single literal AGENT_VERSION from an agent source file."""
|
||||
try:
|
||||
syntax = ast.parse(agent_source.read_text(encoding="utf-8"), agent_source.name)
|
||||
except (OSError, SyntaxError, UnicodeError) as error:
|
||||
raise ReleaseValidationError("agent source cannot be parsed") from error
|
||||
values: list[ast.expr] = []
|
||||
for statement in syntax.body:
|
||||
if isinstance(statement, ast.Assign):
|
||||
if any(
|
||||
isinstance(target, ast.Name) and target.id == "AGENT_VERSION"
|
||||
for target in statement.targets
|
||||
):
|
||||
if not (
|
||||
len(statement.targets) == 1
|
||||
and isinstance(statement.targets[0], ast.Name)
|
||||
and statement.targets[0].id == "AGENT_VERSION"
|
||||
):
|
||||
raise ReleaseValidationError(
|
||||
"agent source AGENT_VERSION assignment is ambiguous"
|
||||
)
|
||||
values.append(statement.value)
|
||||
elif (
|
||||
isinstance(statement, ast.AnnAssign)
|
||||
and isinstance(statement.target, ast.Name)
|
||||
and statement.target.id == "AGENT_VERSION"
|
||||
):
|
||||
if statement.value is None:
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION has no value")
|
||||
values.append(statement.value)
|
||||
if len(values) != 1:
|
||||
raise ReleaseValidationError(
|
||||
"agent source must contain one AGENT_VERSION assignment"
|
||||
)
|
||||
value = values[0]
|
||||
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION must be a literal string")
|
||||
if AGENT_VERSION_PATTERN.fullmatch(value.value) is None:
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION is invalid")
|
||||
return value.value
|
||||
|
||||
|
||||
def validate_release_identity(
|
||||
source_root: Path,
|
||||
*,
|
||||
release_id: str,
|
||||
requested_agent_version: str,
|
||||
) -> str:
|
||||
"""Require release id, requested version, and packaged source to agree."""
|
||||
if RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||
raise ReleaseValidationError("release_id is invalid")
|
||||
if AGENT_VERSION_PATTERN.fullmatch(requested_agent_version) is None:
|
||||
raise ReleaseValidationError("agent_version is invalid")
|
||||
if release_id != f"{RELEASE_ID_PREFIX}{requested_agent_version}":
|
||||
raise ReleaseValidationError("release_id does not match agent_version")
|
||||
agent_source = (
|
||||
source_root / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||
).resolve()
|
||||
actual_agent_version = source_agent_version(agent_source)
|
||||
if actual_agent_version != requested_agent_version:
|
||||
raise ReleaseValidationError(
|
||||
"source AGENT_VERSION does not match requested agent_version"
|
||||
)
|
||||
return actual_agent_version
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(4 * 1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sign_update_manifest(token: bytes, manifest: Mapping[str, Any]) -> str:
|
||||
return hmac.new(
|
||||
token,
|
||||
UPDATE_MANIFEST_DOMAIN + canonical_json_bytes(manifest),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_update_manifest_signature(
|
||||
token: bytes,
|
||||
manifest: Mapping[str, Any],
|
||||
signature: str,
|
||||
) -> None:
|
||||
if SHA256_PATTERN.fullmatch(signature) is None:
|
||||
raise ReleaseValidationError("update manifest signature is invalid")
|
||||
expected = sign_update_manifest(token, manifest)
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise ReleaseValidationError("update manifest signature mismatch")
|
||||
|
||||
|
||||
def _validate_member_name(name: str) -> PurePosixPath:
|
||||
if not name or "\\" in name or name.startswith("/"):
|
||||
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
||||
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||
if any(":" in part for part in path.parts):
|
||||
raise ReleaseValidationError(f"drive-like archive path: {name!r}")
|
||||
return path
|
||||
|
||||
|
||||
def _is_link(info: zipfile.ZipInfo) -> bool:
|
||||
unix_mode = (info.external_attr >> 16) & 0xFFFF
|
||||
return stat.S_IFMT(unix_mode) == stat.S_IFLNK
|
||||
|
||||
|
||||
def _load_content_manifest(
|
||||
archive: zipfile.ZipFile,
|
||||
info: zipfile.ZipInfo,
|
||||
) -> tuple[dict[str, Any], bytes]:
|
||||
if info.file_size > MAX_CONTENT_MANIFEST_BYTES:
|
||||
raise ReleaseValidationError("release content manifest is too large")
|
||||
raw = archive.read(info)
|
||||
try:
|
||||
decoded = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ReleaseValidationError("release content manifest is invalid") from error
|
||||
if not isinstance(decoded, dict):
|
||||
raise ReleaseValidationError("release content manifest must be an object")
|
||||
if raw != canonical_json_bytes(decoded):
|
||||
raise ReleaseValidationError("release content manifest is not canonical JSON")
|
||||
return decoded, raw
|
||||
|
||||
|
||||
def _validate_content_manifest(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
if manifest.get("schema_version") != 1:
|
||||
raise ReleaseValidationError("unsupported release content schema")
|
||||
release_id = manifest.get("release_id")
|
||||
agent_version = manifest.get("agent_version")
|
||||
protocol_version = manifest.get("protocol_version")
|
||||
if not isinstance(release_id, str) or RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||
raise ReleaseValidationError("release_id is invalid")
|
||||
if not isinstance(agent_version, str) or AGENT_VERSION_PATTERN.fullmatch(agent_version) is None:
|
||||
raise ReleaseValidationError("agent_version is invalid")
|
||||
if type(protocol_version) is not int or protocol_version <= 0:
|
||||
raise ReleaseValidationError("protocol_version is invalid")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list):
|
||||
raise ReleaseValidationError("release files list is invalid")
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ReleaseValidationError("release file entry is invalid")
|
||||
name = row.get("path")
|
||||
digest = row.get("sha256")
|
||||
size = row.get("bytes")
|
||||
if not isinstance(name, str):
|
||||
raise ReleaseValidationError("release file path is invalid")
|
||||
_validate_member_name(name)
|
||||
key = name.casefold()
|
||||
if key in (existing.casefold() for existing in files):
|
||||
raise ReleaseValidationError("duplicate release file path")
|
||||
if SHA256_PATTERN.fullmatch(str(digest)) is None:
|
||||
raise ReleaseValidationError(f"release file hash is invalid: {name}")
|
||||
if type(size) is not int or size < 0:
|
||||
raise ReleaseValidationError(f"release file size is invalid: {name}")
|
||||
files[name] = {"path": name, "sha256": digest, "bytes": size}
|
||||
if frozenset(files) != REQUIRED_RELEASE_FILES:
|
||||
unexpected = sorted(set(files) - REQUIRED_RELEASE_FILES)
|
||||
missing = sorted(REQUIRED_RELEASE_FILES - set(files))
|
||||
raise ReleaseValidationError(
|
||||
f"release file allowlist mismatch; unexpected={unexpected}; missing={missing}"
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def inspect_release_archive(
|
||||
package_path: Path,
|
||||
*,
|
||||
expected_package_sha256: str | None = None,
|
||||
expected_package_bytes: int | None = None,
|
||||
expected_content_manifest_sha256: str | None = None,
|
||||
extraction_root: Path | None = None,
|
||||
) -> ReleaseInspection:
|
||||
package_path = package_path.resolve()
|
||||
package_bytes = package_path.stat().st_size
|
||||
if package_bytes <= 0 or package_bytes > MAX_PACKAGE_BYTES:
|
||||
raise ReleaseValidationError("release package size is outside the limit")
|
||||
if expected_package_bytes is not None and package_bytes != expected_package_bytes:
|
||||
raise ReleaseValidationError("release package size mismatch")
|
||||
package_sha256 = sha256_file(package_path)
|
||||
if expected_package_sha256 is not None and package_sha256 != expected_package_sha256:
|
||||
raise ReleaseValidationError("release package SHA256 mismatch")
|
||||
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
entries = archive.infolist()
|
||||
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
|
||||
raise ReleaseValidationError("release archive entry count is invalid")
|
||||
seen: set[str] = set()
|
||||
regular: dict[str, zipfile.ZipInfo] = {}
|
||||
uncompressed_bytes = 0
|
||||
for info in entries:
|
||||
path = _validate_member_name(info.filename)
|
||||
normalized = path.as_posix()
|
||||
key = normalized.casefold()
|
||||
if key in seen:
|
||||
raise ReleaseValidationError("ambiguous duplicate archive entry")
|
||||
seen.add(key)
|
||||
if info.flag_bits & 0x1:
|
||||
raise ReleaseValidationError("encrypted archive entries are prohibited")
|
||||
if _is_link(info):
|
||||
raise ReleaseValidationError("archive links are prohibited")
|
||||
if info.is_dir():
|
||||
raise ReleaseValidationError("explicit archive directory entries are prohibited")
|
||||
uncompressed_bytes += info.file_size
|
||||
if uncompressed_bytes > MAX_UNCOMPRESSED_BYTES:
|
||||
raise ReleaseValidationError("release uncompressed size exceeds the limit")
|
||||
regular[normalized] = info
|
||||
|
||||
if CONTENT_MANIFEST_NAME not in regular:
|
||||
raise ReleaseValidationError("release content manifest is absent")
|
||||
content_manifest, content_raw = _load_content_manifest(
|
||||
archive,
|
||||
regular[CONTENT_MANIFEST_NAME],
|
||||
)
|
||||
content_sha256 = hashlib.sha256(content_raw).hexdigest()
|
||||
if (
|
||||
expected_content_manifest_sha256 is not None
|
||||
and content_sha256 != expected_content_manifest_sha256
|
||||
):
|
||||
raise ReleaseValidationError("release content manifest SHA256 mismatch")
|
||||
files = _validate_content_manifest(content_manifest)
|
||||
expected_names = set(REQUIRED_RELEASE_FILES) | {CONTENT_MANIFEST_NAME}
|
||||
if set(regular) != expected_names:
|
||||
raise ReleaseValidationError("release archive contents are not allowlisted")
|
||||
for name, metadata in files.items():
|
||||
info = regular[name]
|
||||
if info.file_size != metadata["bytes"]:
|
||||
raise ReleaseValidationError(f"release file size mismatch: {name}")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info, "r") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
if digest.hexdigest() != metadata["sha256"]:
|
||||
raise ReleaseValidationError(f"release file SHA256 mismatch: {name}")
|
||||
|
||||
if extraction_root is not None:
|
||||
extraction_root = extraction_root.resolve()
|
||||
extraction_root.mkdir(parents=True, exist_ok=False)
|
||||
for name in sorted(REQUIRED_RELEASE_FILES):
|
||||
target = extraction_root.joinpath(*PurePosixPath(name).parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved = target.resolve()
|
||||
try:
|
||||
resolved.relative_to(extraction_root)
|
||||
except ValueError as error:
|
||||
raise ReleaseValidationError("archive path escaped extraction root") from error
|
||||
with archive.open(regular[name], "r") as source:
|
||||
with target.open("xb") as destination:
|
||||
shutil.copyfileobj(source, destination, length=1024 * 1024)
|
||||
manifest_target = extraction_root / CONTENT_MANIFEST_NAME
|
||||
with manifest_target.open("xb") as stream:
|
||||
stream.write(content_raw)
|
||||
|
||||
return ReleaseInspection(
|
||||
release_id=str(content_manifest["release_id"]),
|
||||
agent_version=str(content_manifest["agent_version"]),
|
||||
protocol_version=int(content_manifest["protocol_version"]),
|
||||
package_sha256=package_sha256,
|
||||
package_bytes=package_bytes,
|
||||
content_manifest_sha256=content_sha256,
|
||||
uncompressed_bytes=uncompressed_bytes,
|
||||
file_count=len(files),
|
||||
)
|
||||
|
||||
|
||||
def build_release_archive(
|
||||
source_root: Path,
|
||||
package_path: Path,
|
||||
*,
|
||||
release_id: str,
|
||||
agent_version: str,
|
||||
protocol_version: int,
|
||||
) -> ReleaseInspection:
|
||||
if protocol_version <= 0:
|
||||
raise ReleaseValidationError("protocol_version is invalid")
|
||||
source_root = source_root.resolve()
|
||||
validate_release_identity(
|
||||
source_root,
|
||||
release_id=release_id,
|
||||
requested_agent_version=agent_version,
|
||||
)
|
||||
rows = []
|
||||
sources: dict[str, Path] = {}
|
||||
for archive_name, relative_source in RELEASE_SOURCE_FILES.items():
|
||||
source = (source_root / relative_source).resolve()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"release source is absent: {source}")
|
||||
sources[archive_name] = source
|
||||
rows.append(
|
||||
{
|
||||
"path": archive_name,
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256": sha256_file(source),
|
||||
}
|
||||
)
|
||||
content = {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": agent_version,
|
||||
"protocol_version": protocol_version,
|
||||
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||
}
|
||||
content_raw = canonical_json_bytes(content)
|
||||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if package_path.exists():
|
||||
raise FileExistsError(f"release package already exists: {package_path}")
|
||||
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||
with zipfile.ZipFile(
|
||||
package_path,
|
||||
"x",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
) as archive:
|
||||
manifest_info = zipfile.ZipInfo(CONTENT_MANIFEST_NAME, timestamp)
|
||||
manifest_info.compress_type = zipfile.ZIP_DEFLATED
|
||||
manifest_info.external_attr = 0o100600 << 16
|
||||
archive.writestr(manifest_info, content_raw)
|
||||
for name in sorted(sources):
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
archive.writestr(info, sources[name].read_bytes())
|
||||
return inspect_release_archive(package_path)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_bytes(canonical_json_bytes(value) + b"\n")
|
||||
os.replace(temporary, path)
|
||||
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Безопасный запуск уже распакованного bootstrap-пакета RX-агента.
|
||||
|
||||
Утилита принадлежит MAIN и не входит в подписанный bootstrap. Она проверяет
|
||||
HMAC и точный состав распакованного пакета до запуска, сохраняет stdout,
|
||||
stderr и снимки дерева только во внешнем каталоге, а после завершения
|
||||
подтверждает, что пакет не изменился.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||
SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||
INSTALLER_NAME = "INSTALL_RX_AGENT.cmd"
|
||||
SIGNATURE_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class BootstrapInstallError(RuntimeError):
|
||||
"""Базовая ошибка безопасного запуска bootstrap."""
|
||||
|
||||
|
||||
class PackageValidationError(BootstrapInstallError):
|
||||
"""Пакет не прошёл HMAC, manifest или точную проверку состава."""
|
||||
|
||||
|
||||
class EvidencePathError(BootstrapInstallError):
|
||||
"""Каталог доказательств небезопасен или уже содержит журналы запуска."""
|
||||
|
||||
|
||||
class PackageMutationError(BootstrapInstallError):
|
||||
"""Дерево bootstrap изменилось во время запуска установщика."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TreeEntry:
|
||||
path: str
|
||||
bytes: int
|
||||
sha256: str
|
||||
modified_time_ns: int
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def snapshot_tree(root: Path) -> list[TreeEntry]:
|
||||
entries: list[TreeEntry] = []
|
||||
for path in sorted(
|
||||
(candidate for candidate in root.rglob("*") if candidate.is_file()),
|
||||
key=lambda candidate: candidate.as_posix().casefold(),
|
||||
):
|
||||
stat = path.stat()
|
||||
entries.append(
|
||||
TreeEntry(
|
||||
path=path.relative_to(root).as_posix(),
|
||||
bytes=stat.st_size,
|
||||
sha256=sha256_file(path),
|
||||
modified_time_ns=stat.st_mtime_ns,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _content_identity(entries: Sequence[TreeEntry]) -> list[tuple[str, int, str]]:
|
||||
return [(entry.path, entry.bytes, entry.sha256) for entry in entries]
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
path.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _relative_manifest_path(value: object) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise PackageValidationError("bootstrap manifest contains an empty path")
|
||||
if "\\" in value or ":" in value:
|
||||
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or ".." in relative.parts or "." in relative.parts:
|
||||
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||
return relative.as_posix()
|
||||
|
||||
|
||||
def validate_pristine_package(package_root: Path, token_file: Path) -> dict[str, Any]:
|
||||
package_root = package_root.resolve()
|
||||
token_file = token_file.resolve()
|
||||
if not package_root.is_dir():
|
||||
raise PackageValidationError("bootstrap package directory is absent")
|
||||
manifest_path = package_root / MANIFEST_NAME
|
||||
signature_path = package_root / SIGNATURE_NAME
|
||||
installer_path = package_root / INSTALLER_NAME
|
||||
for required in (manifest_path, signature_path, installer_path, token_file):
|
||||
if not required.is_file():
|
||||
raise PackageValidationError(f"required file is absent: {required}")
|
||||
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
token = token_file.read_text(encoding="utf-8").strip().encode("utf-8")
|
||||
if len(token) < 32:
|
||||
raise PackageValidationError("installed signing token is invalid")
|
||||
supplied_signature = signature_path.read_text(encoding="ascii").strip().lower()
|
||||
expected_signature = hmac.new(
|
||||
token,
|
||||
SIGNATURE_DOMAIN + manifest_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not SHA256_PATTERN.fullmatch(supplied_signature) or not hmac.compare_digest(
|
||||
supplied_signature,
|
||||
expected_signature,
|
||||
):
|
||||
raise PackageValidationError("bootstrap manifest signature mismatch")
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise PackageValidationError("bootstrap manifest is invalid JSON") from error
|
||||
if not isinstance(manifest, dict):
|
||||
raise PackageValidationError("bootstrap manifest root must be an object")
|
||||
if manifest.get("schema_version") != 1 or manifest.get("package_kind") != "BOOTSTRAP":
|
||||
raise PackageValidationError("unsupported bootstrap manifest")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise PackageValidationError("bootstrap manifest file list is invalid")
|
||||
|
||||
expected: dict[str, tuple[int, str]] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise PackageValidationError("bootstrap manifest file row is invalid")
|
||||
relative = _relative_manifest_path(row.get("path"))
|
||||
folded = relative.casefold()
|
||||
if folded in expected:
|
||||
raise PackageValidationError(f"duplicate bootstrap path: {relative}")
|
||||
byte_count = row.get("bytes")
|
||||
digest = row.get("sha256")
|
||||
if type(byte_count) is not int or byte_count < 0:
|
||||
raise PackageValidationError(f"invalid bootstrap size: {relative}")
|
||||
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest.lower()):
|
||||
raise PackageValidationError(f"invalid bootstrap SHA256: {relative}")
|
||||
expected[folded] = (byte_count, digest.lower())
|
||||
|
||||
source = package_root.joinpath(*PurePosixPath(relative).parts)
|
||||
if not source.is_file() or source.is_symlink():
|
||||
raise PackageValidationError(f"bootstrap file is absent or unsafe: {relative}")
|
||||
if source.stat().st_size != byte_count:
|
||||
raise PackageValidationError(f"bootstrap file size mismatch: {relative}")
|
||||
if sha256_file(source) != digest.lower():
|
||||
raise PackageValidationError(f"bootstrap file SHA256 mismatch: {relative}")
|
||||
|
||||
actual_entries = snapshot_tree(package_root)
|
||||
actual = {entry.path.casefold() for entry in actual_entries}
|
||||
allowed = set(expected) | {MANIFEST_NAME.casefold(), SIGNATURE_NAME.casefold()}
|
||||
unexpected = sorted(actual - allowed)
|
||||
missing = sorted(allowed - actual)
|
||||
if unexpected or missing:
|
||||
raise PackageValidationError(
|
||||
"bootstrap package allowlist mismatch; "
|
||||
f"unexpected={unexpected}; missing={missing}"
|
||||
)
|
||||
return {
|
||||
"bootstrap_id": manifest.get("bootstrap_id"),
|
||||
"agent_release_id": manifest.get("agent_release_id"),
|
||||
"agent_version": manifest.get("agent_version"),
|
||||
"hmac": "PASS",
|
||||
"manifest_file_count": len(expected),
|
||||
"package_file_count": len(actual_entries),
|
||||
"unexpected": 0,
|
||||
"missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def _assert_external_evidence(package_root: Path, evidence_root: Path) -> None:
|
||||
package_root = package_root.resolve()
|
||||
evidence_root = evidence_root.resolve()
|
||||
if evidence_root == package_root or package_root in evidence_root.parents:
|
||||
raise EvidencePathError("evidence directory must be outside the package tree")
|
||||
|
||||
|
||||
def run_installer(
|
||||
package_root: Path,
|
||||
evidence_root: Path,
|
||||
token_file: Path,
|
||||
*,
|
||||
command: Sequence[str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
package_root = package_root.resolve()
|
||||
evidence_root = evidence_root.resolve()
|
||||
_assert_external_evidence(package_root, evidence_root)
|
||||
preflight = validate_pristine_package(package_root, token_file)
|
||||
|
||||
evidence_root.mkdir(parents=True, exist_ok=True)
|
||||
paths = {
|
||||
"stdout": evidence_root / "install.stdout.txt",
|
||||
"stderr": evidence_root / "install.stderr.txt",
|
||||
"runtime": evidence_root / "install_runtime.json",
|
||||
"before": evidence_root / "package_tree_before.json",
|
||||
"immediate": evidence_root / "package_tree_immediate.json",
|
||||
"after": evidence_root / "package_tree_after.json",
|
||||
"preflight": evidence_root / "package_preflight.json",
|
||||
}
|
||||
occupied = [str(path) for path in paths.values() if path.exists()]
|
||||
if occupied:
|
||||
raise EvidencePathError(f"runtime evidence paths already exist: {occupied}")
|
||||
|
||||
before = snapshot_tree(package_root)
|
||||
_write_json(paths["preflight"], preflight)
|
||||
_write_json(paths["before"], [asdict(entry) for entry in before])
|
||||
invocation = list(command) if command is not None else [
|
||||
os.environ.get("COMSPEC", "cmd.exe"),
|
||||
"/d",
|
||||
"/c",
|
||||
"call",
|
||||
str(package_root / INSTALLER_NAME),
|
||||
]
|
||||
metadata: dict[str, Any] = {
|
||||
"started_at_utc": utc_now(),
|
||||
"package_root": str(package_root),
|
||||
"evidence_root": str(evidence_root),
|
||||
"working_directory": str(package_root),
|
||||
"stdout_path": str(paths["stdout"]),
|
||||
"stderr_path": str(paths["stderr"]),
|
||||
"rf_rx": 0,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
with paths["stdout"].open("wb") as stdout_stream, paths["stderr"].open(
|
||||
"wb"
|
||||
) as stderr_stream:
|
||||
process = subprocess.Popen(
|
||||
invocation,
|
||||
cwd=package_root,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_stream,
|
||||
stderr=stderr_stream,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
metadata["installer_process_id"] = process.pid
|
||||
immediate = snapshot_tree(package_root)
|
||||
_write_json(paths["immediate"], [asdict(entry) for entry in immediate])
|
||||
metadata["package_content_unchanged_immediate"] = (
|
||||
_content_identity(immediate) == _content_identity(before)
|
||||
)
|
||||
metadata["package_metadata_unchanged_immediate"] = immediate == before
|
||||
try:
|
||||
exit_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
metadata["timed_out"] = True
|
||||
metadata["completed_at_utc"] = utc_now()
|
||||
_write_json(paths["runtime"], metadata)
|
||||
raise BootstrapInstallError(
|
||||
"installer timeout; the process was not restarted or killed automatically"
|
||||
) from error
|
||||
|
||||
after = snapshot_tree(package_root)
|
||||
_write_json(paths["after"], [asdict(entry) for entry in after])
|
||||
metadata.update(
|
||||
{
|
||||
"completed_at_utc": utc_now(),
|
||||
"exit_code": exit_code,
|
||||
"timed_out": False,
|
||||
"package_content_unchanged_after": (
|
||||
_content_identity(after) == _content_identity(before)
|
||||
),
|
||||
"package_metadata_unchanged_after": after == before,
|
||||
"stdout_bytes": paths["stdout"].stat().st_size,
|
||||
"stderr_bytes": paths["stderr"].stat().st_size,
|
||||
}
|
||||
)
|
||||
_write_json(paths["runtime"], metadata)
|
||||
if not metadata["package_content_unchanged_immediate"] or not metadata[
|
||||
"package_content_unchanged_after"
|
||||
]:
|
||||
raise PackageMutationError("bootstrap package changed during installer run")
|
||||
if exit_code != 0:
|
||||
raise BootstrapInstallError(f"installer exited with code {exit_code}")
|
||||
return metadata
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package-root", required=True, type=Path)
|
||||
parser.add_argument("--evidence-root", required=True, type=Path)
|
||||
parser.add_argument("--token-file", required=True, type=Path)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_args()
|
||||
try:
|
||||
result = run_installer(
|
||||
arguments.package_root,
|
||||
arguments.evidence_root,
|
||||
arguments.token_file,
|
||||
timeout_seconds=arguments.timeout_seconds,
|
||||
)
|
||||
except BootstrapInstallError as error:
|
||||
print(f"BOOTSTRAP INSTALL = FAILED: {error}")
|
||||
print("RF RX = 0")
|
||||
print("RF TX = 0")
|
||||
return 1
|
||||
print("BOOTSTRAP INSTALL = COMPLETE")
|
||||
print(f"INSTALL EXIT CODE = {result['exit_code']}")
|
||||
print(f"EVIDENCE ROOT = {result['evidence_root']}")
|
||||
print("PACKAGE TREE UNCHANGED = YES")
|
||||
print("RF RX = 0")
|
||||
print("RF TX = 0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
16
tools/rf001_bootstrap_readme.txt
Normal file
16
tools/rf001_bootstrap_readme.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
RF-001 RX Agent — первоначальная установка механизма обновления
|
||||
|
||||
1. Распакуйте ZIP целиком в отдельный каталог.
|
||||
2. Запустите INSTALL_RX_AGENT.cmd обычным двойным щелчком.
|
||||
3. Дождитесь строки BOOTSTRAP_INSTALL=COMPLETE и нажмите любую клавишу.
|
||||
|
||||
Установщик использует существующее задание «SDR Rover RX Agent» и уже
|
||||
установленный секрет HTTP-канала. Секрета в этом ZIP нет. Пользователь,
|
||||
уровень прав, сетевые настройки, драйверы и параметры SDR не меняются.
|
||||
|
||||
Установка не запускает захват или передачу. При неудаче запуска новой версии
|
||||
восстанавливается прежнее определение задания Планировщика.
|
||||
|
||||
MAIN: 192.168.255.185:8765
|
||||
Разрешённый адрес RX: 192.168.255.56
|
||||
RF TX: 0
|
||||
355
tools/rf001_build_agent_packages.py
Normal file
355
tools/rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Build authenticated RF-001 agent release and bootstrap packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
from typing import BinaryIO, Mapping
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
from tools.rf001_agent_update import (
|
||||
RELEASE_SOURCE_FILES,
|
||||
UPDATE_MANIFEST_DOMAIN,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
sha256_file,
|
||||
validate_release_identity,
|
||||
)
|
||||
except ModuleNotFoundError: # direct execution from tools/
|
||||
from rf001_agent_update import ( # type: ignore[no-redef]
|
||||
RELEASE_SOURCE_FILES,
|
||||
UPDATE_MANIFEST_DOMAIN,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
sha256_file,
|
||||
validate_release_identity,
|
||||
)
|
||||
|
||||
|
||||
BOOTSTRAP_ID_PATTERN = re.compile(
|
||||
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
|
||||
)
|
||||
BOOTSTRAP_MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||
BOOTSTRAP_SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||
REFERENCE_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||
MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapInspection:
|
||||
bootstrap_id: str
|
||||
agent_release_id: str
|
||||
agent_version: str
|
||||
protocol_version: int
|
||||
package_sha256: str
|
||||
package_bytes: int
|
||||
content_manifest_sha256: str
|
||||
file_count: int
|
||||
|
||||
|
||||
def _token_bytes(path: Path) -> bytes:
|
||||
value = path.read_text(encoding="utf-8-sig").strip().encode("utf-8")
|
||||
if len(value) < 32:
|
||||
raise ValueError("existing signing token is missing or too short")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_archive_name(name: str) -> None:
|
||||
path = PurePosixPath(name)
|
||||
if (
|
||||
not name
|
||||
or "\\" in name
|
||||
or name.startswith("/")
|
||||
or path.is_absolute()
|
||||
or any(part in ("", ".", "..") or ":" in part for part in path.parts)
|
||||
):
|
||||
raise ValueError(f"unsafe bootstrap path: {name!r}")
|
||||
|
||||
|
||||
def _source_files(
|
||||
source_root: Path,
|
||||
*,
|
||||
agent_release_id: str,
|
||||
) -> dict[str, Path]:
|
||||
result = {
|
||||
"INSTALL_RX_AGENT.cmd": source_root / "INSTALL_RX_AGENT.cmd",
|
||||
"README_RX_AGENT.txt": source_root / "tools" / "rf001_bootstrap_readme.txt",
|
||||
"UNINSTALL_RX_AGENT.cmd": source_root / "UNINSTALL_RX_AGENT.cmd",
|
||||
"launcher/rf001_agent_launcher.py": source_root
|
||||
/ "tools"
|
||||
/ "rf001_agent_launcher.py",
|
||||
"reference/lab065_reference_staircase.cs8": source_root
|
||||
/ ".local"
|
||||
/ "rf001_probe"
|
||||
/ "lab065_reference_staircase.cs8",
|
||||
"tools/install_rf001_rx_autostart.ps1": source_root
|
||||
/ "tools"
|
||||
/ "install_rf001_rx_autostart.ps1",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1": source_root
|
||||
/ "tools"
|
||||
/ "uninstall_rf001_rx_autostart.ps1",
|
||||
}
|
||||
for archive_name, relative in RELEASE_SOURCE_FILES.items():
|
||||
result[f"versions/{agent_release_id}/{archive_name}"] = source_root / relative
|
||||
return result
|
||||
|
||||
|
||||
def _copy_to_zip(
|
||||
archive: zipfile.ZipFile,
|
||||
name: str,
|
||||
source: Path,
|
||||
*,
|
||||
timestamp: tuple[int, int, int, int, int, int],
|
||||
) -> None:
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
with source.open("rb") as input_stream, archive.open(info, "w") as output_stream:
|
||||
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
|
||||
|
||||
|
||||
def _write_bytes(
|
||||
archive: zipfile.ZipFile,
|
||||
name: str,
|
||||
value: bytes,
|
||||
*,
|
||||
timestamp: tuple[int, int, int, int, int, int],
|
||||
) -> None:
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
archive.writestr(info, value)
|
||||
|
||||
|
||||
def build_bootstrap_archive(
|
||||
source_root: Path,
|
||||
package_path: Path,
|
||||
*,
|
||||
bootstrap_id: str,
|
||||
agent_release_id: str,
|
||||
agent_version: str,
|
||||
protocol_version: int,
|
||||
token_path: Path,
|
||||
agent_release_bootstrap_id: str | None = None,
|
||||
) -> BootstrapInspection:
|
||||
if BOOTSTRAP_ID_PATTERN.fullmatch(bootstrap_id) is None:
|
||||
raise ValueError("bootstrap_id is invalid")
|
||||
if protocol_version <= 0:
|
||||
raise ValueError("protocol_version is invalid")
|
||||
release_bootstrap_id = agent_release_bootstrap_id or bootstrap_id
|
||||
if BOOTSTRAP_ID_PATTERN.fullmatch(release_bootstrap_id) is None:
|
||||
raise ValueError("agent_release_bootstrap_id is invalid")
|
||||
source_root = source_root.resolve()
|
||||
validate_release_identity(
|
||||
source_root,
|
||||
release_id=agent_release_id,
|
||||
requested_agent_version=agent_version,
|
||||
)
|
||||
sources = _source_files(source_root, agent_release_id=agent_release_id)
|
||||
for name, source in sources.items():
|
||||
_validate_archive_name(name)
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"bootstrap source is absent: {source}")
|
||||
reference = sources["reference/lab065_reference_staircase.cs8"]
|
||||
if sha256_file(reference) != REFERENCE_SHA256:
|
||||
raise ValueError("staircase reference SHA256 is invalid")
|
||||
|
||||
ready = canonical_json_bytes(
|
||||
{
|
||||
"agent_version": agent_version,
|
||||
"bootstrap_id": release_bootstrap_id,
|
||||
"hardware_accessed": False,
|
||||
"protocol_version": protocol_version,
|
||||
"release_id": agent_release_id,
|
||||
"rf_tx": 0,
|
||||
"schema_version": 1,
|
||||
"status": "READY",
|
||||
}
|
||||
) + b"\n"
|
||||
ready_name = f"versions/{agent_release_id}/READY.json"
|
||||
rows = [
|
||||
{
|
||||
"path": name,
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256": sha256_file(source),
|
||||
}
|
||||
for name, source in sources.items()
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"path": ready_name,
|
||||
"bytes": len(ready),
|
||||
"sha256": hashlib.sha256(ready).hexdigest(),
|
||||
}
|
||||
)
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"package_kind": "BOOTSTRAP",
|
||||
"bootstrap_id": bootstrap_id,
|
||||
"agent_release_bootstrap_id": release_bootstrap_id,
|
||||
"agent_release_id": agent_release_id,
|
||||
"agent_version": agent_version,
|
||||
"protocol_version": protocol_version,
|
||||
"main_server": "http://192.168.255.185:8765",
|
||||
"allowed_rx_ipv4": "192.168.255.56",
|
||||
"reference_sha256": REFERENCE_SHA256,
|
||||
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
manifest_raw = canonical_json_bytes(manifest)
|
||||
signature = hmac.new(
|
||||
_token_bytes(token_path),
|
||||
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||
hashlib.sha256,
|
||||
).hexdigest().encode("ascii") + b"\n"
|
||||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if package_path.exists():
|
||||
raise FileExistsError(f"bootstrap package already exists: {package_path}")
|
||||
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||
with zipfile.ZipFile(
|
||||
package_path,
|
||||
"x",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
) as archive:
|
||||
_write_bytes(
|
||||
archive, BOOTSTRAP_MANIFEST_NAME, manifest_raw, timestamp=timestamp
|
||||
)
|
||||
_write_bytes(
|
||||
archive, BOOTSTRAP_SIGNATURE_NAME, signature, timestamp=timestamp
|
||||
)
|
||||
_write_bytes(archive, ready_name, ready, timestamp=timestamp)
|
||||
for name in sorted(sources):
|
||||
_copy_to_zip(archive, name, sources[name], timestamp=timestamp)
|
||||
return inspect_bootstrap_archive(package_path, token_path=token_path)
|
||||
|
||||
|
||||
def _digest_stream(stream: BinaryIO) -> str:
|
||||
digest = hashlib.sha256()
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def inspect_bootstrap_archive(
|
||||
package_path: Path,
|
||||
*,
|
||||
token_path: Path,
|
||||
) -> BootstrapInspection:
|
||||
package_bytes = package_path.stat().st_size
|
||||
if package_bytes <= 0 or package_bytes > MAX_BOOTSTRAP_BYTES:
|
||||
raise ValueError("bootstrap package size is outside the limit")
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
infos: dict[str, zipfile.ZipInfo] = {}
|
||||
for info in archive.infolist():
|
||||
_validate_archive_name(info.filename)
|
||||
key = info.filename.casefold()
|
||||
if key in (name.casefold() for name in infos):
|
||||
raise ValueError("duplicate bootstrap archive entry")
|
||||
if info.is_dir() or info.flag_bits & 0x1:
|
||||
raise ValueError("bootstrap archive contains a prohibited entry")
|
||||
infos[info.filename] = info
|
||||
if BOOTSTRAP_MANIFEST_NAME not in infos or BOOTSTRAP_SIGNATURE_NAME not in infos:
|
||||
raise ValueError("bootstrap authentication metadata is absent")
|
||||
manifest_raw = archive.read(infos[BOOTSTRAP_MANIFEST_NAME])
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
if not isinstance(manifest, dict) or manifest_raw != canonical_json_bytes(manifest):
|
||||
raise ValueError("bootstrap manifest is not canonical JSON")
|
||||
supplied = archive.read(infos[BOOTSTRAP_SIGNATURE_NAME]).decode("ascii").strip()
|
||||
expected = hmac.new(
|
||||
_token_bytes(token_path),
|
||||
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied, expected):
|
||||
raise ValueError("bootstrap manifest signature mismatch")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("bootstrap file list is invalid")
|
||||
expected_names = {BOOTSTRAP_MANIFEST_NAME, BOOTSTRAP_SIGNATURE_NAME}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError("bootstrap file entry is invalid")
|
||||
name = row.get("path")
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("bootstrap file path is invalid")
|
||||
_validate_archive_name(name)
|
||||
if name in expected_names or name not in infos:
|
||||
raise ValueError("bootstrap file set is invalid")
|
||||
expected_names.add(name)
|
||||
info = infos[name]
|
||||
if info.file_size != row.get("bytes"):
|
||||
raise ValueError(f"bootstrap file size mismatch: {name}")
|
||||
with archive.open(info, "r") as stream:
|
||||
digest = _digest_stream(stream)
|
||||
if digest != row.get("sha256"):
|
||||
raise ValueError(f"bootstrap file SHA256 mismatch: {name}")
|
||||
if set(infos) != expected_names:
|
||||
raise ValueError("bootstrap archive contains unexpected files")
|
||||
return BootstrapInspection(
|
||||
bootstrap_id=str(manifest["bootstrap_id"]),
|
||||
agent_release_id=str(manifest["agent_release_id"]),
|
||||
agent_version=str(manifest["agent_version"]),
|
||||
protocol_version=int(manifest["protocol_version"]),
|
||||
package_sha256=sha256_file(package_path),
|
||||
package_bytes=package_bytes,
|
||||
content_manifest_sha256=hashlib.sha256(manifest_raw).hexdigest(),
|
||||
file_count=len(rows),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
release = subparsers.add_parser("release")
|
||||
release.add_argument("--source-root", type=Path, required=True)
|
||||
release.add_argument("--package", type=Path, required=True)
|
||||
release.add_argument("--release-id", required=True)
|
||||
release.add_argument("--agent-version", required=True)
|
||||
release.add_argument("--protocol-version", type=int, default=1)
|
||||
bootstrap = subparsers.add_parser("bootstrap")
|
||||
bootstrap.add_argument("--source-root", type=Path, required=True)
|
||||
bootstrap.add_argument("--package", type=Path, required=True)
|
||||
bootstrap.add_argument("--bootstrap-id", required=True)
|
||||
bootstrap.add_argument("--agent-release-id", required=True)
|
||||
bootstrap.add_argument("--agent-version", required=True)
|
||||
bootstrap.add_argument("--agent-release-bootstrap-id")
|
||||
bootstrap.add_argument("--protocol-version", type=int, default=1)
|
||||
bootstrap.add_argument("--token-file", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.command == "release":
|
||||
inspection = build_release_archive(
|
||||
arguments.source_root,
|
||||
arguments.package,
|
||||
release_id=arguments.release_id,
|
||||
agent_version=arguments.agent_version,
|
||||
protocol_version=arguments.protocol_version,
|
||||
)
|
||||
else:
|
||||
inspection = build_bootstrap_archive(
|
||||
arguments.source_root,
|
||||
arguments.package,
|
||||
bootstrap_id=arguments.bootstrap_id,
|
||||
agent_release_id=arguments.agent_release_id,
|
||||
agent_version=arguments.agent_version,
|
||||
protocol_version=arguments.protocol_version,
|
||||
token_path=arguments.token_file,
|
||||
agent_release_bootstrap_id=arguments.agent_release_bootstrap_id,
|
||||
)
|
||||
print(f"PACKAGE = {arguments.package.resolve()}")
|
||||
print(f"PACKAGE SHA256 = {inspection.package_sha256}")
|
||||
print(f"PACKAGE BYTES = {inspection.package_bytes}")
|
||||
print("HARDWARE ACCESSED = false")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
133
tools/rf001_capture_runner.py
Normal file
133
tools/rf001_capture_runner.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Run one finite RF-001 RX child and persist start/final evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_capture(
|
||||
executable: Path,
|
||||
arguments_path: Path,
|
||||
capture_path: Path,
|
||||
stdout_path: Path,
|
||||
stderr_path: Path,
|
||||
started_path: Path,
|
||||
result_path: Path,
|
||||
) -> int:
|
||||
arguments_value = json.loads(arguments_path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(arguments_value, list) or not all(
|
||||
isinstance(item, str) for item in arguments_value
|
||||
):
|
||||
raise ValueError("arguments JSON must be an array of strings")
|
||||
started_at = utc_now()
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
exit_code: int | None = None
|
||||
runner_error: str | None = None
|
||||
try:
|
||||
with stdout_path.open("xb") as stdout_stream, stderr_path.open(
|
||||
"xb"
|
||||
) as stderr_stream:
|
||||
child = subprocess.Popen(
|
||||
[str(executable), *arguments_value],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_stream,
|
||||
stderr=stderr_stream,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
close_fds=True,
|
||||
)
|
||||
atomic_json(
|
||||
started_path,
|
||||
{
|
||||
"state": "RUNNING",
|
||||
"pid": int(child.pid),
|
||||
"started_at": started_at,
|
||||
"capture_path": str(capture_path),
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
exit_code = int(child.wait())
|
||||
except Exception as error: # persisted at the process boundary
|
||||
runner_error = f"{type(error).__name__}: {error}"
|
||||
finished_at = utc_now()
|
||||
capture_size = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||
capture_hash: str | None = None
|
||||
if capture_path.is_file():
|
||||
try:
|
||||
capture_hash = sha256_file(capture_path)
|
||||
except OSError as error:
|
||||
detail = f"{type(error).__name__}: {error}"
|
||||
runner_error = detail if runner_error is None else f"{runner_error}; {detail}"
|
||||
duration = (
|
||||
datetime.fromisoformat(finished_at) - datetime.fromisoformat(started_at)
|
||||
).total_seconds()
|
||||
atomic_json(
|
||||
result_path,
|
||||
{
|
||||
"pid": int(child.pid) if child is not None else None,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": duration,
|
||||
"exit_code": exit_code,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_size": capture_size,
|
||||
"sha256": capture_hash,
|
||||
"runner_error": runner_error,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
return 255 if exit_code is None else exit_code
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--executable", required=True, type=Path)
|
||||
parser.add_argument("--arguments-json", required=True, type=Path)
|
||||
parser.add_argument("--capture-path", required=True, type=Path)
|
||||
parser.add_argument("--stdout-path", required=True, type=Path)
|
||||
parser.add_argument("--stderr-path", required=True, type=Path)
|
||||
parser.add_argument("--started-status", required=True, type=Path)
|
||||
parser.add_argument("--result-status", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
raise SystemExit(
|
||||
run_capture(
|
||||
arguments.executable,
|
||||
arguments.arguments_json,
|
||||
arguments.capture_path,
|
||||
arguments.stdout_path,
|
||||
arguments.stderr_path,
|
||||
arguments.started_status,
|
||||
arguments.result_status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1805
tools/rf001_http_control.py
Normal file
1805
tools/rf001_http_control.py
Normal file
File diff suppressed because it is too large
Load Diff
742
tools/rf001_launcher_health_diagnostic.py
Normal file
742
tools/rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,742 @@
|
||||
"""Isolated Windows diagnostic for the RF-001 launcher/health handshake.
|
||||
|
||||
The probe imports the production launcher and calls its ``start_release`` path,
|
||||
but replaces only the child command with this module's ``health-child`` mode.
|
||||
That child imports ``write_health`` from the immutable canonical release, writes
|
||||
one diagnostic health file, never polls HTTP, never touches SDR, and exits after
|
||||
the parent has preserved process evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import ModuleType
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
EXPECTED_STATE = "RUNNING"
|
||||
NO_HARDWARE = False
|
||||
NO_RF_TX = 0
|
||||
_ORIGINAL_POPEN = subprocess.Popen
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_utc(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON object is required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessRecord:
|
||||
pid: int
|
||||
parent_pid: int
|
||||
name: str | None
|
||||
executable: str | None
|
||||
command_line: str | None
|
||||
created_at: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandshakeExpectation:
|
||||
release_id: str
|
||||
agent_version: str
|
||||
launch_nonce: str
|
||||
state: str
|
||||
launcher_pid: int
|
||||
popen_pid: int
|
||||
launched_at: str
|
||||
diagnostic_script: str
|
||||
|
||||
|
||||
def _powershell_json(script: str) -> object:
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
process = _ORIGINAL_POPEN(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-EncodedCommand",
|
||||
encoded,
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
)
|
||||
stdout, stderr = process.communicate(timeout=20)
|
||||
if process.returncode != 0:
|
||||
detail = stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"Windows process inventory failed: {detail}")
|
||||
text = stdout.decode("utf-8-sig", errors="strict").strip()
|
||||
if not text:
|
||||
return []
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def capture_windows_processes() -> list[ProcessRecord]:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("Windows process inventory is required")
|
||||
script = r'''
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$rows = @(Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$created = $null
|
||||
if ($null -ne $_.CreationDate) {
|
||||
$created = $_.CreationDate.ToUniversalTime().ToString("o")
|
||||
}
|
||||
[ordered]@{
|
||||
pid = [int]$_.ProcessId
|
||||
parent_pid = [int]$_.ParentProcessId
|
||||
name = $_.Name
|
||||
executable = $_.ExecutablePath
|
||||
command_line = $_.CommandLine
|
||||
created_at = $created
|
||||
}
|
||||
})
|
||||
ConvertTo-Json -InputObject $rows -Depth 4 -Compress
|
||||
'''
|
||||
raw = _powershell_json(script)
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("Windows process inventory is not a list")
|
||||
records: list[ProcessRecord] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
records.append(
|
||||
ProcessRecord(
|
||||
pid=int(row["pid"]),
|
||||
parent_pid=int(row["parent_pid"]),
|
||||
name=row.get("name") if isinstance(row.get("name"), str) else None,
|
||||
executable=(
|
||||
row.get("executable")
|
||||
if isinstance(row.get("executable"), str)
|
||||
else None
|
||||
),
|
||||
command_line=(
|
||||
row.get("command_line")
|
||||
if isinstance(row.get("command_line"), str)
|
||||
else None
|
||||
),
|
||||
created_at=(
|
||||
row.get("created_at")
|
||||
if isinstance(row.get("created_at"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def process_map(records: Sequence[ProcessRecord]) -> dict[int, ProcessRecord]:
|
||||
return {record.pid: record for record in records}
|
||||
|
||||
|
||||
def is_descendant(
|
||||
child_pid: int,
|
||||
ancestor_pid: int,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> bool:
|
||||
by_pid = process_map(records)
|
||||
current = by_pid.get(child_pid)
|
||||
visited: set[int] = set()
|
||||
while current is not None and current.pid not in visited:
|
||||
if current.parent_pid == ancestor_pid:
|
||||
return True
|
||||
visited.add(current.pid)
|
||||
current = by_pid.get(current.parent_pid)
|
||||
return False
|
||||
|
||||
|
||||
def _record_is_fresh(record: ProcessRecord, launched_at: datetime) -> bool:
|
||||
created_at = parse_utc(record.created_at)
|
||||
if created_at is None:
|
||||
return False
|
||||
return created_at >= launched_at
|
||||
|
||||
|
||||
def _record_matches_launch(
|
||||
record: ProcessRecord,
|
||||
expectation: HandshakeExpectation,
|
||||
) -> bool:
|
||||
command_line = record.command_line or ""
|
||||
return (
|
||||
expectation.launch_nonce in command_line
|
||||
and expectation.diagnostic_script.casefold() in command_line.casefold()
|
||||
)
|
||||
|
||||
|
||||
def compare_health_fields(
|
||||
health: Mapping[str, Any] | None,
|
||||
expectation: HandshakeExpectation,
|
||||
) -> dict[str, bool]:
|
||||
if health is None:
|
||||
return {
|
||||
"release_id_match": False,
|
||||
"agent_version_match": False,
|
||||
"launch_nonce_match": False,
|
||||
"state_match": False,
|
||||
"pid_match": False,
|
||||
}
|
||||
return {
|
||||
"release_id_match": health.get("release_id") == expectation.release_id,
|
||||
"agent_version_match": health.get("agent_version")
|
||||
== expectation.agent_version,
|
||||
"launch_nonce_match": health.get("launch_nonce")
|
||||
== expectation.launch_nonce,
|
||||
"state_match": health.get("state") == expectation.state,
|
||||
"pid_match": health.get("pid") == expectation.popen_pid,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_identity(
|
||||
health: Mapping[str, Any] | None,
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
*,
|
||||
popen_alive: bool,
|
||||
health_process_alive: bool,
|
||||
) -> dict[str, Any]:
|
||||
fields = compare_health_fields(health, expectation)
|
||||
result: dict[str, Any] = {
|
||||
**fields,
|
||||
"identity_valid": False,
|
||||
"identity_model": "REJECTED",
|
||||
"reason": None,
|
||||
}
|
||||
if health is None:
|
||||
result["reason"] = "HEALTH_ABSENT"
|
||||
return result
|
||||
if not all(
|
||||
fields[name]
|
||||
for name in (
|
||||
"release_id_match",
|
||||
"agent_version_match",
|
||||
"launch_nonce_match",
|
||||
"state_match",
|
||||
)
|
||||
):
|
||||
result["reason"] = "HEALTH_FIELDS_MISMATCH"
|
||||
return result
|
||||
updated_at = parse_utc(health.get("updated_at"))
|
||||
launched_at = parse_utc(expectation.launched_at)
|
||||
if updated_at is None or launched_at is None or updated_at < launched_at:
|
||||
result["reason"] = "STALE_HEALTH"
|
||||
return result
|
||||
health_pid = health.get("pid")
|
||||
if type(health_pid) is not int:
|
||||
result["reason"] = "HEALTH_PID_INVALID"
|
||||
return result
|
||||
by_pid = process_map(records)
|
||||
health_record = by_pid.get(health_pid)
|
||||
if health_record is None:
|
||||
result["reason"] = "HEALTH_PROCESS_ABSENT"
|
||||
return result
|
||||
if not health_process_alive:
|
||||
result["reason"] = "HEALTH_PROCESS_DIED"
|
||||
return result
|
||||
if not _record_is_fresh(health_record, launched_at):
|
||||
result["reason"] = "PROCESS_CREATION_TIME_MISMATCH"
|
||||
return result
|
||||
if not _record_matches_launch(health_record, expectation):
|
||||
result["reason"] = "PROCESS_COMMAND_MISMATCH"
|
||||
return result
|
||||
|
||||
if health_pid == expectation.popen_pid:
|
||||
if not popen_alive:
|
||||
result["reason"] = "TARGET_DIED"
|
||||
return result
|
||||
result.update(
|
||||
identity_valid=True,
|
||||
identity_model="EXACT_POPEN_PROCESS",
|
||||
reason="PASS",
|
||||
)
|
||||
return result
|
||||
|
||||
popen_record = by_pid.get(expectation.popen_pid)
|
||||
if popen_record is None:
|
||||
result["reason"] = "POPEN_PROCESS_NOT_CAPTURED"
|
||||
return result
|
||||
direct_children = [
|
||||
record
|
||||
for record in records
|
||||
if record.parent_pid == expectation.popen_pid
|
||||
and _record_is_fresh(record, launched_at)
|
||||
and _record_matches_launch(record, expectation)
|
||||
]
|
||||
if health_record.parent_pid != expectation.popen_pid:
|
||||
if is_descendant(health_pid, expectation.popen_pid, records):
|
||||
result["reason"] = "UNEXPECTED_GRANDCHILD"
|
||||
else:
|
||||
result["reason"] = "UNRELATED_PID"
|
||||
return result
|
||||
if len(direct_children) != 1 or direct_children[0].pid != health_pid:
|
||||
result["reason"] = "AMBIGUOUS_DIRECT_CHILD"
|
||||
return result
|
||||
if not _record_matches_launch(popen_record, expectation):
|
||||
result["reason"] = "POPEN_COMMAND_MISMATCH"
|
||||
return result
|
||||
result.update(
|
||||
identity_valid=True,
|
||||
identity_model="UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME",
|
||||
reason="PASS",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def strict_owned_process_ids(
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> list[int]:
|
||||
launched_at = parse_utc(expectation.launched_at)
|
||||
if launched_at is None:
|
||||
return []
|
||||
result: list[int] = []
|
||||
for record in records:
|
||||
if record.pid == expectation.launcher_pid:
|
||||
continue
|
||||
if not _record_is_fresh(record, launched_at):
|
||||
continue
|
||||
if not _record_matches_launch(record, expectation):
|
||||
continue
|
||||
if record.pid == expectation.popen_pid or is_descendant(
|
||||
record.pid, expectation.popen_pid, records
|
||||
):
|
||||
result.append(record.pid)
|
||||
return sorted(set(result), reverse=True)
|
||||
|
||||
|
||||
def relevant_process_tree(
|
||||
records: Sequence[ProcessRecord],
|
||||
expectation: HandshakeExpectation,
|
||||
health_pid: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
by_pid = process_map(records)
|
||||
selected: set[int] = {expectation.launcher_pid, expectation.popen_pid}
|
||||
if health_pid is not None:
|
||||
selected.add(health_pid)
|
||||
for record in records:
|
||||
if is_descendant(record.pid, expectation.popen_pid, records):
|
||||
selected.add(record.pid)
|
||||
for seed in tuple(selected):
|
||||
current = by_pid.get(seed)
|
||||
while current is not None and current.parent_pid not in selected:
|
||||
if current.parent_pid == 0:
|
||||
break
|
||||
selected.add(current.parent_pid)
|
||||
current = by_pid.get(current.parent_pid)
|
||||
return [asdict(by_pid[pid]) for pid in sorted(selected) if pid in by_pid]
|
||||
|
||||
|
||||
def _load_module(path: Path, name: str) -> ModuleType:
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise ImportError(f"Could not load Python module: {path}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def run_health_child(arguments: argparse.Namespace) -> int:
|
||||
agent_source = arguments.agent_source.resolve()
|
||||
agent = _load_module(agent_source, f"rf001_diag_agent_{os.getpid()}")
|
||||
namespace = argparse.Namespace(
|
||||
health_file=arguments.health_file.resolve(),
|
||||
release_id=arguments.release_id,
|
||||
launch_nonce=arguments.launch_nonce,
|
||||
)
|
||||
child_report = arguments.child_report.resolve()
|
||||
started_at = utc_now()
|
||||
atomic_write_json(
|
||||
child_report,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"phase": "BEFORE_HEALTH",
|
||||
"pid": os.getpid(),
|
||||
"parent_pid": os.getppid(),
|
||||
"sys_executable": sys.executable,
|
||||
"command_line": list(sys.argv),
|
||||
"started_at": started_at,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
},
|
||||
)
|
||||
agent.write_health(namespace, None)
|
||||
health_written_at = utc_now()
|
||||
atomic_write_json(
|
||||
child_report,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"phase": "HEALTH_WRITTEN",
|
||||
"pid": os.getpid(),
|
||||
"parent_pid": os.getppid(),
|
||||
"sys_executable": sys.executable,
|
||||
"command_line": list(sys.argv),
|
||||
"started_at": started_at,
|
||||
"health_written_at": health_written_at,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
},
|
||||
)
|
||||
time.sleep(arguments.hold_seconds)
|
||||
return 0
|
||||
|
||||
|
||||
def _process_is_present(pid: int, records: Sequence[ProcessRecord]) -> bool:
|
||||
return any(record.pid == pid for record in records)
|
||||
|
||||
|
||||
def _terminate_owned_processes(
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
for pid in strict_owned_process_ids(expectation, records):
|
||||
fresh = capture_windows_processes()
|
||||
current = process_map(fresh).get(pid)
|
||||
original = process_map(records).get(pid)
|
||||
if current is None or original is None:
|
||||
continue
|
||||
if current.created_at != original.created_at or not _record_matches_launch(
|
||||
current, expectation
|
||||
):
|
||||
actions.append({"pid": pid, "status": "IDENTITY_CHANGED_NOT_TERMINATED"})
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
actions.append({"pid": pid, "status": "ALREADY_EXITED"})
|
||||
else:
|
||||
actions.append({"pid": pid, "status": "TERMINATED"})
|
||||
return actions
|
||||
|
||||
|
||||
def run_probe_once(
|
||||
launcher: ModuleType,
|
||||
*,
|
||||
canonical_release: Path,
|
||||
output_directory: Path,
|
||||
startup_timeout: float,
|
||||
hold_seconds: float,
|
||||
) -> dict[str, Any]:
|
||||
output_directory.mkdir(parents=True, exist_ok=False)
|
||||
ready = read_json(canonical_release / "READY.json")
|
||||
release_id = str(ready["release_id"])
|
||||
agent_version = str(ready["agent_version"])
|
||||
agent_source = canonical_release / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
health_path = output_directory / "diagnostic_health.json"
|
||||
child_report_path = output_directory / "diagnostic_child.json"
|
||||
config: dict[str, object] = {
|
||||
"install_root": str(output_directory / "isolated_install_root"),
|
||||
"releases_root": str(canonical_release.parent),
|
||||
"active_release_file": str(output_directory / "unused_active_release.json"),
|
||||
"launcher_status": str(output_directory / "unused_launcher_status.json"),
|
||||
"launcher_request": str(output_directory / "unused_launcher_request.json"),
|
||||
"health_file": str(health_path),
|
||||
"agent_log": str(output_directory / "diagnostic_child.log"),
|
||||
"startup_timeout_seconds": startup_timeout,
|
||||
"agent_arguments": [],
|
||||
}
|
||||
captured: dict[str, Any] = {}
|
||||
process_ready = threading.Event()
|
||||
health_observed = threading.Event()
|
||||
observer_snapshot: list[ProcessRecord] = []
|
||||
original_command = launcher._agent_command
|
||||
original_spawn_agent = launcher._spawn_agent
|
||||
launcher_pid = os.getpid()
|
||||
launched_at = utc_now()
|
||||
|
||||
def diagnostic_command(
|
||||
_config: Mapping[str, Any],
|
||||
actual_agent_source: Path,
|
||||
actual_release_id: str,
|
||||
launch_nonce: str,
|
||||
) -> list[str]:
|
||||
captured["launch_nonce"] = launch_nonce
|
||||
command = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(Path(__file__).resolve()),
|
||||
"health-child",
|
||||
"--agent-source",
|
||||
str(actual_agent_source),
|
||||
"--release-id",
|
||||
actual_release_id,
|
||||
"--launch-nonce",
|
||||
launch_nonce,
|
||||
"--health-file",
|
||||
str(health_path),
|
||||
"--child-report",
|
||||
str(child_report_path),
|
||||
"--hold-seconds",
|
||||
str(hold_seconds),
|
||||
]
|
||||
captured["command"] = command
|
||||
return command
|
||||
|
||||
def recording_spawn_agent(
|
||||
command: Sequence[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
log_stream: Any,
|
||||
) -> subprocess.Popen[str]:
|
||||
process = original_spawn_agent(command, cwd=cwd, log_stream=log_stream)
|
||||
captured["process"] = process
|
||||
captured["popen_pid"] = process.pid
|
||||
captured["popen_created_at"] = utc_now()
|
||||
process_ready.set()
|
||||
return process
|
||||
|
||||
def observer() -> None:
|
||||
nonlocal observer_snapshot
|
||||
if not process_ready.wait(timeout=10):
|
||||
return
|
||||
deadline = time.monotonic() + max(10.0, startup_timeout + 5.0)
|
||||
while time.monotonic() < deadline:
|
||||
if health_path.exists() and child_report_path.exists():
|
||||
observer_snapshot = capture_windows_processes()
|
||||
health_observed.set()
|
||||
return
|
||||
time.sleep(0.02)
|
||||
|
||||
observer_thread = threading.Thread(target=observer, name="rf001-health-observer")
|
||||
observer_thread.start()
|
||||
launcher._agent_command = diagnostic_command
|
||||
launcher._spawn_agent = recording_spawn_agent
|
||||
start_result = "UNKNOWN"
|
||||
start_error: str | None = None
|
||||
returned_process: subprocess.Popen[str] | None = None
|
||||
try:
|
||||
returned_process, returned_version = launcher.start_release(
|
||||
config,
|
||||
release_id,
|
||||
expected_version=agent_version,
|
||||
)
|
||||
start_result = "PASS"
|
||||
captured["returned_version"] = returned_version
|
||||
except Exception as error:
|
||||
start_result = "FAIL"
|
||||
start_error = f"{type(error).__name__}: {error}"
|
||||
finally:
|
||||
launcher._agent_command = original_command
|
||||
launcher._spawn_agent = original_spawn_agent
|
||||
observer_thread.join(timeout=15)
|
||||
|
||||
popen_pid = int(captured.get("popen_pid", -1))
|
||||
launch_nonce = str(captured.get("launch_nonce", ""))
|
||||
expectation = HandshakeExpectation(
|
||||
release_id=release_id,
|
||||
agent_version=agent_version,
|
||||
launch_nonce=launch_nonce,
|
||||
state=EXPECTED_STATE,
|
||||
launcher_pid=launcher_pid,
|
||||
popen_pid=popen_pid,
|
||||
launched_at=launched_at,
|
||||
diagnostic_script=str(Path(__file__).resolve()),
|
||||
)
|
||||
health = read_json(health_path) if health_path.exists() else None
|
||||
child_report = read_json(child_report_path) if child_report_path.exists() else None
|
||||
evidence_snapshot = observer_snapshot or capture_windows_processes()
|
||||
health_pid = health.get("pid") if isinstance(health, dict) else None
|
||||
typed_health_pid = health_pid if type(health_pid) is int else None
|
||||
popen_process = captured.get("process")
|
||||
popen_alive_at_evaluation = (
|
||||
isinstance(popen_process, subprocess.Popen) and popen_process.poll() is None
|
||||
)
|
||||
health_process_alive = (
|
||||
typed_health_pid is not None
|
||||
and _process_is_present(typed_health_pid, evidence_snapshot)
|
||||
)
|
||||
evaluation = evaluate_identity(
|
||||
health,
|
||||
expectation,
|
||||
evidence_snapshot,
|
||||
popen_alive=popen_alive_at_evaluation,
|
||||
health_process_alive=health_process_alive,
|
||||
)
|
||||
post_failure_snapshot = capture_windows_processes()
|
||||
cleanup_actions = _terminate_owned_processes(expectation, post_failure_snapshot)
|
||||
deadline = time.monotonic() + 5.0
|
||||
final_snapshot = capture_windows_processes()
|
||||
while strict_owned_process_ids(expectation, final_snapshot) and time.monotonic() < deadline:
|
||||
time.sleep(0.1)
|
||||
final_snapshot = capture_windows_processes()
|
||||
orphan_pids = strict_owned_process_ids(expectation, final_snapshot)
|
||||
health_created_at = None
|
||||
if health_path.exists():
|
||||
health_created_at = datetime.fromtimestamp(
|
||||
health_path.stat().st_ctime,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
report: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||
"launcher_source": str(Path(launcher.__file__).resolve()),
|
||||
"canonical_release": str(canonical_release.resolve()),
|
||||
"canonical_release_id": release_id,
|
||||
"canonical_agent_version": agent_version,
|
||||
"launcher_pid": launcher_pid,
|
||||
"launcher_executable": sys.executable,
|
||||
"launcher_command_line": list(sys.argv),
|
||||
"popen_pid": popen_pid,
|
||||
"popen_command": captured.get("command"),
|
||||
"popen_created_at": captured.get("popen_created_at"),
|
||||
"launch_nonce": launch_nonce,
|
||||
"launched_at": launched_at,
|
||||
"start_release_result": start_result,
|
||||
"start_release_error": start_error,
|
||||
"health_observed_before_cleanup": health_observed.is_set(),
|
||||
"health_path": str(health_path.resolve()),
|
||||
"health_file_created_at": health_created_at,
|
||||
"health": health,
|
||||
"child_report": child_report,
|
||||
"field_comparison": compare_health_fields(health, expectation),
|
||||
"strict_identity_evaluation": evaluation,
|
||||
"process_tree_at_health": relevant_process_tree(
|
||||
evidence_snapshot,
|
||||
expectation,
|
||||
typed_health_pid,
|
||||
),
|
||||
"popen_alive_at_evaluation": popen_alive_at_evaluation,
|
||||
"health_process_alive_at_evaluation": health_process_alive,
|
||||
"cleanup_actions": cleanup_actions,
|
||||
"orphan_pids_after_cleanup": orphan_pids,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
"completed_at": utc_now(),
|
||||
}
|
||||
atomic_write_json(output_directory / "diagnostic_report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def run_probe(arguments: argparse.Namespace) -> int:
|
||||
canonical_release = arguments.canonical_release.resolve()
|
||||
launcher_source = arguments.launcher_source.resolve()
|
||||
output_root = arguments.output_root.resolve()
|
||||
if output_root.exists():
|
||||
raise FileExistsError(f"diagnostic output root already exists: {output_root}")
|
||||
output_root.mkdir(parents=True)
|
||||
launcher = _load_module(launcher_source, "rf001_diagnostic_launcher")
|
||||
reports = []
|
||||
for index in range(1, arguments.runs + 1):
|
||||
reports.append(
|
||||
run_probe_once(
|
||||
launcher,
|
||||
canonical_release=canonical_release,
|
||||
output_directory=output_root / f"run_{index:02d}",
|
||||
startup_timeout=arguments.startup_timeout,
|
||||
hold_seconds=arguments.hold_seconds,
|
||||
)
|
||||
)
|
||||
nonces = [str(report["launch_nonce"]) for report in reports]
|
||||
summary = {
|
||||
"schema_version": 1,
|
||||
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||
"runs": reports,
|
||||
"run_count": len(reports),
|
||||
"nonces_unique": len(nonces) == len(set(nonces)),
|
||||
"orphan_pids_after_test": sorted(
|
||||
{
|
||||
pid
|
||||
for report in reports
|
||||
for pid in report["orphan_pids_after_cleanup"]
|
||||
}
|
||||
),
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
"completed_at": utc_now(),
|
||||
}
|
||||
atomic_write_json(output_root / "diagnostic_summary.json", summary)
|
||||
print(f"DIAGNOSTIC_REPORT={output_root / 'diagnostic_summary.json'}")
|
||||
print(f"RUNS={len(reports)}")
|
||||
print(f"NONCES_UNIQUE={summary['nonces_unique']}")
|
||||
print(f"ORPHAN_PIDS={summary['orphan_pids_after_test']}")
|
||||
print("NETWORK_TASK_POLLING=false")
|
||||
print("HARDWARE_ACCESSED=false")
|
||||
print("RF_TX=0")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
child = subparsers.add_parser("health-child")
|
||||
child.add_argument("--agent-source", required=True, type=Path)
|
||||
child.add_argument("--release-id", required=True)
|
||||
child.add_argument("--launch-nonce", required=True)
|
||||
child.add_argument("--health-file", required=True, type=Path)
|
||||
child.add_argument("--child-report", required=True, type=Path)
|
||||
child.add_argument("--hold-seconds", required=True, type=float)
|
||||
probe = subparsers.add_parser("probe")
|
||||
probe.add_argument("--launcher-source", required=True, type=Path)
|
||||
probe.add_argument("--canonical-release", required=True, type=Path)
|
||||
probe.add_argument("--output-root", required=True, type=Path)
|
||||
probe.add_argument("--runs", type=int, default=2)
|
||||
probe.add_argument("--startup-timeout", type=float, default=2.0)
|
||||
probe.add_argument("--hold-seconds", type=float, default=10.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.command == "health-child":
|
||||
raise SystemExit(run_health_child(arguments))
|
||||
if arguments.runs < 1 or arguments.runs > 4:
|
||||
raise ValueError("runs must be 1 to 4")
|
||||
if arguments.startup_timeout <= 0 or arguments.hold_seconds <= 0:
|
||||
raise ValueError("diagnostic timeouts must be positive")
|
||||
raise SystemExit(run_probe(arguments))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
86
tools/rf001_offline_decode.py
Normal file
86
tools/rf001_offline_decode.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Fixed software-only RF-001A decoder used by the HTTP RX agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
Decoder = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def decode_offline(
|
||||
repository: Path,
|
||||
iq_path: Path,
|
||||
truth_path: Path,
|
||||
report_path: Path,
|
||||
*,
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
decoder: Decoder | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decode one generated cs8 file with the dependency-free legacy backend."""
|
||||
|
||||
repository = repository.resolve()
|
||||
if not repository.is_dir():
|
||||
raise FileNotFoundError(f"repository is absent: {repository}")
|
||||
for required in (iq_path, truth_path):
|
||||
if not required.is_file():
|
||||
raise FileNotFoundError(f"required input is absent: {required}")
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"report already exists: {report_path}")
|
||||
|
||||
if decoder is None:
|
||||
sys.path.insert(0, str(repository))
|
||||
from tools.qpsk_rf_rx import decode_file
|
||||
|
||||
decoder = decode_file
|
||||
result = decoder(
|
||||
iq_path,
|
||||
iq_format="cs8",
|
||||
truth_metadata_path=truth_path,
|
||||
decoder_backend="legacy",
|
||||
capture_started_at=capture_started_at,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True, type=Path)
|
||||
parser.add_argument("--iq-file", required=True, type=Path)
|
||||
parser.add_argument("--truth-metadata", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--capture-started-at")
|
||||
parser.add_argument("--tx-started-at")
|
||||
parser.add_argument("--tx-finished-at")
|
||||
arguments = parser.parse_args()
|
||||
result = decode_offline(
|
||||
arguments.repository,
|
||||
arguments.iq_file,
|
||||
arguments.truth_metadata,
|
||||
arguments.report,
|
||||
capture_started_at=arguments.capture_started_at,
|
||||
tx_started_at=arguments.tx_started_at,
|
||||
tx_finished_at=arguments.tx_finished_at,
|
||||
)
|
||||
print(
|
||||
"RF-001 OFFLINE RX: "
|
||||
f"CRC={result['crc_valid_packets']}; "
|
||||
f"exact={result['exact_packets']}; RF TX=0"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
684
tools/rf001_rx_capture.py
Normal file
684
tools/rf001_rx_capture.py
Normal file
@@ -0,0 +1,684 @@
|
||||
"""Fixed, receive-only RF-001A capture operations for the HTTP agent.
|
||||
|
||||
This module has no transmit operation. A capture uses one immutable RF profile,
|
||||
one known RX serial number, a unique directory outside Git, and one finite
|
||||
``hackrf_transfer`` child process. Failed readiness checks stop that exact
|
||||
process tree and never retry the capture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
|
||||
FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3"
|
||||
PROFILE_NAME = "RF001A_COMMON_CLOCK_001"
|
||||
MINIMUM_FREE_BYTES = 6_000_000_000
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
CAPTURE_SECONDS = 300
|
||||
CAPTURE_SAMPLES = 2_400_000_000
|
||||
CAPTURE_FILE_NAME = "rf001a_ota_capture.cs8"
|
||||
MANIFEST_FILE_NAME = "capture_manifest.json"
|
||||
STARTED_FILE_NAME = "capture_started.json"
|
||||
RESULT_FILE_NAME = "capture_result.json"
|
||||
REPORT_FILE_NAME = "offline_decode_report.json"
|
||||
MAX_REPORT_BYTES = 192 * 1024
|
||||
|
||||
EXPECTED_PROFILE: dict[str, object] = {
|
||||
"test_id": PROFILE_NAME,
|
||||
"center_frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": 8_000_000,
|
||||
"symbol_rate_sps": 500_000,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"hardware_format": "cs8",
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160_000_000,
|
||||
}
|
||||
|
||||
CommandRunner = Callable[[Sequence[str], float], Any]
|
||||
BackgroundLauncher = Callable[[Sequence[str]], int]
|
||||
ProcessGate = Callable[[], tuple[bool, Mapping[str, object]]]
|
||||
ProcessStopper = Callable[[int], None]
|
||||
|
||||
|
||||
def _atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON object required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def load_fixed_profile(repository: Path) -> dict[str, object]:
|
||||
path = repository / "configs" / "rf" / "rf001a_common_clock.json"
|
||||
profile = _read_json(path)
|
||||
mismatches = {
|
||||
key: {"expected": expected, "actual": profile.get(key)}
|
||||
for key, expected in EXPECTED_PROFILE.items()
|
||||
if profile.get(key) != expected
|
||||
}
|
||||
if mismatches:
|
||||
raise ValueError(f"RF-001A capture profile mismatch: {mismatches}")
|
||||
return {key: profile[key] for key in EXPECTED_PROFILE}
|
||||
|
||||
|
||||
def resolve_hackrf_tools(
|
||||
hackrf_info_path: Path | None,
|
||||
hackrf_clock_path: Path | None,
|
||||
hackrf_transfer_path: Path | None,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
if hackrf_info_path is None:
|
||||
raise FileNotFoundError("hackrf_info path is required for RX capture")
|
||||
base = hackrf_info_path.parent
|
||||
info = hackrf_info_path
|
||||
clock = hackrf_clock_path or base / "hackrf_clock.exe"
|
||||
transfer = hackrf_transfer_path or base / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"required HackRF tool is absent: {path}")
|
||||
return info, clock, transfer
|
||||
|
||||
|
||||
def default_process_gate() -> tuple[bool, Mapping[str, object]]:
|
||||
from tools.hackrf_process_check import ProcessState, check_processes
|
||||
|
||||
check = check_processes()
|
||||
return check.state is ProcessState.CLEAR, check.to_dict()
|
||||
|
||||
|
||||
def default_background_launcher(arguments: Sequence[str]) -> int:
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
|
||||
subprocess, "CREATE_NO_WINDOW", 0
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
list(arguments),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=flags,
|
||||
close_fds=True,
|
||||
)
|
||||
return int(process.pid)
|
||||
|
||||
|
||||
def default_process_stopper(pid: int) -> None:
|
||||
completed = subprocess.run(
|
||||
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
check=False,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
if completed.returncode not in {0, 128}:
|
||||
raise RuntimeError(
|
||||
f"taskkill failed for pid {pid}: {completed.returncode}: "
|
||||
f"{completed.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def _iso_utc(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must contain a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _bounded_command(result: Any) -> dict[str, object]:
|
||||
return {
|
||||
"arguments": list(result.arguments),
|
||||
"returncode": int(result.returncode),
|
||||
"stdout": str(result.stdout)[-8_000:],
|
||||
"stderr": str(result.stderr)[-8_000:],
|
||||
}
|
||||
|
||||
|
||||
def start_rx_capture(
|
||||
repository: Path,
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
rx_serial: str,
|
||||
*,
|
||||
hackrf_info_path: Path | None,
|
||||
hackrf_clock_path: Path | None,
|
||||
hackrf_transfer_path: Path | None,
|
||||
command_runner: CommandRunner,
|
||||
process_gate: ProcessGate = default_process_gate,
|
||||
background_launcher: BackgroundLauncher = default_background_launcher,
|
||||
process_stopper: ProcessStopper = default_process_stopper,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
"""Start one finite RX capture and return only after the READY gate."""
|
||||
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"state": "NOT_READY",
|
||||
"hardware_accessed": False,
|
||||
"common_clock_verified": False,
|
||||
"rf_rx": 0,
|
||||
"rf_tx": 0,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
runner_pid: int | None = None
|
||||
child_pid: int | None = None
|
||||
try:
|
||||
if rx_serial != FIXED_RX_SERIAL:
|
||||
raise ValueError("RX serial does not match the fixed RF-001 receiver")
|
||||
profile = load_fixed_profile(repository)
|
||||
info_path, clock_path, transfer_path = resolve_hackrf_tools(
|
||||
hackrf_info_path,
|
||||
hackrf_clock_path,
|
||||
hackrf_transfer_path,
|
||||
)
|
||||
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
session_directory.parent.mkdir(parents=True, exist_ok=True)
|
||||
if session_directory.exists():
|
||||
raise FileExistsError(f"capture session already exists: {session_directory}")
|
||||
free_bytes = shutil.disk_usage(session_directory.parent).free
|
||||
result["free_bytes_before"] = free_bytes
|
||||
if free_bytes < MINIMUM_FREE_BYTES:
|
||||
raise RuntimeError(
|
||||
f"capture volume has {free_bytes} free bytes; "
|
||||
f"{MINIMUM_FREE_BYTES} required"
|
||||
)
|
||||
|
||||
clear, process_evidence = process_gate()
|
||||
result["process_gate"] = dict(process_evidence)
|
||||
if not clear:
|
||||
raise RuntimeError("conflicting SDR process gate is not CLEAR")
|
||||
|
||||
info = command_runner([str(info_path)], 20.0)
|
||||
result["hackrf_info"] = _bounded_command(info)
|
||||
result["hardware_accessed"] = True
|
||||
if info.returncode != 0 or rx_serial.lower() not in (
|
||||
f"{info.stdout}\n{info.stderr}".lower()
|
||||
):
|
||||
raise RuntimeError("hackrf_info did not confirm the fixed RX serial")
|
||||
|
||||
clock = command_runner([str(clock_path), "-d", rx_serial, "-i"], 20.0)
|
||||
result["hackrf_clock"] = _bounded_command(clock)
|
||||
clock_lines = {
|
||||
line.strip().lower()
|
||||
for line in f"{clock.stdout}\n{clock.stderr}".splitlines()
|
||||
}
|
||||
if clock.returncode != 0 or not any(
|
||||
line.endswith("clkin status: clock signal detected")
|
||||
for line in clock_lines
|
||||
):
|
||||
raise RuntimeError("CLKIN clock signal was not detected")
|
||||
result["common_clock_verified"] = True
|
||||
|
||||
session_directory.mkdir()
|
||||
|
||||
capture_path = session_directory / CAPTURE_FILE_NAME
|
||||
arguments_path = session_directory / "hackrf_transfer_arguments.json"
|
||||
stdout_path = session_directory / "hackrf_transfer.stdout.txt"
|
||||
stderr_path = session_directory / "hackrf_transfer.stderr.txt"
|
||||
started_path = session_directory / STARTED_FILE_NAME
|
||||
final_path = session_directory / RESULT_FILE_NAME
|
||||
manifest_path = session_directory / MANIFEST_FILE_NAME
|
||||
runner_path = Path(__file__).resolve().with_name("rf001_capture_runner.py")
|
||||
if not runner_path.is_file():
|
||||
raise FileNotFoundError(f"capture runner is absent: {runner_path}")
|
||||
|
||||
transfer_arguments = [
|
||||
"-d", rx_serial,
|
||||
"-r", str(capture_path),
|
||||
"-f", str(profile["center_frequency_hz"]),
|
||||
"-s", str(profile["sample_rate_hz"]),
|
||||
"-n", str(CAPTURE_SAMPLES),
|
||||
"-l", str(profile["rx_lna_db"]),
|
||||
"-g", str(profile["rx_vga_db"]),
|
||||
"-a", "0",
|
||||
"-p", "0",
|
||||
"-B",
|
||||
]
|
||||
arguments_path.write_text(
|
||||
json.dumps(transfer_arguments, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
launch_arguments = [
|
||||
sys.executable,
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(runner_path),
|
||||
"--executable",
|
||||
str(transfer_path),
|
||||
"--arguments-json",
|
||||
str(arguments_path),
|
||||
"--capture-path",
|
||||
str(capture_path),
|
||||
"--stdout-path",
|
||||
str(stdout_path),
|
||||
"--stderr-path",
|
||||
str(stderr_path),
|
||||
"--started-status",
|
||||
str(started_path),
|
||||
"--result-status",
|
||||
str(final_path),
|
||||
]
|
||||
runner_pid = background_launcher(launch_arguments)
|
||||
result["runner_pid"] = runner_pid
|
||||
result["rf_rx"] = 1
|
||||
|
||||
deadline = monotonic() + READINESS_TIMEOUT_SECONDS
|
||||
first_positive_size: int | None = None
|
||||
latest_size = 0
|
||||
started: dict[str, Any] | None = None
|
||||
while monotonic() < deadline:
|
||||
if final_path.is_file():
|
||||
final = _read_json(final_path)
|
||||
raise RuntimeError(f"capture exited before READY: {final}")
|
||||
if started_path.is_file():
|
||||
started = _read_json(started_path)
|
||||
raw_pid = started.get("pid")
|
||||
if type(raw_pid) is int and raw_pid > 0:
|
||||
child_pid = raw_pid
|
||||
if capture_path.is_file():
|
||||
latest_size = capture_path.stat().st_size
|
||||
if latest_size > 0:
|
||||
if first_positive_size is None:
|
||||
first_positive_size = latest_size
|
||||
elif latest_size > first_positive_size and started is not None:
|
||||
break
|
||||
sleep(0.2)
|
||||
else:
|
||||
raise RuntimeError("capture did not become READY within five seconds")
|
||||
|
||||
if started is None or child_pid is None or first_positive_size is None:
|
||||
raise RuntimeError("capture readiness evidence is incomplete")
|
||||
capture_started_at = str(started.get("started_at", ""))
|
||||
expected_end = _iso_utc(capture_started_at) + timedelta(
|
||||
seconds=CAPTURE_SECONDS
|
||||
)
|
||||
expected_bytes = CAPTURE_SAMPLES * 2
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"session_id": session_id,
|
||||
"profile": profile,
|
||||
"capture_seconds": CAPTURE_SECONDS,
|
||||
"capture_samples": CAPTURE_SAMPLES,
|
||||
"capture_duration_reason": "manual AUTHORIZE RF TX gate",
|
||||
"rx_serial": rx_serial,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": capture_started_at,
|
||||
"expected_capture_end": expected_end.isoformat(),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"runner_pid": runner_pid,
|
||||
"child_pid": child_pid,
|
||||
"started_status_path": str(started_path),
|
||||
"result_status_path": str(final_path),
|
||||
"stdout_path": str(stdout_path),
|
||||
"stderr_path": str(stderr_path),
|
||||
"common_clock_verified": True,
|
||||
"automatic_retry": False,
|
||||
"rf_rx": 1,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
_atomic_json(manifest_path, manifest)
|
||||
result.update(
|
||||
{
|
||||
"state": "READY",
|
||||
"session_directory": str(session_directory),
|
||||
"manifest_path": str(manifest_path),
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": capture_started_at,
|
||||
"expected_capture_end": expected_end.isoformat(),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"child_pid": child_pid,
|
||||
"capture_bytes_at_ready": latest_size,
|
||||
"common_clock_verified": True,
|
||||
}
|
||||
)
|
||||
except (
|
||||
ImportError,
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.SubprocessError,
|
||||
) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
for pid in (child_pid, runner_pid):
|
||||
if pid is None:
|
||||
continue
|
||||
try:
|
||||
process_stopper(pid)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as stop_error:
|
||||
errors.append(
|
||||
f"failed to stop pid {pid}: {type(stop_error).__name__}: "
|
||||
f"{stop_error}"
|
||||
)
|
||||
result["rf_rx"] = 0
|
||||
return result, errors
|
||||
|
||||
|
||||
def read_capture_status(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
manifest = _read_json(session_directory / MANIFEST_FILE_NAME)
|
||||
if manifest.get("session_id") != session_id or manifest.get("rf_tx") != 0:
|
||||
raise ValueError("capture manifest does not match the requested session")
|
||||
capture_path = Path(str(manifest["capture_path"]))
|
||||
final_path = Path(str(manifest["result_status_path"]))
|
||||
resolved_session = session_directory.resolve()
|
||||
for path in (capture_path, final_path):
|
||||
try:
|
||||
path.resolve().relative_to(resolved_session)
|
||||
except ValueError as error:
|
||||
raise ValueError("capture manifest path escapes the session") from error
|
||||
expected_bytes = int(manifest["expected_capture_bytes"])
|
||||
current_bytes = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||
result.update(
|
||||
{
|
||||
"manifest_path": str(session_directory / MANIFEST_FILE_NAME),
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": manifest.get("capture_started_at"),
|
||||
"expected_capture_end": manifest.get("expected_capture_end"),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"capture_bytes": current_bytes,
|
||||
"common_clock_verified": manifest.get("common_clock_verified"),
|
||||
}
|
||||
)
|
||||
expected_end = _iso_utc(str(manifest["expected_capture_end"]))
|
||||
result["capture_window_open"] = datetime.now(timezone.utc) < expected_end
|
||||
if final_path.is_file():
|
||||
final = _read_json(final_path)
|
||||
result["runner_result"] = final
|
||||
complete = (
|
||||
final.get("exit_code") == 0
|
||||
and final.get("runner_error") in {None, ""}
|
||||
and final.get("capture_size") == expected_bytes
|
||||
and current_bytes == expected_bytes
|
||||
)
|
||||
result["state"] = "CAPTURE_COMPLETE" if complete else "CAPTURE_FAILED"
|
||||
if not complete:
|
||||
errors.append("finite RX capture did not meet the complete-file gate")
|
||||
else:
|
||||
result["state"] = "CAPTURE_RUNNING"
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
result["state"] = "STATUS_FAILED"
|
||||
return result, errors
|
||||
|
||||
|
||||
def validate_tx_window(
|
||||
capture_started_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> None:
|
||||
capture = _iso_utc(capture_started_at)
|
||||
started = _iso_utc(tx_started_at)
|
||||
finished = _iso_utc(tx_finished_at)
|
||||
if not capture <= started <= finished:
|
||||
raise ValueError("TX timestamps are outside the ordered capture window")
|
||||
|
||||
|
||||
def decode_completed_capture(
|
||||
repository: Path,
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
*,
|
||||
command_runner: CommandRunner,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"mode": "OFFLINE_CAPTURE_DECODE",
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
status, status_errors = read_capture_status(work_root, session_id)
|
||||
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||
capture_started_at = str(status["capture_started_at"])
|
||||
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
report_path = session_directory / "offline_decode_report.json"
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"decode report already exists: {report_path}")
|
||||
decoder_script = Path(__file__).resolve().with_name("rf001_offline_decode.py")
|
||||
truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json"
|
||||
decode = command_runner(
|
||||
[
|
||||
os.fspath(Path(sys.executable)),
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(decoder_script),
|
||||
"--repository",
|
||||
str(repository),
|
||||
"--iq-file",
|
||||
str(status["capture_path"]),
|
||||
"--truth-metadata",
|
||||
str(truth_path),
|
||||
"--report",
|
||||
str(report_path),
|
||||
"--capture-started-at",
|
||||
capture_started_at,
|
||||
"--tx-started-at",
|
||||
tx_started_at,
|
||||
"--tx-finished-at",
|
||||
tx_finished_at,
|
||||
],
|
||||
1_800.0,
|
||||
)
|
||||
result["decode"] = _bounded_command(decode)
|
||||
if decode.returncode != 0:
|
||||
raise RuntimeError(f"offline capture decoder exited with {decode.returncode}")
|
||||
report = _read_json(report_path)
|
||||
diagnostics = read_capture_diagnostics(
|
||||
work_root,
|
||||
session_id,
|
||||
tx_started_at,
|
||||
tx_finished_at,
|
||||
)
|
||||
if diagnostics[1]:
|
||||
raise RuntimeError(
|
||||
f"capture diagnostics failed: {diagnostics[1]}"
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"report_path": str(report_path),
|
||||
"crc_valid_packets": report.get("crc_valid_packets"),
|
||||
"exact_packets": report.get("exact_packets"),
|
||||
"peak_analysis_memory_bytes": report.get("peak_analysis_memory_bytes"),
|
||||
"window_source": report.get("window_source"),
|
||||
"diagnostics": diagnostics[0],
|
||||
}
|
||||
)
|
||||
except (OSError, ValueError, RuntimeError, KeyError, TypeError) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return result, errors
|
||||
|
||||
|
||||
def _power_metrics(
|
||||
capture_path: Path,
|
||||
start_sample: int,
|
||||
stop_sample: int,
|
||||
*,
|
||||
chunk_samples: int = 1_000_000,
|
||||
) -> dict[str, object]:
|
||||
"""Measure one fixed cs8 window without loading it all into memory."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
total_samples = capture_path.stat().st_size // 2
|
||||
if not 0 <= start_sample < stop_sample <= total_samples:
|
||||
raise ValueError("diagnostic window is outside the cs8 capture")
|
||||
raw = np.memmap(capture_path, dtype=np.int8, mode="r")
|
||||
power_sum = 0.0
|
||||
i_sum = 0.0
|
||||
q_sum = 0.0
|
||||
rail_codes = 0
|
||||
peak_component = 0
|
||||
for chunk_start in range(start_sample, stop_sample, chunk_samples):
|
||||
chunk_stop = min(stop_sample, chunk_start + chunk_samples)
|
||||
values = np.asarray(
|
||||
raw[chunk_start * 2:chunk_stop * 2],
|
||||
dtype=np.int8,
|
||||
).reshape(-1, 2)
|
||||
wide = values.astype(np.float64)
|
||||
i_values = wide[:, 0]
|
||||
q_values = wide[:, 1]
|
||||
power_sum += float(np.sum(i_values * i_values + q_values * q_values))
|
||||
i_sum += float(np.sum(i_values))
|
||||
q_sum += float(np.sum(q_values))
|
||||
rail_codes += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||
peak_component = max(peak_component, int(np.max(np.abs(wide))))
|
||||
sample_count = stop_sample - start_sample
|
||||
mean_power = power_sum / sample_count
|
||||
return {
|
||||
"start_sample": start_sample,
|
||||
"stop_sample": stop_sample,
|
||||
"sample_count": sample_count,
|
||||
"mean_power_code_squared": mean_power,
|
||||
"rms_complex_normalized": math.sqrt(mean_power) / 128.0,
|
||||
"mean_i_code": i_sum / sample_count,
|
||||
"mean_q_code": q_sum / sample_count,
|
||||
"peak_component_code": peak_component,
|
||||
"rail_codes": rail_codes,
|
||||
}
|
||||
|
||||
|
||||
def read_capture_diagnostics(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
"""Return the fixed report and signal metrics for one completed capture."""
|
||||
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"mode": "OFFLINE_CAPTURE_DIAGNOSTICS",
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
status, status_errors = read_capture_status(work_root, session_id)
|
||||
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||
capture_started_at = str(status["capture_started_at"])
|
||||
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||
capture_path = Path(str(status["capture_path"]))
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
report_path = session_directory / REPORT_FILE_NAME
|
||||
if not report_path.is_file():
|
||||
raise FileNotFoundError(f"decode report is absent: {report_path}")
|
||||
report_size = report_path.stat().st_size
|
||||
if report_size <= 0 or report_size > MAX_REPORT_BYTES:
|
||||
raise ValueError(
|
||||
f"decode report size {report_size} is outside the allowed range"
|
||||
)
|
||||
report_bytes = report_path.read_bytes()
|
||||
report = json.loads(report_bytes.decode("utf-8-sig"))
|
||||
if not isinstance(report, dict) or report.get("rf_tx") != 0:
|
||||
raise ValueError("decode report is invalid or does not preserve rf_tx = 0")
|
||||
runner_result = status.get("runner_result")
|
||||
if not isinstance(runner_result, dict):
|
||||
raise ValueError("capture result is absent")
|
||||
capture_sha256 = str(runner_result.get("sha256", ""))
|
||||
if report.get("iq_sha256") != capture_sha256:
|
||||
raise ValueError("decode report does not match the capture SHA-256")
|
||||
|
||||
sample_rate = int(EXPECTED_PROFILE["sample_rate_hz"])
|
||||
total_samples = capture_path.stat().st_size // 2
|
||||
capture_time = _iso_utc(capture_started_at)
|
||||
tx_start = _iso_utc(tx_started_at)
|
||||
tx_finish = _iso_utc(tx_finished_at)
|
||||
tx_start_sample = round((tx_start - capture_time).total_seconds() * sample_rate)
|
||||
tx_stop_sample = round((tx_finish - capture_time).total_seconds() * sample_rate)
|
||||
tx_start_sample = max(0, min(total_samples, tx_start_sample))
|
||||
tx_stop_sample = max(0, min(total_samples, tx_stop_sample))
|
||||
if tx_stop_sample <= tx_start_sample:
|
||||
raise ValueError("TX window does not overlap the capture")
|
||||
tx_samples = tx_stop_sample - tx_start_sample
|
||||
pre_start = max(0, tx_start_sample - tx_samples)
|
||||
post_stop = min(total_samples, tx_stop_sample + tx_samples)
|
||||
|
||||
signal = _power_metrics(capture_path, tx_start_sample, tx_stop_sample)
|
||||
pre = _power_metrics(capture_path, pre_start, tx_start_sample)
|
||||
post = _power_metrics(capture_path, tx_stop_sample, post_stop)
|
||||
baseline_power = (
|
||||
float(pre["mean_power_code_squared"])
|
||||
+ float(post["mean_power_code_squared"])
|
||||
) / 2.0
|
||||
signal_power = float(signal["mean_power_code_squared"])
|
||||
power_ratio = signal_power / baseline_power if baseline_power > 0 else None
|
||||
power_delta_db = (
|
||||
10.0 * math.log10(power_ratio)
|
||||
if power_ratio is not None and power_ratio > 0
|
||||
else None
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"report_path": str(report_path),
|
||||
"report_bytes": report_size,
|
||||
"report_sha256": hashlib.sha256(report_bytes).hexdigest(),
|
||||
"capture_sha256": capture_sha256,
|
||||
"report": report,
|
||||
"signal_metrics": {
|
||||
"tx_window": signal,
|
||||
"pre_tx_baseline": pre,
|
||||
"post_tx_baseline": post,
|
||||
"tx_to_baseline_power_ratio": power_ratio,
|
||||
"tx_to_baseline_db": power_delta_db,
|
||||
},
|
||||
}
|
||||
)
|
||||
except (
|
||||
ImportError,
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return result, errors
|
||||
1529
tools/rf001_rx_http_agent.py
Normal file
1529
tools/rf001_rx_http_agent.py
Normal file
File diff suppressed because it is too large
Load Diff
74
tools/rf001_staircase_job.py
Normal file
74
tools/rf001_staircase_job.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Detached software-only worker for one fixed STAIRCASE_SEARCH task."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf001_http_control import PROTOCOL_VERSION, STAIRCASE_SEARCH_TASK, utc_now
|
||||
from tools.rf001_rx_http_agent import run_staircase_search
|
||||
|
||||
|
||||
def _atomic_write(path: Path, value: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def run_job(spec_path: Path, result_path: Path) -> dict[str, object]:
|
||||
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError("job spec must be a JSON object")
|
||||
task = spec.get("task")
|
||||
if not isinstance(task, dict) or task.get("kind") != STAIRCASE_SEARCH_TASK:
|
||||
raise ValueError("job spec does not contain STAIRCASE_SEARCH")
|
||||
started_at = utc_now()
|
||||
task_result, errors = run_staircase_search(
|
||||
Path(str(spec["work_root"])),
|
||||
str(task["session_id"]),
|
||||
str(task["expected_rx_sha256"]),
|
||||
str(task["expected_reference_sha256"]),
|
||||
progress_path=Path(str(spec["progress_path"])),
|
||||
)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": str(task["task_id"]),
|
||||
"agent_id": str(spec["agent_id"]),
|
||||
"status": "COMPLETE" if not errors else "FAILED",
|
||||
"started_at": started_at,
|
||||
"finished_at": utc_now(),
|
||||
"result": task_result,
|
||||
"errors": errors,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
_atomic_write(result_path, result)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--job-spec", required=True, type=Path)
|
||||
parser.add_argument("--result", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
result = run_job(arguments.job_spec, arguments.result)
|
||||
print(f"TASK ID = {result['task_id']}")
|
||||
print(f"TASK STATUS = {result['status']}")
|
||||
print("HARDWARE ACCESSED = false")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
937
tools/rf001_staircase_search.py
Normal file
937
tools/rf001_staircase_search.py
Normal file
@@ -0,0 +1,937 @@
|
||||
"""Потоковый поиск эталонной лестницы Lab064/Lab065 в большом cs8-файле.
|
||||
|
||||
Утилита не обращается к SDR, не использует пакетный приёмник и не меняет
|
||||
исходные IQ-файлы. Полная запись просматривается блоками с перекрытием.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
|
||||
EXPECTED_REFERENCE_SHA256 = (
|
||||
"fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||
)
|
||||
ANALYZER_VERSION = "2026.09.28.1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReferenceLayout:
|
||||
sample_rate_hz: int = 8_000_000
|
||||
symbol_rate: int = 20_000
|
||||
carrier_hz: float = 50_000.0
|
||||
leading_samples: int = 800_000
|
||||
segment_samples: int = 800_000
|
||||
gap_samples: int = 400_000
|
||||
trailing_samples: int = 800_000
|
||||
levels_db: tuple[float, ...] = (-18.0, -12.0, -6.0, 0.0)
|
||||
|
||||
@property
|
||||
def total_samples(self) -> int:
|
||||
return (
|
||||
self.leading_samples
|
||||
+ len(self.levels_db) * self.segment_samples
|
||||
+ (len(self.levels_db) - 1) * self.gap_samples
|
||||
+ self.trailing_samples
|
||||
)
|
||||
|
||||
def active_windows(self) -> tuple[tuple[int, int], ...]:
|
||||
cursor = self.leading_samples
|
||||
windows = []
|
||||
for index in range(len(self.levels_db)):
|
||||
windows.append((cursor, cursor + self.segment_samples))
|
||||
cursor += self.segment_samples
|
||||
if index + 1 < len(self.levels_db):
|
||||
cursor += self.gap_samples
|
||||
return tuple(windows)
|
||||
|
||||
def background_windows(self) -> tuple[tuple[int, int], ...]:
|
||||
width = min(self.gap_samples, self.leading_samples, self.trailing_samples) // 2
|
||||
cursor = (self.leading_samples - width) // 2
|
||||
windows = [(cursor, cursor + width)]
|
||||
cursor = self.leading_samples + self.segment_samples
|
||||
for index in range(len(self.levels_db) - 1):
|
||||
start = cursor + (self.gap_samples - width) // 2
|
||||
windows.append((start, start + width))
|
||||
cursor += self.gap_samples + self.segment_samples
|
||||
final_start = self.total_samples - self.trailing_samples
|
||||
start = final_start + (self.trailing_samples - width) // 2
|
||||
windows.append((start, start + width))
|
||||
return tuple(windows)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchCriteria:
|
||||
decimation: int = 32
|
||||
input_block_samples: int = 4_000_000
|
||||
anti_alias_passband_hz: float = 75_000.0
|
||||
anti_alias_stopband_hz: float = 120_000.0
|
||||
anti_alias_attenuation_db: float = 80.0
|
||||
feature_period_seconds: float = 0.001
|
||||
refinement_radius_seconds: float = 0.003
|
||||
cfo_min_hz: float = -3.0
|
||||
cfo_max_hz: float = 3.0
|
||||
cfo_step_hz: float = 0.25
|
||||
normalized_correlation_minimum: float = 0.05
|
||||
peak_to_background_minimum_db: float = 6.0
|
||||
maximum_step_error_db: float = 3.0
|
||||
l3_matched_to_residual_minimum_db: float = -18.0
|
||||
l3_spectral_contrast_minimum_db: float = 3.0
|
||||
histogram_stride: int = 1024
|
||||
maximum_reported_candidates: int = 5
|
||||
|
||||
|
||||
DEFAULT_LAYOUT = ReferenceLayout()
|
||||
DEFAULT_CRITERIA = SearchCriteria()
|
||||
|
||||
|
||||
def current_process_rss_bytes() -> int:
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return 0
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", ctypes.c_ulong),
|
||||
("page_fault_count", ctypes.c_ulong),
|
||||
("peak_working_set_size", ctypes.c_size_t),
|
||||
("working_set_size", ctypes.c_size_t),
|
||||
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("pagefile_usage", ctypes.c_size_t),
|
||||
("peak_pagefile_usage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||
get_current_process.restype = ctypes.c_void_p
|
||||
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||
get_process_memory_info.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ProcessMemoryCounters),
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
get_process_memory_info.restype = ctypes.c_int
|
||||
succeeded = get_process_memory_info(
|
||||
get_current_process(),
|
||||
ctypes.byref(counters),
|
||||
counters.cb,
|
||||
)
|
||||
return int(counters.working_set_size) if succeeded else 0
|
||||
|
||||
|
||||
class MemoryPeakSampler:
|
||||
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||
self.poll_seconds = poll_seconds
|
||||
self.baseline_bytes = current_process_rss_bytes()
|
||||
self.peak_bytes = self.baseline_bytes
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||
|
||||
def _sample(self) -> None:
|
||||
while not self._stop.wait(self.poll_seconds):
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
|
||||
def __enter__(self) -> "MemoryPeakSampler":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
@property
|
||||
def additional_peak_bytes(self) -> int:
|
||||
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||
|
||||
|
||||
def _write_progress(
|
||||
path: Path,
|
||||
*,
|
||||
phase: str,
|
||||
processed_samples: int,
|
||||
total_samples: int,
|
||||
) -> None:
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"phase": phase,
|
||||
"processed_samples": processed_samples,
|
||||
"total_samples": total_samples,
|
||||
"coverage_complete": (
|
||||
phase == "COMPLETE" and processed_samples == total_samples
|
||||
),
|
||||
"updated_at_epoch": time.time(),
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def sha256_file(
|
||||
path: Path,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
) -> str:
|
||||
digest = hashlib.sha256()
|
||||
total = path.stat().st_size
|
||||
processed = 0
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(4 * 1024 * 1024):
|
||||
digest.update(block)
|
||||
processed += len(block)
|
||||
if progress is not None:
|
||||
progress(processed, total)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def cs8_sample_count(path: Path) -> int:
|
||||
size = path.stat().st_size
|
||||
if size <= 0 or size % 2:
|
||||
raise ValueError("cs8-файл должен содержать целое ненулевое число пар I/Q")
|
||||
return size // 2
|
||||
|
||||
|
||||
def design_antialias_filter(
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> np.ndarray:
|
||||
"""Build the fixed linear-phase FIR used before every decimation."""
|
||||
|
||||
if criteria.decimation <= 0:
|
||||
raise ValueError("decimation must be positive")
|
||||
if criteria.decimation == 1:
|
||||
return np.ones(1, dtype=np.float64)
|
||||
nyquist = layout.sample_rate_hz / 2
|
||||
decimated_nyquist = layout.sample_rate_hz / (2 * criteria.decimation)
|
||||
if not (
|
||||
0 < criteria.anti_alias_passband_hz
|
||||
< criteria.anti_alias_stopband_hz
|
||||
< decimated_nyquist
|
||||
):
|
||||
raise ValueError("anti-alias passband and stopband are invalid")
|
||||
transition_width = (
|
||||
criteria.anti_alias_stopband_hz - criteria.anti_alias_passband_hz
|
||||
) / nyquist
|
||||
taps_count, beta = signal.kaiserord(
|
||||
criteria.anti_alias_attenuation_db,
|
||||
transition_width,
|
||||
)
|
||||
taps_count = max(3, taps_count | 1)
|
||||
cutoff_hz = (
|
||||
criteria.anti_alias_passband_hz + criteria.anti_alias_stopband_hz
|
||||
) / 2
|
||||
return signal.firwin(
|
||||
taps_count,
|
||||
cutoff_hz,
|
||||
window=("kaiser", beta),
|
||||
fs=layout.sample_rate_hz,
|
||||
).astype(np.float64)
|
||||
|
||||
|
||||
def _read_cs8_complex(path: Path, start: int, stop: int) -> np.ndarray:
|
||||
"""Read one bounded source interval; out-of-file samples are zero."""
|
||||
|
||||
if start >= stop:
|
||||
raise ValueError("invalid source interval")
|
||||
total = cs8_sample_count(path)
|
||||
values = np.zeros(stop - start, dtype=np.complex64)
|
||||
clipped_start = max(0, start)
|
||||
clipped_stop = min(total, stop)
|
||||
if clipped_start >= clipped_stop:
|
||||
return values
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
interleaved = np.asarray(
|
||||
raw[2 * clipped_start:2 * clipped_stop],
|
||||
dtype=np.float32,
|
||||
)
|
||||
destination = slice(clipped_start - start, clipped_stop - start)
|
||||
values[destination] = interleaved[0::2] + 1j * interleaved[1::2]
|
||||
return values
|
||||
|
||||
|
||||
def read_filtered_decimated_window(
|
||||
path: Path,
|
||||
start: int,
|
||||
stop: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Read a decimated window with FIR overlap and delay correction.
|
||||
|
||||
Output index ``k`` is centred on input sample ``k * decimation``. The
|
||||
function reads half the FIR on both sides, so adjacent blocks have exactly
|
||||
the overlap required to match one full-file convolution. Zeros are used
|
||||
only outside the physical file.
|
||||
"""
|
||||
|
||||
total = cs8_sample_count(path)
|
||||
total_decimated = math.ceil(total / criteria.decimation)
|
||||
if not 0 <= start < stop <= total_decimated:
|
||||
raise ValueError("invalid filtered-decimated window")
|
||||
delay = (taps.size - 1) // 2
|
||||
first_center = start * criteria.decimation
|
||||
last_center = (stop - 1) * criteria.decimation
|
||||
source_start = first_center - delay
|
||||
source_stop = last_center + delay + 1
|
||||
values = _read_cs8_complex(path, source_start, source_stop)
|
||||
if taps.size == 1:
|
||||
return values[::criteria.decimation].astype(np.complex64, copy=False)
|
||||
filtered = signal.fftconvolve(values, taps, mode="valid")
|
||||
result = filtered[::criteria.decimation]
|
||||
if result.size != stop - start:
|
||||
raise RuntimeError("filtered decimator produced an unexpected length")
|
||||
return result.astype(np.complex64, copy=False)
|
||||
|
||||
|
||||
def iter_filtered_decimated(
|
||||
path: Path,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> Iterable[tuple[int, np.ndarray]]:
|
||||
"""Yield the full file in bounded, boundary-correct decimated blocks."""
|
||||
|
||||
if criteria.input_block_samples <= 0:
|
||||
raise ValueError("input block size must be positive")
|
||||
total = cs8_sample_count(path)
|
||||
total_decimated = math.ceil(total / criteria.decimation)
|
||||
block = max(1, criteria.input_block_samples // criteria.decimation)
|
||||
for start in range(0, total_decimated, block):
|
||||
stop = min(total_decimated, start + block)
|
||||
yield start, read_filtered_decimated_window(
|
||||
path,
|
||||
start,
|
||||
stop,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
|
||||
|
||||
def load_reference(
|
||||
path: Path,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
*,
|
||||
expected_sha256: str | None,
|
||||
) -> tuple[np.ndarray, str]:
|
||||
if cs8_sample_count(path) != layout.total_samples:
|
||||
raise ValueError("размер эталона не соответствует фиксированной структуре")
|
||||
digest = sha256_file(path)
|
||||
if expected_sha256 is not None and digest != expected_sha256:
|
||||
raise ValueError("SHA256 эталона не совпадает с разрешённым значением")
|
||||
stop = math.ceil(layout.total_samples / criteria.decimation)
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
reference = read_filtered_decimated_window(
|
||||
path,
|
||||
0,
|
||||
stop,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
reference -= np.mean(reference, dtype=np.complex128)
|
||||
return reference, digest
|
||||
|
||||
|
||||
def _frequency_grid(criteria: SearchCriteria) -> np.ndarray:
|
||||
count = round(
|
||||
(criteria.cfo_max_hz - criteria.cfo_min_hz) / criteria.cfo_step_hz
|
||||
)
|
||||
return criteria.cfo_min_hz + criteria.cfo_step_hz * np.arange(count + 1)
|
||||
|
||||
|
||||
def _histogram_quantile(histogram: np.ndarray, quantile: float) -> float:
|
||||
total = int(histogram.sum())
|
||||
if total == 0:
|
||||
return 0.0
|
||||
target = math.ceil(total * quantile)
|
||||
index = int(np.searchsorted(np.cumsum(histogram), target, side="left"))
|
||||
return min(1.0, (index + 0.5) / histogram.size)
|
||||
|
||||
|
||||
def _deduplicate_candidates(
|
||||
candidates: Iterable[dict[str, float | int]],
|
||||
minimum_separation: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, float | int]]:
|
||||
selected: list[dict[str, float | int]] = []
|
||||
for candidate in sorted(candidates, key=lambda row: float(row["score"]), reverse=True):
|
||||
start = int(candidate["start_decimated_sample"])
|
||||
if any(
|
||||
abs(start - int(existing["start_decimated_sample"])) < minimum_separation
|
||||
for existing in selected
|
||||
):
|
||||
continue
|
||||
selected.append(candidate)
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def _power_features(values: np.ndarray, stride: int) -> np.ndarray:
|
||||
complete = values.size // stride
|
||||
if complete == 0:
|
||||
return np.empty(0, dtype=np.float64)
|
||||
reshaped = values[:complete * stride].reshape(complete, stride)
|
||||
return np.mean(np.abs(reshaped) ** 2, axis=1, dtype=np.float64)
|
||||
|
||||
|
||||
def _normalized_sliding_scores(
|
||||
values: np.ndarray,
|
||||
template: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
centred = template.astype(np.float64) - float(np.mean(template))
|
||||
template_energy = float(np.vdot(centred, centred).real)
|
||||
count = centred.size
|
||||
prefix = np.concatenate(([0.0], np.cumsum(values, dtype=np.float64)))
|
||||
prefix_power = np.concatenate(
|
||||
([0.0], np.cumsum(values * values, dtype=np.float64))
|
||||
)
|
||||
window_sum = prefix[count:] - prefix[:-count]
|
||||
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||
ac_power = window_power - (window_sum * window_sum) / count
|
||||
denominator = np.sqrt(np.maximum(template_energy * ac_power, 1e-30))
|
||||
correlation = signal.fftconvolve(values, centred[::-1], mode="valid")
|
||||
return np.abs(correlation) / denominator
|
||||
|
||||
|
||||
def _refine_candidate(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
approximate_start: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> dict[str, float | int]:
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
radius = max(1, round(criteria.refinement_radius_seconds * decimated_rate))
|
||||
total_decimated = math.ceil(
|
||||
cs8_sample_count(capture_path) / criteria.decimation
|
||||
)
|
||||
first = max(0, approximate_start - radius)
|
||||
last = min(total_decimated - reference.size, approximate_start + radius)
|
||||
received = read_filtered_decimated_window(
|
||||
capture_path,
|
||||
first,
|
||||
last + reference.size,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
count = reference.size
|
||||
prefix_power = np.concatenate(
|
||||
([0.0], np.cumsum(np.abs(received) ** 2, dtype=np.float64))
|
||||
)
|
||||
prefix_sum = np.concatenate(
|
||||
([0.0 + 0.0j], np.cumsum(received, dtype=np.complex128))
|
||||
)
|
||||
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||
window_sum = prefix_sum[count:] - prefix_sum[:-count]
|
||||
window_ac_power = window_power - (np.abs(window_sum) ** 2) / count
|
||||
reference_energy = float(np.vdot(reference, reference).real)
|
||||
denominator = np.sqrt(np.maximum(reference_energy * window_ac_power, 1e-30))
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||
best_score = -1.0
|
||||
best_index = 0
|
||||
best_cfo = 0.0
|
||||
for cfo_hz in _frequency_grid(criteria):
|
||||
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||
correlation = signal.fftconvolve(
|
||||
received,
|
||||
np.conj(rotated[::-1]),
|
||||
mode="valid",
|
||||
)
|
||||
scores = np.abs(correlation) / denominator
|
||||
index = int(np.argmax(scores))
|
||||
score = float(scores[index])
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_index = index
|
||||
best_cfo = float(cfo_hz)
|
||||
start_decimated = first + best_index
|
||||
return {
|
||||
"score": best_score,
|
||||
"start_decimated_sample": start_decimated,
|
||||
"start_sample": start_decimated * criteria.decimation,
|
||||
"start_seconds": (
|
||||
start_decimated * criteria.decimation / layout.sample_rate_hz
|
||||
),
|
||||
"cfo_hz": best_cfo,
|
||||
}
|
||||
|
||||
|
||||
def locate_reference_streaming(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Scan every source sample through a bounded anti-aliased feature stream."""
|
||||
|
||||
total_samples = cs8_sample_count(capture_path)
|
||||
total_decimated = math.ceil(total_samples / criteria.decimation)
|
||||
if total_decimated < reference.size:
|
||||
raise ValueError("RX короче эталонной лестницы")
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
feature_stride = round(criteria.feature_period_seconds * decimated_rate)
|
||||
if feature_stride <= 0 or criteria.histogram_stride <= 0:
|
||||
raise ValueError("feature period and histogram stride must be positive")
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
feature_parts: list[np.ndarray] = []
|
||||
pending = np.empty(0, dtype=np.complex64)
|
||||
maximum_loaded = 0
|
||||
streamed_decimated = 0
|
||||
for block_start, block in iter_filtered_decimated(
|
||||
capture_path,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
):
|
||||
if block_start != streamed_decimated:
|
||||
raise RuntimeError("decimated stream is not contiguous")
|
||||
streamed_decimated += block.size
|
||||
if progress is not None:
|
||||
progress(
|
||||
min(total_samples, streamed_decimated * criteria.decimation),
|
||||
total_samples,
|
||||
)
|
||||
combined = np.concatenate((pending, block)) if pending.size else block
|
||||
complete = combined.size // feature_stride
|
||||
used = complete * feature_stride
|
||||
if used:
|
||||
feature_parts.append(_power_features(combined[:used], feature_stride))
|
||||
pending = combined[used:].copy()
|
||||
maximum_loaded = max(maximum_loaded, int(combined.size))
|
||||
if pending.size:
|
||||
feature_parts.append(
|
||||
np.array([np.mean(np.abs(pending) ** 2, dtype=np.float64)])
|
||||
)
|
||||
features = np.concatenate(feature_parts)
|
||||
reference_features = _power_features(reference, feature_stride)
|
||||
if features.size < reference_features.size:
|
||||
raise ValueError("RX feature stream is shorter than the reference")
|
||||
scores = _normalized_sliding_scores(features, reference_features)
|
||||
sampled = np.clip(scores[::criteria.histogram_stride], 0.0, 1.0)
|
||||
histogram = np.histogram(sampled, bins=2000, range=(0.0, 1.0))[0]
|
||||
background_p99 = _histogram_quantile(histogram, 0.99)
|
||||
candidate_count = min(max(20, criteria.maximum_reported_candidates * 8), scores.size)
|
||||
indexes = np.argpartition(scores, -candidate_count)[-candidate_count:]
|
||||
coarse = [
|
||||
{
|
||||
"score": float(scores[index]),
|
||||
"start_decimated_sample": int(index * feature_stride),
|
||||
"start_feature": int(index),
|
||||
}
|
||||
for index in indexes
|
||||
]
|
||||
coarse = _deduplicate_candidates(
|
||||
coarse,
|
||||
max(1, reference.size // 3),
|
||||
criteria.maximum_reported_candidates,
|
||||
)
|
||||
refined = []
|
||||
for row in coarse:
|
||||
candidate = _refine_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
int(row["start_decimated_sample"]),
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
temporal_score = float(row["score"])
|
||||
candidate["temporal_power_score"] = temporal_score
|
||||
candidate["score_to_background_p99_db"] = (
|
||||
20.0 * math.log10(temporal_score / background_p99)
|
||||
if temporal_score > 0 and background_p99 > 0
|
||||
else None
|
||||
)
|
||||
refined.append(candidate)
|
||||
independent = _deduplicate_candidates(
|
||||
refined,
|
||||
max(1, reference.size // 3),
|
||||
criteria.maximum_reported_candidates,
|
||||
)
|
||||
return {
|
||||
"coverage_start_sample": 0,
|
||||
"coverage_stop_sample": total_samples,
|
||||
"coverage_seconds": total_samples / layout.sample_rate_hz,
|
||||
"coverage_complete": streamed_decimated == total_decimated,
|
||||
"source_samples_processed": total_samples,
|
||||
"tested_feature_start_positions": int(scores.size),
|
||||
"feature_stride_decimated_samples": feature_stride,
|
||||
"feature_stride_source_samples": feature_stride * criteria.decimation,
|
||||
"frequency_grid_hz": [
|
||||
float(value) for value in _frequency_grid(criteria)
|
||||
],
|
||||
"background_score_p99": background_p99,
|
||||
"candidates": independent,
|
||||
"maximum_loaded_decimated_samples": maximum_loaded,
|
||||
"maximum_loaded_iq_bytes": maximum_loaded * np.dtype(np.complex64).itemsize,
|
||||
"anti_alias_filter": {
|
||||
"kind": "linear_phase_fir_kaiser",
|
||||
"tap_count": int(taps.size),
|
||||
"passband_hz": criteria.anti_alias_passband_hz,
|
||||
"stopband_hz": criteria.anti_alias_stopband_hz,
|
||||
"design_attenuation_db": criteria.anti_alias_attenuation_db,
|
||||
"group_delay_source_samples": (int(taps.size) - 1) // 2,
|
||||
"position_delay_correction_source_samples": (
|
||||
-((int(taps.size) - 1) // 2)
|
||||
),
|
||||
"boundary_handling": "symmetric_overlap_with_zero_extension_at_file_edges",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read_candidate(
|
||||
capture_path: Path,
|
||||
start_decimated: int,
|
||||
count: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> np.ndarray:
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
return read_filtered_decimated_window(
|
||||
capture_path,
|
||||
start_decimated,
|
||||
start_decimated + count,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
|
||||
|
||||
def _decimated_window(window: tuple[int, int], decimation: int) -> tuple[int, int]:
|
||||
return round(window[0] / decimation), round(window[1] / decimation)
|
||||
|
||||
|
||||
def _band_power(values: np.ndarray, sample_rate_hz: float, low: float, high: float) -> float:
|
||||
nperseg = min(4096, values.size)
|
||||
frequencies, density = signal.welch(
|
||||
values,
|
||||
fs=sample_rate_hz,
|
||||
nperseg=nperseg,
|
||||
noverlap=nperseg // 2,
|
||||
return_onesided=False,
|
||||
scaling="density",
|
||||
)
|
||||
mask = (frequencies >= low) & (frequencies <= high)
|
||||
if not np.any(mask):
|
||||
return 0.0
|
||||
return float(np.trapezoid(density[mask], frequencies[mask]))
|
||||
|
||||
|
||||
def verify_candidate(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
candidate: dict[str, float | int],
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> dict[str, object]:
|
||||
start_decimated = int(candidate["start_decimated_sample"])
|
||||
received = _read_candidate(
|
||||
capture_path,
|
||||
start_decimated,
|
||||
reference.size,
|
||||
layout,
|
||||
criteria,
|
||||
)
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
cfo_hz = float(candidate["cfo_hz"])
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||
segment_decimated = round(layout.segment_samples / criteria.decimation)
|
||||
trim = min(
|
||||
max(1, round(21 * layout.sample_rate_hz / layout.symbol_rate / criteria.decimation)),
|
||||
max(1, segment_decimated // 10),
|
||||
)
|
||||
rows = []
|
||||
for index, window in enumerate(layout.active_windows()):
|
||||
start, stop = _decimated_window(window, criteria.decimation)
|
||||
start += trim
|
||||
stop -= trim
|
||||
expected = rotated[start:stop]
|
||||
observed = received[start:stop]
|
||||
expected = expected - np.mean(expected, dtype=np.complex128)
|
||||
observed = observed - np.mean(observed, dtype=np.complex128)
|
||||
expected_energy = float(np.vdot(expected, expected).real)
|
||||
observed_energy = float(np.vdot(observed, observed).real)
|
||||
gain = np.vdot(expected, observed) / max(expected_energy, 1e-30)
|
||||
matched = gain * expected
|
||||
residual = observed - matched
|
||||
matched_power = float(np.mean(np.abs(matched) ** 2))
|
||||
residual_power = float(np.mean(np.abs(residual) ** 2))
|
||||
coherence = float(
|
||||
abs(np.vdot(expected, observed))
|
||||
/ math.sqrt(max(expected_energy * observed_energy, 1e-30))
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"label": f"L{index}",
|
||||
"expected_level_db": layout.levels_db[index],
|
||||
"matched_power": matched_power,
|
||||
"residual_power": residual_power,
|
||||
"matched_to_residual_db": (
|
||||
10.0 * math.log10(matched_power / residual_power)
|
||||
if matched_power > 0 and residual_power > 0
|
||||
else None
|
||||
),
|
||||
"normalized_coherence": coherence,
|
||||
"gain_abs": float(abs(gain)),
|
||||
}
|
||||
)
|
||||
|
||||
step_rows = []
|
||||
for lower, upper in zip(rows, rows[1:]):
|
||||
expected_step = float(upper["expected_level_db"]) - float(lower["expected_level_db"])
|
||||
lower_power = float(lower["matched_power"])
|
||||
upper_power = float(upper["matched_power"])
|
||||
observed_step = (
|
||||
10.0 * math.log10(upper_power / lower_power)
|
||||
if lower_power > 0 and upper_power > 0
|
||||
else None
|
||||
)
|
||||
step_rows.append(
|
||||
{
|
||||
"comparison": f"{upper['label']}/{lower['label']}",
|
||||
"expected_step_db": expected_step,
|
||||
"observed_step_db": observed_step,
|
||||
"error_db": observed_step - expected_step if observed_step is not None else None,
|
||||
}
|
||||
)
|
||||
finite_errors = [
|
||||
abs(float(row["error_db"]))
|
||||
for row in step_rows
|
||||
if row["error_db"] is not None
|
||||
]
|
||||
maximum_step_error = max(finite_errors) if len(finite_errors) == len(step_rows) else None
|
||||
|
||||
l3_start, l3_stop = _decimated_window(layout.active_windows()[-1], criteria.decimation)
|
||||
background_start, background_stop = _decimated_window(
|
||||
layout.background_windows()[-2], criteria.decimation
|
||||
)
|
||||
band_half_width = 0.675 * layout.symbol_rate
|
||||
low = layout.carrier_hz - band_half_width
|
||||
high = layout.carrier_hz + band_half_width
|
||||
l3_band_power = _band_power(received[l3_start:l3_stop], decimated_rate, low, high)
|
||||
background_band_power = _band_power(
|
||||
received[background_start:background_stop], decimated_rate, low, high
|
||||
)
|
||||
spectral_contrast = (
|
||||
10.0 * math.log10(l3_band_power / background_band_power)
|
||||
if l3_band_power > 0 and background_band_power > 0
|
||||
else None
|
||||
)
|
||||
l3_ratio = rows[-1]["matched_to_residual_db"]
|
||||
matched_powers = [float(row["matched_power"]) for row in rows]
|
||||
monotonic = all(upper > lower for lower, upper in zip(matched_powers, matched_powers[1:]))
|
||||
step_pass = (
|
||||
maximum_step_error is not None
|
||||
and maximum_step_error <= criteria.maximum_step_error_db
|
||||
and monotonic
|
||||
)
|
||||
coherence_pass = (
|
||||
l3_ratio is not None
|
||||
and float(l3_ratio) >= criteria.l3_matched_to_residual_minimum_db
|
||||
)
|
||||
spectral_pass = (
|
||||
spectral_contrast is not None
|
||||
and spectral_contrast >= criteria.l3_spectral_contrast_minimum_db
|
||||
)
|
||||
return {
|
||||
"segments": rows,
|
||||
"steps": step_rows,
|
||||
"maximum_absolute_step_error_db": maximum_step_error,
|
||||
"monotonic_matched_power": monotonic,
|
||||
"step_pattern_pass": step_pass,
|
||||
"l3_matched_to_residual_pass": coherence_pass,
|
||||
"l3_band_power": l3_band_power,
|
||||
"background_band_power": background_band_power,
|
||||
"l3_spectral_contrast_db": spectral_contrast,
|
||||
"spectral_contrast_pass": spectral_pass,
|
||||
"qualified": step_pass and coherence_pass and spectral_pass,
|
||||
}
|
||||
|
||||
|
||||
def classify_search(
|
||||
search: dict[str, object],
|
||||
verifications: list[dict[str, object]],
|
||||
criteria: SearchCriteria,
|
||||
) -> tuple[str, list[str]]:
|
||||
if search.get("coverage_complete") is not True:
|
||||
return "ANALYSIS_INCOMPLETE", ["FULL_CAPTURE_COVERAGE_NOT_CONFIRMED"]
|
||||
candidates = list(search["candidates"])
|
||||
if not candidates:
|
||||
return "NOT_DETECTED", ["NO_CANDIDATES"]
|
||||
peak = candidates[0]
|
||||
score = float(peak["score"])
|
||||
contrast = peak.get("score_to_background_p99_db")
|
||||
correlation_pass = score >= criteria.normalized_correlation_minimum
|
||||
contrast_pass = (
|
||||
contrast is not None
|
||||
and float(contrast) >= criteria.peak_to_background_minimum_db
|
||||
)
|
||||
qualified = [row for row in verifications if row.get("qualified") is True]
|
||||
reasons = []
|
||||
if not correlation_pass:
|
||||
reasons.append("NORMALIZED_CORRELATION_BELOW_THRESHOLD")
|
||||
if not contrast_pass:
|
||||
reasons.append("PEAK_TO_BACKGROUND_CONTRAST_BELOW_THRESHOLD")
|
||||
if not qualified:
|
||||
reasons.append("STEP_OR_SPECTRAL_CONFIRMATION_FAILED")
|
||||
if correlation_pass and contrast_pass and len(qualified) == 1:
|
||||
return "DETECTED", []
|
||||
if (correlation_pass and contrast_pass) or qualified:
|
||||
if len(qualified) > 1:
|
||||
reasons.append("MULTIPLE_QUALIFIED_CANDIDATES")
|
||||
return "AMBIGUOUS", reasons
|
||||
return "NOT_DETECTED", reasons
|
||||
|
||||
|
||||
def analyze(
|
||||
reference_path: Path,
|
||||
capture_path: Path,
|
||||
report_path: Path,
|
||||
*,
|
||||
expected_capture_sha256: str,
|
||||
layout: ReferenceLayout = DEFAULT_LAYOUT,
|
||||
criteria: SearchCriteria = DEFAULT_CRITERIA,
|
||||
expected_reference_sha256: str = EXPECTED_REFERENCE_SHA256,
|
||||
progress_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"отчёт уже существует: {report_path}")
|
||||
total_samples = cs8_sample_count(capture_path)
|
||||
if progress_path is not None:
|
||||
_write_progress(
|
||||
progress_path,
|
||||
phase="VERIFYING_REFERENCE",
|
||||
processed_samples=0,
|
||||
total_samples=total_samples,
|
||||
)
|
||||
reference, reference_sha256 = load_reference(
|
||||
reference_path,
|
||||
layout,
|
||||
criteria,
|
||||
expected_sha256=expected_reference_sha256,
|
||||
)
|
||||
capture_progress: Callable[[int, int], None] | None = None
|
||||
if progress_path is not None:
|
||||
capture_progress = lambda processed, total: _write_progress(
|
||||
progress_path,
|
||||
phase="VERIFYING_RX_SHA256",
|
||||
processed_samples=processed // 2,
|
||||
total_samples=total // 2,
|
||||
)
|
||||
capture_sha256 = sha256_file(capture_path, progress=capture_progress)
|
||||
if capture_sha256 != expected_capture_sha256:
|
||||
raise ValueError("SHA256 RX-файла не совпадает с ожидаемым значением")
|
||||
analysis_started = time.perf_counter()
|
||||
with MemoryPeakSampler() as memory:
|
||||
search_progress: Callable[[int, int], None] | None = None
|
||||
if progress_path is not None:
|
||||
search_progress = lambda processed, total: _write_progress(
|
||||
progress_path,
|
||||
phase="SEARCHING",
|
||||
processed_samples=processed,
|
||||
total_samples=total,
|
||||
)
|
||||
search = locate_reference_streaming(
|
||||
capture_path,
|
||||
reference,
|
||||
layout,
|
||||
criteria,
|
||||
progress=search_progress,
|
||||
)
|
||||
verifications = []
|
||||
for candidate in search["candidates"]:
|
||||
verification = verify_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
candidate,
|
||||
layout,
|
||||
criteria,
|
||||
)
|
||||
verifications.append({"candidate": candidate, **verification})
|
||||
verdict, reasons = classify_search(search, verifications, criteria)
|
||||
analysis_seconds = time.perf_counter() - analysis_started
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"analyzer_version": ANALYZER_VERSION,
|
||||
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
||||
"reference_profile": "LAB065_REFERENCE_STAIRCASE",
|
||||
"reference_path": str(reference_path),
|
||||
"reference_sha256": reference_sha256,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_sha256": capture_sha256,
|
||||
"capture_bytes": capture_path.stat().st_size,
|
||||
"capture_complex_samples": cs8_sample_count(capture_path),
|
||||
"layout": asdict(layout),
|
||||
"criteria_fixed_before_capture_analysis": asdict(criteria),
|
||||
"search": search,
|
||||
"verifications": verifications,
|
||||
"verdict": verdict,
|
||||
"verdict_reasons": reasons,
|
||||
"peak_process_memory_bytes": memory.peak_bytes,
|
||||
"peak_additional_memory_bytes": memory.additional_peak_bytes,
|
||||
"analysis_seconds": analysis_seconds,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
serialized = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
|
||||
with report_path.open("x", encoding="utf-8") as stream:
|
||||
stream.write(serialized)
|
||||
if progress_path is not None:
|
||||
_write_progress(
|
||||
progress_path,
|
||||
phase="COMPLETE",
|
||||
processed_samples=total_samples,
|
||||
total_samples=total_samples,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--reference", required=True, type=Path)
|
||||
parser.add_argument("--capture", required=True, type=Path)
|
||||
parser.add_argument("--capture-sha256", required=True)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--progress", type=Path)
|
||||
arguments = parser.parse_args()
|
||||
result = analyze(
|
||||
arguments.reference,
|
||||
arguments.capture,
|
||||
arguments.report,
|
||||
expected_capture_sha256=arguments.capture_sha256,
|
||||
progress_path=arguments.progress,
|
||||
)
|
||||
print(f"REFERENCE STAIRCASE = {result['verdict']}; RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover HTTP Control Server"
|
||||
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $task) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
"AUTOSTART_REMOVED=YES"
|
||||
}
|
||||
else {
|
||||
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||
}
|
||||
"RUNTIME_DATA_REMOVED=NO"
|
||||
"RF_TX=0"
|
||||
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover RX Agent"
|
||||
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $task) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
"AUTOSTART_REMOVED=YES"
|
||||
}
|
||||
else {
|
||||
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||
}
|
||||
"INSTALLATION_FILES_REMOVED=NO"
|
||||
"RUNTIME_DATA_REMOVED=NO"
|
||||
"RF_TX=0"
|
||||
Reference in New Issue
Block a user