From d8cc5367c4f55973d5688a0627298cf5137649a6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 11:43:24 +0300 Subject: [PATCH] Package onboard K1 separately with a private portable installer --- apps/node-agent/packaging/build.py | 1 - apps/node-agent/packaging/build_deb.py | 59 +---- .../packaging/fetch_driver_bundle.py | 3 +- .../packaging/install-k1-credential | 36 --- apps/node-agent/packaging/postinst | 6 - .../2026-09-07-k1-private-installer-r2.md | 81 +++++++ .../packaging/50-mission-core-k1.rules | 0 plugins/xgrids-k1/packaging/README.md | 79 ++++++ plugins/xgrids-k1/packaging/build_deb.py | 187 +++++++++++++++ .../xgrids-k1/packaging/credential_install.py | 127 ++++++++++ plugins/xgrids-k1/packaging/install | 12 + .../xgrids-k1/packaging/install-k1-credential | 7 + .../xgrids-k1}/packaging/k1-bundle.json | 0 .../xgrids-k1}/packaging/k1_bootstrap.py | 0 .../xgrids-k1}/packaging/k1_prepare.py | 0 .../packaging/mission-core-k1.service | 0 plugins/xgrids-k1/packaging/postinst | 16 ++ plugins/xgrids-k1/packaging/postrm | 7 + plugins/xgrids-k1/packaging/preinst | 22 ++ .../packaging/prepare-bundled-profile | 7 + plugins/xgrids-k1/packaging/prerm | 12 + .../xgrids-k1}/packaging/resolve_k1_bundle.py | 0 .../xgrids-k1/packaging/runtime-files.json | 91 +++++++ .../xgrids-k1/packaging/runtime_payload.py | 95 ++++++++ plugins/xgrids-k1/plugin.manifest.json | 2 +- scripts/packaging/debian.py | 49 ++++ .../device_plugins/xgrids_k1/composition.py | 61 +++++ src/k1link/device_plugins/xgrids_k1/facade.py | 47 ---- src/k1link/laboratory/__init__.py | 113 ++++++--- tests/test_k1_installer.py | 224 ++++++++++++++++++ 30 files changed, 1166 insertions(+), 178 deletions(-) delete mode 100644 apps/node-agent/packaging/install-k1-credential create mode 100644 docs/audits/2026-09-07-k1-private-installer-r2.md rename {apps/node-agent => plugins/xgrids-k1}/packaging/50-mission-core-k1.rules (100%) create mode 100644 plugins/xgrids-k1/packaging/README.md create mode 100644 plugins/xgrids-k1/packaging/build_deb.py create mode 100644 plugins/xgrids-k1/packaging/credential_install.py create mode 100644 plugins/xgrids-k1/packaging/install create mode 100644 plugins/xgrids-k1/packaging/install-k1-credential rename {apps/node-agent => plugins/xgrids-k1}/packaging/k1-bundle.json (100%) rename {apps/node-agent => plugins/xgrids-k1}/packaging/k1_bootstrap.py (100%) rename {apps/node-agent => plugins/xgrids-k1}/packaging/k1_prepare.py (100%) rename {apps/node-agent => plugins/xgrids-k1}/packaging/mission-core-k1.service (100%) create mode 100644 plugins/xgrids-k1/packaging/postinst create mode 100644 plugins/xgrids-k1/packaging/postrm create mode 100644 plugins/xgrids-k1/packaging/preinst create mode 100644 plugins/xgrids-k1/packaging/prepare-bundled-profile create mode 100644 plugins/xgrids-k1/packaging/prerm rename {apps/node-agent => plugins/xgrids-k1}/packaging/resolve_k1_bundle.py (100%) create mode 100644 plugins/xgrids-k1/packaging/runtime-files.json create mode 100644 plugins/xgrids-k1/packaging/runtime_payload.py create mode 100644 scripts/packaging/debian.py create mode 100644 src/k1link/device_plugins/xgrids_k1/composition.py create mode 100644 tests/test_k1_installer.py diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index f1b2ffd..0c53aaa 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -34,7 +34,6 @@ def provenance(): "design_guideline_files": guideline_sources(), "shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()}, "k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()}, - "k1_runtime_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "src/k1link").rglob("*")) if p.is_file() and p.suffix in (".py", ".json")}, "toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files} diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 4ecb8df..8a2dca7 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -4,16 +4,17 @@ No install operation, sudo, container, package-manager mutation or network I/O. """ import argparse -import gzip import hashlib -import io import json from pathlib import Path -import tarfile +import sys ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.7.1" +VERSION = "0.8.0" +sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) +from debian import package + BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" @@ -31,29 +32,6 @@ def desktop_icon(brand): + brand + b'\n') -def tarball(files): - stream = io.BytesIO() - with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive: - directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."} - for name in sorted(directories): - item = tarfile.TarInfo(name + "/") - item.type, item.mode = tarfile.DIRTYPE, 0o755 - item.uname = item.gname = "root" - archive.addfile(item) - for name, data, mode in sorted(files): - item = tarfile.TarInfo(name) - item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0 - item.uname = item.gname = "root" - archive.addfile(item, io.BytesIO(data)) - return gzip.compress(stream.getvalue(), mtime=0) - - -def ar_member(name, data): - header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode() - assert len(header) == 60 - return header + data + (b"\n" if len(data) % 2 else b"") - - def build(binary, destination): payload = binary.read_bytes() if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00": @@ -65,7 +43,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, bluez, network-manager, iproute2, ffmpeg +Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2 Description: Mission Core onboard computer configuration Local graphical setup, host inventory, SSH access and persistent node identity. """.encode() @@ -107,35 +85,12 @@ Description: Mission Core onboard computer configuration files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644)) for path in (ROOT / "sensors").glob("*.py"): files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644)) - for name in ("k1_prepare.py", "k1_bootstrap.py"): - files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644)) - files.append(("usr/lib/mission-core-node/install-k1-credential", (p / "install-k1-credential").read_bytes(), 0o755)) - files.append(("usr/lib/systemd/system/mission-core-k1.service", (p / "mission-core-k1.service").read_bytes(), 0o644)) - files.append(("usr/share/polkit-1/rules.d/50-mission-core-k1.rules", (p / "50-mission-core-k1.rules").read_bytes(), 0o644)) - k1_bundle = json.loads((p / "k1-bundle.json").read_text()) - files.append(("usr/share/mission-core-node/k1/bundle.json", (p / "k1-bundle.json").read_bytes(), 0o644)) - for item in k1_bundle["wheels"]: - data = (ROOT / "build/k1-wheels" / item["name"]).read_bytes() - if hashlib.sha256(data).hexdigest() != item["sha256"]: - raise ValueError("K1 bundle hash mismatch") - files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644)) - repository = ROOT.parents[1] - # Reuse the admitted plugin runtime and the transport-neutral renderer. - # No separate Core web service is started on the board. - for path in (repository / "src/k1link").rglob("*"): - if path.is_file() and path.suffix in (".py", ".json"): - files.append(("usr/lib/mission-core-node/k1/src/k1link/" + str(path.relative_to(repository / "src/k1link")), path.read_bytes(), 0o644)) - for relative in ("plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", - "config/observatory-equipment-models.json", - "config/observatory-recorded-capture-profiles.json", - "plugins/xgrids-k1/profiles/fw-3.0.2/local-network.v2.json"): - files.append(("usr/lib/mission-core-node/k1/" + relative, (repository / relative).read_bytes(), 0o644)) sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk" for path in sdk.rglob("*.py"): files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644)) if (ROOT / "build/provenance.json").exists(): files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644)) - archive = b"!\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files)) + archive = package(controls, files) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(archive) digest = hashlib.sha256(archive).hexdigest() diff --git a/apps/node-agent/packaging/fetch_driver_bundle.py b/apps/node-agent/packaging/fetch_driver_bundle.py index be66b17..eacfd47 100644 --- a/apps/node-agent/packaging/fetch_driver_bundle.py +++ b/apps/node-agent/packaging/fetch_driver_bundle.py @@ -11,7 +11,8 @@ root = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser() parser.add_argument("--model", choices=("realsense", "k1"), default="realsense") model = parser.parse_args().model -manifest = json.loads((root / f"packaging/{model}-bundle.json").read_text()) +manifest_path = (root.parents[1] / "plugins/xgrids-k1/packaging/k1-bundle.json" if model == "k1" else root / "packaging/realsense-bundle.json") +manifest = json.loads(manifest_path.read_text()) output = root / f"build/{model}-wheels" output.mkdir(parents=True, exist_ok=True) for item in manifest["wheels"]: diff --git a/apps/node-agent/packaging/install-k1-credential b/apps/node-agent/packaging/install-k1-credential deleted file mode 100644 index 137501b..0000000 --- a/apps/node-agent/packaging/install-k1-credential +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/python3 -I -"""Administrator-only import of the exact application key from protected stdin.""" -import os -import subprocess -import sys -import tempfile -from pathlib import Path - -if os.geteuid() != 0 or len(sys.argv) != 1: - raise SystemExit("Root stdin import required") -secret = bytearray(sys.stdin.buffer.read(1025).strip()) -try: - if len(secret) != 36 or any(v < 33 or v > 126 for v in secret): - raise SystemExit("Credential does not match the reviewed K1 profile") - root = Path("/etc/credstore.encrypted") - root.mkdir(mode=0o700, exist_ok=True) - if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022: - raise SystemExit("Unsafe credential store") - path = root / "k1-application" - if path.is_symlink() or path.exists(): - raise SystemExit("K1 credential already installed; explicit rotation required") - with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory: - staged = Path(directory) / "encrypted" - completed = subprocess.run( - ["/usr/bin/systemd-creds", "encrypt", "--name=k1-application", "--with-key=host", "-", str(staged)], - input=secret, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, - ) - if completed.returncode: - raise SystemExit("K1 credential import failed") - staged.chmod(0o600) - with staged.open("rb") as stream: - os.fsync(stream.fileno()) - # Atomic publication without overwriting a concurrently installed key. - os.link(staged, path) -finally: - secret[:] = b"\0" * len(secret) diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index cacd52a..1bb0274 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -8,10 +8,6 @@ case "$1" in if ! getent passwd mission-core-sensors >/dev/null; then adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors fi - if ! getent passwd mission-core-k1 >/dev/null; then - adduser --system --home /var/lib/mission-core-k1 --no-create-home --disabled-login --ingroup mission-core-node mission-core-k1 - fi - /usr/bin/python3 -I /usr/lib/mission-core-node/k1_prepare.py # Only bootstrap required to open the GUI. Operational configuration is a # versioned job started by «Настройка окружения → Сконфигурировать». if [ -d /run/systemd/system ]; then @@ -19,8 +15,6 @@ case "$1" in systemctl enable mission-core-node.service systemctl restart mission-core-node.service systemctl try-restart mission-core-realsense.service - systemctl enable mission-core-k1.service - systemctl restart mission-core-k1.service fi ;; esac diff --git a/docs/audits/2026-09-07-k1-private-installer-r2.md b/docs/audits/2026-09-07-k1-private-installer-r2.md new file mode 100644 index 0000000..37189a1 --- /dev/null +++ b/docs/audits/2026-09-07-k1-private-installer-r2.md @@ -0,0 +1,81 @@ +# K1 private installer and onboard package boundary R2 + +The owner narrowed the acceptance scope to the current K1 and a transferable +installer for supported onboard hosts. A second scanner and other firmware +versions are explicitly deferred. The reviewed application material may travel +with this private installer; it is not committed to source or made public. + +## Evidence and decision + +ADR 0012 and the retained LixelGO static/wire analysis distinguish the embedded +application value from each scanner's live vendor ID and serial. A per-device +packet dump is not part of the reviewed connection flow. We nevertheless claim +only the currently accepted scanner/FW 3.0.2, not untested fleet-wide support. +The current XGRIDS release notes also identify a communication-protocol change +at 3.0.2 paired with LixelGO 1.2.0; compatibility remains version-scoped. + +Autonomy here means installation on Ubuntu 24.04 amd64 without a Mac, Keychain, +typed key or new capture. The private K1 package supplies the same reviewed +material; its installer encrypts that material using the destination host's +systemd credential facility. Normal Ubuntu repositories may be needed for OS +dependencies. This is not an air-gapped distribution of Ubuntu itself. + +## Implementation + +Node 0.8.0 no longer owns the K1 Python runtime, Linux wheel bundle, service, +polkit rule or credential importer. These moved to the optional +`mission-core-xgrids-k1` package owned under `plugins/xgrids-k1/packaging`. +Its dependencies and Debian migration metadata are explicit. Existing runtime, +journal, identity and encrypted-credential paths are retained. Removal preserves +durable state; package transitions keep the existing idle/safe-worker gate. + +The code-only package is 0.1.0; the prepared private edition is 0.1.0+private.1. +Private material enters the engineering builder through memory/stdin and is +present only in a root-owned 0600 package member. The `.deb` itself is a private +0600 artifact, not an encrypted distributable. Public provenance records only +profile identity, material presence and hashes of public files. The installed +worker uses the encrypted systemd credential, never the package member as a +plaintext runtime fallback. Identical reinstall is idempotent; a different +existing credential requires explicit rotation. No raw value reaches argv, +environment, browser state, Ops or source files. + +The onboard source payload now has an executable 85-module allowlist instead +of copying every one of 459 Python modules. Changed imports fail packaging +until reviewed. The Core/LAB factory moved into `composition.py` with identical +AST; neither its behavior nor the device dialogue changed. Laboratory public +exports became lazy so importing one historical migration predicate does not +load laboratory runners and downstream compute jobs. Existing export names and +resolved objects are retained. + +## Validation before private release + +- 138 installer, isolated-payload, application-authority, Node bridge, session, + laboratory and composition checks passed. The synthetic installer checks + encryption invocation, reinstall, refusal to rotate, invalid inputs, + permissions, symlinks, private/public payload separation and no overwrite. +- 775 additional plugin runtime, acquisition lifecycle, restart rehydration and + laboratory API checks passed, with no errors, failures or skips. +- Ruff and whitespace checks passed for changed runtime/new packaging modules. +- Node TypeScript, production UI and Go binary/package build passed. No second + backend or Docker workload was started. +- The isolated runtime test uses the declared source payload and verifies all + loaded K1/Core modules originate there. It reads idle state and closes the + runtime; no BLE discovery, provisioning, MQTT, START/STOP or hardware test is + performed. The local Mac lacks optional aiortc; full Node media import is a + Linux installation acceptance, not a result claimed by this test. + +Exact release hashes, installed package versions, credential readiness and +hardware acceptance are recorded after the private build and board update. + +## Remaining architecture scope + +This is a separately installable onboard package, not completion of the entire +plugin migration. Local Core still uses an in-process runtime and a statically +composed frontend. The facade still needs internal decomposition, and some +shared session/viewer code remains a declared plugin dependency. No other Rerun +profile, camera producer or recovery state transition was changed here. +Hardware acceptance uses the UI with cache cleared before every test, as +requested. A second scanner, new firmware or different host platform is not +accepted by packaging and software regression alone. + +Vendor reference: https://www.xgrids.com/intl/support/download?page=K1 diff --git a/apps/node-agent/packaging/50-mission-core-k1.rules b/plugins/xgrids-k1/packaging/50-mission-core-k1.rules similarity index 100% rename from apps/node-agent/packaging/50-mission-core-k1.rules rename to plugins/xgrids-k1/packaging/50-mission-core-k1.rules diff --git a/plugins/xgrids-k1/packaging/README.md b/plugins/xgrids-k1/packaging/README.md new file mode 100644 index 0000000..8a4c287 --- /dev/null +++ b/plugins/xgrids-k1/packaging/README.md @@ -0,0 +1,79 @@ +# Onboard K1 distribution + +The accepted target is the owner's current activated K1, FW 3.0.2, on Ubuntu +24.04 amd64. Another physical scanner or firmware is not included in this +acceptance. The same prepared installer can be used on another supported +onboard host without a Mac, Keychain export, new packet capture or typed key. + +`mission-core-node` 0.8.0 contains the host, shared SDK, existing RealSense +support and statically composed sensor UI. `mission-core-xgrids-k1` is an +optional package containing the K1 service, its reviewed Python dependencies, +platform adapters and application-material preparation. The optional package +depends on the host; the host does not depend on the optional package. + +The old 0.7.1 monolithic installation is migrated using Debian `Breaks` and +`Replaces`. Existing filesystem paths, device identity, encrypted material, +recordings and physical-command journals remain in place. Package scripts +require a safe idle state and stop the worker before replacing its modules. +Removing the optional package preserves recordings, journals and material. + +## Private autonomous installer + +The public code package contains no key. The private edition +`0.1.0+private.1` carries the reviewed application material in one root-owned +mode-0600 member. The resulting `.deb` is itself private (mode 0600); distribute +it only as the owner's prepared installer, never through Git or a public +package registry. File permissions on the installed member do not encrypt the +distributable archive. This is a deliberate private distribution boundary, +not a claim that a client application's embedded material is unextractable. + +At installation, the fixed root-owned helper reads that member and uses +`systemd-creds --with-key=host` to encrypt it for the destination computer. +The worker gets only `LoadCredentialEncrypted=k1-application`. Reinstalling +identical material is idempotent; a different existing value fails before +replacement and requires explicit rotation. There is no plaintext runtime +fallback, browser secret, environment variable, cloud service or per-scanner +key derivation. The administrative protected-stdin importer remains supported. + +Building the private edition reads the material only from protected stdin: + +```text +python plugins/xgrids-k1/packaging/build_deb.py + --wheel-root + --output /mission-core-xgrids-k1_0.1.0+private.1_amd64.deb + --private-authority-stdin +``` + +This is an engineering build interface, not an operator prompt to type the key. +An authorized build can read the existing fixed Keychain item once and supply +those bytes in memory. No key value or key fingerprint enters public provenance. +The build refuses to overwrite an existing artifact. Destination paths and +credential values do not occur in source/runtime manifests. + +Python wheels and application material are included. A fresh Ubuntu machine +may still need its normal package repositories for declared OS dependencies; +autonomous credential preparation does not claim an air-gapped OS installer. + +## Runtime composition boundary + +`runtime-files.json` declares the complete onboard Python import closure (85 +modules at this increment). The builder rejects changed imports until the list +is reviewed. It includes shared session/viewer contracts that the existing K1 +runtime still consumes; it does not pretend those dependencies have disappeared. +`composition.py` owns local Core/LAB router and archive composition. Its factory +was moved from the facade without changing its AST. The onboard worker does not +import it. Laboratory public exports are lazy to keep a leaf migration check +from loading all laboratory runners on the board. + +This finishes a separately installable onboard package boundary, not the entire +device-plugin architecture. The local Core backend remains in-process, frontend +composition remains static, and the internal K1 facade still requires further +decomposition. No network protocol, recovery transition, START/STOP transcript, +camera producer or LAB/recorded/live Rerun settings were changed here. + +The source for application-level identity is the retained LixelGO static/wire +analysis in `docs/05_K1_MQTT_STREAM_PROFILE.md` and ADR 0012. It distinguishes the +application value from the scanner's live vendor ID and serial. Current vendor +release notes also document a protocol change at FW 3.0.2 paired with LixelGO +1.2.0; software-version compatibility must therefore remain explicit: +https://www.xgrids.com/intl/support/download?page=K1 diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py new file mode 100644 index 0000000..e842f7a --- /dev/null +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Build the optional onboard K1 integration, including a private autonomous edition. + +The public build contains no application material. --private-authority-stdin +creates a mode-0600 private installer; the key never appears in arguments, +provenance, stdout or a source file. This installer itself is a private artifact. +""" + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +PACKAGING = Path(__file__).resolve().parent +REPOSITORY = PACKAGING.parents[2] +sys.path.insert(0, str(REPOSITORY / "scripts/packaging")) +from credential_install import PROFILE_ID, validate # noqa: E402 +from debian import package # noqa: E402 +from runtime_payload import files as runtime_files # noqa: E402 + +VERSION = "0.1.0" +RESOURCES = ( + "plugins/xgrids-k1/profile_loader.py", + "plugins/xgrids-k1/plugin.manifest.json", + "config/observatory-equipment-models.json", + "config/observatory-recorded-capture-profiles.json", + "plugins/xgrids-k1/profiles/fw-3.0.2/local-network.v2.json", +) + + +def payload(wheel_root, *, authority=None): + files = [] + for name in ("k1_prepare.py", "k1_bootstrap.py", "install-k1-credential"): + files.append( + ( + "usr/lib/mission-core-node/" + name, + (PACKAGING / name).read_bytes(), + 0o755 if name == "install-k1-credential" else 0o644, + ) + ) + files.extend( + [ + ( + "usr/lib/mission-core-node/k1-install/credential_install.py", + (PACKAGING / "credential_install.py").read_bytes(), + 0o644, + ), + ( + "usr/lib/mission-core-node/prepare-k1-profile", + (PACKAGING / "prepare-bundled-profile").read_bytes(), + 0o755, + ), + ( + "usr/lib/systemd/system/mission-core-k1.service", + (PACKAGING / "mission-core-k1.service").read_bytes(), + 0o644, + ), + ( + "usr/share/polkit-1/rules.d/50-mission-core-k1.rules", + (PACKAGING / "50-mission-core-k1.rules").read_bytes(), + 0o644, + ), + ( + "usr/share/mission-core-node/k1/bundle.json", + (PACKAGING / "k1-bundle.json").read_bytes(), + 0o644, + ), + ] + ) + wheels = json.loads((PACKAGING / "k1-bundle.json").read_text())["wheels"] + for item in wheels: + path = wheel_root / item["name"] + if path.name != item["name"] or path.is_symlink(): + raise ValueError("Unsafe K1 wheel path") + data = path.read_bytes() + if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]: + raise ValueError("K1 wheel differs from the admitted bundle") + files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644)) + sources = runtime_files() + for path in sources: + files.append( + ( + "usr/lib/mission-core-node/k1/" + str(path.relative_to(REPOSITORY)), + path.read_bytes(), + 0o644, + ) + ) + for relative in RESOURCES: + files.append( + ( + "usr/lib/mission-core-node/k1/" + relative, + (REPOSITORY / relative).read_bytes(), + 0o644, + ) + ) + # Hash only code and public metadata. Never hash private material into a + # public per-file manifest or represent it as scanner identity. + provenance = { + "package": "mission-core-xgrids-k1", + "version": VERSION + ("+private.1" if authority is not None else ""), + "base_commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=REPOSITORY, text=True + ).strip(), + "application_profile": PROFILE_ID, + "application_material_included": authority is not None, + "acceptance_scope": "owner-controlled-k1-fw-3.0.2-ubuntu-24.04-amd64", + "files": {name: hashlib.sha256(data).hexdigest() for name, data, _ in files}, + } + files.append( + ( + "usr/share/doc/mission-core-xgrids-k1/provenance.json", + (json.dumps(provenance, indent=2) + "\n").encode(), + 0o644, + ) + ) + if authority is not None: + validate(authority) + files.append( + ("usr/share/mission-core-node/k1/private-application-key", bytes(authority), 0o600) + ) + return files + + +def build(destination, wheel_root, *, authority=None): + if authority is not None: + validate(authority) + version = VERSION + ("+private.1" if authority is not None else "") + control = f"""Package: mission-core-xgrids-k1 +Version: {version} +Architecture: amd64 +Maintainer: NODE.DC local build +Section: admin +Priority: optional +Depends: mission-core-node (>= 0.8.0), mission-core-node (<< 0.9.0), + systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg +Breaks: mission-core-node (<< 0.8.0) +Replaces: mission-core-node (<< 0.8.0) +Description: Optional XGRIDS K1 Bridge integration for Mission Core Node + Reviewed FW 3.0.2 runtime with a separate process and durable device journals. +""".encode() + controls = [("control", control, 0o644)] + controls += [ + (name, (PACKAGING / name).read_bytes(), 0o755) + for name in ("preinst", "postinst", "prerm", "postrm") + ] + archive = package(controls, payload(wheel_root, authority=authority)) + destination.parent.mkdir(parents=True, exist_ok=True) + # Never replace a previously published release with different bytes. + fd, staged = tempfile.mkstemp(prefix=".k1-package-", dir=destination.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchmod(stream.fileno(), 0o600 if authority is not None else 0o644) + stream.write(archive) + stream.flush() + os.fsync(stream.fileno()) + os.link(staged, destination) + finally: + os.unlink(staged) + return { + "package": destination.name, + "version": version, + "bytes": len(archive), + "sha256": hashlib.sha256(archive).hexdigest(), + "private": authority is not None, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--wheel-root", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--private-authority-stdin", action="store_true") + args = parser.parse_args() + secret = None + try: + if args.private_authority_stdin: + secret = bytearray(sys.stdin.buffer.read(1025).strip()) + print(json.dumps(build(args.output, args.wheel_root, authority=secret))) + except Exception: + raise SystemExit("K1 package build failed; check inputs and unused output path") from None + finally: + if secret is not None: + secret[:] = b"\0" * len(secret) diff --git a/plugins/xgrids-k1/packaging/credential_install.py b/plugins/xgrids-k1/packaging/credential_install.py new file mode 100644 index 0000000..e3f4a26 --- /dev/null +++ b/plugins/xgrids-k1/packaging/credential_install.py @@ -0,0 +1,127 @@ +"""Root-only preparation of the private application material shipped with K1. + +An autonomous private installer supplies one fixed, root-readable file. The +worker receives only a systemd credential encrypted on this board. Reinstalling +the same material is harmless; changing it requires explicit rotation. +""" + +import hmac +import os +import stat +import subprocess +import tempfile +from pathlib import Path + +PROFILE_ID = "lixelgo.application.k1-fw-3.0.2.v1" +STORE = Path("/etc/credstore.encrypted") +BUNDLE = Path("/usr/share/mission-core-node/k1/private-application-key") + + +class CredentialInstallError(ValueError): + """Secret-free failure; input and subprocess diagnostics never escape.""" + + +def validate(secret): + if len(secret) != 36 or any(v < 33 or v > 126 for v in secret): + raise CredentialInstallError("Invalid material for the reviewed K1 profile") + + +def read_bundle(path=BUNDLE): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise CredentialInstallError( + "Private material must be a root-owned private regular file" + ) + secret = bytearray(stream.read(1025)) + try: + validate(secret) + return secret + except CredentialInstallError: + secret[:] = b"\0" * len(secret) + raise + + +def install(secret, *, root=STORE, runner=subprocess.run): + if os.geteuid() != 0: + raise CredentialInstallError("Administrator authentication required") + validate(secret) + root.mkdir(mode=0o700, exist_ok=True) + if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022: + raise CredentialInstallError("Unsafe credential store") + path = root / "k1-application" + if path.is_symlink(): + raise CredentialInstallError("Unsafe credential target") + if path.exists(): + info = path.stat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise CredentialInstallError("Unsafe installed credential") + result = runner( + ["/usr/bin/systemd-creds", "decrypt", "--name=k1-application", str(path), "-"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=30, + check=False, + ) + current = bytearray(result.stdout) + try: + if result.returncode or not hmac.compare_digest(current, secret): + raise CredentialInstallError( + "Existing material differs; explicit rotation required" + ) + return "unchanged" + finally: + current[:] = b"\0" * len(current) + with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory: + staged = Path(directory) / "encrypted" + result = runner( + [ + "/usr/bin/systemd-creds", + "encrypt", + "--name=k1-application", + "--with-key=host", + "-", + str(staged), + ], + input=secret, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + check=False, + ) + if result.returncode: + raise CredentialInstallError("Application material preparation failed") + staged.chmod(0o600) + with staged.open("rb") as stream: + os.fsync(stream.fileno()) + os.link(staged, path) + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + return "installed" + + +def main(*, bundled=False): + import sys + + secret = bytearray() + try: + if os.geteuid() != 0 or len(sys.argv) != 1: + raise CredentialInstallError("Administrator-only preparation required") + if bundled: + if not BUNDLE.exists() and not BUNDLE.is_symlink(): + return + secret = read_bundle() + else: + secret = bytearray(sys.stdin.buffer.read(1025).strip()) + install(secret) + except (OSError, ValueError, subprocess.SubprocessError): + raise SystemExit( + "K1 application profile was not installed; " + "check the private release or existing profile" + ) from None + finally: + secret[:] = b"\0" * len(secret) diff --git a/plugins/xgrids-k1/packaging/install b/plugins/xgrids-k1/packaging/install new file mode 100644 index 0000000..da5d3b5 --- /dev/null +++ b/plugins/xgrids-k1/packaging/install @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +mc_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$mc_release_dir" +/usr/bin/sha256sum --check SHA256SUMS +mc_node_package="$mc_release_dir/mission-core-node_0.8.0_amd64.deb" +mc_k1_package="$mc_release_dir/mission-core-xgrids-k1_0.1.0+private.1_amd64.deb" +if [ -t 0 ]; then + exec /usr/bin/sudo /usr/bin/apt-get install -y "$mc_node_package" "$mc_k1_package" +fi +exec /usr/bin/gnome-terminal --wait --title="Mission Core · K1" -- \ + /usr/bin/sudo /usr/bin/apt-get install -y "$mc_node_package" "$mc_k1_package" diff --git a/plugins/xgrids-k1/packaging/install-k1-credential b/plugins/xgrids-k1/packaging/install-k1-credential new file mode 100644 index 0000000..253abb5 --- /dev/null +++ b/plugins/xgrids-k1/packaging/install-k1-credential @@ -0,0 +1,7 @@ +#!/usr/bin/python3 -I +import sys + +sys.path.insert(0, "/usr/lib/mission-core-node/k1-install") +from credential_install import main + +main() diff --git a/apps/node-agent/packaging/k1-bundle.json b/plugins/xgrids-k1/packaging/k1-bundle.json similarity index 100% rename from apps/node-agent/packaging/k1-bundle.json rename to plugins/xgrids-k1/packaging/k1-bundle.json diff --git a/apps/node-agent/packaging/k1_bootstrap.py b/plugins/xgrids-k1/packaging/k1_bootstrap.py similarity index 100% rename from apps/node-agent/packaging/k1_bootstrap.py rename to plugins/xgrids-k1/packaging/k1_bootstrap.py diff --git a/apps/node-agent/packaging/k1_prepare.py b/plugins/xgrids-k1/packaging/k1_prepare.py similarity index 100% rename from apps/node-agent/packaging/k1_prepare.py rename to plugins/xgrids-k1/packaging/k1_prepare.py diff --git a/apps/node-agent/packaging/mission-core-k1.service b/plugins/xgrids-k1/packaging/mission-core-k1.service similarity index 100% rename from apps/node-agent/packaging/mission-core-k1.service rename to plugins/xgrids-k1/packaging/mission-core-k1.service diff --git a/plugins/xgrids-k1/packaging/postinst b/plugins/xgrids-k1/packaging/postinst new file mode 100644 index 0000000..5b88e12 --- /dev/null +++ b/plugins/xgrids-k1/packaging/postinst @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +case "$1" in + configure) + if ! getent passwd mission-core-k1 >/dev/null; then + adduser --system --home /var/lib/mission-core-k1 --no-create-home --disabled-login --ingroup mission-core-node mission-core-k1 + fi + /usr/bin/python3 -I /usr/lib/mission-core-node/k1_prepare.py + /usr/lib/mission-core-node/prepare-k1-profile + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable mission-core-k1.service + systemctl restart mission-core-k1.service + fi + ;; +esac diff --git a/plugins/xgrids-k1/packaging/postrm b/plugins/xgrids-k1/packaging/postrm new file mode 100644 index 0000000..c31c021 --- /dev/null +++ b/plugins/xgrids-k1/packaging/postrm @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +if [ -d /run/systemd/system ]; then + systemctl daemon-reload +fi +# Removal preserves recordings, physical-command journals and encrypted +# application material. Package removal is not device state reset or rotation. diff --git a/plugins/xgrids-k1/packaging/preinst b/plugins/xgrids-k1/packaging/preinst new file mode 100644 index 0000000..5ae94cc --- /dev/null +++ b/plugins/xgrids-k1/packaging/preinst @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu +case "$1" in + install|upgrade) + . /etc/os-release + if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then + echo "K1 integration requires Ubuntu 24.04 amd64." >&2 + exit 1 + fi + if [ -d /run/systemd/system ]; then + if [ -S /run/mission-core-k1/driver.sock ]; then + if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then + echo "Завершите подключение или запись K1 перед обновлением интеграции." >&2 + exit 1 + fi + fi + if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then + systemctl stop mission-core-k1.service + fi + fi + ;; +esac diff --git a/plugins/xgrids-k1/packaging/prepare-bundled-profile b/plugins/xgrids-k1/packaging/prepare-bundled-profile new file mode 100644 index 0000000..fbe2b0c --- /dev/null +++ b/plugins/xgrids-k1/packaging/prepare-bundled-profile @@ -0,0 +1,7 @@ +#!/usr/bin/python3 -I +import sys + +sys.path.insert(0, "/usr/lib/mission-core-node/k1-install") +from credential_install import main + +main(bundled=True) diff --git a/plugins/xgrids-k1/packaging/prerm b/plugins/xgrids-k1/packaging/prerm new file mode 100644 index 0000000..e884764 --- /dev/null +++ b/plugins/xgrids-k1/packaging/prerm @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +if [ -d /run/systemd/system ]; then + if [ -S /run/mission-core-k1/driver.sock ]; then + if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then + echo "Завершите подключение или запись K1 перед удалением интеграции." >&2 + exit 1 + fi + fi + systemctl stop mission-core-k1.service + case "$1" in remove|deconfigure) systemctl disable mission-core-k1.service || true ;; esac +fi diff --git a/apps/node-agent/packaging/resolve_k1_bundle.py b/plugins/xgrids-k1/packaging/resolve_k1_bundle.py similarity index 100% rename from apps/node-agent/packaging/resolve_k1_bundle.py rename to plugins/xgrids-k1/packaging/resolve_k1_bundle.py diff --git a/plugins/xgrids-k1/packaging/runtime-files.json b/plugins/xgrids-k1/packaging/runtime-files.json new file mode 100644 index 0000000..98c0257 --- /dev/null +++ b/plugins/xgrids-k1/packaging/runtime-files.json @@ -0,0 +1,91 @@ +{ + "schema": "missioncore.plugin.python-payload/v1", + "entrypoint": "k1link.device_plugins.xgrids_k1.node_bridge", + "files": [ + "src/k1link/__init__.py", + "src/k1link/artifact_gateway.py", + "src/k1link/artifacts.py", + "src/k1link/compute/__init__.py", + "src/k1link/compute/live_perception.py", + "src/k1link/data_plane/__init__.py", + "src/k1link/data_plane/views.py", + "src/k1link/device_plugins/__init__.py", + "src/k1link/device_plugins/xgrids_k1/__init__.py", + "src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py", + "src/k1link/device_plugins/xgrids_k1/application_control_process_lease.py", + "src/k1link/device_plugins/xgrids_k1/ble/__init__.py", + "src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py", + "src/k1link/device_plugins/xgrids_k1/ble/runtime_arbiter.py", + "src/k1link/device_plugins/xgrids_k1/ble/scanner.py", + "src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py", + "src/k1link/device_plugins/xgrids_k1/calibration_schema.py", + "src/k1link/device_plugins/xgrids_k1/calibration_snapshot.py", + "src/k1link/device_plugins/xgrids_k1/camera.py", + "src/k1link/device_plugins/xgrids_k1/connection_attempt.py", + "src/k1link/device_plugins/xgrids_k1/connection_supervisor.py", + "src/k1link/device_plugins/xgrids_k1/device_identity_pin_store.py", + "src/k1link/device_plugins/xgrids_k1/facade.py", + "src/k1link/device_plugins/xgrids_k1/firmware_credential.py", + "src/k1link/device_plugins/xgrids_k1/host_diagnostics.py", + "src/k1link/device_plugins/xgrids_k1/linux_host.py", + "src/k1link/device_plugins/xgrids_k1/live_perception_shadow.py", + "src/k1link/device_plugins/xgrids_k1/mqtt/__init__.py", + "src/k1link/device_plugins/xgrids_k1/mqtt/capture.py", + "src/k1link/device_plugins/xgrids_k1/network_mutation_ledger.py", + "src/k1link/device_plugins/xgrids_k1/network_provisioning_idempotency_journal.py", + "src/k1link/device_plugins/xgrids_k1/node_bridge.py", + "src/k1link/device_plugins/xgrids_k1/node_sensor.py", + "src/k1link/device_plugins/xgrids_k1/physical_command_coordinator.py", + "src/k1link/device_plugins/xgrids_k1/physical_command_ledger.py", + "src/k1link/device_plugins/xgrids_k1/protocol/__init__.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py", + "src/k1link/device_plugins/xgrids_k1/protocol/application_session.py", + "src/k1link/device_plugins/xgrids_k1/protocol/calibration_file.py", + "src/k1link/device_plugins/xgrids_k1/protocol/calibration_mqtt.py", + "src/k1link/device_plugins/xgrids_k1/protocol/modeling.py", + "src/k1link/device_plugins/xgrids_k1/protocol/modeling_control.py", + "src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py", + "src/k1link/device_plugins/xgrids_k1/protocol/normalizer.py", + "src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py", + "src/k1link/device_plugins/xgrids_k1/protocol/streams.py", + "src/k1link/device_plugins/xgrids_k1/quick_connect_profile.py", + "src/k1link/device_plugins/xgrids_k1/semantic_topology_store.py", + "src/k1link/device_plugins/xgrids_k1/viewer/__init__.py", + "src/k1link/device_plugins/xgrids_k1/viewer/messages.py", + "src/k1link/device_plugins/xgrids_k1/viewer/replay.py", + "src/k1link/device_plugins/xgrids_k1/viewer/runtime.py", + "src/k1link/device_plugins/xgrids_k1/wifi_failure.py", + "src/k1link/host_network/__init__.py", + "src/k1link/host_network/wifi.py", + "src/k1link/laboratory/__init__.py", + "src/k1link/laboratory/canonical_recorded_migration.py", + "src/k1link/media_fragments.py", + "src/k1link/sessions/__init__.py", + "src/k1link/sessions/active.py", + "src/k1link/sessions/camera_frame.py", + "src/k1link/sessions/equipment.py", + "src/k1link/sessions/lab_cache.py", + "src/k1link/sessions/media.py", + "src/k1link/sessions/models.py", + "src/k1link/sessions/plugin_contract.py", + "src/k1link/sessions/preparation.py", + "src/k1link/sessions/recording.py", + "src/k1link/sessions/store.py", + "src/k1link/viewer/__init__.py", + "src/k1link/viewer/metrics.py", + "src/k1link/viewer/node_media.py", + "src/k1link/viewer/node_rerun.py", + "src/k1link/viewer/recorded.py", + "src/k1link/viewer/recorded_blueprint_lifecycle.py", + "src/k1link/viewer/rerun_bridge.py", + "src/k1link/web/__init__.py", + "src/k1link/web/camera_archive.py", + "src/k1link/web/device_lifecycle.py", + "src/k1link/web/plugin_runtime.py" + ] +} diff --git a/plugins/xgrids-k1/packaging/runtime_payload.py b/plugins/xgrids-k1/packaging/runtime_payload.py new file mode 100644 index 0000000..3ac80b0 --- /dev/null +++ b/plugins/xgrids-k1/packaging/runtime_payload.py @@ -0,0 +1,95 @@ +"""Reviewable import closure for the onboard plugin; no entire Core tree.""" + +import ast +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MANIFEST = Path(__file__).with_name("runtime-files.json") +ENTRYPOINT = "k1link.device_plugins.xgrids_k1.node_bridge" + + +def runtime_nodes(node): + # A lazy package's typing-only reexports must not ship every LAB algorithm. + if isinstance(node, ast.If) and ( + isinstance(node.test, ast.Name) + and node.test.id == "TYPE_CHECKING" + or isinstance(node.test, ast.Attribute) + and node.test.attr == "TYPE_CHECKING" + ): + for child in node.orelse: + yield from runtime_nodes(child) + return + yield node + for child in ast.iter_child_nodes(node): + yield from runtime_nodes(child) + + +def import_closure(repository=ROOT): + source = repository / "src" + modules = {} + for path in (source / "k1link").rglob("*.py"): + name = ".".join(path.relative_to(source).with_suffix("").parts).removesuffix(".__init__") + modules[name] = path + seen, todo = set(), [ENTRYPOINT] + while todo: + name = todo.pop() + if name in seen: + continue + seen.add(name) + path = modules[name] + package = name if path.name == "__init__.py" else name.rpartition(".")[0] + imports = {".".join(name.split(".")[:i]) for i in range(1, len(name.split(".")))} + for node in runtime_nodes(ast.parse(path.read_text())): + if isinstance(node, ast.Import): + imports.update(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + if node.level: + prefix = ".".join( + package.split(".")[: len(package.split(".")) - node.level + 1] + ) + base = prefix + ("." + base if base else "") + imports.add(base) + imports.update(base + "." + a.name for a in node.names) + todo.extend(sorted((imports & modules.keys()) - seen)) + return sorted(str(modules[name].relative_to(repository)) for name in seen) + + +def files(repository=ROOT): + declared = json.loads(MANIFEST.read_text()) + actual = import_closure(repository) + if ( + declared["schema"] != "missioncore.plugin.python-payload/v1" + or declared["entrypoint"] != ENTRYPOINT + or actual != declared["files"] + ): + raise ValueError("K1 runtime imports changed; review its payload manifest before packaging") + result = [] + for name in actual: + path = repository / name + if path.is_symlink(): + raise ValueError("Symlink in plugin source payload") + result.append(path) + return result + + +def provenance(repository=ROOT): + return { + str(p.relative_to(repository)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in files(repository) + } + + +if __name__ == "__main__": + print( + json.dumps( + { + "schema": "missioncore.plugin.python-payload/v1", + "entrypoint": ENTRYPOINT, + "files": import_closure(), + }, + indent=2, + ) + ) diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index b460bff..b360c9f 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -9,7 +9,7 @@ "spec": { "hostApiRange": "v1alpha2", "runtime": { - "backendEntrypoint": "k1link.device_plugins.xgrids_k1.facade:build_xgrids_k1_plugin", + "backendEntrypoint": "k1link.device_plugins.xgrids_k1.composition:build_xgrids_k1_plugin", "isolation": "transitional-in-process" }, "compatibilityProfiles": [ diff --git a/scripts/packaging/debian.py b/scripts/packaging/debian.py new file mode 100644 index 0000000..e408a7c --- /dev/null +++ b/scripts/packaging/debian.py @@ -0,0 +1,49 @@ +"""Deterministic archive primitives shared by host and optional plugin builders.""" + +import gzip +import io +import tarfile +from pathlib import PurePosixPath + + +def tarball(files): + names = [name for name, _, _ in files] + if len(set(names)) != len(names): + raise ValueError("Duplicate package member") + if any(PurePosixPath(n).is_absolute() or ".." in PurePosixPath(n).parts for n in names): + raise ValueError("Unsafe package member") + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive: + directories = { + str(parent) + for name in names + for parent in PurePosixPath(name).parents + if str(parent) != "." + } + for name in sorted(directories): + item = tarfile.TarInfo(name + "/") + item.type, item.mode = tarfile.DIRTYPE, 0o755 + item.uname = item.gname = "root" + archive.addfile(item) + for name, data, mode in sorted(files): + item = tarfile.TarInfo(name) + item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0 + item.uname = item.gname = "root" + archive.addfile(item, io.BytesIO(data)) + return gzip.compress(stream.getvalue(), mtime=0) + + +def ar_member(name, data): + header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode() + if len(header) != 60: + raise ValueError("Invalid archive header") + return header + data + (b"\n" if len(data) % 2 else b"") + + +def package(controls, files): + return ( + b"!\n" + + ar_member("debian-binary", b"2.0\n") + + ar_member("control.tar.gz", tarball(controls)) + + ar_member("data.tar.gz", tarball(files)) + ) diff --git a/src/k1link/device_plugins/xgrids_k1/composition.py b/src/k1link/device_plugins/xgrids_k1/composition.py new file mode 100644 index 0000000..7ef5e93 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/composition.py @@ -0,0 +1,61 @@ +"""Local LAB composition; the onboard runtime does not import this module.""" + +from pathlib import Path + +from missioncore_plugin_sdk.v0alpha2 import RuntimePluginDescriptor + +from k1link.web.plugin_runtime import DevicePluginRuntimeContribution, InProcessDevicePluginRuntime + +from .camera import build_xgrids_k1_camera_router +from .facade import ( + XGRIDS_K1_PLUGIN_ID, + XGRIDS_K1_PLUGIN_VERSION, + XgridsK1CompatibilityService, + XgridsK1PluginFacade, + _validate_installed_compatibility_profile, +) +from .live_perception_shadow import ( + build_live_perception_result_receiver, + build_live_perception_shadow_router, +) + + +def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution: + """Manifest entrypoint for the reviewed XGRIDS compatibility adapter.""" + + from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation + from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router + + _validate_installed_compatibility_profile(repository_root) + service = XgridsK1CompatibilityService(repository_root) + adapter = XgridsK1PluginFacade(service) + runtime = InProcessDevicePluginRuntime( + adapter, + RuntimePluginDescriptor( + plugin_id=XGRIDS_K1_PLUGIN_ID, + plugin_version=XGRIDS_K1_PLUGIN_VERSION, + supported_host_api_versions=("missioncore.nodedc/v1alpha2",), + action_ids=tuple(sorted(adapter.action_ids)), + ), + close=service.close, + ) + return DevicePluginRuntimeContribution( + runtime=runtime, + legacy_routers=( + build_xgrids_k1_legacy_router(runtime), + build_xgrids_k1_camera_router( + service.camera_preview, + XGRIDS_K1_PLUGIN_ID, + ), + build_live_perception_shadow_router( + service.live_perception_ingress, + XGRIDS_K1_PLUGIN_ID, + bearer_token=service._live_perception_token, + result_receiver=build_live_perception_result_receiver( + service.live_perception_ingress, + service.runtime.publish_perception_frame, + ), + ), + ), + observation=build_xgrids_k1_observation(repository_root), + ) diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index c7caedc..348dac7 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -31,7 +31,6 @@ from uuid import uuid4 from bleak.exc import BleakDeviceNotFoundError, BleakError from missioncore_plugin_sdk.v0alpha2 import ( RuntimeActionInvocation, - RuntimePluginDescriptor, ) from pydantic import ( BaseModel, @@ -120,7 +119,6 @@ from k1link.device_plugins.xgrids_k1.camera import ( CameraSourceId, CommittedCameraSegment, XgridsK1CameraGateway, - build_xgrids_k1_camera_router, classify_camera_recording_health, ) from k1link.device_plugins.xgrids_k1.connection_attempt import ( @@ -155,8 +153,6 @@ from k1link.device_plugins.xgrids_k1.host_diagnostics import ( host_diagnostic_for_reason, ) from k1link.device_plugins.xgrids_k1.live_perception_shadow import ( - build_live_perception_result_receiver, - build_live_perception_shadow_router, ensure_live_shadow_token, ) from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4 @@ -263,8 +259,6 @@ from k1link.web.device_lifecycle import ( new_device_session_id, ) from k1link.web.plugin_runtime import ( - DevicePluginRuntimeContribution, - InProcessDevicePluginRuntime, PluginActionNotFoundError, PluginExecutionError, ) @@ -35506,44 +35500,3 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None: XGRIDS_K1_COMPATIBILITY_PROFILE_ID ): raise RuntimeError("XGRIDS compatibility profile identity does not match the runtime") - - -def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution: - """Manifest entrypoint for the reviewed XGRIDS compatibility adapter.""" - - from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation - from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router - - _validate_installed_compatibility_profile(repository_root) - service = XgridsK1CompatibilityService(repository_root) - adapter = XgridsK1PluginFacade(service) - runtime = InProcessDevicePluginRuntime( - adapter, - RuntimePluginDescriptor( - plugin_id=XGRIDS_K1_PLUGIN_ID, - plugin_version=XGRIDS_K1_PLUGIN_VERSION, - supported_host_api_versions=("missioncore.nodedc/v1alpha2",), - action_ids=tuple(sorted(adapter.action_ids)), - ), - close=service.close, - ) - return DevicePluginRuntimeContribution( - runtime=runtime, - legacy_routers=( - build_xgrids_k1_legacy_router(runtime), - build_xgrids_k1_camera_router( - service.camera_preview, - XGRIDS_K1_PLUGIN_ID, - ), - build_live_perception_shadow_router( - service.live_perception_ingress, - XGRIDS_K1_PLUGIN_ID, - bearer_token=service._live_perception_token, - result_receiver=build_live_perception_result_receiver( - service.live_perception_ingress, - service.runtime.publish_perception_frame, - ), - ), - ), - observation=build_xgrids_k1_observation(repository_root), - ) diff --git a/src/k1link/laboratory/__init__.py b/src/k1link/laboratory/__init__.py index 77d7c05..d47aec6 100644 --- a/src/k1link/laboratory/__init__.py +++ b/src/k1link/laboratory/__init__.py @@ -1,38 +1,42 @@ -"""Configuration contracts for Mission Core laboratory evidence.""" +"""Lazy public exports for laboratory evidence; leaf imports stay lightweight.""" -from k1link.laboratory.evidence_registry import ( - LABORATORY_EVIDENCE_DEFINITION_SCHEMA, - LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA, - LaboratoryEvidenceDefinition, - LaboratoryEvidenceRegistry, - LaboratoryEvidenceVariant, - LaboratoryRegistryError, -) -from k1link.laboratory.evidence_report import ( - LABORATORY_EVIDENCE_REPORT_SCHEMA, - LaboratoryEvidenceReportError, - LaboratoryEvidenceReportNotFound, - LaboratoryEvidenceReportService, - verify_laboratory_evidence_result, -) -from k1link.laboratory.execution import ( - LABORATORY_EXECUTION_REGISTRY_SCHEMA, - LABORATORY_RUN_RECEIPT_SCHEMA, - LaboratoryAdapterResult, - LaboratoryExecutionDefinition, - LaboratoryExecutionError, - LaboratoryExecutionRegistry, - LaboratoryRunner, - LaboratoryRunRequest, - LaboratoryRunResult, -) -from k1link.laboratory.value_review_registry import ( - LABORATORY_VALUE_REVIEW_INDEX_SCHEMA, - LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA, - LaboratoryValueReviewEntry, - LaboratoryValueReviewRegistry, - LaboratoryValueReviewRegistryError, -) +from importlib import import_module +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from k1link.laboratory.evidence_registry import ( + LABORATORY_EVIDENCE_DEFINITION_SCHEMA, + LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA, + LaboratoryEvidenceDefinition, + LaboratoryEvidenceRegistry, + LaboratoryEvidenceVariant, + LaboratoryRegistryError, + ) + from k1link.laboratory.evidence_report import ( + LABORATORY_EVIDENCE_REPORT_SCHEMA, + LaboratoryEvidenceReportError, + LaboratoryEvidenceReportNotFound, + LaboratoryEvidenceReportService, + verify_laboratory_evidence_result, + ) + from k1link.laboratory.execution import ( + LABORATORY_EXECUTION_REGISTRY_SCHEMA, + LABORATORY_RUN_RECEIPT_SCHEMA, + LaboratoryAdapterResult, + LaboratoryExecutionDefinition, + LaboratoryExecutionError, + LaboratoryExecutionRegistry, + LaboratoryRunner, + LaboratoryRunRequest, + LaboratoryRunResult, + ) + from k1link.laboratory.value_review_registry import ( + LABORATORY_VALUE_REVIEW_INDEX_SCHEMA, + LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA, + LaboratoryValueReviewEntry, + LaboratoryValueReviewRegistry, + LaboratoryValueReviewRegistryError, + ) __all__ = [ "LABORATORY_EVIDENCE_DEFINITION_SCHEMA", @@ -61,3 +65,44 @@ __all__ = [ "LaboratoryValueReviewRegistry", "LaboratoryValueReviewRegistryError", ] + +_EXPORTS: Final = { + "LABORATORY_EVIDENCE_DEFINITION_SCHEMA": "k1link.laboratory.evidence_registry", + "LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA": "k1link.laboratory.evidence_registry", + "LaboratoryEvidenceDefinition": "k1link.laboratory.evidence_registry", + "LaboratoryEvidenceRegistry": "k1link.laboratory.evidence_registry", + "LaboratoryEvidenceVariant": "k1link.laboratory.evidence_registry", + "LaboratoryRegistryError": "k1link.laboratory.evidence_registry", + "LABORATORY_EVIDENCE_REPORT_SCHEMA": "k1link.laboratory.evidence_report", + "LaboratoryEvidenceReportError": "k1link.laboratory.evidence_report", + "LaboratoryEvidenceReportNotFound": "k1link.laboratory.evidence_report", + "LaboratoryEvidenceReportService": "k1link.laboratory.evidence_report", + "verify_laboratory_evidence_result": "k1link.laboratory.evidence_report", + "LABORATORY_EXECUTION_REGISTRY_SCHEMA": "k1link.laboratory.execution", + "LABORATORY_RUN_RECEIPT_SCHEMA": "k1link.laboratory.execution", + "LaboratoryAdapterResult": "k1link.laboratory.execution", + "LaboratoryExecutionDefinition": "k1link.laboratory.execution", + "LaboratoryExecutionError": "k1link.laboratory.execution", + "LaboratoryExecutionRegistry": "k1link.laboratory.execution", + "LaboratoryRunner": "k1link.laboratory.execution", + "LaboratoryRunRequest": "k1link.laboratory.execution", + "LaboratoryRunResult": "k1link.laboratory.execution", + "LABORATORY_VALUE_REVIEW_INDEX_SCHEMA": "k1link.laboratory.value_review_registry", + "LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA": "k1link.laboratory.value_review_registry", + "LaboratoryValueReviewEntry": "k1link.laboratory.value_review_registry", + "LaboratoryValueReviewRegistry": "k1link.laboratory.value_review_registry", + "LaboratoryValueReviewRegistryError": "k1link.laboratory.value_review_registry", +} + + +def __getattr__(name: str) -> Any: + module_name = _EXPORTS.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *__all__}) diff --git a/tests/test_k1_installer.py b/tests/test_k1_installer.py new file mode 100644 index 0000000..ccdc058 --- /dev/null +++ b/tests/test_k1_installer.py @@ -0,0 +1,224 @@ +"""Private installer and import-boundary checks with synthetic material only.""" + +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PACKAGING = ROOT / "plugins/xgrids-k1/packaging" +SYNTHETIC_KEY = b"11111111-2222-3333-4444-555555555555" + + +@pytest.fixture +def modules(monkeypatch): + monkeypatch.syspath_prepend(str(PACKAGING)) + from importlib import import_module + + credential = import_module("credential_install") + runtime = import_module("runtime_payload") + spec = importlib.util.spec_from_file_location( + "k1_package_builder_test", PACKAGING / "build_deb.py" + ) + builder = importlib.util.module_from_spec(spec) + spec.loader.exec_module(builder) + return credential, runtime, builder + + +@pytest.fixture +def root_metadata(monkeypatch, tmp_path, modules): + credential, _, _ = modules + original_stat = Path.stat + original_fstat = os.fstat + + def owned(info): + values = list(info) + values[4] = 0 + return os.stat_result(values) + + def file_stat(path, *args, **kwargs): + info = original_stat(path, *args, **kwargs) + return owned(info) if path.is_relative_to(tmp_path) else info + + monkeypatch.setattr(Path, "stat", file_stat) + monkeypatch.setattr(credential.os, "fstat", lambda fd: owned(original_fstat(fd))) + monkeypatch.setattr(credential.os, "geteuid", lambda: 0) + return credential + + +def test_private_material_is_encrypted_on_each_host_and_reinstall_is_idempotent( + root_metadata, + tmp_path, +): + credential = root_metadata + calls = [] + + def runner(args, **kwargs): + calls.append(args) + assert SYNTHETIC_KEY.decode() not in str(args) + if args[1] == "encrypt": + assert kwargs["input"] == SYNTHETIC_KEY + Path(args[-1]).write_bytes(b"synthetic-host-encrypted-material") + return subprocess.CompletedProcess(args, 0) + return subprocess.CompletedProcess(args, 0, stdout=SYNTHETIC_KEY) + + store = tmp_path / "credentials" + assert credential.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner) == "installed" + before = (store / "k1-application").stat().st_ino + assert credential.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner) == "unchanged" + assert (store / "k1-application").stat().st_ino == before + assert (store / "k1-application").stat().st_mode & 0o777 == 0o600 + assert [args[1] for args in calls] == ["encrypt", "decrypt"] + + +def test_different_material_never_rotates_an_existing_installation(root_metadata, tmp_path): + store = tmp_path / "credentials" + store.mkdir(mode=0o700) + target = store / "k1-application" + target.write_bytes(b"old-encrypted-credential") + target.chmod(0o600) + + def runner(args, **kwargs): + assert args[1] == "decrypt" + return subprocess.CompletedProcess(args, 0, stdout=b"a-different-private-application-value") + + with pytest.raises(ValueError, match="explicit rotation") as error: + root_metadata.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner) + assert SYNTHETIC_KEY.decode() not in str(error.value) + assert target.read_bytes() == b"old-encrypted-credential" + + +@pytest.mark.parametrize("bad", [b"", b"short", b"x" * 1025, b"\x00" * 36, b" " * 36]) +def test_bad_material_fails_before_system_tools(modules, tmp_path, bad): + credential, _, _ = modules + + def forbidden(*args, **kwargs): + pytest.fail("Invalid material reached an operating-system command") + + with pytest.raises(ValueError): + credential.install(bytearray(bad), root=tmp_path / "absent", runner=forbidden) + assert not (tmp_path / "absent").exists() + + +def test_bundle_rejects_symlink_and_public_permissions(root_metadata, tmp_path): + target = tmp_path / "profile" + target.write_bytes(SYNTHETIC_KEY) + target.chmod(0o644) + with pytest.raises(ValueError): + root_metadata.read_bundle(target) + target.chmod(0o600) + link = tmp_path / "link" + link.symlink_to(target) + with pytest.raises(OSError): + root_metadata.read_bundle(link) + assert root_metadata.read_bundle(target) == SYNTHETIC_KEY + + +def test_private_release_contains_material_only_in_root_private_member( + modules, monkeypatch, tmp_path +): + _, _, builder = modules + packaging = tmp_path / "packaging" + shutil.copytree(PACKAGING, packaging, ignore=shutil.ignore_patterns("__pycache__")) + (packaging / "k1-bundle.json").write_text('{"wheels": []}') + monkeypatch.setattr(builder, "PACKAGING", packaging) + public = builder.payload(tmp_path) + private = builder.payload(tmp_path, authority=bytearray(SYNTHETIC_KEY)) + assert all(SYNTHETIC_KEY not in data for _, data, _ in public) + secret_members = [(name, mode) for name, data, mode in private if SYNTHETIC_KEY in data] + assert secret_members == [("usr/share/mission-core-node/k1/private-application-key", 0o600)] + provenance = next(data for name, data, _ in private if name.endswith("provenance.json")) + metadata = json.loads(provenance) + assert metadata["application_material_included"] is True + assert not any("private-application-key" in key for key in metadata["files"]) + assert metadata["acceptance_scope"] == "owner-controlled-k1-fw-3.0.2-ubuntu-24.04-amd64" + + destination = tmp_path / "private.deb" + builder.build(destination, tmp_path, authority=bytearray(SYNTHETIC_KEY)) + assert destination.stat().st_mode & 0o777 == 0o600 + original = destination.read_bytes() + with pytest.raises(FileExistsError): + builder.build(destination, tmp_path, authority=bytearray(SYNTHETIC_KEY)) + assert destination.read_bytes() == original + position = 8 + members = {} + while position < len(original): + header = original[position : position + 60] + length = int(header[48:58]) + members[header[:16].decode().strip().rstrip("/")] = original[ + position + 60 : position + 60 + length + ] + position += 60 + length + length % 2 + with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive: + control = archive.extractfile("control").read().decode() + assert "Depends: mission-core-node (>= 0.8.0)" in control + assert "Replaces: mission-core-node (<< 0.8.0)" in control + + +def test_onboard_imports_run_from_declared_payload_without_core_checkout(modules, tmp_path): + _, runtime, builder = modules + stage = tmp_path / "installed" + for path in runtime.files(): + target = stage / path.relative_to(ROOT) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) + for relative in builder.RESOURCES: + target = stage / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, target) + script = """ +import asyncio, pathlib, sys +stage = pathlib.Path(sys.argv[1]) +sys.path.insert(0, str(stage / "src")) +from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge +assert "k1link.laboratory.execution" not in sys.modules +assert "k1link.device_plugins.xgrids_k1.legacy_api" not in sys.modules +assert "k1link.compute.jobs" not in sys.modules +import k1link +assert pathlib.Path(k1link.__file__).is_relative_to(stage) +bridge = NodeBridge(stage) +async def check(): + try: + value = await bridge.state() + assert value["phase"] == "idle" + assert value["candidates"] == [] + for name, module in list(sys.modules.items()): + if name.startswith("k1link.") and getattr(module, "__file__", None): + assert pathlib.Path(module.__file__).is_relative_to(stage), name + finally: + bridge.service.close() +asyncio.run(check()) +""" + env = dict(os.environ, MISSIONCORE_DATA_DIR=str(tmp_path / "state")) + result = subprocess.run( + [sys.executable, "-I", "-c", script, str(stage)], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=40, + ) + assert result.returncode == 0, result.stderr + + +def test_lab_lazy_exports_keep_the_public_objects_and_do_not_load_on_leaf_import(tmp_path): + script = """ +import importlib, sys +import k1link.laboratory as lab +from k1link.laboratory.canonical_recorded_migration import matches_historical_recorded_projection +assert "k1link.laboratory.execution" not in sys.modules +assert set(lab.__all__) == set(lab._EXPORTS) +for name in lab.__all__: + assert getattr(lab, name) is getattr(importlib.import_module(lab._EXPORTS[name]), name) +""" + result = subprocess.run( + [sys.executable, "-c", script], cwd=tmp_path, capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr