Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""Owner-facing, versioned installer; first SDK Open belongs to fixed prepare.
|
|
|
|
Invoke through the release's local Ubuntu terminal. The OS password is never
|
|
accepted by this script or sent to Mission Core. No arbitrary commands/URLs.
|
|
"""
|
|
|
|
import hashlib
|
|
import http.client
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from contextlib import suppress
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
STAGING = Path("/var/tmp/mission-core-x4-installs")
|
|
SERVICES = (
|
|
"mission-core-node.service",
|
|
"mission-core-k1.service",
|
|
"mission-core-realsense.service",
|
|
)
|
|
|
|
|
|
def private(path):
|
|
path.mkdir(mode=0o700, exist_ok=True)
|
|
info = path.lstat()
|
|
if path.is_symlink() or not path.is_dir() or info.st_uid != 0 or info.st_mode & 0o077:
|
|
raise RuntimeError("Installer staging is not root-owned and private")
|
|
|
|
|
|
def request(path, operation=None):
|
|
timeout = 55 if operation else 3
|
|
client = http.client.HTTPConnection("driver", timeout=timeout)
|
|
client.sock = socket.socket(socket.AF_UNIX)
|
|
client.sock.settimeout(timeout)
|
|
try:
|
|
client.sock.connect(str(path))
|
|
if operation is None:
|
|
client.request("GET", "/snapshot")
|
|
else:
|
|
client.request(
|
|
"POST", "/operation", json.dumps(operation), {"Content-Type": "application/json"}
|
|
)
|
|
response = client.getresponse()
|
|
content = response.read(65537)
|
|
if response.status != 200 or len(content) > 65536:
|
|
raise RuntimeError("Camera status is unavailable")
|
|
return json.loads(content)
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def main():
|
|
if os.geteuid() or sys.argv[1:]:
|
|
raise RuntimeError("Запустите установщик в локальном окне Ubuntu через sudo.")
|
|
os.umask(0o077)
|
|
source = Path(__file__).resolve().parent
|
|
manifest = json.loads((source / "release.json").read_text())
|
|
if manifest["schema"] != "missioncore.insta360.owner-release/v1":
|
|
raise ValueError("Unsupported release")
|
|
version = manifest["version"]
|
|
revision = manifest["runtime_revision"]
|
|
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version) or not re.fullmatch(
|
|
r"[0-9a-f]{24}", revision
|
|
):
|
|
raise ValueError("Invalid release version")
|
|
if manifest["qualification_profile"] != "sdk-status-and-image-verification":
|
|
raise ValueError("Unsupported qualification profile")
|
|
package = "mission-core-insta360-x4_" + version + "_amd64.deb"
|
|
admitted = manifest["files"]
|
|
if set(admitted) != {package, "install_release.py", "install"}:
|
|
raise ValueError("Unexpected installer contents")
|
|
for name, expected in admitted.items():
|
|
data = (source / name).read_bytes()
|
|
if len(data) != expected["bytes"] or hashlib.sha256(data).hexdigest() != expected["sha256"]:
|
|
raise ValueError("Установщик повреждён. Контрольная сумма не совпала.")
|
|
private(STAGING)
|
|
identifier = uuid.uuid4().hex
|
|
folder = STAGING / identifier
|
|
private(folder)
|
|
# Snapshot the exact admitted bytes to root-only staging before APT, so
|
|
# it never consumes a mutable package from the operator's Downloads path.
|
|
data = (source / package).read_bytes()
|
|
if hashlib.sha256(data).hexdigest() != admitted[package]["sha256"]:
|
|
raise ValueError("Package changed before staging")
|
|
(folder / package).write_bytes(data)
|
|
report = {
|
|
"schema": "missioncore.insta360.install-run/v1",
|
|
"session_id": identifier,
|
|
"started_at": datetime.now(UTC).isoformat(),
|
|
"monotonic_started": time.monotonic(),
|
|
"release_sha256": hashlib.sha256((source / "release.json").read_bytes()).hexdigest(),
|
|
"package_sha256": admitted[package]["sha256"],
|
|
"state": "running",
|
|
"scope": "install-profile-SDK-status-and-bounded-image-verification",
|
|
"steps": [],
|
|
}
|
|
env = {
|
|
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
|
"LANG": "C.UTF-8",
|
|
"DEBIAN_FRONTEND": "noninteractive",
|
|
}
|
|
|
|
def publish():
|
|
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
|
|
|
def run(step, command, timeout=180):
|
|
report["steps"].append(
|
|
{"id": step, "state": "running", "started_at": datetime.now(UTC).isoformat()}
|
|
)
|
|
publish()
|
|
result = subprocess.run(command, env=env, capture_output=True, timeout=timeout)
|
|
(folder / (step + ".stdout")).write_bytes(result.stdout)
|
|
(folder / (step + ".stderr")).write_bytes(result.stderr)
|
|
report["steps"][-1].update(
|
|
state="complete" if result.returncode == 0 else "error", exit_code=result.returncode
|
|
)
|
|
publish()
|
|
if result.returncode:
|
|
raise RuntimeError("Не завершён этап установки: " + step)
|
|
return result.stdout
|
|
|
|
publish()
|
|
try:
|
|
# These remain private to the authenticated owner; the terminal wrapper
|
|
# saves stdout with umask 077. No camera identifiers enter public logs.
|
|
previous = sorted(STAGING.glob("*/report.json"), key=lambda p: p.stat().st_mtime)
|
|
for path in previous[-6:]:
|
|
if path.parent != folder:
|
|
print("MISSION_CORE_X4_PREVIOUS " + path.read_text().replace("\n", ""), flush=True)
|
|
baseline = run(
|
|
"baseline",
|
|
[
|
|
"/usr/bin/systemctl",
|
|
"show",
|
|
*SERVICES,
|
|
"-p",
|
|
"Id",
|
|
"-p",
|
|
"ActiveState",
|
|
"-p",
|
|
"NRestarts",
|
|
],
|
|
)
|
|
report["existing_services"] = baseline.decode().splitlines()
|
|
run(
|
|
"apt-plan",
|
|
["/usr/bin/apt-get", "--simulate", "--no-remove", "install", str(folder / package)],
|
|
)
|
|
run(
|
|
"package",
|
|
["/usr/bin/apt-get", "install", "-y", "--no-remove", str(folder / package)],
|
|
timeout=None,
|
|
)
|
|
# This is the identical installed model job started by Node's local or
|
|
# paired remote prepare command. No SDK demo, root SDK or manual grant.
|
|
preparation = Path("/var/lib/mission-core-insta360/preparation.json")
|
|
prepared = json.loads(preparation.read_text()) if preparation.exists() else {}
|
|
active = Path("/var/lib/mission-core-insta360/active.path")
|
|
# An upgrade's postinst already executes this same fixed preparation.
|
|
# Do not race its asynchronously connecting workers with a second run.
|
|
if not (
|
|
prepared.get("state") == "complete"
|
|
and prepared.get("revision") == revision
|
|
and active.exists()
|
|
and active.read_text().strip() == revision
|
|
):
|
|
run(
|
|
"prepare",
|
|
["/usr/bin/systemctl", "start", "mission-core-node-insta360-x4-prepare.service"],
|
|
)
|
|
deadline = time.monotonic() + 50
|
|
while True:
|
|
values = []
|
|
for path in Path("/run/mission-core-x4-instances").glob("instax4_*/driver.sock"):
|
|
if not re.fullmatch(r"instax4_[0-9a-f]{32}", path.parent.name):
|
|
continue
|
|
with suppress(OSError, ValueError, RuntimeError, http.client.HTTPException):
|
|
values.append(request(path))
|
|
if values and all(item.get("prepared") and item.get("online") for item in values):
|
|
break
|
|
if time.monotonic() >= deadline:
|
|
report["camera_status"] = values
|
|
raise RuntimeError(
|
|
"Пакет установлен, но подключение X4 не подтверждено. "
|
|
"Проверьте питание и USB-режим камеры."
|
|
)
|
|
time.sleep(1)
|
|
report["camera_status"] = values
|
|
if len(values) != 1:
|
|
raise RuntimeError("Для этой аппаратной проверки требуется ровно одна X4.")
|
|
camera = values[0]
|
|
if not re.fullmatch(r"instax4_[0-9a-f]{32}", camera["id"]):
|
|
raise RuntimeError("Camera identity is invalid")
|
|
now = datetime.now(UTC)
|
|
operation = "op_" + uuid.uuid4().hex
|
|
command = {
|
|
"api_version": "missioncore.nodedc/plugin-sdk/v0alpha2",
|
|
"kind": "OperationRequest",
|
|
"operation_id": operation,
|
|
"idempotency_key": operation,
|
|
"session": {"device_id": camera["id"], "session_id": camera["session_id"]},
|
|
"action_id": "verify",
|
|
"requested_at": now.isoformat(),
|
|
"deadline_at": (now + timedelta(seconds=60)).isoformat(),
|
|
"parameters": {},
|
|
}
|
|
report["image_verification"] = {"request": command, "state": "running"}
|
|
publish()
|
|
result = request(
|
|
Path("/run/mission-core-x4-instances") / camera["id"] / "driver.sock", command
|
|
)
|
|
report["image_verification"].update(state=result.get("state"), result=result)
|
|
publish()
|
|
if (
|
|
result.get("state") != "complete"
|
|
or result.get("result", {}).get("verified") is not True
|
|
):
|
|
raise RuntimeError("SDK подключён, но проверка изображения не завершилась успешно.")
|
|
after = run(
|
|
"existing-services-after",
|
|
[
|
|
"/usr/bin/systemctl",
|
|
"show",
|
|
*SERVICES,
|
|
"-p",
|
|
"Id",
|
|
"-p",
|
|
"ActiveState",
|
|
"-p",
|
|
"NRestarts",
|
|
],
|
|
)
|
|
report["existing_services_unchanged"] = after == baseline
|
|
report["state"] = "complete"
|
|
print("Пакет X4 установлен. SDK подключился; получение изображения подтверждено.")
|
|
print("WebRTC и команды записи остаются отдельными проверками.")
|
|
except Exception as error:
|
|
report["state"] = "error"
|
|
report["error"] = str(error)[:300]
|
|
raise
|
|
finally:
|
|
report["finished_at"] = datetime.now(UTC).isoformat()
|
|
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
|
publish()
|
|
# Retain bounded private evidence and remove only the installer-owned
|
|
# temporary package copy. Runtime/journals remain owned by the .deb.
|
|
(folder / package).unlink()
|
|
print("MISSION_CORE_X4_RESULT " + json.dumps(report, ensure_ascii=False), flush=True)
|
|
print("Отчёт установки:", folder / "report.json")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
print(str(error), file=sys.stderr)
|
|
sys.exit(1)
|