Add packaged Insta360 X4 integration and recover paired Node channels
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.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-insta360-x4-prepare.service" && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="2e1a", ATTR{idProduct}=="0002", ATTR{product}=="Insta360 X4", GROUP="mission-core-x4-usb", MODE="0660"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Isolated, root-owned bootstrap; no dependency on system Python packages."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CODE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(CODE))
|
||||
from layout import active, trusted # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
trusted(CODE, True)
|
||||
runtime = trusted(active(), True)
|
||||
trusted(runtime / "python", True)
|
||||
sys.path[:0] = [str(runtime / "python"), str(CODE)]
|
||||
os.umask(0o007)
|
||||
if sys.argv[1:] == ["broker"]:
|
||||
from runtime.broker import main as run
|
||||
|
||||
run()
|
||||
elif len(sys.argv) == 3 and sys.argv[1] == "worker":
|
||||
from runtime.worker import main as run
|
||||
|
||||
os.chdir(runtime / "bin")
|
||||
run(sys.argv[2], runtime)
|
||||
else:
|
||||
raise ValueError("Unsupported runtime entry point")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Build the optional X4 package from pinned SDK, native output and wheel bytes."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY = ROOT.parents[1]
|
||||
PACKAGING = ROOT / "packaging"
|
||||
sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
from fetch_sdk import verify # noqa: E402
|
||||
|
||||
VERSION = "0.1.3-3"
|
||||
WHEELS = {
|
||||
"aiohappyeyeballs",
|
||||
"aiohttp",
|
||||
"aioice",
|
||||
"aiortc",
|
||||
"aiosignal",
|
||||
"annotated_types",
|
||||
"attrs",
|
||||
"av",
|
||||
"cffi",
|
||||
"cryptography",
|
||||
"dnspython",
|
||||
"frozenlist",
|
||||
"google_crc32c",
|
||||
"idna",
|
||||
"ifaddr",
|
||||
"multidict",
|
||||
"propcache",
|
||||
"pycparser",
|
||||
"pydantic",
|
||||
"pydantic_core",
|
||||
"pyee",
|
||||
"pylibsrtp",
|
||||
"pyopenssl",
|
||||
"typing_extensions",
|
||||
"typing_inspection",
|
||||
"yarl",
|
||||
}
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def archive_bytes(files):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, data in sorted(files.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0))
|
||||
info.external_attr = 0o644 << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def runtime_payload():
|
||||
sdk = ROOT / "build/sdk"
|
||||
lock = json.loads((PACKAGING / "sdk-lock.json").read_text())
|
||||
verify(sdk, lock)
|
||||
native = ROOT / "build/native"
|
||||
provenance = json.loads((native / "provenance.json").read_text())
|
||||
for name, expected in provenance["source"].items():
|
||||
if digest((ROOT / "native" / name).read_bytes()) != expected:
|
||||
raise ValueError("Native adapter sources changed since compilation")
|
||||
if provenance["sdk_lock_sha256"] != digest((PACKAGING / "sdk-lock.json").read_bytes()):
|
||||
raise ValueError("Native adapter SDK input changed")
|
||||
binary = (native / "libmissioncore_x4.so").read_bytes()
|
||||
if digest(binary) != provenance["binary"]["sha256"] or provenance["abi"] != 1:
|
||||
raise ValueError("Native adapter provenance mismatch")
|
||||
files = {"lib/libmissioncore_x4.so": binary}
|
||||
for item in lock["files"]:
|
||||
if item["path"].startswith(("bin/", "lib/")):
|
||||
files[item["path"]] = (sdk / item["path"]).read_bytes()
|
||||
wheels = json.loads((PACKAGING / "python-lock.json").read_text())["wheels"]
|
||||
for item in wheels:
|
||||
source = REPOSITORY / "apps/node-agent/build/realsense-wheels" / item["name"]
|
||||
data = source.read_bytes()
|
||||
if source.is_symlink() or len(data) != item["bytes"] or digest(data) != item["sha256"]:
|
||||
raise ValueError("Pinned Python wheel mismatch")
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
for entry in archive.infolist():
|
||||
if entry.is_dir():
|
||||
continue
|
||||
name = entry.filename
|
||||
path = PurePosixPath(name)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or path.as_posix() != name
|
||||
or "\\" in name
|
||||
or ".data/" in name
|
||||
or name.endswith(".pth")
|
||||
or (entry.external_attr >> 16) & 0o170000 == 0o120000
|
||||
):
|
||||
raise ValueError("Unsupported Python wheel member")
|
||||
target = "python/" + name
|
||||
content = archive.read(entry)
|
||||
if target in files and files[target] != content:
|
||||
raise ValueError("Python runtime files collide")
|
||||
files[target] = content
|
||||
sdk_python = REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk"
|
||||
for path in sdk_python.rglob("*.py"):
|
||||
files["python/missioncore_plugin_sdk/" + path.relative_to(sdk_python).as_posix()] = (
|
||||
path.read_bytes()
|
||||
)
|
||||
payload = archive_bytes(files)
|
||||
result = {
|
||||
"schema": "missioncore.insta360.runtime-bundle/v1",
|
||||
"version": VERSION,
|
||||
"platform": "ubuntu-24.04-amd64",
|
||||
"python": "3.12",
|
||||
"sdk_lock_sha256": digest((PACKAGING / "sdk-lock.json").read_bytes()),
|
||||
"python_lock_sha256": digest((PACKAGING / "python-lock.json").read_bytes()),
|
||||
"native": provenance,
|
||||
"runtime_source_sha256": {
|
||||
path.name: digest(path.read_bytes()) for path in sorted((ROOT / "runtime").glob("*.py"))
|
||||
},
|
||||
"payload_sha256": digest(payload),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": digest(data)}
|
||||
for name, data in sorted(files.items())
|
||||
},
|
||||
}
|
||||
result["revision"] = digest(json.dumps(result, sort_keys=True).encode())[:24]
|
||||
return payload, result
|
||||
|
||||
|
||||
def build(output):
|
||||
payload, bundle = runtime_payload()
|
||||
files = [
|
||||
("usr/share/mission-core-node/insta360/payload.zip", payload, 0o644),
|
||||
(
|
||||
"usr/share/mission-core-node/insta360/bundle.json",
|
||||
(json.dumps(bundle, indent=2) + "\n").encode(),
|
||||
0o644,
|
||||
),
|
||||
]
|
||||
for name in ("sdk-lock.json", "python-lock.json"):
|
||||
files.append(
|
||||
(
|
||||
"usr/share/doc/mission-core-insta360-x4/" + name,
|
||||
(PACKAGING / name).read_bytes(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
for name in ("layout.py", "bootstrap.py", "supervisor.py", "prepare.py"):
|
||||
files.append(
|
||||
("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644)
|
||||
)
|
||||
for path in sorted((ROOT / "runtime").glob("*.py")):
|
||||
files.append(
|
||||
("usr/lib/mission-core-node/insta360/runtime/" + path.name, path.read_bytes(), 0o644)
|
||||
)
|
||||
for path in sorted(PACKAGING.glob("*.service")):
|
||||
files.append(("usr/lib/systemd/system/" + path.name, path.read_bytes(), 0o644))
|
||||
files.extend(
|
||||
[
|
||||
(
|
||||
"usr/lib/udev/rules.d/70-mission-core-insta360.rules",
|
||||
(PACKAGING / "70-mission-core-insta360.rules").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
"usr/share/polkit-1/rules.d/50-mission-core-insta360.rules",
|
||||
(PACKAGING / "50-mission-core-insta360.rules").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
]
|
||||
)
|
||||
provenance = {
|
||||
"package": "mission-core-insta360-x4",
|
||||
"version": VERSION,
|
||||
"revision": bundle["revision"],
|
||||
"files": {name: digest(data) for name, data, _ in files},
|
||||
"hardware_qualified": False,
|
||||
"clean_image_qualified": False,
|
||||
}
|
||||
files.append(
|
||||
(
|
||||
"usr/share/doc/mission-core-insta360-x4/provenance.json",
|
||||
(json.dumps(provenance, indent=2) + "\n").encode(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
control = f"""Package: mission-core-insta360-x4
|
||||
Version: {VERSION}
|
||||
Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: mission-core-node (>= 0.8.16), mission-core-node (<< 0.9.0),
|
||||
systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), adduser, udev, polkitd,
|
||||
libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Optional Insta360 X4 control and operator camera integration
|
||||
Private USB instances and pinned offline runtime for Ubuntu 24.04 amd64.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)] + [
|
||||
(name, (PACKAGING / name).read_bytes(), 0o755)
|
||||
for name in ("preinst", "postinst", "prerm", "postrm")
|
||||
]
|
||||
data = package(controls, files)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return {
|
||||
"file": str(output),
|
||||
"bytes": len(data),
|
||||
"sha256": digest(data),
|
||||
"revision": bundle["revision"],
|
||||
"version": VERSION,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.output)))
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the private X4 adapter through a versioned Linux build artifact.
|
||||
|
||||
Input SDK bytes must already satisfy sdk-lock.json. No network, dependency
|
||||
installation, SDK execution, device access or mutation of system directories.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fetch_sdk import LOCK, ROOT, inspect_elf, verify
|
||||
|
||||
|
||||
def build(sdk, destination):
|
||||
if platform.system() != "Linux" or platform.machine() != "x86_64":
|
||||
raise RuntimeError("Native compilation requires Linux x86_64 and the declared compiler")
|
||||
lock = json.loads(LOCK.read_text())
|
||||
verify(sdk, lock)
|
||||
if destination.exists():
|
||||
raise ValueError("Do not replace a previously built adapter")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=".native-", dir=destination.parent))
|
||||
env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "SOURCE_DATE_EPOCH": "1788825600"}
|
||||
try:
|
||||
target = stage / "libmissioncore_x4.so"
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/g++",
|
||||
"-std=c++17",
|
||||
"-O2",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-fPIC",
|
||||
"-fvisibility=hidden",
|
||||
"-shared",
|
||||
"-pthread",
|
||||
"-ffile-prefix-map=" + str(ROOT) + "=/source/insta360-x4",
|
||||
"-I",
|
||||
str(sdk / "include"),
|
||||
str(ROOT / "native/bridge.cpp"),
|
||||
"-L",
|
||||
str(sdk / "lib"),
|
||||
"-Wl,-z,defs,-z,relro,-z,now",
|
||||
"-Wl,-rpath,$ORIGIN",
|
||||
"-lCameraSDK",
|
||||
"-o",
|
||||
str(target),
|
||||
],
|
||||
env=env,
|
||||
check=True,
|
||||
timeout=120,
|
||||
)
|
||||
elf = inspect_elf(target)
|
||||
if "libCameraSDK.so" not in elf["needed"]:
|
||||
raise ValueError("Built adapter does not depend on the admitted SDK")
|
||||
report = {
|
||||
"schema": "missioncore.insta360.native-build/v1",
|
||||
"abi": 1,
|
||||
"sdk_lock_sha256": hashlib.sha256(LOCK.read_bytes()).hexdigest(),
|
||||
"source": {
|
||||
p.name: hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted((ROOT / "native").glob("*"))
|
||||
if p.is_file()
|
||||
},
|
||||
"compiler": subprocess.check_output(
|
||||
["/usr/bin/g++", "--version"], env=env, text=True
|
||||
).splitlines()[0],
|
||||
"binary": {
|
||||
"name": target.name,
|
||||
"bytes": target.stat().st_size,
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
},
|
||||
"elf": elf,
|
||||
"sdk_executed": False,
|
||||
"hardware_tested": False,
|
||||
}
|
||||
target.chmod(0o644)
|
||||
(stage / "provenance.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
os.rename(stage, destination)
|
||||
return report
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sdk", type=Path, default=ROOT / "build/sdk")
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "build/native")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.sdk.resolve(), args.output.resolve()), indent=2))
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Isolated interpreter bootstrap for the fixed, verified build module."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from build_native import ROOT, build # noqa: E402
|
||||
|
||||
build(ROOT / "build/sdk", ROOT / "build/native")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Wrap the qualified .deb and the fixed local installer into one owned artifact."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def build(qualified):
|
||||
expected = json.loads((qualified / "package-result.json").read_text())
|
||||
version = expected["version"]
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version):
|
||||
raise ValueError("Invalid qualified version")
|
||||
package_name = "mission-core-insta360-x4_" + version + "_amd64.deb"
|
||||
package = (qualified / package_name).read_bytes()
|
||||
if (
|
||||
len(package) != expected["bytes"]
|
||||
or hashlib.sha256(package).hexdigest() != expected["sha256"]
|
||||
):
|
||||
raise ValueError("Qualified package changed")
|
||||
files = {
|
||||
package_name: package,
|
||||
**{
|
||||
name: (ROOT / "packaging" / name).read_bytes()
|
||||
for name in ("install", "install_release.py")
|
||||
},
|
||||
}
|
||||
entrypoint = (ROOT / "packaging/owner_release_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.insta360.owner-release/v1",
|
||||
"version": version,
|
||||
"runtime_revision": expected["revision"],
|
||||
"qualification_profile": "sdk-status-and-image-verification",
|
||||
"qualification": "Ubuntu native build, cold imports, static closure, synthetic tests",
|
||||
"hardware_qualified": False,
|
||||
"clean_image_qualified": False,
|
||||
"entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
|
||||
ident = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-x4-install-" + ident + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {"__main__.py": entrypoint, "release.json": raw, **files}.items():
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0))
|
||||
info.external_attr = 0o600 << 16
|
||||
archive.writestr(info, data)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"release_id": ident,
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--qualified", type=Path, required=True)
|
||||
print(json.dumps(build(parser.parse_args().qualified)))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Snapshot package inputs for compilation/qualification only on the Ubuntu Mini."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from build_deb import VERSION # noqa: E402
|
||||
from fetch_sdk import ROOT, verify # noqa: E402
|
||||
|
||||
REPOSITORY = ROOT.parents[1]
|
||||
|
||||
|
||||
def build():
|
||||
sdk = json.loads((ROOT / "packaging/sdk-lock.json").read_text())
|
||||
verify(ROOT / "build/sdk", sdk)
|
||||
paths = list((ROOT / "packaging").glob("*.py"))
|
||||
paths += [
|
||||
p
|
||||
for pattern in ("*.json", "*.service", "*.rules")
|
||||
for p in (ROOT / "packaging").glob(pattern)
|
||||
]
|
||||
paths += [ROOT / "packaging" / name for name in ("preinst", "postinst", "prerm", "postrm")]
|
||||
paths += list((ROOT / "runtime").glob("*.py")) + list((ROOT / "tests").glob("*.py"))
|
||||
paths += [ROOT / "native" / name for name in ("bridge.cpp", "bridge.h")]
|
||||
paths += [ROOT / "build/native" / name for name in ("libmissioncore_x4.so", "provenance.json")]
|
||||
paths += [ROOT / "build/sdk" / entry["path"] for entry in sdk["files"]]
|
||||
wheels = json.loads((ROOT / "packaging/python-lock.json").read_text())["wheels"]
|
||||
paths += [
|
||||
REPOSITORY / "apps/node-agent/build/realsense-wheels" / entry["name"] for entry in wheels
|
||||
]
|
||||
paths += list((REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk").rglob("*.py"))
|
||||
paths += [REPOSITORY / "scripts/packaging/debian.py"]
|
||||
paths += [REPOSITORY / "apps/node-agent/packaging/insta360_profile.py"]
|
||||
paths += [
|
||||
REPOSITORY / name
|
||||
for name in (
|
||||
"src/k1link/__init__.py",
|
||||
"src/k1link/fleet/__init__.py",
|
||||
"src/k1link/fleet/sensors.py",
|
||||
"src/k1link/fleet/trust.py",
|
||||
)
|
||||
]
|
||||
files = {
|
||||
path.relative_to(REPOSITORY).as_posix(): path.read_bytes() for path in sorted(set(paths))
|
||||
}
|
||||
entrypoint = (ROOT / "packaging/native_build_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.insta360.build-source/v1",
|
||||
"version": "0.1.0",
|
||||
"kind": "package",
|
||||
"package_version": VERSION,
|
||||
"entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
|
||||
ident = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-x4-package-build-" + ident + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {
|
||||
"__main__.py": entrypoint,
|
||||
"source.json": raw,
|
||||
**{"source/" + name: data for name, data in files.items()},
|
||||
}.items():
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0))
|
||||
info.external_attr = 0o600 << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"source_id": ident,
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(build()))
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package exact native sources and locked SDK input for the Ubuntu build step."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fetch_sdk import LOCK, ROOT, verify
|
||||
|
||||
|
||||
def build():
|
||||
lock = json.loads(LOCK.read_text())
|
||||
sdk = ROOT / "build/sdk"
|
||||
verify(sdk, lock)
|
||||
names = [
|
||||
"native/bridge.cpp",
|
||||
"native/bridge.h",
|
||||
"native/tests/fake_sdk.cpp",
|
||||
"native/tests/check_abi.py",
|
||||
"packaging/build_native.py",
|
||||
"packaging/build_native_entry.py",
|
||||
"packaging/fetch_sdk.py",
|
||||
"packaging/sdk-lock.json",
|
||||
]
|
||||
names += ["build/sdk/" + entry["path"] for entry in lock["files"]]
|
||||
files = {name: (ROOT / name).read_bytes() for name in sorted(names)}
|
||||
entrypoint = (Path(__file__).with_name("native_build_entry.py")).read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.insta360.build-source/v1",
|
||||
"version": "0.1.0",
|
||||
"entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in files.items()
|
||||
},
|
||||
}
|
||||
content = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
|
||||
ident = hashlib.sha256(content).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-x4-build-" + ident + ".pyz")
|
||||
entries = {"__main__.py": entrypoint, "source.json": content}
|
||||
entries.update({"source/" + name: data for name, data in files.items()})
|
||||
with (
|
||||
output.open("xb") as stream,
|
||||
zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive,
|
||||
):
|
||||
for name, data in entries.items():
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0))
|
||||
info.external_attr = 0o600 << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
"source_id": ident,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(build()))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Cold Python imports and synthetic installer/control tests, no vendor execution."""
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(ROOT), str(ROOT / "packaging")]
|
||||
from build_deb import VERSION # noqa: E402
|
||||
from fetch_sdk import inspect_elf # noqa: E402
|
||||
from prepare import entries # noqa: E402
|
||||
|
||||
package = ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb")
|
||||
data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(package)], timeout=30)
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||
payload = archive.extractfile("usr/share/mission-core-node/insta360/payload.zip").read()
|
||||
bundle = json.load(archive.extractfile("usr/share/mission-core-node/insta360/bundle.json"))
|
||||
if hashlib.sha256(payload).hexdigest() != bundle["payload_sha256"]:
|
||||
raise ValueError("Package payload hash mismatch")
|
||||
stage = ROOT / "build/cold-runtime"
|
||||
stage.mkdir(mode=0o700)
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
for name, content in entries(archive, bundle):
|
||||
path = stage / name
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
python = stage / "python"
|
||||
sys.path.insert(0, str(python))
|
||||
versions = {}
|
||||
for name in ("aiohttp", "aiortc", "av", "pydantic", "cryptography", "cffi"):
|
||||
module = importlib.import_module(name)
|
||||
if not Path(module.__file__).is_relative_to(python):
|
||||
raise RuntimeError("An undeclared system Python package was imported")
|
||||
versions[name] = getattr(module, "__version__", "available")
|
||||
libraries = [
|
||||
path for path in stage.rglob("*") if path.is_file() and path.read_bytes()[:4] == b"\x7fELF"
|
||||
]
|
||||
provided = {path.name for path in libraries}
|
||||
system = {
|
||||
"libc.so.6",
|
||||
"libm.so.6",
|
||||
"libmvec.so.1",
|
||||
"libpthread.so.0",
|
||||
"libdl.so.2",
|
||||
"librt.so.1",
|
||||
"libresolv.so.2",
|
||||
"libgcc_s.so.1",
|
||||
"libstdc++.so.6",
|
||||
"ld-linux-x86-64.so.2",
|
||||
"libz.so.1",
|
||||
}
|
||||
closure = {str(path.relative_to(stage)): inspect_elf(path)["needed"] for path in libraries}
|
||||
missing = {
|
||||
name for needed in closure.values() for name in needed if name not in provided | system
|
||||
}
|
||||
if missing:
|
||||
raise RuntimeError("Undeclared OS library dependencies: " + ", ".join(sorted(missing)))
|
||||
(ROOT / "build/runtime-check.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"python_imports": versions,
|
||||
"elf_dependencies": closure,
|
||||
"declared_os_libraries": sorted(system),
|
||||
"sdk_executed": False,
|
||||
"clean_os_image_tested": False,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="check_*.py")
|
||||
if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful():
|
||||
raise RuntimeError("Package qualification tests failed")
|
||||
finally:
|
||||
shutil.rmtree(stage)
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build-time SDK acquisition. Never installed or executed on an operator Node.
|
||||
|
||||
Pin every byte from a public mirror; do not execute SDK code to inspect it.
|
||||
The source lock is provenance, not a claim of vendor authenticity or licensing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCK = Path(__file__).with_name("sdk-lock.json")
|
||||
|
||||
|
||||
def entries(lock):
|
||||
if lock["schema"] != "missioncore.insta360.sdk-source/v1":
|
||||
raise ValueError("Unsupported SDK lock")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", lock["commit"]):
|
||||
raise ValueError("SDK source must be an immutable commit")
|
||||
seen = set()
|
||||
for entry in lock["files"]:
|
||||
path = PurePosixPath(entry["path"])
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or "\\" in entry["path"]
|
||||
or path.as_posix() != entry["path"]
|
||||
or entry["path"] in seen
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"])
|
||||
or not 0 < entry["bytes"] <= 32 * 1024 * 1024
|
||||
):
|
||||
raise ValueError("Invalid SDK payload entry")
|
||||
seen.add(entry["path"])
|
||||
yield entry
|
||||
|
||||
|
||||
def verified_bytes(data, entry):
|
||||
if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
|
||||
raise ValueError("SDK checksum mismatch: " + entry["path"])
|
||||
return data
|
||||
|
||||
|
||||
def inspect_elf(path):
|
||||
"""Parse ELF64 sections without loading the library (never ldd/dlopen)."""
|
||||
data = path.read_bytes()
|
||||
if (
|
||||
len(data) < 64
|
||||
or data[:7] != b"\x7fELF\x02\x01\x01"
|
||||
or struct.unpack_from("<H", data, 18)[0] != 62
|
||||
):
|
||||
raise ValueError("SDK is not Linux x86_64 ELF64")
|
||||
offset = struct.unpack_from("<Q", data, 40)[0]
|
||||
size, count = struct.unpack_from("<HH", data, 58)
|
||||
if size != 64 or not 0 < count < 4096 or offset + count * size > len(data):
|
||||
raise ValueError("Invalid ELF section table")
|
||||
sections = [struct.unpack_from("<IIQQQQIIQQ", data, offset + i * size) for i in range(count)]
|
||||
needed = []
|
||||
for section in sections:
|
||||
if section[1] != 6: # SHT_DYNAMIC
|
||||
continue
|
||||
if section[6] >= count:
|
||||
raise ValueError("Invalid ELF string table")
|
||||
strings = sections[section[6]]
|
||||
table = data[strings[4] : strings[4] + strings[5]]
|
||||
if section[4] + section[5] > len(data) or section[5] % 16:
|
||||
raise ValueError("Invalid ELF dynamic table")
|
||||
for at in range(section[4], section[4] + section[5], 16):
|
||||
tag, value = struct.unpack_from("<qQ", data, at)
|
||||
if tag == 1: # DT_NEEDED
|
||||
end = table.find(b"\0", value)
|
||||
if end < value:
|
||||
raise ValueError("Invalid ELF dependency name")
|
||||
needed.append(table[value:end].decode("ascii"))
|
||||
versions = sorted(
|
||||
set(x.decode() for x in re.findall(rb"(?:GLIBCXX|GLIBC|CXXABI)_[0-9.]+", data))
|
||||
)
|
||||
return {"architecture": "linux-x86_64", "needed": sorted(needed), "abi_versions": versions}
|
||||
|
||||
|
||||
def verify(folder, lock):
|
||||
for entry in entries(lock):
|
||||
path = folder / entry["path"]
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise ValueError("SDK payload cannot contain symlinks")
|
||||
verified_bytes(path.read_bytes(), entry)
|
||||
return inspect_elf(folder / "lib/libCameraSDK.so")
|
||||
|
||||
|
||||
def fetch(destination, source=None):
|
||||
lock = json.loads(LOCK.read_text())
|
||||
if destination.exists():
|
||||
return verify(destination, lock)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=".sdk-", dir=destination.parent))
|
||||
try:
|
||||
for entry in entries(lock):
|
||||
if source:
|
||||
path = source / entry["path"]
|
||||
if path.is_symlink():
|
||||
raise ValueError("SDK source cannot be a symlink")
|
||||
data = path.read_bytes()
|
||||
else:
|
||||
host = (
|
||||
"https://media.githubusercontent.com/media/"
|
||||
if entry["lfs"]
|
||||
else "https://raw.githubusercontent.com/"
|
||||
)
|
||||
repo = lock["repository"].removeprefix("https://github.com/")
|
||||
url = host + "/".join((repo, lock["commit"], lock["directory"], entry["path"]))
|
||||
with urllib.request.urlopen(url, timeout=45) as response:
|
||||
data = response.read(entry["bytes"] + 1)
|
||||
target = stage / entry["path"]
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(verified_bytes(data, entry))
|
||||
target.chmod(0o644)
|
||||
report = verify(stage, lock)
|
||||
stage.rename(destination)
|
||||
return report
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--destination", type=Path, default=ROOT / "build/sdk")
|
||||
parser.add_argument(
|
||||
"--source", type=Path, help="Import an already acquired SDK using the same lock"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="Verify existing payload without network access"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
result = (
|
||||
verify(args.destination, json.loads(LOCK.read_text()))
|
||||
if args.check
|
||||
else fetch(args.destination, args.source)
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mc_x4_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core · Insta360 X4" -- "$mc_x4_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core · Insta360 X4' 'Установка драйвера и проверка подключения SDK на этом Ubuntu-компьютере.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_x4_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_x4_release_dir/install-output.log"
|
||||
mc_x4_install_result=${PIPESTATUS[0]}
|
||||
printf '\nКод завершения: %s\nНажмите Enter, чтобы закрыть окно.\n' "$mc_x4_install_result"
|
||||
read -r mc_x4_close
|
||||
exit "$mc_x4_install_result"
|
||||
@@ -0,0 +1,263 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Fixed root-owned locations shared by installer and runtime bootstrap."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
CODE = Path("/usr/lib/mission-core-node/insta360")
|
||||
SHARE = Path("/usr/share/mission-core-node/insta360")
|
||||
STATE = Path("/var/lib/mission-core-insta360")
|
||||
CONTROL = Path("/run/mission-core-x4-control")
|
||||
|
||||
|
||||
def trusted(path, directory=False):
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Untrusted camera installation path")
|
||||
if path.is_dir() != directory:
|
||||
raise RuntimeError("Unexpected camera installation entry")
|
||||
return path
|
||||
|
||||
|
||||
def manifest():
|
||||
trusted(SHARE, True)
|
||||
return json.loads(trusted(SHARE / "bundle.json").read_text())
|
||||
|
||||
|
||||
def active():
|
||||
trusted(STATE, True)
|
||||
ident = trusted(STATE / "active.path").read_text().strip()
|
||||
if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident):
|
||||
raise RuntimeError("Invalid installed runtime revision")
|
||||
return trusted(STATE / "runtime", True) / ident
|
||||
|
||||
|
||||
def directory(path, mode=0o755):
|
||||
path.mkdir(mode=mode, exist_ok=True)
|
||||
trusted(path, True)
|
||||
|
||||
|
||||
def write(path, data, mode=0o644):
|
||||
import tempfile
|
||||
|
||||
if path.exists() or path.is_symlink():
|
||||
trusted(path)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".x4-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
folder = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(folder)
|
||||
finally:
|
||||
os.close(folder)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Mission Core X4 USB instance supervisor
|
||||
After=systemd-udevd.service
|
||||
ConditionPathExists=/var/lib/mission-core-insta360/active.path
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/supervisor.py
|
||||
RuntimeDirectory=mission-core-x4-control
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
UMask=0022
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ReadWritePaths=/run/systemd/system
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
CapabilityBoundingSet=
|
||||
TasksMax=16
|
||||
MemoryMax=96M
|
||||
Restart=no
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Mission Core Insta360 model broker
|
||||
ConditionPathExists=/var/lib/mission-core-insta360/active.path
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-insta360
|
||||
Group=mission-core-node
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py broker
|
||||
RuntimeDirectory=mission-core-insta360
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=mission-core-insta360-broker
|
||||
StateDirectoryMode=0700
|
||||
UMask=0007
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
TasksMax=192
|
||||
MemoryMax=1G
|
||||
CPUQuota=150%
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Mission Core fixed Insta360 X4 preparation
|
||||
After=systemd-udevd.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py
|
||||
TimeoutStartSec=180
|
||||
UMask=0022
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Entry point embedded in the versioned, self-verifying Ubuntu build artifact.
|
||||
|
||||
No system install, SDK loading, discovery, camera command or root execution.
|
||||
All writes belong to this private build directory and its immutable archive.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import resource
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
BUILD_ROOT = Path("/var/tmp/mission-core-x4-builds")
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def private_directory(path):
|
||||
path.mkdir(mode=0o700, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_dir()
|
||||
or info.st_uid != os.geteuid()
|
||||
or info.st_mode & 0o077
|
||||
):
|
||||
raise RuntimeError("Build directory is not private and owned")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64":
|
||||
raise RuntimeError("Use the unprivileged Ubuntu x86_64 build account")
|
||||
os.umask(0o077)
|
||||
started, monotonic = datetime.now(UTC).isoformat(), time.monotonic()
|
||||
artifact = Path(sys.argv[0]).resolve()
|
||||
with zipfile.ZipFile(artifact) as archive:
|
||||
raw_manifest = archive.read("source.json")
|
||||
manifest = json.loads(raw_manifest)
|
||||
ident = digest(raw_manifest)[:24]
|
||||
if manifest["schema"] != "missioncore.insta360.build-source/v1":
|
||||
raise ValueError("Unsupported build artifact")
|
||||
if digest(archive.read("__main__.py")) != manifest["entrypoint_sha256"]:
|
||||
raise ValueError("Build entry point differs from the manifest")
|
||||
private_directory(BUILD_ROOT)
|
||||
folder = BUILD_ROOT / ident
|
||||
private_directory(folder)
|
||||
output = folder / "result.tar.gz"
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1:] != ["--clean"]:
|
||||
raise ValueError("No build commands or paths are accepted")
|
||||
shutil.rmtree(folder)
|
||||
print(json.dumps({"cleaned": ident}))
|
||||
return
|
||||
if output.exists():
|
||||
print(
|
||||
json.dumps(
|
||||
{"result": str(output), "sha256": digest(output.read_bytes()), "reused": True}
|
||||
)
|
||||
)
|
||||
return
|
||||
if (folder / "source").exists():
|
||||
raise RuntimeError(
|
||||
"Incomplete prior attempt; preserve evidence and use --clean before retry"
|
||||
)
|
||||
source = folder / "source"
|
||||
source.mkdir(mode=0o700)
|
||||
allowed = {"__main__.py", "source.json"} | {"source/" + p for p in manifest["files"]}
|
||||
if len(archive.namelist()) != len(allowed) or set(archive.namelist()) != allowed:
|
||||
raise ValueError("Unexpected source archive entries")
|
||||
for relative, expected in manifest["files"].items():
|
||||
path = PurePosixPath(relative)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or path.as_posix() != relative
|
||||
or "\\" in relative
|
||||
):
|
||||
raise ValueError("Unsafe build payload path")
|
||||
entry = archive.getinfo("source/" + relative)
|
||||
if entry.file_size != expected["bytes"] or entry.file_size > 100 * 1024 * 1024:
|
||||
raise ValueError("Invalid source size")
|
||||
data = archive.read(entry)
|
||||
if digest(data) != expected["sha256"]:
|
||||
raise ValueError("Source hash mismatch")
|
||||
target = source / relative
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
(folder / "intent.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "missioncore.insta360.build-run/v1",
|
||||
"id": ident,
|
||||
"started_at": started,
|
||||
"monotonic_started": monotonic,
|
||||
"artifact_sha256": digest(artifact.read_bytes()),
|
||||
"source_manifest_sha256": digest(raw_manifest),
|
||||
"scope": "package-and-synthetic-tests-only"
|
||||
if manifest.get("kind") == "package"
|
||||
else "compile-and-synthetic-tests-only",
|
||||
"state": "running",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
# Bound compiler consumption on the shared Mini. This does not alter OS,
|
||||
# service, user or shell configuration; limits apply to this process tree.
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
resource.setrlimit(resource.RLIMIT_CPU, (180, 180))
|
||||
resource.setrlimit(resource.RLIMIT_AS, (1024 * 1024 * 1024, 1024 * 1024 * 1024))
|
||||
os.nice(10)
|
||||
env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
report = json.loads((folder / "intent.json").read_text())
|
||||
package_build = manifest.get("kind") == "package"
|
||||
if package_build:
|
||||
plugin = source / "plugins/insta360-x4"
|
||||
version = manifest["package_version"]
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version):
|
||||
raise ValueError("Invalid package build version")
|
||||
package_name = "mission-core-insta360-x4_" + version + "_amd64.deb"
|
||||
jobs = (
|
||||
(
|
||||
"package-build",
|
||||
["/usr/bin/python3", "-I", str(plugin / "packaging/package_build_entry.py")],
|
||||
),
|
||||
(
|
||||
"package-tests",
|
||||
["/usr/bin/python3", "-I", str(plugin / "packaging/check_package_entry.py")],
|
||||
),
|
||||
)
|
||||
outputs = [
|
||||
(
|
||||
plugin / "build" / package_name,
|
||||
package_name,
|
||||
),
|
||||
(plugin / "build/package-result.json", "package-result.json"),
|
||||
(plugin / "build/runtime-check.json", "runtime-check.json"),
|
||||
(folder / "package-tests.stderr", "package-tests.txt"),
|
||||
]
|
||||
else:
|
||||
jobs = (
|
||||
(
|
||||
"native-build",
|
||||
["/usr/bin/python3", "-I", str(source / "packaging/build_native_entry.py")],
|
||||
),
|
||||
(
|
||||
"synthetic-abi-tests",
|
||||
["/usr/bin/python3", "-I", str(source / "native/tests/check_abi.py"), "-v"],
|
||||
),
|
||||
)
|
||||
outputs = [
|
||||
(source / "build/native/libmissioncore_x4.so", "libmissioncore_x4.so"),
|
||||
(source / "build/native/provenance.json", "provenance.json"),
|
||||
(folder / "synthetic-abi-tests.stderr", "synthetic-abi-tests.txt"),
|
||||
]
|
||||
outputs.append((folder / "report.json", "build-report.json"))
|
||||
try:
|
||||
for name, command in jobs:
|
||||
result = subprocess.run(command, cwd=source, env=env, capture_output=True, timeout=150)
|
||||
(folder / (name + ".stdout")).write_bytes(result.stdout)
|
||||
(folder / (name + ".stderr")).write_bytes(result.stderr)
|
||||
if result.returncode:
|
||||
raise RuntimeError(name + " failed; inspect the private build report")
|
||||
report.update(
|
||||
state="complete",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - monotonic,
|
||||
)
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
data = io.BytesIO()
|
||||
with tarfile.open(fileobj=data, mode="w:gz") as archive:
|
||||
for path, name in outputs:
|
||||
archive.add(path, arcname=name, recursive=False)
|
||||
output.write_bytes(data.getvalue())
|
||||
print(
|
||||
json.dumps(
|
||||
{"result": str(output), "sha256": digest(output.read_bytes()), "state": "complete"}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
report.update(
|
||||
state="error",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - monotonic,
|
||||
)
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Stage, inspect or open the fixed Ubuntu installer; never collect passwords."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
PROFILES = {
|
||||
"missioncore.insta360.owner-release/v1": (
|
||||
"mission-core-insta360-x4",
|
||||
"/var/tmp/mission-core-x4-releases",
|
||||
"Mission Core · Insta360 X4",
|
||||
),
|
||||
"missioncore.node.owner-release/v1": (
|
||||
"mission-core-node",
|
||||
"/var/tmp/mission-core-node-releases",
|
||||
"Mission Core Node",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def private(path):
|
||||
path.mkdir(mode=0o700, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise RuntimeError("Release directory is not private and owned")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64":
|
||||
raise RuntimeError("Откройте установщик обычным пользователем на Ubuntu amd64.")
|
||||
if sys.argv[1:] not in (["--stage"], ["--plan"], ["--launch"]):
|
||||
raise ValueError("Use --stage, --plan or --launch")
|
||||
os.umask(0o077)
|
||||
with zipfile.ZipFile(sys.argv[0]) as archive:
|
||||
raw = archive.read("release.json")
|
||||
manifest = json.loads(raw)
|
||||
if manifest.get("schema") not in PROFILES:
|
||||
raise ValueError("Invalid release schema")
|
||||
package_id, root, title = PROFILES[manifest["schema"]]
|
||||
version = manifest["version"]
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version):
|
||||
raise ValueError("Invalid release version")
|
||||
package = package_id + "_" + version + "_amd64.deb"
|
||||
if hashlib.sha256(archive.read("__main__.py")).hexdigest() != manifest["entrypoint_sha256"]:
|
||||
raise ValueError("Release entry point changed")
|
||||
allowed = {
|
||||
"release.json",
|
||||
"__main__.py",
|
||||
"install",
|
||||
"install_release.py",
|
||||
package,
|
||||
}
|
||||
if set(archive.namelist()) != allowed or len(archive.namelist()) != len(allowed):
|
||||
raise ValueError("Invalid release contents")
|
||||
if set(manifest["files"]) != allowed - {"release.json", "__main__.py"}:
|
||||
raise ValueError("Invalid release manifest")
|
||||
identifier = hashlib.sha256(raw).hexdigest()[:24]
|
||||
private(Path(root))
|
||||
folder = Path(root) / identifier
|
||||
private(folder)
|
||||
files = {name: archive.read(name) for name in manifest["files"]}
|
||||
for name, data in files.items():
|
||||
expected = manifest["files"][name]
|
||||
if (
|
||||
len(data) != expected["bytes"]
|
||||
or hashlib.sha256(data).hexdigest() != expected["sha256"]
|
||||
):
|
||||
raise ValueError("Release payload changed")
|
||||
files["release.json"] = raw
|
||||
for name, data in files.items():
|
||||
path = folder / name
|
||||
if path.is_symlink():
|
||||
raise ValueError("Unexpected release symlink")
|
||||
if path.exists():
|
||||
if path.read_bytes() != data:
|
||||
raise ValueError("Existing release was modified")
|
||||
else:
|
||||
with path.open("xb") as stream:
|
||||
stream.write(data)
|
||||
path.chmod(0o700 if name == "install" else 0o600)
|
||||
print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True)
|
||||
if sys.argv[1] == "--plan":
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/apt-get",
|
||||
"--simulate",
|
||||
"--no-remove",
|
||||
"install",
|
||||
str(folder / package),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
)
|
||||
(folder / "apt-plan.stdout").write_text(result.stdout)
|
||||
(folder / "apt-plan.stderr").write_text(result.stderr)
|
||||
print(result.stdout)
|
||||
if result.returncode:
|
||||
raise RuntimeError("APT plan failed; inspect the release's private report")
|
||||
elif sys.argv[1] == "--launch":
|
||||
environment = dict(os.environ)
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "--user", "show-environment"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=True,
|
||||
)
|
||||
# Only graphical session coordinates are admitted. Other user-service
|
||||
# environment values are neither passed on nor printed in evidence.
|
||||
for line in result.stdout.splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if separator and key in {
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"XAUTHORITY",
|
||||
}:
|
||||
environment[key] = value
|
||||
with (
|
||||
(folder / "launcher.stdout").open("ab") as out,
|
||||
(folder / "launcher.stderr").open("ab") as err,
|
||||
):
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/gnome-terminal",
|
||||
"--wait",
|
||||
"--title=" + title + " · " + version,
|
||||
"--",
|
||||
str(folder / "install"),
|
||||
],
|
||||
env=environment,
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
start_new_session=True,
|
||||
)
|
||||
(folder / "launch.json").write_text(json.dumps({"pid": process.pid}) + "\n")
|
||||
print("Окно установщика открывается в локальном сеансе Ubuntu.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Fixed package-build entry point within the versioned Ubuntu source artifact."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from build_deb import ROOT, VERSION, build # noqa: E402
|
||||
|
||||
result = build(ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb"))
|
||||
(ROOT / "build/package-result.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(json.dumps(result))
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = configure ]; then
|
||||
if ! getent group mission-core-x4-usb >/dev/null; then
|
||||
addgroup --system mission-core-x4-usb
|
||||
fi
|
||||
if ! getent passwd mission-core-insta360 >/dev/null; then
|
||||
adduser --system --home /var/lib/mission-core-insta360 --no-create-home --disabled-login --ingroup mission-core-node mission-core-insta360
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
if [ -f /var/lib/mission-core-insta360/active.path ]; then
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
udevadm control --reload-rules
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Device state and operation receipts are retained. No SD files are removed.
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -f /usr/lib/mission-core-node/insta360/prepare.py ]; then
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce
|
||||
fi
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Idempotent fixed profile preparation. All Ubuntu prerequisites have an owner."""
|
||||
|
||||
import fcntl
|
||||
import grp
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from layout import SHARE, STATE, directory, manifest, trusted, write # noqa: E402
|
||||
from runtime.http import request # noqa: E402
|
||||
|
||||
STEPS = [
|
||||
("platform", "Проверка совместимости системы"),
|
||||
("payload", "Проверка встроенного драйвера"),
|
||||
("runtime", "Развёртывание драйвера"),
|
||||
("access", "Настройка доступа к камере"),
|
||||
("service", "Запуск службы камеры"),
|
||||
]
|
||||
SOCKET = Path("/run/mission-core-insta360/driver.sock")
|
||||
INSTANCES = Path("/run/mission-core-x4-instances")
|
||||
|
||||
|
||||
def run(*args):
|
||||
subprocess.run(args, check=True, timeout=45, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
def assert_safe():
|
||||
if SOCKET.exists():
|
||||
if request(SOCKET, "/prepare-safe", timeout=5).get("safe") is not True:
|
||||
raise RuntimeError("Остановите просмотр и запись X4 перед обновлением драйвера.")
|
||||
elif list(INSTANCES.glob("instax4_*")):
|
||||
raise RuntimeError("Состояние X4 неизвестно. Подготовка не изменяла службы камеры.")
|
||||
|
||||
|
||||
def entries(archive, bundle):
|
||||
expected = bundle["files"]
|
||||
if len(archive.infolist()) != len(expected) or set(archive.namelist()) != set(expected):
|
||||
raise RuntimeError("Состав встроенного драйвера изменён.")
|
||||
for name, info in expected.items():
|
||||
path = PurePosixPath(name)
|
||||
entry = archive.getinfo(name)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or path.as_posix() != name
|
||||
or "\\" in name
|
||||
or entry.is_dir()
|
||||
or entry.file_size != info["bytes"]
|
||||
or entry.file_size > 100 * 1024 * 1024
|
||||
):
|
||||
raise RuntimeError("Недопустимый файл драйвера.")
|
||||
data = archive.read(entry)
|
||||
if hashlib.sha256(data).hexdigest() != info["sha256"]:
|
||||
raise RuntimeError("Контрольная сумма драйвера не совпала.")
|
||||
yield name, data
|
||||
|
||||
|
||||
def install_runtime(bundle):
|
||||
parent = STATE / "runtime"
|
||||
directory(parent)
|
||||
ident = bundle["revision"]
|
||||
if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident):
|
||||
raise RuntimeError("Некорректная версия драйвера.")
|
||||
target, stage = parent / ident, parent / (ident + ".partial")
|
||||
source = trusted(SHARE / "payload.zip")
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != bundle["payload_sha256"]:
|
||||
raise RuntimeError("Встроенный драйвер повреждён. Переустановите пакет.")
|
||||
if stage.exists():
|
||||
trusted(stage, True)
|
||||
shutil.rmtree(stage)
|
||||
if not target.exists():
|
||||
directory(stage)
|
||||
try:
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
for name, data in entries(archive, bundle):
|
||||
path = stage / name
|
||||
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
path.chmod(0o644)
|
||||
stage.rename(target)
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
trusted(target, True)
|
||||
for name, info in bundle["files"].items():
|
||||
path = target / name
|
||||
for parent in path.parents:
|
||||
if parent == target.parent:
|
||||
break
|
||||
trusted(parent, True)
|
||||
data = trusted(path).read_bytes()
|
||||
if len(data) != info["bytes"] or hashlib.sha256(data).hexdigest() != info["sha256"]:
|
||||
raise RuntimeError("Установленный драйвер изменён. Нужна переустановка пакета.")
|
||||
return ident
|
||||
|
||||
|
||||
def prepare():
|
||||
directory(STATE)
|
||||
lock = STATE / "prepare.lock"
|
||||
with lock.open("a") as handle:
|
||||
trusted(lock)
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with lifecycle_lock():
|
||||
return prepare_locked()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def lifecycle_lock():
|
||||
directory(STATE)
|
||||
path = STATE / "lifecycle.lock"
|
||||
descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o640)
|
||||
with os.fdopen(descriptor, "r+b") as handle:
|
||||
trusted(path)
|
||||
os.fchown(handle.fileno(), 0, grp.getgrnam("mission-core-node").gr_gid)
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
yield
|
||||
|
||||
|
||||
def prepare_locked():
|
||||
bundle = manifest()
|
||||
report = {
|
||||
"schema": "missioncore.node.device-preparation/v1",
|
||||
"model_id": "insta360.x4",
|
||||
"revision": bundle["revision"],
|
||||
"run_id": str(uuid.uuid4()),
|
||||
"started_at": time.time(),
|
||||
"monotonic_started": time.monotonic(),
|
||||
"state": "running",
|
||||
"steps": [{"id": key, "label": label, "state": "pending"} for key, label in STEPS],
|
||||
}
|
||||
|
||||
def publish():
|
||||
report["updated_at"] = time.time()
|
||||
write(STATE / "preparation.json", (json.dumps(report, ensure_ascii=False) + "\n").encode())
|
||||
|
||||
publish()
|
||||
try:
|
||||
assert_safe()
|
||||
for step in report["steps"]:
|
||||
step["state"] = "running"
|
||||
publish()
|
||||
if step["id"] == "platform":
|
||||
release = platform.freedesktop_os_release()
|
||||
if (
|
||||
release.get("ID"),
|
||||
release.get("VERSION_ID"),
|
||||
platform.machine(),
|
||||
sys.version_info[:2],
|
||||
) != ("ubuntu", "24.04", "x86_64", (3, 12)):
|
||||
raise RuntimeError("Этот пакет поддерживает Ubuntu 24.04 amd64.")
|
||||
elif step["id"] == "payload":
|
||||
data = trusted(SHARE / "payload.zip").read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != bundle["payload_sha256"]:
|
||||
raise RuntimeError("Встроенный драйвер повреждён.")
|
||||
elif step["id"] == "runtime":
|
||||
ident = install_runtime(bundle)
|
||||
assert_safe()
|
||||
write(STATE / "active.path", (ident + "\n").encode())
|
||||
elif step["id"] == "access":
|
||||
run("/usr/bin/udevadm", "control", "--reload-rules")
|
||||
run(
|
||||
"/usr/bin/udevadm",
|
||||
"trigger",
|
||||
"--action=change",
|
||||
"--subsystem-match=usb",
|
||||
"--attr-match=idVendor=2e1a",
|
||||
"--attr-match=idProduct=0002",
|
||||
"--attr-match=product=Insta360 X4",
|
||||
)
|
||||
run("/usr/bin/udevadm", "settle", "--timeout=10")
|
||||
elif step["id"] == "service":
|
||||
maintenance = STATE / "maintenance"
|
||||
if maintenance.exists():
|
||||
trusted(maintenance).unlink()
|
||||
run("/usr/bin/systemctl", "daemon-reload")
|
||||
for service in (
|
||||
"mission-core-insta360.service",
|
||||
"mission-core-insta360-supervisor.service",
|
||||
):
|
||||
run("/usr/bin/systemctl", "enable", service)
|
||||
run("/usr/bin/systemctl", "start", service)
|
||||
run("/usr/bin/systemctl", "is-active", "--quiet", service)
|
||||
step["state"] = "complete"
|
||||
publish()
|
||||
report["state"] = "complete"
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.SubprocessError,
|
||||
zipfile.BadZipFile,
|
||||
) as error:
|
||||
report["state"] = "error"
|
||||
message = (
|
||||
str(error)
|
||||
if isinstance(error, RuntimeError)
|
||||
else "Не удалось подготовить X4. Повторите действие."
|
||||
)
|
||||
report["message"] = message[:300]
|
||||
for step in report["steps"]:
|
||||
if step["state"] == "running":
|
||||
step.update(state="error", message=message[:300])
|
||||
elif step["state"] == "pending":
|
||||
step["state"] = "blocked"
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish()
|
||||
return report["state"] == "complete"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o022)
|
||||
if os.geteuid() or sys.argv[1:] not in ([], ["--assert-safe"], ["--quiesce"]):
|
||||
sys.exit(1)
|
||||
if sys.argv[1:] == ["--quiesce"]:
|
||||
with lifecycle_lock():
|
||||
assert_safe()
|
||||
write(STATE / "maintenance", b"package-lifecycle\n")
|
||||
elif sys.argv[1:]:
|
||||
assert_safe()
|
||||
else:
|
||||
sys.exit(0 if prepare() else 1)
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl stop mission-core-insta360-supervisor.service
|
||||
systemctl stop 'mission-core-x4@*.service'
|
||||
systemctl stop mission-core-insta360.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"schema": "missioncore.insta360.python-lock/v1",
|
||||
"python": "3.12",
|
||||
"platform": "linux-amd64",
|
||||
"wheels": [
|
||||
{
|
||||
"name": "aiohappyeyeballs-2.7.1-py3-none-any.whl",
|
||||
"sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472",
|
||||
"bytes": 15038
|
||||
},
|
||||
{
|
||||
"name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545",
|
||||
"bytes": 1719929
|
||||
},
|
||||
{
|
||||
"name": "aioice-0.10.2-py3-none-any.whl",
|
||||
"sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf",
|
||||
"bytes": 24875
|
||||
},
|
||||
{
|
||||
"name": "aiortc-1.14.0-py3-none-any.whl",
|
||||
"sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e",
|
||||
"bytes": 93183
|
||||
},
|
||||
{
|
||||
"name": "aiosignal-1.4.0-py3-none-any.whl",
|
||||
"sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e",
|
||||
"bytes": 7490
|
||||
},
|
||||
{
|
||||
"name": "annotated_types-0.8.0-py3-none-any.whl",
|
||||
"sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0",
|
||||
"bytes": 13427
|
||||
},
|
||||
{
|
||||
"name": "attrs-26.1.0-py3-none-any.whl",
|
||||
"sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||
"bytes": 67548
|
||||
},
|
||||
{
|
||||
"name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2",
|
||||
"bytes": 41174337
|
||||
},
|
||||
{
|
||||
"name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf",
|
||||
"bytes": 221822
|
||||
},
|
||||
{
|
||||
"name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef",
|
||||
"bytes": 4712478
|
||||
},
|
||||
{
|
||||
"name": "dnspython-2.8.0-py3-none-any.whl",
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
|
||||
"bytes": 331094
|
||||
},
|
||||
{
|
||||
"name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383",
|
||||
"bytes": 242411
|
||||
},
|
||||
{
|
||||
"name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411",
|
||||
"bytes": 33364
|
||||
},
|
||||
{
|
||||
"name": "idna-3.19-py3-none-any.whl",
|
||||
"sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4",
|
||||
"bytes": 68550
|
||||
},
|
||||
{
|
||||
"name": "ifaddr-0.2.0-py3-none-any.whl",
|
||||
"sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
|
||||
"bytes": 12314
|
||||
},
|
||||
{
|
||||
"name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961",
|
||||
"bytes": 256322
|
||||
},
|
||||
{
|
||||
"name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476",
|
||||
"bytes": 61639
|
||||
},
|
||||
{
|
||||
"name": "pycparser-3.0-py3-none-any.whl",
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
|
||||
"bytes": 48172
|
||||
},
|
||||
{
|
||||
"name": "pydantic-2.11.7-py3-none-any.whl",
|
||||
"sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b",
|
||||
"bytes": 444782
|
||||
},
|
||||
{
|
||||
"name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1",
|
||||
"bytes": 2002028
|
||||
},
|
||||
{
|
||||
"name": "pyee-14.0.0-py3-none-any.whl",
|
||||
"sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d",
|
||||
"bytes": 15553
|
||||
},
|
||||
{
|
||||
"name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc",
|
||||
"bytes": 2434534
|
||||
},
|
||||
{
|
||||
"name": "pyopenssl-26.4.0-py3-none-any.whl",
|
||||
"sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c",
|
||||
"bytes": 56026
|
||||
},
|
||||
{
|
||||
"name": "typing_extensions-4.16.0-py3-none-any.whl",
|
||||
"sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8",
|
||||
"bytes": 45571
|
||||
},
|
||||
{
|
||||
"name": "typing_inspection-0.4.4-py3-none-any.whl",
|
||||
"sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147",
|
||||
"bytes": 14750
|
||||
},
|
||||
{
|
||||
"name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9",
|
||||
"bytes": 109835
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"schema": "missioncore.insta360.sdk-source/v1",
|
||||
"sdk_version": "2.1.8",
|
||||
"repository": "https://github.com/pdxmusic/insta360sdk",
|
||||
"commit": "3db9641ba612c639db10591d1402231662a1eb5d",
|
||||
"directory": "CameraSDK-2.1.8-20260828_171805-linux-x86_64",
|
||||
"source_kind": "public-third-party-mirror",
|
||||
"vendor_authenticity": "not-independently-confirmed",
|
||||
"redistribution_terms": "not-present-in-inspected-sdk-files",
|
||||
"files": [
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_ONE_X2.json",
|
||||
"bytes": 10160,
|
||||
"sha256": "058a31c1cc15539b4466452caa6f653c8ad9255fd4a7681d2877fe87463c7260",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_One2.json",
|
||||
"bytes": 8952,
|
||||
"sha256": "76b0c9b9c331237ce69129c91629345ff91c5658e7c06cf3842cbf72556dab43",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_OneR.json",
|
||||
"bytes": 12177,
|
||||
"sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_OneRS_283.json",
|
||||
"bytes": 12297,
|
||||
"sha256": "7260649cb20caf3f42ed604220f4c0209db41a4ecbf7cd82350d38cbc2c1fd47",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_OneRS_577.json",
|
||||
"bytes": 12177,
|
||||
"sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_X3.json",
|
||||
"bytes": 14391,
|
||||
"sha256": "cf756d9155e3d4829e66f34e81d71731913366a4f0aa3677323c9663f112039c",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_X4.json",
|
||||
"bytes": 145665,
|
||||
"sha256": "b88b647847096dcb012f21255e3dfeaff15dc6f701f257c89c484337ecdb2104",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_X4_Air.json",
|
||||
"bytes": 115749,
|
||||
"sha256": "da0f7048c3125e1e1e11b20e641f04db4b65e9a23b51558a9ea85f99cff4097b",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_X5.json",
|
||||
"bytes": 152752,
|
||||
"sha256": "1a7a016439fca88928ad86844e7b4902b3153d9f2553719eb4a81aed98d314c7",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "bin/jsons/camera_conf_Insta360_X6.json",
|
||||
"bytes": 237283,
|
||||
"sha256": "9325937d5c4a60555f4c556c2e0f69ac0b89012232e4a6022f50c29bbd308aa0",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/camera/camera.h",
|
||||
"bytes": 19842,
|
||||
"sha256": "1e39bb9923d9ad87173bcb566936b7a84858d63f585a6b7d00046af4225498cf",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/camera/device_discovery.h",
|
||||
"bytes": 1080,
|
||||
"sha256": "55a03fa3597b2f913b112a4860ac7311f0620d7d01b1a75c6150779178d8b97c",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/camera/ins_types.h",
|
||||
"bytes": 5960,
|
||||
"sha256": "df13cb17a4972aec2cb05aa01383eb1c0d58d47d9379b9fc53940da581a98eac",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/camera/photography_settings.h",
|
||||
"bytes": 20377,
|
||||
"sha256": "f19047dde6a7903b9de8099685a92200caba3b279e9308f7563990e3db0b19b5",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/stream/stream_delegate.h",
|
||||
"bytes": 1425,
|
||||
"sha256": "115f757427a1c8542bcc912aeb10550615a8c538f7c37d0813fa9565d524b465",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "include/stream/stream_types.h",
|
||||
"bytes": 436,
|
||||
"sha256": "2791062f6e45603b47f9b26390506c2b49965efcafa7f5748cb71e89a49baaf9",
|
||||
"lfs": false
|
||||
},
|
||||
{
|
||||
"path": "lib/libCameraSDK.so",
|
||||
"bytes": 17031504,
|
||||
"sha256": "6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373",
|
||||
"lfs": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Root-only USB-to-unit reconciler. No client commands, SDK loading or network."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from layout import CONTROL, active, directory, trusted, write # noqa: E402
|
||||
from runtime.identity import read_binding # noqa: E402
|
||||
|
||||
UNITS = Path("/run/systemd/system")
|
||||
PREFIX = "mission-core-x4@"
|
||||
PATTERN = re.compile(r"instax4_[0-9a-f]{32}")
|
||||
|
||||
|
||||
def systemctl(*args):
|
||||
subprocess.run(
|
||||
["/usr/bin/systemctl", *args],
|
||||
check=True,
|
||||
timeout=20,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def unit(binding):
|
||||
runtime = trusted(active(), True)
|
||||
# Every interpolated value is a verified OS binding or root-owned revision.
|
||||
return f"""[Unit]
|
||||
Description=Mission Core isolated Insta360 X4
|
||||
After=mission-core-insta360-supervisor.service
|
||||
[Service]
|
||||
Type=exec
|
||||
DynamicUser=yes
|
||||
User=mcx4-{binding.device_id[-20:]}
|
||||
Group=mission-core-node
|
||||
SupplementaryGroups=mission-core-x4-usb
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py worker {binding.port}
|
||||
WorkingDirectory={runtime}/bin
|
||||
StateDirectory=mission-core-x4/{binding.device_id}
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=mission-core-x4-instances/{binding.device_id}
|
||||
RuntimeDirectoryMode=0750
|
||||
UMask=0007
|
||||
PrivateDevices=yes
|
||||
BindPaths={binding.device_path}
|
||||
DevicePolicy=closed
|
||||
DeviceAllow={binding.device_path} rw
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
LockPersonality=yes
|
||||
SystemCallFilter=~@mount
|
||||
ReadOnlyPaths={CONTROL}
|
||||
TasksMax=64
|
||||
MemoryMax=384M
|
||||
CPUQuota=150%
|
||||
LimitNOFILE=256
|
||||
LimitCORE=0
|
||||
TimeoutStopSec=8
|
||||
KillMode=control-group
|
||||
Restart=no
|
||||
StandardOutput=null
|
||||
StandardError=null
|
||||
""".encode()
|
||||
|
||||
|
||||
def bindings():
|
||||
found, duplicates = {}, set()
|
||||
for path in Path("/sys/bus/usb/devices").iterdir():
|
||||
try:
|
||||
value = read_binding(path.name)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if value.device_id in found:
|
||||
duplicates.add(value.device_id)
|
||||
found[value.device_id] = value
|
||||
return {ident: value for ident, value in found.items() if ident not in duplicates}
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() or len(sys.argv) != 1:
|
||||
raise RuntimeError("Use the fixed installed supervisor unit")
|
||||
directory(CONTROL)
|
||||
# Captured by root; a worker need not gain ptrace access to PID 1 merely
|
||||
# to prove it uses a different network namespace.
|
||||
write(CONTROL / "host-net-inode", str(Path("/proc/self/ns/net").stat().st_ino).encode())
|
||||
stopping = False
|
||||
|
||||
def stop(*_):
|
||||
nonlocal stopping
|
||||
stopping = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
known = {}
|
||||
startup = True
|
||||
record = CONTROL / "bindings.json"
|
||||
if record.exists():
|
||||
previous = json.loads(trusted(record).read_text())
|
||||
for ident, info in previous.items():
|
||||
if not PATTERN.fullmatch(ident):
|
||||
raise RuntimeError("Invalid previous camera binding")
|
||||
known[ident] = info
|
||||
while not stopping:
|
||||
observed = bindings()
|
||||
current = {
|
||||
ident: {
|
||||
"port": item.port,
|
||||
"bus": item.bus,
|
||||
"address": item.address,
|
||||
"revision": active().name,
|
||||
}
|
||||
for ident, item in observed.items()
|
||||
}
|
||||
changed = False
|
||||
for ident in list(known):
|
||||
if current.get(ident) != known[ident]:
|
||||
systemctl("stop", PREFIX + ident + ".service")
|
||||
path = UNITS / (PREFIX + ident + ".service")
|
||||
if path.exists():
|
||||
trusted(path).unlink()
|
||||
del known[ident]
|
||||
changed = True
|
||||
additions = [ident for ident in current if ident not in known]
|
||||
for ident in additions:
|
||||
item = observed[ident]
|
||||
if read_binding(item.port) != item:
|
||||
continue
|
||||
write(UNITS / (PREFIX + ident + ".service"), unit(item))
|
||||
known[ident] = current[ident]
|
||||
changed = True
|
||||
if changed:
|
||||
write(record, json.dumps(known, sort_keys=True).encode())
|
||||
systemctl("daemon-reload")
|
||||
for ident in list(current) if startup else additions:
|
||||
if ident in known:
|
||||
systemctl("start", PREFIX + ident + ".service")
|
||||
startup = False
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user