Pin RAK3401 OTA chain participants by public key

This commit is contained in:
mikecarper committed 2026-09-28 17:46:41 -07:00
1 parent fd7b93c52d
commit addfa0f683
3 files changed
+132 -39

No files matched your search

+9
View File
@@ -209,6 +209,15 @@ python3 tools/lora_ota/rak3401_mota_chain.py \
--yes
```
`--target-key` accepts a full contact public key or a unique hexadecimal prefix
of at least eight characters, such as the 12-character prefix shown by
`meshcli lc`. The chain resolves it against the controller's saved contacts
and uses the full key for every remote command and transfer step. Emoji or a
later change in the repeater's display name does not affect selection. Each
`--relay` can likewise use a full key or unique prefix instead of a name.
The target still must be a repeater contact with the exact hardware, firmware,
and bootloader required by this chain.
Keep the work directory. Rerunning the same command resumes only when the live
EndF body hash matches an exact chain node. Never manually skip a package.
`--legacy-full-airtime` temporarily sets the destination airtime factor to
+59 -38
View File
@@ -1541,9 +1541,28 @@ def resolve_target_by_key(
)
full_key, contact = matches[0]
name = contact.get("adv_name")
if contact.get("type") != 2 or not isinstance(name, str) or not name:
raise ota.OtaError("target key does not identify a named repeater contact")
return name, full_key
if contact.get("type") != 2:
raise ota.OtaError("target key does not identify a repeater contact")
return name if isinstance(name, str) and name else f"[{full_key[:12]}]", full_key
def bind_relay_keys(
controller: ota.Controller,
target_key: str,
relay_specs: list[str],
default_password: str,
) -> list[tuple[str, str]]:
"""Resolve relays locally before the chain sends any remote command."""
if not relay_specs:
return []
selectors = SimpleNamespace(
target=target_key,
relay_values=[ota.parse_relay(value, default_password) for value in relay_specs],
source_contact_value=None,
source_shares_controller=True, # bind only destination and relays here
)
ota.bind_contact_selectors(controller, selectors)
return selectors.relay_values
def source_namespace(args: argparse.Namespace) -> SimpleNamespace:
@@ -2352,6 +2371,7 @@ def run_step(
expected_body_hash: bytes,
work_dir: Path,
controller: ota.Controller,
relays: list[tuple[str, str]],
) -> None:
command = [
str(step.path),
@@ -2388,8 +2408,8 @@ def run_step(
])
if args.debug:
command.append("--debug")
for relay in args.relay:
command.extend(["--relay", relay])
for relay_key, relay_password in relays:
command.extend(["--relay", f"{relay_key}={relay_password}"])
result = ota.main(command, controller_override=controller)
if result != 0:
raise ota.OtaError(f"chain step {step.number} exited with status {result}")
@@ -2443,8 +2463,8 @@ def build_parser() -> argparse.ArgumentParser:
"--relay",
action="append",
default=[],
metavar="NAME[=PASSWORD]",
help="intermediate relay, farthest-to-nearest; repeat for each relay",
metavar="NAME_OR_KEY[=PASSWORD]",
help="intermediate relay name, full public key, or unique key prefix; farthest-to-nearest",
)
parser.add_argument(
"--relay-txdelay",
@@ -2723,13 +2743,16 @@ def main(argv: list[str] | None = None) -> int:
ota.preflight_source_cli(source_args)
controller = ota.Controller(controller_namespace(args), password)
ota.verify_shared_source_identity(controller, source_args)
target_name, full_key = resolve_target_by_key(controller, args.target_key)
target = query_live_target(controller, args, target_name)
target_name, target_key = resolve_target_by_key(controller, args.target_key)
relays = bind_relay_keys(controller, target_key, args.relay, password)
# The name is only for the operator's plan. Keep every remote command
# and nested transfer pinned to the full key, including after renames.
target = query_live_target(controller, args, target_key)
first_index = find_resume_index(target, steps, final_body_hash)
require_rescue_capability_before_next_transition(
controller, target_name, first_index, len(steps)
controller, target_key, first_index, len(steps)
)
confirm_chain(args, target_name, full_key, target, first_index, steps)
confirm_chain(args, target_name, target_key, target, first_index, steps)
if args.preflight_only:
return 0
@@ -2738,26 +2761,24 @@ def main(argv: list[str] | None = None) -> int:
transfer_path = work_dir / TRANSFER_SETTINGS_FILE
if transfer_path.exists():
transfer_settings = load_or_capture_transfer_settings(
controller, target_name, full_key, work_dir
controller, target_key, target_key, work_dir
)
restore_and_retire_transfer_settings(
controller, target_name, transfer_settings, work_dir
controller, target_key, transfer_settings, work_dir
)
if not args.keep_watchdog_off:
enabled = controller.remote_command(target_name, "set system.watchdog on")
enabled = controller.remote_command(target_key, "set system.watchdog on")
if not enabled.lower().startswith("ok - system watchdog enabled"):
raise ota.OtaError(f"could not enable system watchdog: {enabled}")
require_watchdog_state(controller, target_name, "on")
require_watchdog_state(controller, target_key, "on")
print("RAK3401 already matches the verified final endpoint.")
return 0
transfer_settings = load_or_capture_transfer_settings(
controller, target_name, full_key, work_dir
controller, target_key, target_key, work_dir
)
participant_args = source_namespace(args)
participant_args.relay_values = [
ota.parse_relay(value, password) for value in args.relay
]
participant_args.relay_values = relays
participant_versions = ota.read_lora_ota_participant_versions(
controller, participant_args, target
)
@@ -2773,10 +2794,10 @@ def main(argv: list[str] | None = None) -> int:
participant_versions
)
prepare_watchdog(controller, target_name)
prepare_watchdog(controller, target_key)
enforce_transfer_guardrails(
controller,
target_name,
target_key,
saved=transfer_settings,
current_version=target.current_version,
temp_values=args.temp_values,
@@ -2785,13 +2806,13 @@ def main(argv: list[str] | None = None) -> int:
),
legacy_full_airtime=args.legacy_full_airtime,
)
enforce_ota_hops(controller, target_name, args.ota_hops)
enforce_ota_hops(controller, target_key, args.ota_hops)
for index in range(first_index, len(steps)):
step = steps[index]
resolved_name, current_key = resolve_target_by_key(controller, args.target_key)
if current_key != full_key or resolved_name != target_name:
_current_name, current_key = resolve_target_by_key(controller, args.target_key)
if current_key != target_key:
raise ota.OtaError("target contact identity changed during the chain")
target = query_live_target(controller, args, target_name)
target = query_live_target(controller, args, target_key)
current_index = find_resume_index(target, steps, final_body_hash)
if current_index > index:
print(f"[chain] step {step.number:02d} is already installed; continuing")
@@ -2800,10 +2821,10 @@ def main(argv: list[str] | None = None) -> int:
raise ota.OtaError(
f"live target is at chain index {current_index}, expected {index}"
)
require_watchdog_state(controller, target_name, "off")
require_watchdog_state(controller, target_key, "off")
enforce_transfer_guardrails(
controller,
target_name,
target_key,
saved=transfer_settings,
current_version=target.current_version,
temp_values=args.temp_values,
@@ -2812,7 +2833,7 @@ def main(argv: list[str] | None = None) -> int:
),
legacy_full_airtime=args.legacy_full_airtime,
)
enforce_ota_hops(controller, target_name, args.ota_hops)
enforce_ota_hops(controller, target_key, args.ota_hops)
print(
f"\n[chain] step {step.number:02d}/{len(steps)}: "
f"{step.from_version} -> {step.to_version}"
@@ -2820,11 +2841,11 @@ def main(argv: list[str] | None = None) -> int:
previous_step = steps[index - 1] if index > 0 else None
expected_hash = expected_hash_after(steps, final_body_hash, index)
run_step(
args, target_name, step, previous_step, expected_hash,
work_dir, controller
args, target_key, step, previous_step, expected_hash,
work_dir, controller, relays
)
target = query_live_target(controller, args, target_name)
target = query_live_target(controller, args, target_key)
if target.base_hash != expected_hash:
raise ota.OtaError(
f"step {step.number} returned body hash {target.base_hash.hex().upper()}, "
@@ -2850,25 +2871,25 @@ def main(argv: list[str] | None = None) -> int:
f"not the EndF chain version {step.to_version}"
)
require_rescue_capability_before_next_transition(
controller, target_name, index + 1, len(steps)
controller, target_key, index + 1, len(steps)
)
clear_completed_download(controller, target_name, step)
require_watchdog_state(controller, target_name, "off")
clear_completed_download(controller, target_key, step)
require_watchdog_state(controller, target_key, "off")
append_progress(work_dir, step, target.base_hash)
print(f"[chain] step {step.number:02d} verified")
final_target = query_live_target(controller, args, target_name)
final_target = query_live_target(controller, args, target_key)
if find_resume_index(final_target, steps, final_body_hash) != len(steps):
raise ota.OtaError("final target identity did not match the release endpoint")
restore_persisted_source_rxps(work_dir, source_args)
restore_and_retire_transfer_settings(
controller, target_name, transfer_settings, work_dir
controller, target_key, transfer_settings, work_dir
)
if not args.keep_watchdog_off:
enabled = controller.remote_command(target_name, "set system.watchdog on")
enabled = controller.remote_command(target_key, "set system.watchdog on")
if not enabled.lower().startswith("ok - system watchdog enabled"):
raise ota.OtaError(f"could not enable system watchdog: {enabled}")
require_watchdog_state(controller, target_name, "on")
require_watchdog_state(controller, target_key, "on")
print(f"[watchdog] re-enabled after verified step {len(steps)} boot")
print(
f"RAK3401 update complete: {final_target.current_version} "
+64 -1
View File
@@ -6888,6 +6888,64 @@ class Rak3401ExtractionCacheTests(unittest.TestCase):
class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
def test_chain_preflight_uses_public_key_for_emoji_named_target(self) -> None:
target_key = "37ea80c102ed" + "ab" * 26
contact_name = "#Calloway 1W Rptr \U0001f4e1"
controller = mock.Mock()
controller._run.return_value = [{target_key: {
"public_key": target_key, "adv_name": contact_name, "type": 2,
}}]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with (
mock.patch.dict(os.environ, {"MESHCORE_ADMIN_PASSWORD": "test-password"}),
mock.patch.object(
rak_chain, "validate_args",
side_effect=lambda args, _parser: setattr(args, "temp_values", (909.95, 250, 5, 5, 120)),
),
mock.patch.object(rak_chain, "locate_bundle", return_value=root / "bundle"),
mock.patch.object(rak_chain, "require_bundle_work_separation"),
mock.patch.object(rak_chain, "snapshot_verified_bundle", return_value=root / "snapshot"),
mock.patch.object(rak_chain, "parse_chain", return_value=([mock.Mock()], b"final")),
mock.patch.object(rak_chain, "verify_motatool"),
mock.patch.object(rak_chain, "require_live_release_safe"),
mock.patch.object(rak_chain, "require_meshcli_version"),
mock.patch.object(rak_chain.ota, "preflight_source_cli"),
mock.patch.object(rak_chain.ota, "Controller", return_value=controller),
mock.patch.object(rak_chain.ota, "verify_shared_source_identity"),
mock.patch.object(rak_chain, "query_live_target", return_value=mock.Mock()) as query,
mock.patch.object(rak_chain, "find_resume_index", return_value=0),
mock.patch.object(rak_chain, "require_rescue_capability_before_next_transition") as rescue,
mock.patch.object(rak_chain, "confirm_chain") as confirm,
):
result = rak_chain.main([
"--bundle", str(root / "bundle"),
"--work-dir", str(root / "work"),
"--controller-tcp", "192.0.2.8",
"--target-key", target_key[:12],
"--preflight-only",
])
self.assertEqual(result, 0)
self.assertEqual(query.call_args.args[2], target_key)
self.assertEqual(rescue.call_args.args[1], target_key)
self.assertEqual(confirm.call_args.args[1:3], (contact_name, target_key))
controller.remote_command.assert_not_called()
def test_chain_binds_emoji_named_relay_to_public_key(self) -> None:
target_key = "37ea80c102ed" + "ab" * 26
relay_key = "3ee21f453f8f" + "cd" * 26
controller = mock.Mock()
controller._run.return_value = [{
target_key: {"public_key": target_key, "adv_name": "target", "type": 2},
relay_key: {"public_key": relay_key, "adv_name": "Relay \U0001f4e1", "type": 2},
}]
with contextlib.redirect_stdout(io.StringIO()):
relays = rak_chain.bind_relay_keys(
controller, target_key, [f"{relay_key[:12]}=relay-password"], "default"
)
self.assertEqual(relays, [(relay_key, "relay-password")])
controller.remote_command.assert_not_called()
@staticmethod
def live_chain_args() -> argparse.Namespace:
return argparse.Namespace(
@@ -7068,6 +7126,8 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
package=mock.Mock(manifest_id="1234ABCD"),
)
controller = mock.Mock()
target_key = "37ea80c102ed" + "ab" * 26
relay_key = "3ee21f453f8f" + "cd" * 26
for enabled in (False, True):
with self.subTest(debug=enabled), tempfile.TemporaryDirectory() as directory:
@@ -7075,15 +7135,18 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
with mock.patch.object(rak_chain.ota, "main", return_value=0) as nested:
rak_chain.run_step(
args,
"remote",
target_key,
step,
None,
bytes.fromhex("0011223344556677"),
Path(directory),
controller,
[(relay_key, "relay-password")],
)
command = nested.call_args.args[0]
self.assertEqual(command.count("--debug"), int(enabled))
self.assertEqual(command[1], target_key)
self.assertEqual(command[command.index("--relay") + 1], f"{relay_key}=relay-password")
def test_live_chain_is_blocked_after_failed_physical_step_6(self) -> None:
self.assertEqual(rak_chain.KNOWN_UNSAFE_STEP, 6)