#!/usr/bin/env python3 """Deploy measured Go SDK packages through the device's independent worker. Python and OpenSSH run on the development host only. SSH configuration provides authentication and host-key trust; this tool never starts network authentication. """ import argparse import hashlib import json import os from pathlib import Path import re import shlex import subprocess import sys import time from urllib.parse import urlsplit MAX_MANIFEST = 256 * 1024 MAX_PACKAGE = 16 * 1024**2 # The installed payload budget, read from the file that owns it so the uploader # and the device agree. The device enforces update.MaxPayloadBytes; a stale copy # here would refuse a package the router would have accepted. MAX_PAYLOAD = json.loads( (Path(__file__).resolve().parents[1] / "targets.json").read_text(encoding="utf-8") )["development_payload_limit_bytes"] NAMES = {"core": "smart-srun", "luci": "luci-app-smart-srun", "bundle": "luci-app-smart-srun-bundle"} STATUS_COMMAND = "if [ -x /var/run/smart-srun/update-worker ]; then /var/run/smart-srun/update-worker update status; else /usr/bin/srunnet update status; fi" def unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("Duplicate JSON field: " + key) result[key] = value return result def load_manifest(path, directory): path = Path(path) if path.stat().st_size > MAX_MANIFEST: raise ValueError("Manifest exceeds size limit") data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) if data.get("schema_version") != 1 or not isinstance(data.get("assets"), list) or not 0 < len(data["assets"]) <= 256: raise ValueError("Invalid release manifest") root = Path(directory).resolve() inputs, ids = [], set() for asset in data["assets"]: identifier, fmt = asset["id"], asset["format"] if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", identifier) or identifier in ids: raise ValueError("Invalid or duplicate asset ID") ids.add(identifier) if (asset["package_manager"], fmt) not in (("opkg", "ipk"), ("apk", "apk")) or asset["kind"] not in NAMES: raise ValueError("Invalid package kind or format") url = urlsplit(asset["url"]) prefix = "/matthewlu070111/smart-srun/releases/download/" + data["release"] + "/" filename = url.path.removeprefix(prefix) if url.scheme != "https" or url.netloc != "github.com" or url.query or url.fragment or not url.path.startswith(prefix): raise ValueError("Package URL is not an official release asset") if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.~+-]{0,199}", filename) or not filename.endswith("." + fmt): raise ValueError("Unsafe package filename") # Manifests may cover several targets; verify the packages that are # actually present, then require every selected package before upload. file = root / filename if file.exists() or file.is_symlink(): verify_local(file, asset) inputs.append((asset, file)) return data, inputs def verify_local(file, asset): if file.is_symlink() or not file.is_file() or file.stat().st_size != asset["bytes"] or not 0 < asset["bytes"] <= MAX_PACKAGE: raise ValueError("Missing, oversized or changed package: " + file.name) with file.open("rb") as stream: digest = hashlib.file_digest(stream, "sha256").hexdigest() if digest != asset["sha256"]: raise ValueError("Package SHA256 mismatch: " + file.name) def select(inputs, inventory, recovery=False): packages = inventory["packages"] names = set(packages) modes = {frozenset([NAMES["core"]]): ["core"], frozenset([NAMES["core"], NAMES["luci"]]): ["core", "luci"], frozenset([NAMES["bundle"]]): ["bundle"]} if frozenset(names) not in modes or len(set(packages.values())) != 1: raise ValueError("Installed package set is incomplete or inconsistent") chosen = [] for kind in modes[frozenset(names)]: arch = inventory["architecture"] if kind != "luci" else ("all" if inventory["package_manager"] == "opkg" else "noarch") matches = [(a, p) for a, p in inputs if a["kind"] == kind and a["package_manager"] == inventory["package_manager"] and a["openwrt_arch"] == arch and inventory["firmware_family"] in a["firmware_compat"] and a["validation"].get("build") is True and (kind == "luci" or a["validation"].get("elf") is True)] if len(matches) != 1: raise ValueError("No unique compatible validated package for " + kind) asset, path = matches[0] if recovery and asset["package_version"] != packages[NAMES[kind]]: raise ValueError("Recovery package is not the exact installed version") verify_local(path, asset) chosen.append((asset, path)) if len({a["package_version"] for a, _ in chosen}) != 1 or sum(a["installed_bytes"] for a, _ in chosen) > MAX_PAYLOAD: raise ValueError(f"Split version mismatch or installed payload exceeds {MAX_PAYLOAD} bytes") return chosen class SSH: def __init__(self, args): if not args.host or args.host.startswith("-") or re.search(r"[\s\x00-\x1f]", args.host): raise ValueError("Provide a valid SSH host or configured alias with --host") self.argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15"] if args.ssh_config: self.argv += ["-F", str(Path(args.ssh_config).resolve())] if args.port: self.argv += ["-p", str(args.port)] self.argv.append(args.host) def run(self, command, data=None): result = subprocess.run(self.argv + [command], input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) if result.returncode: # Fixed commands never read configuration or account credentials. raise RuntimeError("Remote operation failed: " + result.stderr.decode("utf-8", "replace")[-2000:]) if len(result.stdout) > 256 * 1024: raise RuntimeError("Remote response exceeds size limit") return result.stdout def json(self, command, data=None): return json.loads(self.run(command, data), object_pairs_hook=unique_object) def upload(self, folder, asset, path): verify_local(path, asset) directory = "/var/run/smart-srun/update-inbox/" + folder destination = directory + "/" + asset["id"] + "." + asset["format"] # Private, exclusive staging prevents a stale symlink being followed. # Only this exact staging file is cleaned up on exit, including errors. command = "umask 077; mkdir -p " + directory + "; chmod 0700 /var/run/smart-srun/update-inbox " + directory + "; " command += "stage=$(mktemp " + directory + "/.upload-XXXXXX) || exit 1; trap 'rm -f -- \"$stage\"' EXIT; " command += 'cat > "$stage" && chmod 0600 "$stage" && mv -f -- "$stage" ' + shlex.quote(destination) self.run(command, path.read_bytes()) digest = self.run("sha256sum " + shlex.quote(destination)).split()[0].decode("ascii") if digest != asset["sha256"]: raise ValueError("Uploaded package checksum mismatch") def deploy(args, ssh, manifests): new, old = manifests inventory = ssh.json("/usr/bin/srunnet update inventory") status = ssh.json(STATUS_COMMAND) if status.get("running") or status.get("phase") == "recovery_required": raise RuntimeError("Resolve the existing update task before deploying") if old[0]["release"] != inventory["display_version"]: raise ValueError("Recovery manifest does not match installed version") chosen = [("recovery", select(old[1], inventory, True)), ("new", select(new[1], inventory))] print(json.dumps({"inventory": inventory, "target": new[0]["release"], "packages": [p.name for _, files in chosen for _, p in files]}, ensure_ascii=False)) if args.probe: return 0 for folder, files in chosen: for asset, path in files: ssh.upload(folder, asset, path) payload = json.dumps({"manifest": new[0], "recovery": old[0]}, separators=(",", ":")).encode() # The device independently selects, validates metadata/signatures and hashes. plan = ssh.json("/usr/bin/srunnet update prepare-local", payload) if plan.get("ok") is not True or not re.fullmatch("[0-9a-f]{64}", plan.get("plan_id", "")): raise RuntimeError("Device did not return a verified update plan") if args.prepare: print(json.dumps(plan, ensure_ascii=False)) return 0 task = ssh.json("/usr/bin/srunnet update run " + plan["plan_id"] + " --background") if not task.get("running") or not re.fullmatch("[0-9a-f]{32}", task.get("job_id", "")): raise RuntimeError("Worker did not acknowledge the installation") print(json.dumps(task, ensure_ascii=False), flush=True) if args.background: return 0 deadline, previous = time.monotonic() + args.wait, None while time.monotonic() < deadline: status = ssh.json(STATUS_COMMAND) if status.get("job_id") != task["job_id"]: raise RuntimeError("Update task identity changed") if status.get("phase") != previous or not status.get("running"): print(json.dumps(status, ensure_ascii=False), flush=True) previous = status.get("phase") if not status.get("running"): return 0 if status.get("ok") is True and status.get("phase") == "completed" else 1 time.sleep(1) raise TimeoutError("Stopped waiting; the independent worker continues. Read srunnet update status before taking further action.") def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default=os.environ.get("SMARTSRUN_ROUTER_HOST", ""), help="OpenSSH alias or user@host") parser.add_argument("--port", type=int) parser.add_argument("--ssh-config", type=Path) parser.add_argument("--manifest", type=Path, required=True) parser.add_argument("--assets", type=Path, required=True) parser.add_argument("--recovery-manifest", type=Path, required=True) parser.add_argument("--recovery-assets", type=Path, required=True) mode = parser.add_mutually_exclusive_group() mode.add_argument("--dry-run", action="store_true", help="Verify local bytes without SSH or device changes") mode.add_argument("--probe", action="store_true", help="Read inventory and select packages without device changes") mode.add_argument("--prepare", action="store_true", help="Upload and verify packages without installing") mode.add_argument("--background", action="store_true") parser.add_argument("--wait", type=int, default=900, help="Wait budget; timeout never kills the installer") args = parser.parse_args(argv) try: if not 1 <= args.wait <= 86400 or (args.port is not None and not 1 <= args.port <= 65535): raise ValueError("Invalid wait budget or SSH port") manifests = [load_manifest(args.manifest, args.assets), load_manifest(args.recovery_manifest, args.recovery_assets)] if args.dry_run: present = [p for _, files in manifests for _, p in files if p.is_file()] if not present: raise ValueError("No local packages to verify") print(json.dumps({"local_files_verified": [str(p) for p in present], "device_checked": False}, ensure_ascii=False)) return 0 return deploy(args, SSH(args), manifests) except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as exc: print(str(exc), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())