425 lines
22 KiB
Python
425 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Local ROM checks and explicit V1/V2 discovery, identity and firmware operations."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import hashlib
|
|
import importlib.util
|
|
import ipaddress
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
|
|
|
|
VENDOR = Path(__file__).resolve().parent / "vendor"
|
|
FIRMWARE_FIELDS = ("inner.version", "outer.version")
|
|
V1_NATIVE_FIELDS = ("firmware", "dev_ver")
|
|
|
|
|
|
def load_vendor(filename: str, module_name: str):
|
|
"""Load only the bundled, hash-pinned helper without modifying sys.path."""
|
|
path = VENDOR / filename
|
|
manifest = json.loads((VENDOR / "source-manifest.json").read_text(encoding="utf-8"))
|
|
entry = next(item for item in manifest["files"] if item["file"] == filename)
|
|
if hashlib.sha256(path.read_bytes()).hexdigest() != entry["vendored_sha256"]:
|
|
raise RuntimeError("Bundled protocol helper checksum mismatch: " + filename)
|
|
if module_name in sys.modules:
|
|
return sys.modules[module_name]
|
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except BaseException:
|
|
sys.modules.pop(module_name, None)
|
|
raise
|
|
return module
|
|
|
|
|
|
protocol = load_vendor("device_upgrade.py", "artemis_device_vendor_upgrade")
|
|
_v1_spec = importlib.util.spec_from_file_location("artemis_device_v1", Path(__file__).with_name("v1_protocol.py"))
|
|
v1 = importlib.util.module_from_spec(_v1_spec)
|
|
_v1_spec.loader.exec_module(v1)
|
|
|
|
|
|
class UnsupportedProtocolError(protocol.UpgradeError):
|
|
"""A selected protocol has no implementation; no device I/O is allowed."""
|
|
|
|
|
|
def require_supported_protocol(name: str):
|
|
if name not in ("v1", "v2"):
|
|
raise UnsupportedProtocolError("Unknown device protocol")
|
|
|
|
|
|
def capabilities() -> dict:
|
|
return {
|
|
"status": "CAPABILITIES", "network_used": False, "write_executed": False,
|
|
"firmware_verified": False, "default_protocol": "v2",
|
|
"protocols": {
|
|
"v1": {"implemented": True, "operations": ["discover", "identify", "info", "upgrade", "verify-version"],
|
|
"upgrade_preflight": True, "upgrade_transfer": True,
|
|
"upgrade_default": "online_preflight_only",
|
|
"upgrade_transport": "tcp_4941_2170_md5_ascii32_plus_rom",
|
|
"version_fields": list(V1_NATIVE_FIELDS),
|
|
"documentation": "references/protocol-support.md"},
|
|
"v2": {"implemented": True,
|
|
"operations": ["discover", "identify", "info", "upgrade", "verify-version"],
|
|
"upgrade_default": "online_preflight_only",
|
|
"documentation": "references/device-adapter.md"},
|
|
},
|
|
}
|
|
|
|
|
|
class EventCollector:
|
|
"""Preserve protocol evidence inside one JSON result without changing streams."""
|
|
def __init__(self):
|
|
self.events: list[dict[str, Any]] = []
|
|
|
|
def emit(self, event: str, **fields: Any):
|
|
if event == "protocol_success":
|
|
# Upstream reports only a remote status; the adapter still checks our upload.
|
|
event = "device_reported_status_2"
|
|
self.events.append({"event": event, **fields})
|
|
|
|
|
|
def nonempty(value: str) -> str:
|
|
if not value.strip():
|
|
raise argparse.ArgumentTypeError("value must not be empty or whitespace")
|
|
return value
|
|
|
|
|
|
def positive_seconds(value: str) -> float:
|
|
number = float(value)
|
|
if not math.isfinite(number) or not 0 < number <= 3600:
|
|
raise argparse.ArgumentTypeError("timeout must be finite and in (0, 3600]")
|
|
return number
|
|
|
|
|
|
def add_identity(parser):
|
|
parser.add_argument("--expected-sn", required=True, type=nonempty,
|
|
help="Full serial number; exact equality, no suffix matching")
|
|
parser.add_argument("--expected-model", required=True, type=nonempty,
|
|
help="Exact dev_type for V1, or inner/outer/hardware.type for V2")
|
|
|
|
|
|
def add_target(parser):
|
|
parser.add_argument("--target-version", required=True, type=nonempty)
|
|
parser.add_argument("--version-field", required=True, choices=FIRMWARE_FIELDS + V1_NATIVE_FIELDS,
|
|
help="Explicit firmware field; hardware.version is not firmware")
|
|
|
|
|
|
def add_protocol(parser):
|
|
parser.add_argument("--protocol", choices=("v1", "v2"), default="v2",
|
|
help="Device wire protocol; default v2. No automatic fallback.")
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
commands.add_parser("capabilities", help="List implemented protocols without network access")
|
|
discover = commands.add_parser("discover", help="Read-only broadcast discovery; forwards native arguments", add_help=False)
|
|
add_protocol(discover)
|
|
discover.add_argument("arguments", nargs=argparse.REMAINDER)
|
|
offline = commands.add_parser("offline", help="Only inspect/hash a local ROM; no network")
|
|
offline.add_argument("--rom", required=True, type=Path)
|
|
offline.add_argument("--rom-sha256", help="Optional expected SHA-256")
|
|
identify = commands.add_parser("identify", help="Read-only query with full SN to observe the reported model")
|
|
add_protocol(identify)
|
|
identify.add_argument("--host", required=True, type=nonempty)
|
|
identify.add_argument("--timeout", type=positive_seconds, default=10.0)
|
|
identify.add_argument("--expected-sn", required=True, type=nonempty)
|
|
info = commands.add_parser("info", help="Read-only identity query; no ROM needed")
|
|
add_protocol(info)
|
|
info.add_argument("--host", required=True, type=nonempty)
|
|
info.add_argument("--timeout", type=positive_seconds, default=10.0)
|
|
add_identity(info)
|
|
upgrade = commands.add_parser("upgrade", help="Online preflight by default; write only with --execute")
|
|
add_protocol(upgrade)
|
|
upgrade.add_argument("--host", required=True, type=nonempty,
|
|
help="Explicit numeric unicast IPv4/IPv6; hostnames are rejected even for preflight")
|
|
add_identity(upgrade)
|
|
add_target(upgrade)
|
|
upgrade.add_argument("--rom", required=True, type=Path)
|
|
upgrade.add_argument("--rom-sha256", help="Expected digest; mandatory with --execute")
|
|
upgrade.add_argument("--execute", action="store_true")
|
|
upgrade.add_argument("--timeout", type=positive_seconds, default=10.0)
|
|
upgrade.add_argument("--stall-timeout", type=positive_seconds, default=60.0)
|
|
upgrade.add_argument("--total-timeout", type=positive_seconds, default=600.0)
|
|
verify = commands.add_parser("verify-version", help="Compare an explicit firmware version against recorded or live info")
|
|
add_protocol(verify)
|
|
source = verify.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--response", type=Path, help="Protocol response JSON or info result JSON; V1 requires its explicit envelope")
|
|
source.add_argument("--host", type=nonempty, help="Query selected protocol once; read-only")
|
|
verify.add_argument("--timeout", type=positive_seconds, default=10.0)
|
|
add_identity(verify)
|
|
add_target(verify)
|
|
return parser
|
|
|
|
|
|
def inspect_rom(path: Path, expected_digest: str | None) -> dict:
|
|
if expected_digest is not None and re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest) is None:
|
|
raise protocol.UpgradeError("--rom-sha256 must contain exactly 64 hexadecimal characters")
|
|
rom = protocol.validate_rom(path)
|
|
if expected_digest is not None and rom["sha256"] != expected_digest.lower():
|
|
raise protocol.UpgradeError("ROM SHA-256 mismatch")
|
|
rom["expected_sha256"] = expected_digest.lower() if expected_digest else None
|
|
rom["expected_digest_verified"] = expected_digest is not None
|
|
return rom
|
|
|
|
|
|
def numeric_upgrade_target(host: str) -> str:
|
|
"""Bind both protocol connections to one numeric address without DNS selection."""
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
except ValueError as exc:
|
|
raise protocol.UpgradeError("upgrade --host requires an explicit numeric IPv4/IPv6 address") from exc
|
|
effective = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
|
|
effective = effective if effective is not None else address
|
|
if effective.is_multicast or effective.is_unspecified or str(effective) == "255.255.255.255":
|
|
raise protocol.UpgradeError("upgrade --host must be unicast; multicast, unspecified and limited-broadcast addresses are rejected")
|
|
return str(address)
|
|
|
|
|
|
def validate_response(response: Any, expected_sn: str, expected_model: str | None, protocol_name="v2"):
|
|
if protocol_name == "v1":
|
|
return v1.validate_identity(response, expected_sn, expected_model)
|
|
if not isinstance(response, dict):
|
|
raise protocol.UpgradeError("30101 response must be a JSON object")
|
|
if response.get("cmd") != 30101 or response.get("code") != 0:
|
|
raise protocol.UpgradeError("Expected a successful cmd=30101, code=0 response")
|
|
return protocol.validate_identity(response, expected_sn, expected_model)
|
|
|
|
|
|
def read_response(path: Path) -> dict:
|
|
if path.stat().st_size > 2 * protocol.MAX_JSON_SIZE:
|
|
raise protocol.UpgradeError("Recorded response exceeds 2 MiB")
|
|
response = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
if isinstance(response, dict) and "response" in response:
|
|
response = response["response"]
|
|
return response
|
|
|
|
|
|
def verify_version(response: dict, expected_sn: str, expected_model: str,
|
|
target_version: str, version_field: str, protocol_name="v2") -> dict:
|
|
allowed = FIRMWARE_FIELDS + V1_NATIVE_FIELDS if protocol_name == "v1" else FIRMWARE_FIELDS
|
|
if version_field not in allowed:
|
|
raise protocol.UpgradeError("Selected firmware field is not supported by this protocol")
|
|
data, models = validate_response(response, expected_sn, expected_model, protocol_name)
|
|
if "." in version_field:
|
|
section, field = version_field.split(".")
|
|
values = data.get(section)
|
|
actual = values.get(field) if isinstance(values, dict) else None
|
|
else:
|
|
actual = data.get(version_field)
|
|
available = isinstance(actual, str) and bool(actual.strip())
|
|
matched = available and actual == target_version
|
|
return {
|
|
"status": "PASS" if matched else ("FAIL" if available else "INCOMPLETE"),
|
|
"firmware_verified": bool(matched),
|
|
"acceptance_scope": "identity_and_selected_firmware_version_only",
|
|
"sn": data["sn"], "models": models,
|
|
"version_field": version_field, "target_version": target_version,
|
|
"actual_version": actual,
|
|
"reason": "exact_match" if matched else ("version_mismatch" if available else "firmware_version_missing"),
|
|
"response": response,
|
|
}
|
|
|
|
|
|
@contextmanager
|
|
def protected_rom_snapshot(source: Path, expected_sha256: str, expected_size: int):
|
|
"""Hold a Windows read-only, no-write/no-delete share lock through transfer."""
|
|
if os.name != "nt":
|
|
raise protocol.UpgradeError("Protected firmware snapshots require Windows")
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
kernel.CreateFileW.restype = wintypes.HANDLE
|
|
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
kernel.CloseHandle.restype = wintypes.BOOL
|
|
with tempfile.TemporaryDirectory(prefix="artemis-rom-") as folder:
|
|
snapshot = Path(folder) / "firmware.rom"
|
|
with source.open("rb") as src, snapshot.open("xb") as dst:
|
|
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
# FILE_SHARE_READ only: conflicting existing writers prevent this open;
|
|
# future writers, renames and deletion remain blocked until CloseHandle.
|
|
handle = kernel.CreateFileW(str(snapshot), 0x80000000, 1, None, 3, 0x80, None)
|
|
if handle == ctypes.c_void_p(-1).value:
|
|
raise protocol.UpgradeError("Unable to protect firmware snapshot")
|
|
try:
|
|
info = inspect_rom(snapshot, expected_sha256)
|
|
if info["size"] != expected_size:
|
|
raise protocol.UpgradeError("Firmware snapshot size differs from preflight")
|
|
yield snapshot
|
|
finally:
|
|
kernel.CloseHandle(handle)
|
|
|
|
|
|
def run(args, logger: EventCollector) -> dict:
|
|
if args.command == "capabilities":
|
|
return capabilities()
|
|
if args.command == "offline":
|
|
return {"status": "OFFLINE_READY", "mode": "offline", "firmware_verified": False,
|
|
"rom": inspect_rom(args.rom, args.rom_sha256), "network_used": False}
|
|
require_supported_protocol(args.protocol)
|
|
if args.command in ("upgrade", "verify-version") and args.protocol == "v2" and args.version_field not in FIRMWARE_FIELDS:
|
|
raise protocol.UpgradeError("V2 requires inner.version or outer.version")
|
|
driver = v1 if args.protocol == "v1" else protocol
|
|
if args.command == "verify-version":
|
|
live = args.host is not None
|
|
response = (driver.query_device_info(args.host, args.timeout, logger)
|
|
if live else read_response(args.response))
|
|
result = verify_version(response, args.expected_sn, args.expected_model,
|
|
args.target_version, args.version_field, args.protocol)
|
|
result.update(mode="read_only" if live else "offline", live_readback=live,
|
|
evidence_source=("live_v1_2104" if args.protocol == "v1" else "live_30101") if live else "recorded_response",
|
|
network_used=live)
|
|
return result
|
|
rom = None
|
|
host = args.host
|
|
if args.command == "upgrade":
|
|
host = numeric_upgrade_target(args.host)
|
|
logger.emit("upgrade_target_bound", bound_ip=host)
|
|
if args.execute and not args.rom_sha256:
|
|
raise protocol.UpgradeError("--execute requires an explicitly reviewed --rom-sha256")
|
|
rom = inspect_rom(args.rom, args.rom_sha256)
|
|
response = driver.query_device_info(host, args.timeout, logger)
|
|
expected_model = getattr(args, "expected_model", None)
|
|
data, models = validate_response(response, args.expected_sn, expected_model, args.protocol)
|
|
result = {"status": "IDENTITY_CONFIRMED", "mode": "read_only", "firmware_verified": False,
|
|
"sn": data["sn"], "models": models, "response": response,
|
|
"network_used": True, "write_executed": False}
|
|
if args.protocol == "v1":
|
|
result["native_versions"] = {key: data[key] for key in ("firmware", "dev_ver", "hardware") if key in data}
|
|
if args.command == "identify":
|
|
result.update(status="IDENTITY_OBSERVED", sn_verified=True, model_verified=False)
|
|
return result
|
|
if args.command == "info":
|
|
return result
|
|
result.update(status="PREFLIGHT_ONLY", rom=rom, target_version=args.target_version,
|
|
version_field=args.version_field, write_executed=False,
|
|
postcheck_required=True, bound_ip=host,
|
|
rom_model_verification="Caller must verify ROM compatibility; protocol cannot prove it")
|
|
if args.protocol == "v1":
|
|
observation = verify_version(response, args.expected_sn, args.expected_model,
|
|
args.target_version, args.version_field, "v1")
|
|
result.update(preflight_complete=True, write_supported=True,
|
|
selected_version_field_available=observation["reason"] != "firmware_version_missing",
|
|
current_selected_version=observation["actual_version"])
|
|
if not args.execute:
|
|
return result
|
|
# Hash and transfer the same snapshot while Windows denies writes and replacement.
|
|
with protected_rom_snapshot(Path(rom["path"]), args.rom_sha256, rom["size"]) as snapshot:
|
|
logger.emit("write_attempt", rom_sha256=rom["sha256"], bound_ip=host,
|
|
target_version=args.target_version, version_field=args.version_field)
|
|
transfer_options = {"expected_sha256": args.rom_sha256} if args.protocol == "v1" else {}
|
|
transfer = driver.transfer_rom(
|
|
host=host, rom_path=snapshot, rom_size=rom["size"],
|
|
connect_timeout=args.timeout, stall_timeout=args.stall_timeout,
|
|
total_timeout=args.total_timeout, chunk_size=256 * 1024, logger=logger,
|
|
**transfer_options,
|
|
)
|
|
logger.emit("transfer_result", **transfer)
|
|
if args.protocol == "v1":
|
|
if (transfer.get("acknowledged") is not True
|
|
or transfer.get("response_command") != v1.UPGRADE_COMMAND
|
|
or type(transfer.get("response_code")) is not int or transfer["response_code"] != 0):
|
|
raise protocol.UpgradeError("Device did not acknowledge the V1 upgrade command with integer code=0")
|
|
elif transfer.get("status") != 2:
|
|
raise protocol.UpgradeError("Device did not report protocol status=2")
|
|
bytes_sent = transfer.get("bytes_sent")
|
|
if type(bytes_sent) is not int or bytes_sent != rom["size"]:
|
|
raise protocol.UpgradeError(
|
|
"Device reported success before a complete ROM upload: expected %d bytes, sent %r"
|
|
% (rom["size"], bytes_sent)
|
|
)
|
|
result.update(status="PROTOCOL_SUCCESS_POSTCHECK_REQUIRED", mode="write",
|
|
write_executed=True, protocol_success=True, transfer=transfer)
|
|
return result
|
|
|
|
|
|
def discover_v1_cli(argv, logger):
|
|
parser = argparse.ArgumentParser(description="V1 UDP ff00/ff01 read-only discovery; never upgrades a device")
|
|
parser.add_argument("--interface", action="append", default=[], metavar="IPv4/PREFIX")
|
|
parser.add_argument("--include-virtual", action="store_true")
|
|
parser.add_argument("--expected-sn", type=nonempty)
|
|
parser.add_argument("--expected-model", type=nonempty, help="Exact dev_type")
|
|
parser.add_argument("--timeout", type=positive_seconds, default=3.0)
|
|
parser.add_argument("--client-port", type=int, default=v1.DISCOVERY_PORT,
|
|
help="Local receive port; default same broadcast channel 4942; V1 source-port rule is unspecified")
|
|
args = parser.parse_args(argv)
|
|
if args.timeout > 300 or not 1 <= args.client_port <= 65535:
|
|
raise protocol.UpgradeError("V1 discovery requires timeout <= 300s and client-port in 1..65535")
|
|
# Reuse only the reviewed Windows interface enumeration, never V2 packets or ports.
|
|
helper = load_vendor("v2_discover.py", "artemis_device_vendor_discover")
|
|
try:
|
|
interfaces = helper.explicit_interfaces(args.interface) if args.interface else helper.powershell_interfaces()
|
|
except helper.DiscoveryError as exc:
|
|
raise protocol.UpgradeError(str(exc)) from exc
|
|
selected = []
|
|
for interface in interfaces:
|
|
reason = helper.exclusion_reason(interface, args.include_virtual)
|
|
logger.emit("v1_interface", alias=interface.alias, address=str(interface.address),
|
|
prefix_length=interface.prefix_length, selected=reason is None, exclusion_reason=reason)
|
|
if reason is None:
|
|
selected.append(interface)
|
|
if not selected:
|
|
raise protocol.UpgradeError("No eligible V1 broadcast interface")
|
|
return v1.discover(selected, args.timeout, args.client_port, logger,
|
|
args.expected_sn, args.expected_model)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
# Only CLI execution configures streams; importing the adapter is side-effect free.
|
|
for stream in (sys.stdout, sys.stderr):
|
|
if hasattr(stream, "reconfigure"):
|
|
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
logger = EventCollector()
|
|
selected_protocol = None
|
|
try:
|
|
if argv and argv[0] == "discover":
|
|
router = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
add_protocol(router)
|
|
routing, native_arguments = router.parse_known_args(argv[1:])
|
|
selected_protocol = routing.protocol
|
|
require_supported_protocol(selected_protocol)
|
|
if selected_protocol == "v2":
|
|
discovery = load_vendor("v2_discover.py", "artemis_device_vendor_discover")
|
|
return discovery.main(native_arguments)
|
|
result = discover_v1_cli(native_arguments, logger)
|
|
else:
|
|
args = build_parser().parse_args(argv)
|
|
selected_protocol = getattr(args, "protocol", None)
|
|
result = run(args, logger)
|
|
code = 0 if result["status"] not in ("FAIL", "INCOMPLETE", "BLOCKED") else 1
|
|
except (OSError, ValueError, protocol.UpgradeError) as exc:
|
|
write_attempted = any(event["event"] == "write_attempt" for event in logger.events)
|
|
bound_ip = next((event["bound_ip"] for event in logger.events
|
|
if event["event"] == "upgrade_target_bound"), None)
|
|
result = {"status": "ERROR", "firmware_verified": False,
|
|
"error_type": type(exc).__name__, "message": str(exc),
|
|
"automatic_retry": False, "write_may_have_started": write_attempted,
|
|
"postcheck_required": write_attempted, "bound_ip": bound_ip}
|
|
if isinstance(exc, UnsupportedProtocolError):
|
|
result.update(status="BLOCKED", error_code=getattr(exc, "error_code", "protocol_not_implemented"),
|
|
network_used=False, write_executed=False)
|
|
code = 1
|
|
if selected_protocol is not None:
|
|
result["protocol"] = selected_protocol
|
|
result["events"] = logger.events
|
|
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|