From 53818230f900d48e17cefbfcfed9246d9f57c8c6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 25 Sep 2026 16:37:56 +0300 Subject: [PATCH] feat(node): ship managed environment startup and USB recovery --- AGENTS.md | 18 + .../internal/node/environment-profile.json | 4 +- apps/node-agent/internal/node/presentation.go | 1 + .../50-mission-core-device-prepare.rules | 2 +- apps/node-agent/packaging/build.py | 3 +- apps/node-agent/packaging/build_deb.py | 13 +- .../packaging/build_linux_source.py | 9 +- apps/node-agent/packaging/desktop_startup.py | 30 ++ .../packaging/environment_helper.py | 28 +- .../packaging/install-owner-release | 2 +- .../packaging/install_owner_release.py | 2 +- apps/node-agent/packaging/linux_build_job.py | 4 + .../mission-core-node-autostart.desktop | 10 + .../mission-core-node-usb-startup.service | 35 ++ apps/node-agent/packaging/postinst | 4 + apps/node-agent/packaging/preinst | 13 + apps/node-agent/packaging/prerm | 43 +++ .../packaging/test_desktop_startup.py | 25 ++ .../packaging/test_environment_helper.py | 18 + .../packaging/test_usb_startup_recovery.py | 220 ++++++++++++ apps/node-agent/packaging/usb-startup.json | 1 + .../packaging/usb_startup_recovery.py | 332 ++++++++++++++++++ apps/node-agent/ui/src/main.tsx | 2 +- .../packaging/owner_release_entry.py | 45 ++- .../packaging/test_owner_release_entry.py | 57 +++ 25 files changed, 900 insertions(+), 21 deletions(-) create mode 100644 apps/node-agent/packaging/desktop_startup.py create mode 100644 apps/node-agent/packaging/mission-core-node-autostart.desktop create mode 100644 apps/node-agent/packaging/mission-core-node-usb-startup.service create mode 100644 apps/node-agent/packaging/test_desktop_startup.py create mode 100644 apps/node-agent/packaging/test_usb_startup_recovery.py create mode 100644 apps/node-agent/packaging/usb-startup.json create mode 100644 apps/node-agent/packaging/usb_startup_recovery.py create mode 100644 plugins/insta360-x4/packaging/test_owner_release_entry.py diff --git a/AGENTS.md b/AGENTS.md index eb97e6d..8d8efec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code. - Synthetic or explicitly redacted fixtures may be committed under `tests/fixtures/`. +## Onboard environment ownership — owner requirement, 2026-09-24 + +- Every onboard OS change belongs to the shipped, versioned installer or the + application's environment/device preparation workflow from the first test. + The operator installs the build and configures it in the application; never + require hand-written service files, USB rules, permission fixes or commands. +- This applies to all Node features, including GUI/service autostart and USB + recovery, not just individual camera drivers. Read-only SSH inspection and + bounded artifact-owned build staging remain allowed. +- Detect the distribution, architecture and required capabilities. Maintain + explicit supported environment profiles; do not claim arbitrary Linux + compatibility from a qualified Ubuntu build. Preserve foreign configuration, + report unsupported features, make preparation repeatable, and ship rollback. +- Do not add USB administration controls or port resets to inventory refresh. + Automatic startup recovery may retry terminal enumeration failures only; + preserve enumerated devices and active companion ports. Device identity and + assignments must survive changes of USB port and tty number. + ## Insta360 and clean-host installation — owner requirement, 2026-09-08 - The current X4 starting point is USB enumeration only. SDK installation, diff --git a/apps/node-agent/internal/node/environment-profile.json b/apps/node-agent/internal/node/environment-profile.json index 0d0a983..21b8b39 100644 --- a/apps/node-agent/internal/node/environment-profile.json +++ b/apps/node-agent/internal/node/environment-profile.json @@ -1,10 +1,12 @@ { "schema": "missioncore.node.environment/v1", - "revision": "ubuntu-24.04-amd64/2", + "revision": "ubuntu-24.04-amd64/3", "steps": [ {"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]}, {"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]}, {"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]}, + {"id":"desktop-autostart","label":"Автозапуск приложения","description":"Открытие окна при входе в рабочий стол","requires":["node-service"]}, + {"id":"usb-startup","label":"Обнаружение устройств при загрузке","description":"Автоматическое восстановление незавершённого подключения USB","requires":["platform"]}, {"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]}, {"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]}, {"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]}, diff --git a/apps/node-agent/internal/node/presentation.go b/apps/node-agent/internal/node/presentation.go index d2741d8..2cce8a9 100644 --- a/apps/node-agent/internal/node/presentation.go +++ b/apps/node-agent/internal/node/presentation.go @@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error { } func (s *Server) presentationRoutes(mux *http.ServeMux) { + s.boardLayoutRoutes(mux) p := s.Presentation mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) { if !s.authorized(w, r) { diff --git a/apps/node-agent/packaging/50-mission-core-device-prepare.rules b/apps/node-agent/packaging/50-mission-core-device-prepare.rules index 59237f8..367c73e 100644 --- a/apps/node-agent/packaging/50-mission-core-device-prepare.rules +++ b/apps/node-agent/packaging/50-mission-core-device-prepare.rules @@ -1,7 +1,7 @@ // An authenticated Node action may start only this fixed model job. polkit.addRule(function(action, subject) { var unit = action.lookup("unit"); - if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") { + if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-vesc-prepare.service" || unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") { return polkit.Result.YES; } }); diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index 1963f3e..a6c2610 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -11,7 +11,7 @@ import sys from build_deb import build, VERSION, BRAND_SHA256 ROOT = Path(__file__).resolve().parents[1] -DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5" +DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44" def guideline_sources(): @@ -36,6 +36,7 @@ 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()}, "shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()}, + "vesc_plugin_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/vesc").rglob("*")) if p.is_file() and not any(x in p.relative_to(ROOT.parents[1]).parts for x in ("__pycache__", "build"))}, "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()}, "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 54cccfc..5f53a88 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,8 +11,8 @@ import sys ROOT = Path(__file__).resolve().parents[1] -BINARY_VERSION = "0.8.21" -VERSION = "0.8.21-3" +BINARY_VERSION = "0.8.45" +VERSION = "0.8.45-1" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package @@ -44,7 +44,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g +Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, usbutils, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g Description: Mission Core onboard computer configuration Local graphical setup, host inventory, SSH access and persistent node identity. """.encode() @@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration ("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644), ("configure-system", "usr/lib/mission-core-node/configure-system", 0o755), ("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644), + ("desktop_startup.py", "usr/lib/mission-core-node/desktop_startup.py", 0o644), + ("mission-core-node-autostart.desktop", "usr/share/mission-core-node/mission-core-node-autostart.desktop", 0o644), + ("usb-startup.json", "usr/share/mission-core-node/usb-startup.json", 0o644), + ("usb_startup_recovery.py", "usr/lib/mission-core-node/usb_startup_recovery.py", 0o644), + ("mission-core-node-usb-startup.service", "usr/lib/systemd/system/mission-core-node-usb-startup.service", 0o644), ("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644), ("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644), ("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755), @@ -111,6 +116,8 @@ Description: Mission Core onboard computer configuration 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)) + import runpy + files.extend(runpy.run_path(str(ROOT.parents[1] / "plugins/vesc/packaging/payload.py"))["payload"]()) archive = package(controls, files) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(archive) diff --git a/apps/node-agent/packaging/build_linux_source.py b/apps/node-agent/packaging/build_linux_source.py index 4c34630..8eb9c0d 100644 --- a/apps/node-agent/packaging/build_linux_source.py +++ b/apps/node-agent/packaging/build_linux_source.py @@ -11,7 +11,7 @@ from pathlib import Path NODE = Path(__file__).resolve().parents[1] REPO = NODE.parents[1] DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE" -DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5" +DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44" def files(root): @@ -73,6 +73,13 @@ def build(qualified, node_only=False): # board, runtime path override or operator compiler dependency is needed. virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name) entries[virtual] = qualified + native_root = REPO / "plugins/vesc" + native = json.loads((native_root / "packaging/native-runtime.json").read_text()) + native_path = native_root / "build/native-runtime" / native["file"] + native_bytes = native_path.read_bytes() + if len(native_bytes) != native["bytes"] or hashlib.sha256(native_bytes).hexdigest() != native["sha256"]: + raise ValueError("Qualified VESC Tool runtime changed") + entries[REPO.name + "/plugins/vesc/build/native-runtime/" + native["file"]] = native_path metadata = {} for name, path in entries.items(): data = path.read_bytes() diff --git a/apps/node-agent/packaging/desktop_startup.py b/apps/node-agent/packaging/desktop_startup.py new file mode 100644 index 0000000..5913038 --- /dev/null +++ b/apps/node-agent/packaging/desktop_startup.py @@ -0,0 +1,30 @@ +"""Unprivileged XDG startup: wait for Node before opening its ordinary UI.""" +import http.client +import os +import time + + +def wait_for_service(now=time.monotonic, sleep=time.sleep, connection=http.client.HTTPConnection): + deadline = now() + 180 + while now() < deadline: + client = connection('127.0.0.1', 8780, timeout=2) + try: + client.request('GET', '/') + response = client.getresponse() + if response.status == 200: + return True + except (OSError, http.client.HTTPException): + pass + finally: + client.close() + sleep(1) + return False + + +if __name__ == '__main__': + if os.geteuid() == 0: + raise SystemExit('Run in the normal graphical user session') + wait_for_service() + # Gtk.Application retains one window per desktop session. Its ordinary + # polkit authorization and error handling are unchanged. + os.execv('/usr/bin/mission-core-node', ['/usr/bin/mission-core-node']) diff --git a/apps/node-agent/packaging/environment_helper.py b/apps/node-agent/packaging/environment_helper.py index 5c34852..f17f2c6 100644 --- a/apps/node-agent/packaging/environment_helper.py +++ b/apps/node-agent/packaging/environment_helper.py @@ -219,7 +219,33 @@ def tailscale_install(): return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно." -OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install} +def desktop_autostart(): + owned_config(Path('/usr/share/mission-core-node/mission-core-node-autostart.desktop'), + Path('/etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop')) + return "Окно приложения открывается при входе в рабочий стол. Служба БК работает и без входа." + + +def usb_startup(): + # Enabling the next boot is distinct from executing recovery now. This + # workflow never resets devices in a running operator session. + owned_config(Path('/usr/share/mission-core-node/usb-startup.json'), + Path('/etc/mission-core-node/usb-startup.json')) + command(['/usr/bin/systemctl', 'daemon-reload']) + command(['/usr/bin/systemctl', 'enable', 'mission-core-node-usb-startup.service']) + command(['/usr/bin/systemctl', 'is-enabled', 'mission-core-node-usb-startup.service']) + report = json.loads(command(['/usr/bin/python3', '-I', '-B', '/usr/lib/mission-core-node/usb_startup_recovery.py', '--inspect'], timeout=75)) + if report.get('state') != 'inspected': + raise SetupError("Не удалось проверить поддержку восстановления USB. Повторите настройку.") + supported = sum(h.get('individual_power') is True for h in report.get('hubs', [])) + if not supported: + return "Проверка при загрузке включена. Поддержка отдельного переключения USB-портов не подтверждена; они будут пропущены." + return "Восстановление при загрузке включено для поддерживаемых USB-портов. Обнаруженные устройства сохраняют подключение." + + +OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, + "desktop-autostart": desktop_autostart, "usb-startup": usb_startup, + "network-inventory": network_inventory, "usb-inventory": usb_inventory, + "ssh-service": ssh_service, "tailscale-install": tailscale_install} def run_steps(profile, operations, save): diff --git a/apps/node-agent/packaging/install-owner-release b/apps/node-agent/packaging/install-owner-release index 31a45e6..d9cafde 100644 --- a/apps/node-agent/packaging/install-owner-release +++ b/apps/node-agent/packaging/install-owner-release @@ -5,7 +5,7 @@ mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ ! -t 0 ]; then exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install" fi -printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.' +printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных драйверов на этом Ubuntu-компьютере.' 'Подготовка VESC и X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.' set +e /usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log" mc_node_install_result=${PIPESTATUS[0]} diff --git a/apps/node-agent/packaging/install_owner_release.py b/apps/node-agent/packaging/install_owner_release.py index 386ad8b..7cf86b7 100644 --- a/apps/node-agent/packaging/install_owner_release.py +++ b/apps/node-agent/packaging/install_owner_release.py @@ -351,7 +351,7 @@ def main(): if report["state"] != "complete": raise RuntimeError(report["error"]) print( - "Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.", + "Mission Core Node обновлён. VESC и X4 можно подготовить из списка устройств в Node или Core.", flush=True, ) diff --git a/apps/node-agent/packaging/linux_build_job.py b/apps/node-agent/packaging/linux_build_job.py index 97770b8..b59c107 100644 --- a/apps/node-agent/packaging/linux_build_job.py +++ b/apps/node-agent/packaging/linux_build_job.py @@ -243,6 +243,10 @@ def main(): sys.path.insert(0, str(node / "packaging")) from build_deb import BINARY_VERSION, VERSION, build + run("node-environment-tests", ["/usr/bin/python3", "-m", "unittest", "test_environment_helper", "test_usb_startup_recovery", "test_desktop_startup", "-v"], cwd=node / "packaging") + run("owner-release-staging-tests", ["/usr/bin/python3", "plugins/insta360-x4/packaging/test_owner_release_entry.py"], cwd=repo) + run("node-usb-unit-validation", ["/usr/bin/systemd-analyze", "verify", str(node / "packaging/mission-core-node-usb-startup.service")], cwd=node) + run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo) binary = node / "build/node-agent-linux-amd64" run( "node-binary", diff --git a/apps/node-agent/packaging/mission-core-node-autostart.desktop b/apps/node-agent/packaging/mission-core-node-autostart.desktop new file mode 100644 index 0000000..ce1f4d7 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-autostart.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Mission Core Node +Comment=Настройка и диагностика бортового компьютера +TryExec=/usr/bin/mission-core-node +Exec=/usr/bin/python3 -I -B /usr/lib/mission-core-node/desktop_startup.py +Icon=org.nodedc.MissionCoreNode +Terminal=false +StartupNotify=false diff --git a/apps/node-agent/packaging/mission-core-node-usb-startup.service b/apps/node-agent/packaging/mission-core-node-usb-startup.service new file mode 100644 index 0000000..a2700b4 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-usb-startup.service @@ -0,0 +1,35 @@ +[Unit] +Description=Mission Core bounded USB startup recovery +Wants=systemd-udev-settle.service +After=systemd-udev-settle.service +Before=mission-core-node.service mission-core-vesc.service +ConditionPathExists=/etc/mission-core-node/usb-startup.json + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py +ExecStopPost=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py --restore +TimeoutStartSec=130 +TimeoutStopSec=15 +RuntimeDirectory=mission-core-usb-startup +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +UMask=0077 +NoNewPrivileges=yes +PrivateNetwork=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_NETLINK +CapabilityBoundingSet= +ReadWritePaths=/sys/devices /run/mission-core-usb-startup +DevicePolicy=closed +DeviceAllow=char-usb_device rw +TasksMax=16 +MemoryMax=64M + +[Install] +WantedBy=multi-user.target diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index 0d8a505..c0975e9 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -2,6 +2,7 @@ set -eu case "$1" in configure) + /usr/bin/python3 -I /usr/lib/mission-core-vesc/clear_runtime_cache.py if ! getent passwd mission-core-node >/dev/null; then adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node fi @@ -15,6 +16,9 @@ case "$1" in systemctl enable mission-core-node.service systemctl restart mission-core-node.service systemctl try-restart mission-core-realsense.service + if [ -f /var/lib/mission-core-node-profiles/vesc/preparation.json ]; then + systemctl start mission-core-node-vesc-prepare.service + fi if [ -f /run/mission-core-node-k1-upgrade-active ]; then # A jointly upgraded plugin starts itself after its own configuration. # Restore it here only when that package is already configured. diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index 7a1534d..541aad9 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -14,10 +14,22 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then exit 1 fi fi + mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true) + case "$mc_node_usb_job" in + activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;; + esac + if [ -f /run/mission-core-usb-startup/pending.json ]; then + echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2 + exit 1 + fi mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true) case "$mc_node_x4_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;; esac + mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true) + case "$mc_node_vesc_job" in + active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;; + esac mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) case "$mc_node_device_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; @@ -38,6 +50,7 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then (umask 077; : > /run/mission-core-node-k1-upgrade-active) systemctl stop mission-core-k1.service fi + systemctl stop mission-core-vesc.service 2>/dev/null || true systemctl stop mission-core-node-monitor.service 2>/dev/null || true fi . /etc/os-release diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index 0c9fd9e..754ea7d 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -13,10 +13,22 @@ if [ -d /run/systemd/system ]; then exit 1 fi fi + mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true) + case "$mc_node_usb_job" in + activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;; + esac + if [ -f /run/mission-core-usb-startup/pending.json ]; then + echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2 + exit 1 + fi mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true) case "$mc_node_x4_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;; esac + mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true) + case "$mc_node_vesc_job" in + active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;; + esac mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) case "$mc_node_device_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; @@ -30,8 +42,39 @@ if [ -d /run/systemd/system ]; then ;; esac fi +retire_startup_settings() { + for mc_node_owned in /etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop /etc/mission-core-node/usb-startup.json; do + case "$mc_node_owned" in + *.desktop) mc_node_template=/usr/share/mission-core-node/mission-core-node-autostart.desktop ;; + *.json) mc_node_template=/usr/share/mission-core-node/usb-startup.json ;; + esac + if [ ! -L "$mc_node_owned" ] && cmp -s "$mc_node_template" "$mc_node_owned"; then + rm "$mc_node_owned" + fi + done + if [ -d /run/systemd/system ]; then + systemctl disable --now mission-core-node-usb-startup.service + fi +} +# A downgrade removes helpers introduced in 0.8.37. Retire their owned +# configuration first; do not leave an enabled unit or XDG entry dangling. +if [ "$1" = upgrade ] && [ -n "${2:-}" ] && dpkg --compare-versions "$2" lt 0.8.37-1; then + retire_startup_settings +fi +case "$1" in + upgrade|remove|deconfigure) + if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then + systemctl disable --now mission-core-vesc.service + fi + ;; +esac case "$1" in remove|deconfigure) + if cmp -s /usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules /etc/udev/rules.d/70-mission-core-vesc.rules; then + rm /etc/udev/rules.d/70-mission-core-vesc.rules + if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi + fi + retire_startup_settings mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf if [ -e "$mc_node_ssh_snippet" ]; then if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then diff --git a/apps/node-agent/packaging/test_desktop_startup.py b/apps/node-agent/packaging/test_desktop_startup.py new file mode 100644 index 0000000..55c67f4 --- /dev/null +++ b/apps/node-agent/packaging/test_desktop_startup.py @@ -0,0 +1,25 @@ +import unittest +from unittest.mock import Mock +import desktop_startup as startup + + +class DesktopStartupTests(unittest.TestCase): + def test_waits_for_service_without_credentials_and_closes_each_connection(self): + clock = [0] + def sleep(seconds): clock[0] += seconds + client = Mock() + client.getresponse.side_effect = [OSError('not started'), Mock(status=503), Mock(status=200)] + factory = Mock(return_value=client) + self.assertTrue(startup.wait_for_service(lambda: clock[0], sleep, factory)) + self.assertEqual(clock[0], 2) + self.assertEqual(client.close.call_count, 3) + self.assertEqual(client.request.call_args.args, ('GET', '/')) + + def test_stopped_service_does_not_hold_startup_forever(self): + clock = [0] + def sleep(seconds): clock[0] += seconds + client = Mock() + client.request.side_effect = OSError('offline') + self.assertFalse(startup.wait_for_service(lambda: clock[0], sleep, lambda *a, **kw: client)) + self.assertEqual(clock[0], 180) + self.assertEqual(client.close.call_count, 180) diff --git a/apps/node-agent/packaging/test_environment_helper.py b/apps/node-agent/packaging/test_environment_helper.py index acfd543..2a2e2a0 100644 --- a/apps/node-agent/packaging/test_environment_helper.py +++ b/apps/node-agent/packaging/test_environment_helper.py @@ -15,6 +15,24 @@ PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-pro class EnvironmentWorkflowTests(unittest.TestCase): + def test_profile_and_shipped_operations_match(self): + self.assertEqual({s['id'] for s in PROFILE['steps']}, set(helper.OPERATIONS)) + + def test_usb_setup_enables_next_boot_without_resetting_live_devices(self): + def command(argv, **_): + return json.dumps({'state': 'inspected', 'hubs': [{'individual_power': True}]}) if '--inspect' in argv else '' + with patch.object(helper, 'owned_config') as config, patch.object(helper, 'command', side_effect=command) as run: + self.assertIn('включено', helper.usb_startup()) + config.assert_called_once() + for call in run.call_args_list: + self.assertFalse({'--now', 'start', 'restart'} & set(call.args[0])) + self.assertTrue(any('--inspect' in call.args[0] for call in run.call_args_list)) + + def test_autostart_preserves_foreign_configuration_and_does_not_start_a_gui_as_root(self): + with patch.object(helper, 'owned_config', side_effect=helper.SetupError('conflict')), patch.object(helper, 'command') as run: + with self.assertRaises(helper.SetupError): helper.desktop_autostart() + run.assert_not_called() + def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self): saved = [] operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']} diff --git a/apps/node-agent/packaging/test_usb_startup_recovery.py b/apps/node-agent/packaging/test_usb_startup_recovery.py new file mode 100644 index 0000000..a498373 --- /dev/null +++ b/apps/node-agent/packaging/test_usb_startup_recovery.py @@ -0,0 +1,220 @@ +"""Synthetic sysfs/journal only: these tests never open a physical USB port.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch +import usb_startup_recovery as recovery + + +def event(second, message, transport='kernel'): + return {'__MONOTONIC_TIMESTAMP': str(int(second * 1000000)), + '_TRANSPORT': transport, 'MESSAGE': message} + + +class JournalTests(unittest.TestCase): + def test_only_terminal_boot_failures_with_no_later_connection_are_candidates(self): + records = [event(20, 'usb usb1-port2: unable to enumerate USB device'), + event(18, 'usb usb1-port3: unable to enumerate USB device'), + event(23, 'usb 1-3: new full-speed USB device number 8 using xhci_hcd'), + event(12, 'usb 1-1-port2: unable to enumerate USB device'), + event(65, 'usb usb2-port1: unable to enumerate USB device'), + event(15, 'usb usb2-port3: unable to enumerate USB device', 'stdout'), + event(18, 'usb 1-4: device descriptor read/64, error -71')] + self.assertEqual(recovery.failed_ports(list(reversed(records))), ['1-1-port2', 'usb1-port2']) + + def test_success_or_user_disconnect_invalidates_old_failure(self): + for message in ['New USB device found, idVendor=0000', 'USB disconnect, device number 8']: + self.assertEqual(recovery.failed_ports([ + event(20, 'usb usb1-port2: unable to enumerate USB device'), event(21, 'usb 1-2: ' + message)]), []) + + def test_invalid_port_names_cannot_be_paths(self): + for name in ['../../etc/passwd', 'usb0-port0', 'usb1-port1/disable']: + with self.assertRaises(ValueError): recovery.child_name(name) + self.assertEqual(recovery.child_name('1-2.3-port4'), '1-2.3.4') + + +class SysfsTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + base = Path(self.temp.name) + self.root, self.physical = base/'bus', base/'devices' + self.root.mkdir(); self.physical.mkdir() + self.usb = recovery.USB(self.root, self.physical) + self.ports = [] + for n in (1, 2): + hub = self.physical/f'usb{n}'; hub.mkdir() + for key, value in {'bDeviceClass':'09', 'busnum':str(n), 'devnum':'1'}.items(): + (hub/key).write_text(value) + (self.root/hub.name).symlink_to(hub, target_is_directory=True) + interface = hub/f'{n}-0:1.0'; interface.mkdir() + (self.root/interface.name).symlink_to(interface, target_is_directory=True) + port = interface/f'usb{n}-port2'; port.mkdir() + for key, value in {'state':'not attached', 'disable':'0\n', 'over_current_count':'0', 'connect_type':'unknown'}.items(): + (port/key).write_text(value) + self.ports.append(port) + (self.ports[0]/'peer').symlink_to(self.ports[1], target_is_directory=True) + (self.ports[1]/'peer').symlink_to(self.ports[0], target_is_directory=True) + + def plan(self): + with patch.object(recovery, 'run', return_value=' wHubCharacteristic 0x0009\n'): + return self.usb.plan('usb1-port2') + + def test_individual_power_pair_can_be_disabled_and_restored(self): + entries = self.plan() + self.assertEqual([e['name'] for e in entries], ['usb1-port2', 'usb2-port2']) + for entry in entries: self.usb.write(entry, True) + self.assertTrue(all((p/'disable').read_text() == '1\n' for p in self.ports)) + for entry in reversed(entries): self.usb.write(entry, False) + self.assertTrue(all((p/'disable').read_text() == '0\n' for p in self.ports)) + + def test_connected_companion_blocks_both_ports_before_descriptor_query(self): + (self.ports[1]/'device').symlink_to(self.physical/'camera') + with patch.object(recovery, 'run') as run: + with self.assertRaises(OSError): self.usb.plan('usb1-port2') + run.assert_not_called() + self.assertEqual((self.ports[0]/'disable').read_text(), '0\n') + + def test_non_individual_power_or_missing_descriptor_cannot_reset(self): + for output in ['wHubCharacteristics 0x0000', 'wHubCharacteristics 0x0002', '']: + with self.subTest(output=output), patch.object(recovery, 'run', return_value=output): + with self.assertRaises(OSError): self.usb.plan('usb1-port2') + + def test_internal_disabled_overcurrent_and_mid_enumeration_ports_skipped(self): + for attribute, value in [('connect_type', 'hardwired'), ('disable', '1'), ('over_current_count', '1'), ('state', 'powered')]: + p = self.ports[0]/attribute; original = p.read_text(); p.write_text(value) + with self.subTest(attribute=attribute), self.assertRaises(OSError): self.plan() + p.write_text(original) + + def test_attachment_after_planning_prevents_write(self): + entries = self.plan() + (self.ports[0]/'device').symlink_to(self.physical/'new-device') + with self.assertRaises(OSError): self.usb.write(entries[0], True) + self.assertEqual((self.ports[0]/'disable').read_text(), '0\n') + + def test_hub_replacement_after_planning_prevents_write(self): + entries = self.plan() + (self.physical/'usb1'/'devnum').write_text('4') + with self.assertRaises(OSError): self.usb.write(entries[0], True) + + def test_first_hub_replaced_while_inspecting_companion_invalidates_plan(self): + def inspect(name): + if name == 'usb2-port2': (self.physical/'usb1'/'devnum').write_text('4') + return True + with patch.object(self.usb, 'individual_power', side_effect=inspect): + with self.assertRaises(OSError): self.usb.plan('usb1-port2') + + def test_enabled_port_is_not_written_during_cleanup(self): + entries = self.plan() + with patch.object(recovery.os, 'open', side_effect=AssertionError('No write expected')): + self.usb.write(entries[0], False) + + def test_foreign_companion_path_is_rejected(self): + (self.ports[0]/'peer').unlink() + (self.ports[0]/'peer').symlink_to(self.root) + with self.assertRaises(OSError): self.usb.companions('usb1-port2') + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup) + self.state = Path(self.temp.name) + self.clock = 30 + self.usb = Mock() + self.entries = [{'name': 'usb1-port2', 'generation': {'address': 1}}, + {'name': 'usb2-port2', 'generation': {'address': 1}}] + self.usb.plan.return_value = self.entries + self.usb.empty.return_value = True + self.usb.generation.return_value = {'address': 1} + self.usb.outcome.return_value = {'idVendor': '0000', 'idProduct': '0001', 'product': 'Synthetic'} + + def sleep(self, seconds): self.clock += seconds + + def run_recovery(self, ports=None): + return recovery.recover(self.usb, self.state, 'boot', ports or ['usb1-port2'], lambda: self.clock, self.sleep) + + def test_pair_is_attempted_only_once_and_restored_before_enumeration_check(self): + result = self.run_recovery(['usb1-port2', 'usb2-port2']) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['state'], 'enumerated') + self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False]) + self.assertFalse((self.state/'pending.json').exists()) + + def test_second_disable_failure_restores_both_and_keeps_failure_detail(self): + def write(entry, disabled): + if disabled and entry['name'] == 'usb2-port2': raise OSError('failed second write') + self.usb.write.side_effect = write + result = self.run_recovery() + self.assertIn('failed second write', result[0]['reason']) + self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False]) + self.assertFalse((self.state/'pending.json').exists()) + + def test_termination_during_off_period_restores_ports(self): + def interrupted(_): raise InterruptedError('stop') + result = recovery.recover(self.usb, self.state, 'boot', ['usb1-port2'], lambda: self.clock, interrupted) + self.assertIn('stop', result[0]['reason']) + self.assertFalse((self.state/'pending.json').exists()) + self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False]) + + def test_restore_failure_preserves_pending_and_stops_other_ports(self): + def write(_, disabled): + if not disabled: raise OSError('restore failed') + self.usb.write.side_effect = write + result = self.run_recovery(['usb1-port2', 'usb1-port3']) + self.assertEqual(result[0]['state'], 'restore_failed') + self.usb.plan.assert_called_once() + self.assertTrue((self.state/'pending.json').exists()) + self.usb.write.side_effect = None + recovery.restore(self.usb, self.state, 'boot') + self.assertFalse((self.state/'pending.json').exists()) + + def test_slow_planning_cannot_start_a_late_reset(self): + def plan(_): self.clock = 179; return self.entries + self.usb.plan.side_effect = plan + result = self.run_recovery() + self.assertIn('deadline', result[0]['reason']) + self.usb.write.assert_not_called() + + def test_failed_enumeration_has_no_repeated_power_loop(self): + self.usb.outcome.return_value = None + result = self.run_recovery() + self.assertEqual(result[0]['state'], 'retried') + self.assertEqual(self.clock, 39) + self.assertEqual(self.usb.write.call_count, 4) + + def test_previous_boot_pending_state_cannot_address_current_ports(self): + recovery.atomic(self.state/'pending.json', {'boot_id': 'old', 'ports': self.entries}) + with self.assertRaises(OSError): recovery.restore(self.usb, self.state, 'new') + self.usb.write.assert_not_called() + + def test_boot_wait_occurs_before_journal_snapshot_and_second_run_preserves_report(self): + boot_file = self.state/'boot-id'; boot_file.write_text('boot') + self.clock = 5 + def journal(): + self.assertGreaterEqual(self.clock, recovery.MIN_AGE) + return [] + with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \ + patch.object(recovery, 'state_directory'), patch.object(recovery, 'enabled'), \ + patch.object(recovery.os, 'geteuid', return_value=0), patch.object(recovery.sys, 'argv', ['helper']), \ + patch.object(recovery.time, 'monotonic', side_effect=lambda: self.clock), \ + patch.object(recovery.time, 'sleep', side_effect=self.sleep), \ + patch.object(recovery, 'journal', side_effect=journal) as read, patch('builtins.print'): + self.assertEqual(recovery.main(), 0) + first = (self.state/'result.json').read_bytes() + self.assertEqual(recovery.main(), 0) + self.assertEqual((self.state/'result.json').read_bytes(), first) + read.assert_called_once() + + def test_late_service_start_cannot_read_or_reset_ports(self): + boot_file = self.state/'boot-id'; boot_file.write_text('boot') + with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \ + patch.object(recovery, 'state_directory'), patch.object(recovery.os, 'geteuid', return_value=0), \ + patch.object(recovery.sys, 'argv', ['helper']), patch.object(recovery.time, 'monotonic', return_value=200), \ + patch.object(recovery, 'journal') as read, patch.object(recovery, 'recover') as reset, patch('builtins.print'): + self.assertEqual(recovery.main(), 0) + read.assert_not_called(); reset.assert_not_called() + self.assertEqual(json.loads((self.state/'result.json').read_text())['reason'], 'Outside startup window') + + +if __name__ == '__main__': unittest.main() diff --git a/apps/node-agent/packaging/usb-startup.json b/apps/node-agent/packaging/usb-startup.json new file mode 100644 index 0000000..da3b167 --- /dev/null +++ b/apps/node-agent/packaging/usb-startup.json @@ -0,0 +1 @@ +{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"} diff --git a/apps/node-agent/packaging/usb_startup_recovery.py b/apps/node-agent/packaging/usb_startup_recovery.py new file mode 100644 index 0000000..542595c --- /dev/null +++ b/apps/node-agent/packaging/usb_startup_recovery.py @@ -0,0 +1,332 @@ +"""One bounded boot-time retry of failed USB enumeration; no device commands. + +Only root's fixed systemd job may apply. No request supplies a path or command. +Healthy ports (including USB3 companions) and non-individual-power hubs are +excluded. The application still identifies controllers by firmware UUID. +""" +import fcntl +import json +import os +from pathlib import Path +import re +import signal +import stat +import subprocess +import sys +import time + +STATE = Path('/run/mission-core-usb-startup') +BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +POLICY = Path('/etc/mission-core-node/usb-startup.json') +EXPECTED_POLICY = {'schema': 'missioncore.node.usb-startup-policy/v1', 'enabled': True, + 'mode': 'terminal-enumeration-failures'} +BOOT_WINDOW = 180 +FAILURE_WINDOW = 60 +MIN_AGE = 30 +BUDGET = 90 +PORT = re.compile(r'(usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port([1-9][0-9]*)') +FAILURE = re.compile(r'usb ((?:usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port[1-9][0-9]*): unable to enumerate USB device') + + +def child_name(port): + match = PORT.fullmatch(port) + if not match: + raise ValueError('Invalid USB port') + hub, number = match.groups() + return hub[3:] + '-' + number if hub.startswith('usb') else hub + '.' + number + + +def failed_ports(records): + """Ignore failures superseded by a later connection/disconnection event.""" + failures, changes = {}, {} + for entry in sorted(records, key=lambda r: int(r.get('__MONOTONIC_TIMESTAMP', 0))): + if entry.get('_TRANSPORT') != 'kernel': + continue + stamp = int(entry.get('__MONOTONIC_TIMESTAMP', 0)) / 1000000 + message = entry.get('MESSAGE', '') + if not isinstance(message, str): + continue + match = FAILURE.fullmatch(message) + if match and 0 < stamp <= FAILURE_WINDOW: + failures[match[1]] = stamp + match = re.match(r'usb ([0-9]+-[0-9]+(?:\.[0-9]+)*): (?:new |New USB device found|USB disconnect)', message) + if match: + changes[match[1]] = stamp + return [p for p, stamp in sorted(failures.items()) if changes.get(child_name(p), 0) <= stamp] + + +def run(args): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=5, + env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'}) + except subprocess.SubprocessError as error: + raise OSError('USB inspection command timed out or failed') from error + if result.returncode or len(result.stdout) > 2 * 1024**2: + raise OSError('USB inspection command failed') + return result.stdout + + +def journal(): + raw = run(['/usr/bin/journalctl', '-k', '-b', '0', '--no-pager', '-o', 'json', '-n', '2000', + '--grep=unable to enumerate USB device|new .* USB device|New USB device found|USB disconnect']) + return [json.loads(line) for line in raw.splitlines()] + + +def atomic(path, value): + temporary = path.with_suffix('.tmp') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + + +class USB: + def __init__(self, root=Path('/sys/bus/usb/devices'), physical=Path('/sys/devices')): + self.root, self.physical = root, physical.resolve() + + def resolve(self, name): + match = PORT.fullmatch(name) + if not match: + raise ValueError('Invalid port name') + hub = match[1] + interface = hub[3:] + '-0' if hub.startswith('usb') else hub + paths = list(self.root.glob(interface + ':*/' + name)) + if len(paths) != 1: + raise OSError('USB port topology unavailable') + path = paths[0].resolve(strict=True) + if not path.is_relative_to(self.physical) or path.name != name: + raise OSError('USB port outside sysfs devices') + return path + + def generation(self, name): + hub = self.root / PORT.fullmatch(name)[1] + if (hub / 'bDeviceClass').read_text().strip() != '09': + raise OSError('Parent is not a USB hub') + return {'bus': int((hub / 'busnum').read_text()), 'address': int((hub / 'devnum').read_text()), + 'path': str(hub.resolve(strict=True)), 'inode': hub.stat().st_ino} + + def empty(self, name): + p = self.resolve(name) + return (not os.path.lexists(p / 'device') and + (p / 'state').read_text().strip() == 'not attached' and + (p / 'disable').read_text().strip() == '0' and + (p / 'over_current_count').read_text().strip() == '0' and + (p / 'connect_type').read_text().strip() in ('hotplug', 'unknown')) + + def companions(self, name): + p = self.resolve(name) + names = [name] + if os.path.lexists(p / 'peer'): + peer = (p / 'peer').resolve(strict=True) + if not peer.is_relative_to(self.physical) or self.resolve(peer.name) != peer: + raise OSError('USB companion topology changed') + if (peer / 'peer').resolve(strict=True) != p: + raise OSError('USB companion is not reciprocal') + names.append(peer.name) + return names + + def individual_power(self, name): + before = self.generation(name) + raw = run(['/usr/bin/lsusb', '-v', '-s', f"{before['bus']:03}:{before['address']:03}"]) + characteristics = re.findall(r'^\s*wHubCharacteristic[s]?\s+0x([0-9a-fA-F]+)\s*$', raw, re.M) + return (before == self.generation(name) and len(characteristics) == 1 and + int(characteristics[0], 16) & 3 == 1) + + def plan(self, name): + names = self.companions(name) + entries = [{'name': n, 'generation': self.generation(n)} for n in names] + # Test every companion before querying hub descriptors or opening any port. + if not all(self.empty(n) for n in names): + raise OSError('Port or USB companion already attached, disabled, internal or over-current') + if not all(self.individual_power(n) for n in names): + raise OSError('Individual USB port power switching is not confirmed') + if any(self.generation(e['name']) != e['generation'] for e in entries): + raise OSError('USB hub changed during companion inspection') + return entries + + def write(self, entry, disabled): + name = entry['name'] + if self.generation(name) != entry['generation']: + raise OSError('USB hub generation changed') + if disabled and not self.empty(name): + raise OSError('USB port became occupied') + p = self.resolve(name) / 'disable' + # Pending state is durable before the first write. An interrupted or + # rejected first write must not cause a redundant enable on a port + # which has since successfully enumerated. + if not disabled and p.read_text().strip() == '0': + return + fd = os.open(p, os.O_WRONLY | os.O_NOFOLLOW) + try: + if self.generation(name) != entry['generation'] or (disabled and not self.empty(name)): + raise OSError('USB topology changed before write') + if os.write(fd, b'1\n' if disabled else b'0\n') != 2: + raise OSError('Incomplete USB port write') + finally: + os.close(fd) + + def outcome(self, name): + p = self.resolve(name) + if not (p / 'device').exists(): + return None + child = (p / 'device').resolve(strict=True) + if not child.is_relative_to(self.physical): + raise OSError('Unexpected USB child') + return {key: (child / key).read_text().strip() for key in ('idVendor', 'idProduct', 'product')} + + +def restore(usb, state, boot): + p = state / 'pending.json' + if not p.exists(): + return + pending = json.loads(p.read_text()) + if pending['boot_id'] != boot: + raise OSError('Pending recovery belongs to another boot') + errors = [] + for entry in reversed(pending['ports']): + try: + usb.write(entry, False) + except OSError as error: + errors.append(str(error)) + if errors: + raise OSError('; '.join(errors)) + p.unlink() + + +def recover(usb, state, boot, candidates, now=time.monotonic, sleep=time.sleep): + deadline = min(now() + BUDGET, BOOT_WINDOW) + results, processed = [], set() + for name in candidates[:32]: + if name in processed: + continue + item = {'port': name, 'state': 'skipped'} + results.append(item) + if now() + 12 >= deadline: + item['reason'] = 'Boot recovery deadline reached' + break + try: + entries = usb.plan(name) + if now() + 12 >= deadline: + raise OSError('Boot recovery deadline reached during planning') + # Recheck all companions after descriptor reads and before any write. + if not all(usb.empty(e['name']) and usb.generation(e['name']) == e['generation'] for e in entries): + raise OSError('USB port changed during planning') + processed.update(e['name'] for e in entries) + item['ports'] = [e['name'] for e in entries] + atomic(state / 'pending.json', {'boot_id': boot, 'ports': entries}) + try: + for entry in entries: + usb.write(entry, True) + sleep(1) + finally: + restore(usb, state, boot) + item['state'] = 'retried' + until = min(now() + 8, deadline) + while now() < until: + result = usb.outcome(name) + if result: + item.update(state='enumerated', descriptor=result) + break + sleep(0.25) + except (OSError, ValueError) as error: + item['reason'] = str(error) + if (state / 'pending.json').exists(): + item['state'] = 'restore_failed' + break + return results + + +def state_directory(): + STATE.mkdir(mode=0o755, exist_ok=True) + info = STATE.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise OSError('Untrusted recovery state directory') + + +def enabled(): + info = POLICY.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 1024: + raise OSError('Untrusted startup recovery policy') + if json.loads(POLICY.read_text()) != EXPECTED_POLICY: + raise OSError('Startup recovery policy is not supported') + + +def inspect(usb): + hubs = [] + deadline = time.monotonic() + 60 + for p in sorted(usb.root.glob('*')): + if time.monotonic() >= deadline or len(hubs) >= 16: + break + if not re.fullmatch(r'usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*', p.name): + continue + try: + if (p / 'bDeviceClass').read_text().strip() != '09': + continue + hubs.append({'hub': p.name, 'individual_power': usb.individual_power(p.name + '-port1')}) + except OSError as error: + hubs.append({'hub': p.name, 'error': str(error)}) + return hubs + + +def main(): + if os.geteuid() != 0 or sys.argv[1:] not in ([], ['--inspect'], ['--restore']): + raise ValueError('Fixed root-owned startup job only') + mode = sys.argv[1:] or ['apply'] + state_directory() + lock = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + boot = BOOT_ID.read_text().strip() + usb = USB() + if mode == ['--restore']: + try: + restore(usb, STATE, boot) + finally: + os.close(lock) + return 0 + if mode == ['apply'] and (STATE / 'attempted').exists(): + # Preserve the first result, including restore failures, across a later + # daemon/package restart. Recovery is never retriggered by refresh. + try: + restore(usb, STATE, boot) + finally: + os.close(lock) + return 0 + report = {'schema': 'missioncore.node.usb-startup-recovery/v1', 'boot_id': boot, + 'observed_at_unix': time.time(), 'boot_seconds': time.monotonic(), + 'mode': mode[0], 'state': 'skipped', 'results': []} + destination = STATE / ('inspection.json' if mode == ['--inspect'] else 'result.json') + try: + if mode == ['--inspect']: + report['hubs'] = inspect(usb) + report['state'] = 'inspected' + elif time.monotonic() >= BOOT_WINDOW: + report['reason'] = 'Outside startup window' + else: + enabled() + atomic(STATE / 'attempted', {'boot_id': boot}) + restore(usb, STATE, boot) + # udev-settle can return before the hub driver's delayed retries + # give up. Wait before taking the first snapshot, including when + # the journal currently contains no terminal failure yet. + time.sleep(max(0, MIN_AGE - time.monotonic())) + candidates = failed_ports(journal()) + if candidates: + report['results'] = recover(usb, STATE, boot, candidates) + report['state'] = 'complete' + except (OSError, ValueError, subprocess.SubprocessError) as error: + report.update(state='error', reason=str(error)) + finally: + atomic(destination, report) + destination.chmod(0o644) + os.close(lock) + print(json.dumps(report, sort_keys=True)) + return 1 if report['state'] == 'error' or any(r['state'] == 'restore_failed' for r in report['results']) else 0 + + +if __name__ == '__main__': + def interrupted(*_): + raise InterruptedError('Startup recovery interrupted') + signal.signal(signal.SIGTERM, interrupted) + sys.exit(main()) diff --git a/apps/node-agent/ui/src/main.tsx b/apps/node-agent/ui/src/main.tsx index b8d9f51..e8c26e0 100644 --- a/apps/node-agent/ui/src/main.tsx +++ b/apps/node-agent/ui/src/main.tsx @@ -38,7 +38,7 @@ function App() { function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); } function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); } const content = !value ? null : workspace.activeView === "environment" ? : workspace.activeView === "overview" ? - : workspace.activeView === "sensors" ? + : workspace.activeView === "sensors" ? openView("environment")} /> : workspace.activeView === "network" ? : workspace.activeView === "usb" ? : workspace.activeView === "core" ? diff --git a/plugins/insta360-x4/packaging/owner_release_entry.py b/plugins/insta360-x4/packaging/owner_release_entry.py index d3a2a1a..548ee61 100644 --- a/plugins/insta360-x4/packaging/owner_release_entry.py +++ b/plugins/insta360-x4/packaging/owner_release_entry.py @@ -7,6 +7,7 @@ import platform import re import subprocess import sys +import tempfile import zipfile from pathlib import Path @@ -24,11 +25,44 @@ PROFILES = { } +def sync_directory(path): + directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + 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") + sync_directory(path.parent) + + +def stage_file(path, data, mode): + """Publish complete, durable installer files before opening the sudo UI.""" + if path.is_symlink(): + raise ValueError("Unexpected release symlink") + if path.exists(): + with path.open("rb") as stream: + if stream.read() != data: + raise ValueError("Existing release was modified") + os.fsync(stream.fileno()) + else: + descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + # Do not overwrite another launcher or a modified staged file. + os.link(temporary, path) + finally: + os.unlink(temporary) + sync_directory(path.parent) def main(): @@ -74,16 +108,7 @@ def main(): 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) + stage_file(folder / name, data, 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( diff --git a/plugins/insta360-x4/packaging/test_owner_release_entry.py b/plugins/insta360-x4/packaging/test_owner_release_entry.py new file mode 100644 index 0000000..c8c98cc --- /dev/null +++ b/plugins/insta360-x4/packaging/test_owner_release_entry.py @@ -0,0 +1,57 @@ +"""Installer staging survives interrupted writes without admitting corrupt files.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "owner_release_entry", Path(__file__).with_name("owner_release_entry.py") +) +entry = importlib.util.module_from_spec(spec) +spec.loader.exec_module(entry) + + +class ReleaseStagingTests(unittest.TestCase): + def test_sync_failure_never_publishes_partial_payload(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "package.deb" + with patch.object(entry.os, "fsync", side_effect=OSError("disk failure")): + with self.assertRaises(OSError): + entry.stage_file(target, b"complete package", 0o600) + self.assertEqual(list(Path(directory).iterdir()), []) + entry.stage_file(target, b"complete package", 0o600) + self.assertEqual(target.read_bytes(), b"complete package") + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + + def test_valid_staging_can_be_repeated_without_replacing_inode(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "install" + entry.stage_file(target, b"installer", 0o700) + inode = target.stat().st_ino + entry.stage_file(target, b"installer", 0o700) + self.assertEqual(target.stat().st_ino, inode) + self.assertEqual(target.stat().st_mode & 0o777, 0o700) + + def test_truncated_existing_file_is_preserved_and_rejected(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "package.deb" + target.write_bytes(b"partial") + with self.assertRaisesRegex(ValueError, "modified"): + entry.stage_file(target, b"complete package", 0o600) + self.assertEqual(target.read_bytes(), b"partial") + + def test_symlink_never_changes_its_target(self): + with tempfile.TemporaryDirectory() as directory: + real = Path(directory) / "real" + real.write_bytes(b"keep") + target = Path(directory) / "package.deb" + target.symlink_to(real) + with self.assertRaisesRegex(ValueError, "symlink"): + entry.stage_file(target, b"replacement", 0o600) + self.assertEqual(real.read_bytes(), b"keep") + + +if __name__ == "__main__": + unittest.main()