1806 lines
70 KiB
Python
1806 lines
70 KiB
Python
"""Authenticated HTTP control channel for the RF-001 RX laptop.
|
|
|
|
The RX laptop always initiates the connection. The server exposes only a
|
|
small JSON API, never a filesystem, and accepts only fixed RF-001 task kinds.
|
|
RF capture is receive-only and requires an exact local authorization phrase.
|
|
RF transmission is deliberately absent from the network protocol.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import hmac
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any, Mapping
|
|
from urllib.error import HTTPError
|
|
from urllib.parse import parse_qs, quote, urlsplit
|
|
from urllib.request import ProxyHandler, Request, build_opener
|
|
|
|
try:
|
|
from tools.rf001_agent_update import (
|
|
RELEASE_ID_PATTERN,
|
|
ReleaseValidationError,
|
|
inspect_release_archive,
|
|
sign_update_manifest,
|
|
)
|
|
except ModuleNotFoundError: # Direct execution from tools/.
|
|
from rf001_agent_update import ( # type: ignore[no-redef]
|
|
RELEASE_ID_PATTERN,
|
|
ReleaseValidationError,
|
|
inspect_release_archive,
|
|
sign_update_manifest,
|
|
)
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PROTOCOL_VERSION = 1
|
|
MAX_JSON_BYTES = 256 * 1024
|
|
AUTH_WINDOW_SECONDS = 60
|
|
TASK_LEASE_SECONDS = 30
|
|
INVENTORY_TASK = "INVENTORY"
|
|
OFFLINE_VERIFY_TASK = "OFFLINE_VERIFY"
|
|
RX_CAPTURE_START_TASK = "RX_CAPTURE_START"
|
|
RX_CAPTURE_STATUS_TASK = "RX_CAPTURE_STATUS"
|
|
CAPTURE_DECODE_TASK = "CAPTURE_DECODE"
|
|
CAPTURE_DIAGNOSTICS_TASK = "CAPTURE_DIAGNOSTICS"
|
|
STAIRCASE_SEARCH_TASK = "STAIRCASE_SEARCH"
|
|
UPDATE_AGENT_TASK = "UPDATE_AGENT"
|
|
RX_AUTHORIZATION_PHRASE = "AUTHORIZE RF RX"
|
|
FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3"
|
|
TASK_ID_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,95}$")
|
|
AGENT_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{2,63}$")
|
|
BOOTSTRAP_RELEASE_ID_PATTERN = re.compile(
|
|
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
|
|
)
|
|
GIT_HEAD_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
SESSION_ID_PATTERN = re.compile(r"^RF001A_[0-9]{8}T[0-9]{6}Z_[0-9A-F]{8}$")
|
|
DEFAULT_STATE_PATH = ROOT / ".local" / "rf001_http_server_state.json"
|
|
DEFAULT_TOKEN_PATH = ROOT / ".local" / "rf001_http_token.txt"
|
|
DEFAULT_SERVER_URL = "http://192.168.255.185:8765"
|
|
DEFAULT_RELEASES_ROOT = ROOT / ".local" / "releases"
|
|
|
|
|
|
def redirect_output(path: Path) -> None:
|
|
"""Send service-mode stdout and stderr to one line-buffered UTF-8 log."""
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
stream = path.open("a", encoding="utf-8", buffering=1)
|
|
sys.stdout = stream
|
|
sys.stderr = stream
|
|
|
|
|
|
class ProtocolError(ValueError):
|
|
"""The peer sent an invalid or unsupported protocol message."""
|
|
|
|
|
|
class AuthenticationError(ProtocolError):
|
|
"""Request authentication failed."""
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
|
|
|
|
def read_token(path: Path) -> bytes:
|
|
token = path.read_text(encoding="ascii").strip().encode("ascii")
|
|
if len(token) < 32:
|
|
raise ValueError("token must contain at least 32 ASCII bytes")
|
|
return token
|
|
|
|
|
|
def initialize_token(path: Path) -> str:
|
|
"""Create a token without overwriting an existing credential."""
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
token = secrets.token_urlsafe(48)
|
|
try:
|
|
with path.open("x", encoding="ascii", newline="\n") as stream:
|
|
stream.write(token + "\n")
|
|
except FileExistsError as error:
|
|
raise FileExistsError(f"token already exists: {path}") from error
|
|
return token
|
|
|
|
|
|
def _signature_payload(
|
|
method: str,
|
|
target: str,
|
|
timestamp: str,
|
|
nonce: str,
|
|
body: bytes,
|
|
) -> bytes:
|
|
body_digest = hashlib.sha256(body).hexdigest()
|
|
fields = (
|
|
str(PROTOCOL_VERSION),
|
|
timestamp,
|
|
nonce,
|
|
method.upper(),
|
|
target,
|
|
body_digest,
|
|
)
|
|
return "\n".join(fields).encode("ascii")
|
|
|
|
|
|
def signed_headers(
|
|
token: bytes,
|
|
method: str,
|
|
target: str,
|
|
body: bytes,
|
|
*,
|
|
timestamp: int | None = None,
|
|
nonce: str | None = None,
|
|
) -> dict[str, str]:
|
|
timestamp_text = str(int(time.time()) if timestamp is None else timestamp)
|
|
nonce_text = secrets.token_hex(16) if nonce is None else nonce
|
|
if not re.fullmatch(r"[0-9a-f]{32}", nonce_text):
|
|
raise ValueError("nonce must be 32 lowercase hexadecimal characters")
|
|
payload = _signature_payload(
|
|
method,
|
|
target,
|
|
timestamp_text,
|
|
nonce_text,
|
|
body,
|
|
)
|
|
signature = hmac.new(token, payload, hashlib.sha256).hexdigest()
|
|
return {
|
|
"Content-Type": "application/json",
|
|
"X-SDR-Version": str(PROTOCOL_VERSION),
|
|
"X-SDR-Timestamp": timestamp_text,
|
|
"X-SDR-Nonce": nonce_text,
|
|
"X-SDR-Signature": signature,
|
|
}
|
|
|
|
|
|
class RequestAuthenticator:
|
|
"""Verify request signatures and reject nonce replay within the time window."""
|
|
|
|
def __init__(
|
|
self,
|
|
token: bytes,
|
|
*,
|
|
window_seconds: int = AUTH_WINDOW_SECONDS,
|
|
) -> None:
|
|
self.token = token
|
|
self.window_seconds = window_seconds
|
|
self._nonces: OrderedDict[str, int] = OrderedDict()
|
|
self._lock = threading.Lock()
|
|
|
|
def verify(
|
|
self,
|
|
method: str,
|
|
target: str,
|
|
body: bytes,
|
|
headers: Mapping[str, str],
|
|
*,
|
|
now: int | None = None,
|
|
) -> None:
|
|
version = headers.get("X-SDR-Version", "")
|
|
timestamp_text = headers.get("X-SDR-Timestamp", "")
|
|
nonce = headers.get("X-SDR-Nonce", "")
|
|
signature = headers.get("X-SDR-Signature", "")
|
|
if version != str(PROTOCOL_VERSION):
|
|
raise AuthenticationError("unsupported authentication version")
|
|
if not timestamp_text.isdecimal():
|
|
raise AuthenticationError("invalid authentication timestamp")
|
|
if not re.fullmatch(r"[0-9a-f]{32}", nonce):
|
|
raise AuthenticationError("invalid authentication nonce")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", signature):
|
|
raise AuthenticationError("invalid authentication signature")
|
|
|
|
current = int(time.time()) if now is None else now
|
|
request_time = int(timestamp_text)
|
|
if abs(current - request_time) > self.window_seconds:
|
|
raise AuthenticationError("authentication timestamp is outside the window")
|
|
expected = hmac.new(
|
|
self.token,
|
|
_signature_payload(method, target, timestamp_text, nonce, body),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
if not hmac.compare_digest(signature, expected):
|
|
raise AuthenticationError("authentication signature mismatch")
|
|
|
|
cutoff = current - self.window_seconds
|
|
with self._lock:
|
|
while self._nonces:
|
|
oldest_nonce, oldest_time = next(iter(self._nonces.items()))
|
|
if oldest_time >= cutoff:
|
|
break
|
|
self._nonces.pop(oldest_nonce)
|
|
if nonce in self._nonces:
|
|
raise AuthenticationError("authentication nonce was already used")
|
|
self._nonces[nonce] = request_time
|
|
|
|
|
|
def _validate_agent_id(value: Any) -> str:
|
|
if not isinstance(value, str) or AGENT_ID_PATTERN.fullmatch(value) is None:
|
|
raise ProtocolError("invalid agent_id")
|
|
return value
|
|
|
|
|
|
def _validate_task_id(value: Any) -> str:
|
|
if not isinstance(value, str) or TASK_ID_PATTERN.fullmatch(value) is None:
|
|
raise ProtocolError("invalid task_id")
|
|
return value
|
|
|
|
|
|
def _require_mapping(value: Any, field: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise ProtocolError(f"{field} must be a JSON object")
|
|
return value
|
|
|
|
|
|
def _validate_session_id(value: Any) -> str:
|
|
if not isinstance(value, str) or SESSION_ID_PATTERN.fullmatch(value) is None:
|
|
raise ProtocolError("invalid session_id")
|
|
return value
|
|
|
|
|
|
def _validate_timestamp(value: Any, field: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise ProtocolError(f"{field} must be a timestamp string")
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as error:
|
|
raise ProtocolError(f"{field} is not an ISO timestamp") from error
|
|
if parsed.tzinfo is None:
|
|
raise ProtocolError(f"{field} must contain a timezone")
|
|
return parsed.astimezone(timezone.utc).isoformat()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InventoryTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
include_hackrf_info: bool
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": INVENTORY_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"include_hackrf_info": self.include_hackrf_info,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OfflineVerifyTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
expected_head: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": OFFLINE_VERIFY_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"expected_head": self.expected_head,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RxCaptureStartTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
expected_head: str
|
|
session_id: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": RX_CAPTURE_START_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"expected_head": self.expected_head,
|
|
"session_id": self.session_id,
|
|
"rx_serial": FIXED_RX_SERIAL,
|
|
"rx_authorized": True,
|
|
"automatic_retry": False,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RxCaptureStatusTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
session_id: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": RX_CAPTURE_STATUS_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"session_id": self.session_id,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CaptureDecodeTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
session_id: str
|
|
tx_started_at: str
|
|
tx_finished_at: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": CAPTURE_DECODE_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"session_id": self.session_id,
|
|
"tx_started_at": self.tx_started_at,
|
|
"tx_finished_at": self.tx_finished_at,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CaptureDiagnosticsTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
session_id: str
|
|
tx_started_at: str
|
|
tx_finished_at: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": CAPTURE_DIAGNOSTICS_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"session_id": self.session_id,
|
|
"tx_started_at": self.tx_started_at,
|
|
"tx_finished_at": self.tx_finished_at,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StaircaseSearchTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
session_id: str
|
|
expected_rx_sha256: str
|
|
expected_reference_sha256: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": STAIRCASE_SEARCH_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"session_id": self.session_id,
|
|
"expected_rx_sha256": self.expected_rx_sha256,
|
|
"expected_reference_sha256": self.expected_reference_sha256,
|
|
"hardware_accessed": False,
|
|
"automatic_retry": False,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UpdateAgentTask:
|
|
task_id: str
|
|
agent_id: str
|
|
created_at: str
|
|
release_id: str
|
|
agent_version: str
|
|
package_sha256: str
|
|
package_bytes: int
|
|
content_manifest_sha256: str
|
|
compatible_protocol_version: int
|
|
approval_expires_at: str
|
|
|
|
def as_mapping(self) -> dict[str, Any]:
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": self.task_id,
|
|
"kind": UPDATE_AGENT_TASK,
|
|
"agent_id": self.agent_id,
|
|
"created_at": self.created_at,
|
|
"release_id": self.release_id,
|
|
"agent_version": self.agent_version,
|
|
"package_sha256": self.package_sha256,
|
|
"package_bytes": self.package_bytes,
|
|
"content_manifest_sha256": self.content_manifest_sha256,
|
|
"compatible_protocol_version": self.compatible_protocol_version,
|
|
"approval_expires_at": self.approval_expires_at,
|
|
"hardware_accessed": False,
|
|
"automatic_retry": False,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
class ControlState:
|
|
"""Thread-safe single-agent task state persisted as atomic JSON."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
self._lock = threading.Lock()
|
|
self._data = self._load()
|
|
|
|
def _load(self) -> dict[str, Any]:
|
|
if not self.path.exists():
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"agents": {},
|
|
"tasks": [],
|
|
"results": {},
|
|
"releases": {},
|
|
"bootstrap_approvals": {},
|
|
}
|
|
raw = json.loads(self.path.read_text(encoding="utf-8-sig"))
|
|
data = _require_mapping(raw, "state")
|
|
if data.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("unsupported saved state version")
|
|
if not isinstance(data.get("agents"), dict):
|
|
raise ProtocolError("saved agents state is invalid")
|
|
if not isinstance(data.get("tasks"), list):
|
|
raise ProtocolError("saved tasks state is invalid")
|
|
if not isinstance(data.get("results"), dict):
|
|
raise ProtocolError("saved results state is invalid")
|
|
data.setdefault("releases", {})
|
|
data.setdefault("bootstrap_approvals", {})
|
|
if not isinstance(data["releases"], dict):
|
|
raise ProtocolError("saved releases state is invalid")
|
|
if not isinstance(data["bootstrap_approvals"], dict):
|
|
raise ProtocolError("saved bootstrap approvals state is invalid")
|
|
return data
|
|
|
|
def _save(self) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
temporary.write_bytes(canonical_json(self._data) + b"\n")
|
|
os.replace(temporary, self.path)
|
|
|
|
def enqueue_inventory(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
include_hackrf_info: bool,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
created_at = utc_now()
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task_id = f"INVENTORY_{compact_time}_{secrets.token_hex(4).upper()}"
|
|
task = InventoryTask(
|
|
task_id=task_id,
|
|
agent_id=agent,
|
|
created_at=created_at,
|
|
include_hackrf_info=include_hackrf_info,
|
|
).as_mapping()
|
|
record = {
|
|
**task,
|
|
"state": "QUEUED",
|
|
"leased_at_epoch": None,
|
|
"completed_at": None,
|
|
}
|
|
with self._lock:
|
|
self._data["tasks"].append(record)
|
|
self._save()
|
|
return task
|
|
|
|
def enqueue_offline_verify(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
expected_head: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
if GIT_HEAD_PATTERN.fullmatch(expected_head) is None:
|
|
raise ProtocolError("expected_head must be a lowercase 40-character hash")
|
|
created_at = utc_now()
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task_id = f"OFFLINE_{compact_time}_{secrets.token_hex(4).upper()}"
|
|
task = OfflineVerifyTask(
|
|
task_id=task_id,
|
|
agent_id=agent,
|
|
created_at=created_at,
|
|
expected_head=expected_head,
|
|
).as_mapping()
|
|
record = {
|
|
**task,
|
|
"state": "QUEUED",
|
|
"leased_at_epoch": None,
|
|
"completed_at": None,
|
|
}
|
|
with self._lock:
|
|
self._data["tasks"].append(record)
|
|
self._save()
|
|
return task
|
|
|
|
def _append_task(self, task: dict[str, Any]) -> dict[str, Any]:
|
|
record = {
|
|
**task,
|
|
"state": "QUEUED",
|
|
"leased_at_epoch": None,
|
|
"completed_at": None,
|
|
}
|
|
with self._lock:
|
|
self._data["tasks"].append(record)
|
|
self._save()
|
|
return task
|
|
|
|
def enqueue_rx_capture(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
expected_head: str,
|
|
authorization: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
if GIT_HEAD_PATTERN.fullmatch(expected_head) is None:
|
|
raise ProtocolError("expected_head must be a lowercase 40-character hash")
|
|
if authorization != RX_AUTHORIZATION_PHRASE:
|
|
raise ProtocolError("exact RX authorization phrase is required")
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
suffix = secrets.token_hex(4).upper()
|
|
task = RxCaptureStartTask(
|
|
task_id=f"RXSTART_{compact_time}_{suffix}",
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
expected_head=expected_head,
|
|
session_id=f"RF001A_{compact_time}_{suffix}",
|
|
).as_mapping()
|
|
return self._append_task(task)
|
|
|
|
def enqueue_rx_status(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
session_id: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
session = _validate_session_id(session_id)
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task = RxCaptureStatusTask(
|
|
task_id=f"RXSTATUS_{compact_time}_{secrets.token_hex(4).upper()}",
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
session_id=session,
|
|
).as_mapping()
|
|
return self._append_task(task)
|
|
|
|
def enqueue_capture_decode(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
session_id: str,
|
|
tx_started_at: str,
|
|
tx_finished_at: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
session = _validate_session_id(session_id)
|
|
started = _validate_timestamp(tx_started_at, "tx_started_at")
|
|
finished = _validate_timestamp(tx_finished_at, "tx_finished_at")
|
|
if datetime.fromisoformat(finished) < datetime.fromisoformat(started):
|
|
raise ProtocolError("tx_finished_at precedes tx_started_at")
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task = CaptureDecodeTask(
|
|
task_id=f"DECODE_{compact_time}_{secrets.token_hex(4).upper()}",
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
session_id=session,
|
|
tx_started_at=started,
|
|
tx_finished_at=finished,
|
|
).as_mapping()
|
|
return self._append_task(task)
|
|
|
|
def enqueue_capture_diagnostics(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
session_id: str,
|
|
tx_started_at: str,
|
|
tx_finished_at: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
session = _validate_session_id(session_id)
|
|
started = _validate_timestamp(tx_started_at, "tx_started_at")
|
|
finished = _validate_timestamp(tx_finished_at, "tx_finished_at")
|
|
if datetime.fromisoformat(finished) < datetime.fromisoformat(started):
|
|
raise ProtocolError("tx_finished_at precedes tx_started_at")
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task = CaptureDiagnosticsTask(
|
|
task_id=f"DIAG_{compact_time}_{secrets.token_hex(4).upper()}",
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
session_id=session,
|
|
tx_started_at=started,
|
|
tx_finished_at=finished,
|
|
).as_mapping()
|
|
return self._append_task(task)
|
|
|
|
def enqueue_staircase_search(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
session_id: str,
|
|
expected_rx_sha256: str,
|
|
expected_reference_sha256: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
session = _validate_session_id(session_id)
|
|
if SHA256_PATTERN.fullmatch(expected_rx_sha256) is None:
|
|
raise ProtocolError("expected_rx_sha256 is invalid")
|
|
if SHA256_PATTERN.fullmatch(expected_reference_sha256) is None:
|
|
raise ProtocolError("expected_reference_sha256 is invalid")
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
task = StaircaseSearchTask(
|
|
task_id=(
|
|
f"STAIRCASE_{compact_time}_{secrets.token_hex(4).upper()}"
|
|
),
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
session_id=session,
|
|
expected_rx_sha256=expected_rx_sha256,
|
|
expected_reference_sha256=expected_reference_sha256,
|
|
).as_mapping()
|
|
return self._append_task(task)
|
|
|
|
@staticmethod
|
|
def _release_path(package_path: str) -> Path:
|
|
candidate = Path(package_path).resolve()
|
|
releases_root = DEFAULT_RELEASES_ROOT.resolve()
|
|
try:
|
|
candidate.relative_to(releases_root)
|
|
except ValueError as error:
|
|
raise ProtocolError("release package must be inside .local/releases") from error
|
|
if not candidate.is_file():
|
|
raise ProtocolError("release package is absent")
|
|
return candidate
|
|
|
|
def register_agent_release(
|
|
self,
|
|
package_path: str,
|
|
*,
|
|
token: bytes,
|
|
) -> dict[str, Any]:
|
|
candidate = self._release_path(package_path)
|
|
try:
|
|
inspection = inspect_release_archive(candidate)
|
|
except (OSError, ReleaseValidationError) as error:
|
|
raise ProtocolError(f"release package is invalid: {error}") from error
|
|
manifest = {**inspection.descriptor(), "package_kind": "AGENT_RELEASE"}
|
|
signature = sign_update_manifest(token, manifest)
|
|
record = {
|
|
"manifest": manifest,
|
|
"manifest_signature": signature,
|
|
"package_path": str(candidate),
|
|
"registered_at": utc_now(),
|
|
}
|
|
with self._lock:
|
|
previous = self._data["releases"].get(inspection.release_id)
|
|
if previous is not None and canonical_json(previous) != canonical_json(record):
|
|
raise ProtocolError("release_id is already registered differently")
|
|
self._data["releases"][inspection.release_id] = record
|
|
self._save()
|
|
return {
|
|
"release_id": inspection.release_id,
|
|
"agent_version": inspection.agent_version,
|
|
"package_sha256": inspection.package_sha256,
|
|
"package_bytes": inspection.package_bytes,
|
|
"package_kind": "AGENT_RELEASE",
|
|
"registered": True,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
def register_bootstrap_release(
|
|
self,
|
|
package_path: str,
|
|
*,
|
|
release_id: str,
|
|
agent_version: str,
|
|
content_manifest_sha256: str,
|
|
token: bytes,
|
|
) -> dict[str, Any]:
|
|
candidate = self._release_path(package_path)
|
|
if BOOTSTRAP_RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
|
raise ProtocolError("release_id is invalid")
|
|
if re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+", agent_version) is None:
|
|
raise ProtocolError("agent_version is invalid")
|
|
if SHA256_PATTERN.fullmatch(content_manifest_sha256) is None:
|
|
raise ProtocolError("content_manifest_sha256 is invalid")
|
|
digest = hashlib.sha256()
|
|
with candidate.open("rb") as stream:
|
|
while block := stream.read(4 * 1024 * 1024):
|
|
digest.update(block)
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"release_id": release_id,
|
|
"agent_version": agent_version,
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"package_kind": "BOOTSTRAP",
|
|
"package_sha256": digest.hexdigest(),
|
|
"package_bytes": candidate.stat().st_size,
|
|
"content_manifest_sha256": content_manifest_sha256,
|
|
}
|
|
record = {
|
|
"manifest": manifest,
|
|
"manifest_signature": sign_update_manifest(token, manifest),
|
|
"package_path": str(candidate),
|
|
"registered_at": utc_now(),
|
|
}
|
|
with self._lock:
|
|
previous = self._data["releases"].get(release_id)
|
|
if previous is not None and canonical_json(previous) != canonical_json(record):
|
|
raise ProtocolError("release_id is already registered differently")
|
|
self._data["releases"][release_id] = record
|
|
self._save()
|
|
return {**manifest, "registered": True, "rf_tx": 0}
|
|
|
|
def enqueue_agent_update(
|
|
self,
|
|
agent_id: str,
|
|
*,
|
|
release_id: str,
|
|
validity_seconds: int,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
if not 60 <= validity_seconds <= 86_400:
|
|
raise ProtocolError("update approval validity must be 60 to 86400 seconds")
|
|
with self._lock:
|
|
release = self._data["releases"].get(release_id)
|
|
if not isinstance(release, dict):
|
|
raise ProtocolError("release_id is not registered")
|
|
manifest = _require_mapping(release.get("manifest"), "release manifest")
|
|
if manifest.get("package_kind") != "AGENT_RELEASE":
|
|
raise ProtocolError("bootstrap package cannot be used for remote update")
|
|
if manifest.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("release protocol version is incompatible")
|
|
existing = [
|
|
task
|
|
for task in self._data["tasks"]
|
|
if task.get("kind") == UPDATE_AGENT_TASK
|
|
and task.get("agent_id") == agent
|
|
and task.get("release_id") == release_id
|
|
and task.get("state") not in {"CANCELLED", "LEASE_EXPIRED"}
|
|
]
|
|
if existing:
|
|
raise ProtocolError("this release already has an update task")
|
|
compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
expires = datetime.fromtimestamp(
|
|
time.time() + validity_seconds,
|
|
timezone.utc,
|
|
).isoformat()
|
|
task = UpdateAgentTask(
|
|
task_id=f"UPDATE_{compact_time}_{secrets.token_hex(4).upper()}",
|
|
agent_id=agent,
|
|
created_at=utc_now(),
|
|
release_id=release_id,
|
|
agent_version=str(manifest["agent_version"]),
|
|
package_sha256=str(manifest["package_sha256"]),
|
|
package_bytes=int(manifest["package_bytes"]),
|
|
content_manifest_sha256=str(
|
|
manifest["content_manifest_sha256"]
|
|
),
|
|
compatible_protocol_version=int(manifest["protocol_version"]),
|
|
approval_expires_at=expires,
|
|
).as_mapping()
|
|
record = {
|
|
**task,
|
|
"state": "QUEUED",
|
|
"leased_at_epoch": None,
|
|
"completed_at": None,
|
|
}
|
|
self._data["tasks"].append(record)
|
|
self._save()
|
|
return task
|
|
|
|
def approve_bootstrap_download(
|
|
self,
|
|
release_id: str,
|
|
*,
|
|
validity_seconds: int,
|
|
) -> dict[str, Any]:
|
|
if not 60 <= validity_seconds <= 3_600:
|
|
raise ProtocolError("bootstrap validity must be 60 to 3600 seconds")
|
|
ticket = secrets.token_urlsafe(32)
|
|
with self._lock:
|
|
release = self._data["releases"].get(release_id)
|
|
if not isinstance(release, dict):
|
|
raise ProtocolError("release_id is not registered")
|
|
manifest = _require_mapping(release.get("manifest"), "release manifest")
|
|
if manifest.get("package_kind") != "BOOTSTRAP":
|
|
raise ProtocolError("release is not a bootstrap package")
|
|
self._data["bootstrap_approvals"][release_id] = {
|
|
"ticket_sha256": hashlib.sha256(ticket.encode("ascii")).hexdigest(),
|
|
"expires_at_epoch": int(time.time()) + validity_seconds,
|
|
"attempts": 0,
|
|
"maximum_attempts": 2,
|
|
"created_at": utc_now(),
|
|
}
|
|
self._save()
|
|
return {
|
|
"release_id": release_id,
|
|
"ticket": ticket,
|
|
"expires_in_seconds": validity_seconds,
|
|
"maximum_download_attempts": 2,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
def acquire_bootstrap_download(
|
|
self,
|
|
release_id: str,
|
|
ticket: str,
|
|
) -> dict[str, Any]:
|
|
with self._lock:
|
|
approval = self._data["bootstrap_approvals"].get(release_id)
|
|
release = self._data["releases"].get(release_id)
|
|
if not isinstance(approval, dict) or not isinstance(release, dict):
|
|
raise AuthenticationError("bootstrap download is not approved")
|
|
expected = str(approval.get("ticket_sha256", ""))
|
|
actual = hashlib.sha256(ticket.encode("ascii", errors="ignore")).hexdigest()
|
|
if not hmac.compare_digest(expected, actual):
|
|
raise AuthenticationError("bootstrap download ticket is invalid")
|
|
if int(approval.get("expires_at_epoch", 0)) < int(time.time()):
|
|
raise AuthenticationError("bootstrap download ticket expired")
|
|
attempts = int(approval.get("attempts", 0))
|
|
maximum = int(approval.get("maximum_attempts", 0))
|
|
if attempts >= maximum:
|
|
raise AuthenticationError("bootstrap download attempt limit reached")
|
|
approval["attempts"] = attempts + 1
|
|
approval["last_attempt_at"] = utc_now()
|
|
self._save()
|
|
return json.loads(canonical_json(release))
|
|
|
|
def approved_agent_release(
|
|
self,
|
|
agent_id: str,
|
|
release_id: str,
|
|
) -> dict[str, Any]:
|
|
agent = _validate_agent_id(agent_id)
|
|
with self._lock:
|
|
approved = [
|
|
task
|
|
for task in self._data["tasks"]
|
|
if task.get("kind") == UPDATE_AGENT_TASK
|
|
and task.get("agent_id") == agent
|
|
and task.get("release_id") == release_id
|
|
and task.get("state") in {"QUEUED", "LEASED"}
|
|
and datetime.fromisoformat(str(task.get("approval_expires_at")))
|
|
>= datetime.now(timezone.utc)
|
|
]
|
|
release = self._data["releases"].get(release_id)
|
|
if len(approved) != 1 or not isinstance(release, dict):
|
|
raise AuthenticationError("agent release download is not approved")
|
|
return json.loads(canonical_json(release))
|
|
|
|
def check_in(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
if payload.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("unsupported protocol version")
|
|
agent_id = _validate_agent_id(payload.get("agent_id"))
|
|
if payload.get("rf_tx") != 0:
|
|
raise ProtocolError("agent check-in must report rf_tx = 0")
|
|
inventory = _require_mapping(payload.get("inventory"), "inventory")
|
|
active_payload = payload.get("active_task")
|
|
active_task: dict[str, Any] | None = None
|
|
if active_payload is not None:
|
|
active_task = _require_mapping(active_payload, "active_task")
|
|
_validate_task_id(active_task.get("task_id"))
|
|
if active_task.get("state") not in {
|
|
"STARTING",
|
|
"RUNNING",
|
|
"RESULT_PENDING",
|
|
}:
|
|
raise ProtocolError("active task state is invalid")
|
|
processed = active_task.get("processed_samples")
|
|
total = active_task.get("total_samples")
|
|
if type(processed) is not int or type(total) is not int:
|
|
raise ProtocolError("active task progress must use integers")
|
|
if processed < 0 or total <= 0 or processed > total:
|
|
raise ProtocolError("active task progress is invalid")
|
|
if active_task.get("rf_tx") != 0:
|
|
raise ProtocolError("active task must report rf_tx = 0")
|
|
now_epoch = int(time.time())
|
|
with self._lock:
|
|
self._data["agents"][agent_id] = {
|
|
"last_seen_at": utc_now(),
|
|
"remote_inventory": inventory,
|
|
"active_task": active_task,
|
|
"rf_tx": 0,
|
|
}
|
|
selected: dict[str, Any] | None = None
|
|
active_matched = False
|
|
if active_task is not None:
|
|
matches = [
|
|
task
|
|
for task in self._data["tasks"]
|
|
if task.get("task_id") == active_task.get("task_id")
|
|
and task.get("agent_id") == agent_id
|
|
]
|
|
if len(matches) != 1 or matches[0].get("state") != "LEASED":
|
|
raise ProtocolError("active task does not match a leased task")
|
|
matches[0]["leased_at_epoch"] = now_epoch
|
|
matches[0]["progress"] = active_task
|
|
active_matched = True
|
|
for pending in self._data["tasks"]:
|
|
if (
|
|
pending.get("kind") == UPDATE_AGENT_TASK
|
|
and pending.get("agent_id") == agent_id
|
|
and pending.get("state") == "QUEUED"
|
|
):
|
|
pending["update_status"] = "WAITING_FOR_IDLE"
|
|
pending["blocked_by_task_id"] = active_task["task_id"]
|
|
pending["status_updated_at"] = utc_now()
|
|
for task in ([] if active_matched else self._data["tasks"]):
|
|
if task.get("agent_id") != agent_id:
|
|
continue
|
|
state = task.get("state")
|
|
leased_at = task.get("leased_at_epoch")
|
|
expired = (
|
|
state == "LEASED"
|
|
and isinstance(leased_at, int)
|
|
and now_epoch - leased_at > TASK_LEASE_SECONDS
|
|
)
|
|
if expired and task.get("automatic_retry") is False:
|
|
task["state"] = "LEASE_EXPIRED"
|
|
task["lease_expired_at"] = utc_now()
|
|
task["completed_at"] = task["lease_expired_at"]
|
|
continue
|
|
if state == "QUEUED" or expired:
|
|
task["state"] = "LEASED"
|
|
task["leased_at_epoch"] = now_epoch
|
|
selected = {
|
|
key: value
|
|
for key, value in task.items()
|
|
if key not in {"state", "leased_at_epoch", "completed_at"}
|
|
}
|
|
break
|
|
self._save()
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"server_time": utc_now(),
|
|
"task": selected,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
def record_result(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
if payload.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("unsupported protocol version")
|
|
task_id = _validate_task_id(payload.get("task_id"))
|
|
agent_id = _validate_agent_id(payload.get("agent_id"))
|
|
if payload.get("rf_tx") != 0:
|
|
raise ProtocolError("result must report rf_tx = 0")
|
|
if payload.get("status") not in {"COMPLETE", "FAILED"}:
|
|
raise ProtocolError("invalid result status")
|
|
_require_mapping(payload.get("result"), "result")
|
|
errors = payload.get("errors")
|
|
if not isinstance(errors, list) or not all(
|
|
isinstance(item, str) for item in errors
|
|
):
|
|
raise ProtocolError("errors must be a list of strings")
|
|
serialized = canonical_json(payload)
|
|
if len(serialized) > MAX_JSON_BYTES:
|
|
raise ProtocolError("result is too large")
|
|
|
|
with self._lock:
|
|
matching = [
|
|
task
|
|
for task in self._data["tasks"]
|
|
if task.get("task_id") == task_id
|
|
]
|
|
if len(matching) != 1 or matching[0].get("agent_id") != agent_id:
|
|
raise ProtocolError("result does not match a known task")
|
|
if matching[0].get("state") == "CANCELLED":
|
|
raise ProtocolError("result belongs to a cancelled task")
|
|
previous = self._data["results"].get(task_id)
|
|
if previous is not None:
|
|
if canonical_json(previous) != serialized:
|
|
raise ProtocolError("conflicting duplicate result")
|
|
return {"accepted": True, "duplicate": True, "rf_tx": 0}
|
|
self._data["results"][task_id] = dict(payload)
|
|
matching[0]["state"] = "COMPLETE"
|
|
matching[0]["completed_at"] = utc_now()
|
|
self._save()
|
|
return {"accepted": True, "duplicate": False, "rf_tx": 0}
|
|
|
|
def cancel_task(self, task_id: str, *, reason: str) -> dict[str, Any]:
|
|
task_identifier = _validate_task_id(task_id)
|
|
if not isinstance(reason, str) or not reason.strip() or len(reason) > 500:
|
|
raise ProtocolError("cancellation reason must contain 1 to 500 characters")
|
|
with self._lock:
|
|
matching = [
|
|
task
|
|
for task in self._data["tasks"]
|
|
if task.get("task_id") == task_identifier
|
|
]
|
|
if len(matching) != 1:
|
|
raise ProtocolError("task to cancel was not found")
|
|
task = matching[0]
|
|
if task.get("state") == "COMPLETE":
|
|
raise ProtocolError("completed task cannot be cancelled")
|
|
if task.get("state") != "CANCELLED":
|
|
task["state"] = "CANCELLED"
|
|
task["cancelled_at"] = utc_now()
|
|
task["cancellation_reason"] = reason.strip()
|
|
task["completed_at"] = task["cancelled_at"]
|
|
self._save()
|
|
return {
|
|
"task_id": task_identifier,
|
|
"state": "CANCELLED",
|
|
"cancellation_reason": task.get("cancellation_reason"),
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
def snapshot(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
return json.loads(canonical_json(self._data))
|
|
|
|
|
|
class ControlHTTPServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(
|
|
self,
|
|
server_address: tuple[str, int],
|
|
*,
|
|
state: ControlState,
|
|
token: bytes,
|
|
allowed_agent_ip: str,
|
|
) -> None:
|
|
super().__init__(server_address, ControlRequestHandler)
|
|
self.state = state
|
|
self.token = token
|
|
self.authenticator = RequestAuthenticator(token)
|
|
self.allowed_agent_ip = allowed_agent_ip
|
|
|
|
|
|
class ControlRequestHandler(BaseHTTPRequestHandler):
|
|
server: ControlHTTPServer
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
message = format % args
|
|
print(f"HTTP {self.client_address[0]} {message}")
|
|
|
|
def _send_json(self, status: HTTPStatus, payload: Mapping[str, Any]) -> None:
|
|
body = canonical_json(payload) + b"\n"
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_package(self, release: Mapping[str, Any]) -> None:
|
|
package_path = Path(str(release.get("package_path", "")))
|
|
manifest = _require_mapping(release.get("manifest"), "release manifest")
|
|
expected_size = int(manifest.get("package_bytes", -1))
|
|
if not package_path.is_file() or package_path.stat().st_size != expected_size:
|
|
raise ProtocolError("registered release package is unavailable")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/zip")
|
|
self.send_header("Content-Length", str(expected_size))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header(
|
|
"Content-Disposition",
|
|
f'attachment; filename="{manifest["release_id"]}.zip"',
|
|
)
|
|
self.end_headers()
|
|
with package_path.open("rb") as stream:
|
|
while block := stream.read(1024 * 1024):
|
|
self.wfile.write(block)
|
|
|
|
def _read_body(self) -> bytes:
|
|
length_text = self.headers.get("Content-Length", "")
|
|
if not length_text.isdecimal():
|
|
raise ProtocolError("valid Content-Length is required")
|
|
length = int(length_text)
|
|
if length > MAX_JSON_BYTES:
|
|
raise ProtocolError("request body is too large")
|
|
return self.rfile.read(length)
|
|
|
|
def _read_authenticated_json(self) -> dict[str, Any]:
|
|
body = self._read_body()
|
|
self.server.authenticator.verify(
|
|
self.command,
|
|
self.path,
|
|
body,
|
|
self.headers,
|
|
)
|
|
try:
|
|
decoded = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise ProtocolError("request body is not valid UTF-8 JSON") from error
|
|
return _require_mapping(decoded, "request")
|
|
|
|
def _require_agent_source(self) -> None:
|
|
if self.client_address[0] != self.server.allowed_agent_ip:
|
|
raise AuthenticationError("agent source IP is not allowed")
|
|
|
|
def _require_local_source(self) -> None:
|
|
bound_address = self.server.server_address[0]
|
|
allowed = {"127.0.0.1", "::1"}
|
|
if bound_address not in {"0.0.0.0", "::"}:
|
|
allowed.add(bound_address)
|
|
if self.client_address[0] not in allowed:
|
|
raise AuthenticationError("local endpoint requires the server host")
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
try:
|
|
split = urlsplit(self.path)
|
|
parts = split.path.strip("/").split("/")
|
|
if self.path == "/api/v1/health":
|
|
self._send_json(
|
|
HTTPStatus.OK,
|
|
{
|
|
"status": "OK",
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"server_time": utc_now(),
|
|
"rf_tx": 0,
|
|
},
|
|
)
|
|
return
|
|
if self.path == "/api/v1/local/status":
|
|
self._require_local_source()
|
|
self.server.authenticator.verify(
|
|
self.command,
|
|
self.path,
|
|
b"",
|
|
self.headers,
|
|
)
|
|
self._send_json(HTTPStatus.OK, self.server.state.snapshot())
|
|
return
|
|
if (
|
|
len(parts) == 6
|
|
and parts[:4] == ["api", "v1", "agent", "releases"]
|
|
and parts[5] in {"manifest", "package"}
|
|
):
|
|
self._require_agent_source()
|
|
self.server.authenticator.verify(
|
|
self.command,
|
|
self.path,
|
|
b"",
|
|
self.headers,
|
|
)
|
|
release_id = parts[4]
|
|
query = parse_qs(split.query, strict_parsing=True)
|
|
agent_values = query.get("agent_id", [])
|
|
if len(agent_values) != 1:
|
|
raise ProtocolError("agent_id query parameter is required")
|
|
release = self.server.state.approved_agent_release(
|
|
agent_values[0],
|
|
release_id,
|
|
)
|
|
if parts[5] == "manifest":
|
|
self._send_json(
|
|
HTTPStatus.OK,
|
|
{
|
|
"manifest": release["manifest"],
|
|
"manifest_signature": release["manifest_signature"],
|
|
"rf_tx": 0,
|
|
},
|
|
)
|
|
else:
|
|
self._send_package(release)
|
|
return
|
|
if (
|
|
len(parts) == 6
|
|
and parts[:4] == ["api", "v1", "bootstrap", "releases"]
|
|
and parts[5] == "package"
|
|
):
|
|
self._require_agent_source()
|
|
query = parse_qs(split.query, strict_parsing=True)
|
|
tickets = query.get("ticket", [])
|
|
if len(tickets) != 1:
|
|
raise AuthenticationError("bootstrap ticket is required")
|
|
release = self.server.state.acquire_bootstrap_download(
|
|
parts[4],
|
|
tickets[0],
|
|
)
|
|
self._send_package(release)
|
|
return
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
except AuthenticationError as error:
|
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": str(error)})
|
|
except ProtocolError as error:
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
try:
|
|
if self.path == "/api/v1/agent/check-in":
|
|
self._require_agent_source()
|
|
payload = self._read_authenticated_json()
|
|
response = self.server.state.check_in(payload)
|
|
self._send_json(HTTPStatus.OK, response)
|
|
return
|
|
if self.path == "/api/v1/agent/result":
|
|
self._require_agent_source()
|
|
payload = self._read_authenticated_json()
|
|
response = self.server.state.record_result(payload)
|
|
self._send_json(HTTPStatus.OK, response)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/inventory":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
include = payload.get("include_hackrf_info", True)
|
|
if type(include) is not bool:
|
|
raise ProtocolError("include_hackrf_info must be boolean")
|
|
task = self.server.state.enqueue_inventory(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
include_hackrf_info=include,
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/offline-verify":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_offline_verify(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
expected_head=str(payload.get("expected_head", "")),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/rx-capture":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_rx_capture(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
expected_head=str(payload.get("expected_head", "")),
|
|
authorization=str(payload.get("authorization", "")),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/rx-status":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_rx_status(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
session_id=str(payload.get("session_id", "")),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/capture-decode":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_capture_decode(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
session_id=str(payload.get("session_id", "")),
|
|
tx_started_at=str(payload.get("tx_started_at", "")),
|
|
tx_finished_at=str(payload.get("tx_finished_at", "")),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/capture-diagnostics":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_capture_diagnostics(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
session_id=str(payload.get("session_id", "")),
|
|
tx_started_at=str(payload.get("tx_started_at", "")),
|
|
tx_finished_at=str(payload.get("tx_finished_at", "")),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/staircase-search":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
task = self.server.state.enqueue_staircase_search(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
session_id=str(payload.get("session_id", "")),
|
|
expected_rx_sha256=str(
|
|
payload.get("expected_rx_sha256", "")
|
|
),
|
|
expected_reference_sha256=str(
|
|
payload.get("expected_reference_sha256", "")
|
|
),
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/releases/register-agent":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
release = self.server.state.register_agent_release(
|
|
str(payload.get("package_path", "")),
|
|
token=self.server.token,
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, release)
|
|
return
|
|
if self.path == "/api/v1/local/releases/register-bootstrap":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
release = self.server.state.register_bootstrap_release(
|
|
str(payload.get("package_path", "")),
|
|
release_id=str(payload.get("release_id", "")),
|
|
agent_version=str(payload.get("agent_version", "")),
|
|
content_manifest_sha256=str(
|
|
payload.get("content_manifest_sha256", "")
|
|
),
|
|
token=self.server.token,
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, release)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/update-agent":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
validity = payload.get("validity_seconds")
|
|
if type(validity) is not int:
|
|
raise ProtocolError("validity_seconds must be an integer")
|
|
task = self.server.state.enqueue_agent_update(
|
|
_validate_agent_id(payload.get("agent_id")),
|
|
release_id=str(payload.get("release_id", "")),
|
|
validity_seconds=validity,
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, task)
|
|
return
|
|
if self.path == "/api/v1/local/releases/approve-bootstrap":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
validity = payload.get("validity_seconds")
|
|
if type(validity) is not int:
|
|
raise ProtocolError("validity_seconds must be an integer")
|
|
approval = self.server.state.approve_bootstrap_download(
|
|
str(payload.get("release_id", "")),
|
|
validity_seconds=validity,
|
|
)
|
|
host, port = self.server.server_address[:2]
|
|
approval["download_url"] = (
|
|
f"http://{host}:{port}/api/v1/bootstrap/releases/"
|
|
f"{approval['release_id']}/package?ticket="
|
|
f"{quote(str(approval['ticket']), safe='')}"
|
|
)
|
|
self._send_json(HTTPStatus.CREATED, approval)
|
|
return
|
|
if self.path == "/api/v1/local/tasks/cancel":
|
|
self._require_local_source()
|
|
payload = self._read_authenticated_json()
|
|
response = self.server.state.cancel_task(
|
|
str(payload.get("task_id", "")),
|
|
reason=str(payload.get("reason", "")),
|
|
)
|
|
self._send_json(HTTPStatus.OK, response)
|
|
return
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
except AuthenticationError as error:
|
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": str(error)})
|
|
except ProtocolError as error:
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
|
|
except Exception as error: # pragma: no cover - defensive server boundary
|
|
self._send_json(
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
{"error": f"internal server error: {type(error).__name__}"},
|
|
)
|
|
|
|
|
|
def request_json(
|
|
server_url: str,
|
|
token: bytes,
|
|
method: str,
|
|
target: str,
|
|
payload: Mapping[str, Any] | None = None,
|
|
*,
|
|
timeout: float = 10.0,
|
|
) -> dict[str, Any]:
|
|
body = b"" if payload is None else canonical_json(payload)
|
|
headers = signed_headers(token, method, target, body)
|
|
request = Request(
|
|
server_url.rstrip("/") + target,
|
|
data=body if method.upper() != "GET" else None,
|
|
headers=headers,
|
|
method=method.upper(),
|
|
)
|
|
opener = build_opener(ProxyHandler({}))
|
|
try:
|
|
with opener.open(request, timeout=timeout) as response:
|
|
response_body = response.read(MAX_JSON_BYTES + 1)
|
|
except HTTPError as error:
|
|
detail = error.read(MAX_JSON_BYTES).decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
|
|
if len(response_body) > MAX_JSON_BYTES:
|
|
raise ProtocolError("server response is too large")
|
|
decoded = json.loads(response_body.decode("utf-8"))
|
|
return _require_mapping(decoded, "response")
|
|
|
|
|
|
def request_file(
|
|
server_url: str,
|
|
token: bytes,
|
|
target: str,
|
|
destination: Path,
|
|
*,
|
|
expected_bytes: int,
|
|
timeout: float = 30.0,
|
|
) -> None:
|
|
if expected_bytes <= 0:
|
|
raise ProtocolError("expected download size must be positive")
|
|
headers = signed_headers(token, "GET", target, b"")
|
|
request = Request(
|
|
server_url.rstrip("/") + target,
|
|
headers=headers,
|
|
method="GET",
|
|
)
|
|
opener = build_opener(ProxyHandler({}))
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with opener.open(request, timeout=timeout) as response:
|
|
length = response.headers.get("Content-Length", "")
|
|
if not length.isdecimal() or int(length) != expected_bytes:
|
|
raise ProtocolError("download Content-Length mismatch")
|
|
written = 0
|
|
with destination.open("xb") as stream:
|
|
while block := response.read(min(1024 * 1024, expected_bytes - written + 1)):
|
|
written += len(block)
|
|
if written > expected_bytes:
|
|
raise ProtocolError("download exceeded approved size")
|
|
stream.write(block)
|
|
if written != expected_bytes:
|
|
raise ProtocolError("download ended before the approved size")
|
|
except HTTPError as error:
|
|
detail = error.read(MAX_JSON_BYTES).decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
token_parser = subparsers.add_parser("init-token")
|
|
token_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
|
|
serve_parser = subparsers.add_parser("serve")
|
|
serve_parser.add_argument("--bind", default="192.168.255.185")
|
|
serve_parser.add_argument("--port", type=int, default=8765)
|
|
serve_parser.add_argument("--allowed-agent-ip", default="192.168.255.56")
|
|
serve_parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
|
serve_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
serve_parser.add_argument("--log-file", type=Path)
|
|
serve_parser.add_argument("--bind-retry-seconds", type=float, default=0.0)
|
|
|
|
enqueue_parser = subparsers.add_parser("enqueue-inventory")
|
|
enqueue_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
enqueue_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
enqueue_parser.add_argument("--agent-id", required=True)
|
|
enqueue_parser.add_argument(
|
|
"--without-hackrf-info",
|
|
action="store_true",
|
|
)
|
|
|
|
offline_parser = subparsers.add_parser("enqueue-offline-verify")
|
|
offline_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
offline_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
offline_parser.add_argument("--agent-id", required=True)
|
|
offline_parser.add_argument("--expected-head", required=True)
|
|
|
|
rx_parser = subparsers.add_parser("enqueue-rx-capture")
|
|
rx_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
rx_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
rx_parser.add_argument("--agent-id", required=True)
|
|
rx_parser.add_argument("--expected-head", required=True)
|
|
rx_parser.add_argument("--authorize-rx", required=True)
|
|
|
|
rx_status_parser = subparsers.add_parser("enqueue-rx-status")
|
|
rx_status_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
rx_status_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
rx_status_parser.add_argument("--agent-id", required=True)
|
|
rx_status_parser.add_argument("--session-id", required=True)
|
|
|
|
decode_parser = subparsers.add_parser("enqueue-capture-decode")
|
|
decode_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
decode_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
decode_parser.add_argument("--agent-id", required=True)
|
|
decode_parser.add_argument("--session-id", required=True)
|
|
decode_parser.add_argument("--tx-started-at", required=True)
|
|
decode_parser.add_argument("--tx-finished-at", required=True)
|
|
|
|
diagnostics_parser = subparsers.add_parser("enqueue-capture-diagnostics")
|
|
diagnostics_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
diagnostics_parser.add_argument(
|
|
"--token-file", type=Path, default=DEFAULT_TOKEN_PATH
|
|
)
|
|
diagnostics_parser.add_argument("--agent-id", required=True)
|
|
diagnostics_parser.add_argument("--session-id", required=True)
|
|
diagnostics_parser.add_argument("--tx-started-at", required=True)
|
|
diagnostics_parser.add_argument("--tx-finished-at", required=True)
|
|
|
|
staircase_parser = subparsers.add_parser("enqueue-staircase-search")
|
|
staircase_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
staircase_parser.add_argument(
|
|
"--token-file", type=Path, default=DEFAULT_TOKEN_PATH
|
|
)
|
|
staircase_parser.add_argument("--agent-id", required=True)
|
|
staircase_parser.add_argument("--session-id", required=True)
|
|
staircase_parser.add_argument("--expected-rx-sha256", required=True)
|
|
staircase_parser.add_argument("--expected-reference-sha256", required=True)
|
|
|
|
register_release_parser = subparsers.add_parser("register-agent-release")
|
|
register_release_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
register_release_parser.add_argument(
|
|
"--token-file", type=Path, default=DEFAULT_TOKEN_PATH
|
|
)
|
|
register_release_parser.add_argument("--package", required=True, type=Path)
|
|
|
|
register_bootstrap_parser = subparsers.add_parser("register-bootstrap-release")
|
|
register_bootstrap_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
register_bootstrap_parser.add_argument(
|
|
"--token-file", type=Path, default=DEFAULT_TOKEN_PATH
|
|
)
|
|
register_bootstrap_parser.add_argument("--package", required=True, type=Path)
|
|
register_bootstrap_parser.add_argument("--release-id", required=True)
|
|
register_bootstrap_parser.add_argument("--agent-version", required=True)
|
|
register_bootstrap_parser.add_argument(
|
|
"--content-manifest-sha256", required=True
|
|
)
|
|
|
|
update_parser = subparsers.add_parser("enqueue-agent-update")
|
|
update_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
update_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
update_parser.add_argument("--agent-id", required=True)
|
|
update_parser.add_argument("--release-id", required=True)
|
|
update_parser.add_argument("--validity-seconds", type=int, default=3600)
|
|
|
|
bootstrap_parser = subparsers.add_parser("approve-bootstrap-download")
|
|
bootstrap_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
bootstrap_parser.add_argument(
|
|
"--token-file", type=Path, default=DEFAULT_TOKEN_PATH
|
|
)
|
|
bootstrap_parser.add_argument("--release-id", required=True)
|
|
bootstrap_parser.add_argument("--validity-seconds", type=int, default=1800)
|
|
|
|
cancel_parser = subparsers.add_parser("cancel-task")
|
|
cancel_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
cancel_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
cancel_parser.add_argument("--task-id", required=True)
|
|
cancel_parser.add_argument("--reason", required=True)
|
|
|
|
status_parser = subparsers.add_parser("status")
|
|
status_parser.add_argument("--server", default=DEFAULT_SERVER_URL)
|
|
status_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
arguments = build_parser().parse_args()
|
|
if arguments.command == "serve" and arguments.log_file is not None:
|
|
redirect_output(arguments.log_file)
|
|
if arguments.command == "init-token":
|
|
initialize_token(arguments.token_file)
|
|
print(f"TOKEN_FILE = {arguments.token_file}")
|
|
print("TOKEN_CREATED = YES")
|
|
print("RF TX = 0")
|
|
return
|
|
|
|
token = read_token(arguments.token_file)
|
|
if arguments.command == "serve":
|
|
if arguments.bind_retry_seconds < 0:
|
|
raise ValueError("bind retry interval cannot be negative")
|
|
state = ControlState(arguments.state_file)
|
|
deadline = time.monotonic() + arguments.bind_retry_seconds
|
|
waiting_for_address = False
|
|
while True:
|
|
try:
|
|
server = ControlHTTPServer(
|
|
(arguments.bind, arguments.port),
|
|
state=state,
|
|
token=token,
|
|
allowed_agent_ip=arguments.allowed_agent_ip,
|
|
)
|
|
break
|
|
except OSError as error:
|
|
address_unavailable = getattr(error, "winerror", None) == 10049
|
|
retry_open = time.monotonic() < deadline
|
|
if not address_unavailable or not retry_open:
|
|
raise
|
|
if not waiting_for_address:
|
|
print(
|
|
f"WAITING FOR LOCAL ADDRESS = {arguments.bind} "
|
|
f"({arguments.bind_retry_seconds:g} seconds maximum)"
|
|
)
|
|
print("RF TX = 0")
|
|
waiting_for_address = True
|
|
time.sleep(min(5.0, max(0.1, deadline - time.monotonic())))
|
|
print(f"SERVER = http://{arguments.bind}:{arguments.port}")
|
|
print(f"ALLOWED AGENT IP = {arguments.allowed_agent_ip}")
|
|
print(
|
|
"TASK KINDS = INVENTORY, OFFLINE_VERIFY, RX_CAPTURE_START, "
|
|
"RX_CAPTURE_STATUS, CAPTURE_DECODE, CAPTURE_DIAGNOSTICS, "
|
|
"STAIRCASE_SEARCH, UPDATE_AGENT"
|
|
)
|
|
print("RF TX = 0")
|
|
try:
|
|
server.serve_forever(poll_interval=0.2)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
return
|
|
|
|
if arguments.command == "enqueue-inventory":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/inventory",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"include_hackrf_info": not arguments.without_hackrf_info,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-offline-verify":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/offline-verify",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"expected_head": arguments.expected_head,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-rx-capture":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/rx-capture",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"expected_head": arguments.expected_head,
|
|
"authorization": arguments.authorize_rx,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-rx-status":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/rx-status",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"session_id": arguments.session_id,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-capture-decode":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/capture-decode",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"session_id": arguments.session_id,
|
|
"tx_started_at": arguments.tx_started_at,
|
|
"tx_finished_at": arguments.tx_finished_at,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-capture-diagnostics":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/capture-diagnostics",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"session_id": arguments.session_id,
|
|
"tx_started_at": arguments.tx_started_at,
|
|
"tx_finished_at": arguments.tx_finished_at,
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-staircase-search":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/staircase-search",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"session_id": arguments.session_id,
|
|
"expected_rx_sha256": arguments.expected_rx_sha256,
|
|
"expected_reference_sha256": (
|
|
arguments.expected_reference_sha256
|
|
),
|
|
},
|
|
)
|
|
elif arguments.command == "register-agent-release":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/releases/register-agent",
|
|
{"package_path": str(arguments.package.resolve())},
|
|
)
|
|
elif arguments.command == "register-bootstrap-release":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/releases/register-bootstrap",
|
|
{
|
|
"package_path": str(arguments.package.resolve()),
|
|
"release_id": arguments.release_id,
|
|
"agent_version": arguments.agent_version,
|
|
"content_manifest_sha256": (
|
|
arguments.content_manifest_sha256
|
|
),
|
|
},
|
|
)
|
|
elif arguments.command == "enqueue-agent-update":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/update-agent",
|
|
{
|
|
"agent_id": arguments.agent_id,
|
|
"release_id": arguments.release_id,
|
|
"validity_seconds": arguments.validity_seconds,
|
|
},
|
|
)
|
|
elif arguments.command == "approve-bootstrap-download":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/releases/approve-bootstrap",
|
|
{
|
|
"release_id": arguments.release_id,
|
|
"validity_seconds": arguments.validity_seconds,
|
|
},
|
|
)
|
|
elif arguments.command == "cancel-task":
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/local/tasks/cancel",
|
|
{
|
|
"task_id": arguments.task_id,
|
|
"reason": arguments.reason,
|
|
},
|
|
)
|
|
else:
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"GET",
|
|
"/api/v1/local/status",
|
|
)
|
|
print(json.dumps(response, ensure_ascii=False, indent=2, sort_keys=True))
|
|
print("RF TX = 0")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|