"""Node-owned X4 bootstrap; the same fixed job serves local and paired Core UI. This exists before the optional model package is installed. All package bytes and hashes come from the Node release, never from a device or a client request. """ import fcntl import hashlib import json import os import platform import re import subprocess import sys import tempfile import time import uuid from pathlib import Path SHARE = Path("/usr/share/mission-core-node/profiles/insta360-x4") STATE = Path("/var/lib/mission-core-node-profiles/insta360-x4") PLUGIN_STATE = Path("/var/lib/mission-core-insta360") UNIT = "mission-core-node-insta360-x4-prepare.service" PACKAGE = "mission-core-insta360-x4" def trusted(path, directory=False): info = path.lstat() if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: raise RuntimeError("Небезопасный файл профиля камеры.") if path.is_dir() != directory: raise RuntimeError("Недопустимый файл профиля камеры.") return path def publish(value): fd, name = tempfile.mkstemp(prefix=".preparation-", dir=STATE) try: with os.fdopen(fd, "w") as stream: os.fchmod(stream.fileno(), 0o644) json.dump(value, stream, ensure_ascii=False) stream.flush() os.fsync(stream.fileno()) target = STATE / "preparation.json" if target.exists() or target.is_symlink(): trusted(target) os.replace(name, target) descriptor = os.open(STATE, os.O_RDONLY) try: os.fsync(descriptor) finally: os.close(descriptor) finally: if os.path.exists(name): os.unlink(name) def payload(): for path in (SHARE.parent, SHARE): trusted(path, True) value = json.loads(trusted(SHARE / "profile.json").read_text()) if value.get("schema") != "missioncore.node.bundled-model/v1": raise RuntimeError("Неизвестный профиль камеры.") version = value["version"] if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): raise RuntimeError("Некорректная версия профиля камеры.") if not re.fullmatch(r"[0-9a-f]{24}", value["revision"]): raise RuntimeError("Некорректная версия драйвера.") path = trusted(SHARE / (PACKAGE + "_" + version + "_amd64.deb")) data = path.read_bytes() if len(data) != value["bytes"] or hashlib.sha256(data).hexdigest() != value["sha256"]: raise RuntimeError("Встроенный пакет камеры повреждён. Переустановите Mission Core Node.") return value, path def prepare(): for path in (STATE.parent, STATE): path.mkdir(mode=0o755, exist_ok=True) trusted(path, True) fd = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, "r+b") as lock: trusted(STATE / "prepare.lock") fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) return prepare_locked() def prepare_locked(): report = { "schema": "missioncore.node.device-preparation/v1", "model_id": "insta360.x4", "run_id": uuid.uuid4().hex, "started_at": time.time(), "monotonic_started": time.monotonic(), "state": "running", "steps": [ {"id": key, "label": label, "state": "pending"} for key, label in ( ("platform", "Проверка совместимости системы"), ("payload", "Проверка встроенного пакета камеры"), ("package", "Установка драйвера камеры"), ("prepare", "Подготовка камеры"), ) ], } env = { "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive", } evidence = STATE / report["run_id"] evidence.mkdir(mode=0o700) trusted(evidence, True) sequence = 0 def run(command, timeout=60): nonlocal sequence sequence += 1 result = subprocess.run(command, capture_output=True, env=env, timeout=timeout) for suffix, data in (("stdout", result.stdout), ("stderr", result.stderr)): path = evidence / (str(sequence) + "." + suffix) with path.open("xb") as stream: os.fchmod(stream.fileno(), 0o600) stream.write(data) if result.returncode: raise RuntimeError( "Этап установки камеры не завершён. Повторите подготовку устройства." ) return result.stdout.decode().strip() publish(report) try: for step in report["steps"]: step["state"] = "running" publish(report) if step["id"] == "platform": release = platform.freedesktop_os_release() if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ( "ubuntu", "24.04", "x86_64", ): raise RuntimeError("Этот профиль поддерживает Ubuntu 24.04 amd64.") elif step["id"] == "payload": bundle, path = payload() report.update(revision=bundle["revision"], package_sha256=bundle["sha256"]) elif step["id"] == "package": result = subprocess.run( ["/usr/bin/dpkg-query", "-W", "-f", "${Version}\t${Status}", PACKAGE], env=env, capture_output=True, text=True, timeout=10, ) previous = result.stdout.strip().split("\t") if result.returncode == 0 else [] if ( previous and subprocess.run( [ "/usr/bin/dpkg", "--compare-versions", previous[0], "gt", bundle["version"], ], env=env, capture_output=True, timeout=10, ).returncode == 0 ): raise RuntimeError( "Установлен более новый драйвер X4. Обновите Mission Core Node." ) if previous != [bundle["version"], "install ok installed"]: # Node already declares every OS dependency. Install only # the hash-verified local archive: APT --no-download drops # its local-file acquisition path on Ubuntu 24.04. dpkg # retains dependency and package-lock checks, without a # network acquisition or changes to unrelated packages. # Model preinst/prerm retain recording/preview safety. run( [ "/usr/bin/dpkg", "--install", str(path), ], timeout=None, ) elif step["id"] == "prepare": current = PLUGIN_STATE / "preparation.json" # Package postinst prepares an existing installation during an # upgrade. Avoid a second preparation while its SDK connects. value = json.loads(trusted(current).read_text()) if current.exists() else {} same_upgrade = ( previous != [bundle["version"], "install ok installed"] and value.get("started_at", 0) >= report["started_at"] and value.get("state") == "complete" and value.get("revision") == bundle["revision"] ) if not same_upgrade: run(["/usr/bin/systemctl", "start", UNIT], timeout=200) value = json.loads(trusted(current).read_text()) if value.get("state") != "complete" or value.get("revision") != bundle["revision"]: raise RuntimeError("Подготовка драйвера камеры не подтверждена.") step["state"] = "complete" publish(report) report["state"] = "complete" except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error: report["state"] = "error" report["message"] = ( str(error)[:300] if isinstance(error, RuntimeError) else "Не удалось подготовить камеру." ) for step in report["steps"]: if step["state"] == "running": step.update(state="error", message=report["message"]) elif step["state"] == "pending": step["state"] = "blocked" report["duration_seconds"] = time.monotonic() - report["monotonic_started"] publish(report) return report["state"] == "complete" if __name__ == "__main__": if os.geteuid() or sys.argv[1:]: sys.exit(1) os.umask(0o022) sys.exit(0 if prepare() else 1)