fix(node): verify D455 access and share sensor progress and recording UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 23:25:11 +03:00
parent b1aaa40508
commit a8647c4d87
25 changed files with 497 additions and 61 deletions
@@ -2,3 +2,4 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="iio", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors", RUN+="/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_iio_access.py %p"
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "8a79dfe84d895c9f1d42b8d285bc6670114f939f"
DG_COMMIT = "17e150b1c74ab8a345fe34ce51dccd5bb862fa85"
def guideline_sources():
+2 -2
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.0"
VERSION = "0.6.6"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -92,7 +92,7 @@ Description: Mission Core onboard computer configuration
]:
files.append((path, (p / source).read_bytes(), mode))
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
for name in ("realsense_prepare.py",):
for name in ("realsense_prepare.py", "realsense_iio_access.py"):
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
@@ -12,6 +12,7 @@ StateDirectoryMode=0700
RuntimeDirectory=mission-core-sensors
RuntimeDirectoryMode=0750
UMask=0077
Environment=OPENBLAS_NUM_THREADS=1
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
+1
View File
@@ -14,6 +14,7 @@ case "$1" in
systemctl daemon-reload
systemctl enable mission-core-node.service
systemctl restart mission-core-node.service
systemctl try-restart mission-core-realsense.service
fi
;;
esac
+6
View File
@@ -2,6 +2,12 @@
set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then
if [ -d /run/systemd/system ]; then
if [ -S /run/mission-core-sensors/driver.sock ]; then
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
exit 1
fi
fi
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 ;;
+6
View File
@@ -1,6 +1,12 @@
#!/bin/sh
set -eu
if [ -d /run/systemd/system ]; then
if [ -S /run/mission-core-sensors/driver.sock ]; then
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
exit 1
fi
fi
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 ;;
@@ -0,0 +1,60 @@
"""udev-owned D455 IMU permission grant, limited to SDK capture controls."""
import grp
import os
import re
import sys
from pathlib import Path
def allowed_attributes(root):
names = [
"buffer/enable",
"buffer/length",
"buffer/watermark",
"current_timestamp_clock",
"in_accel_sampling_frequency",
"in_accel_hysteresis",
"in_anglvel_hysteresis",
"in_anglvel_sampling_frequency",
"scan_elements/in_timestamp_en",
]
names += [
f"scan_elements/in_{kind}_{axis}_en" for kind in ("accel", "anglvel") for axis in "xyz"
]
for name in names:
path = root / name
if path.exists() and not path.is_symlink() and path.resolve().is_relative_to(root):
yield path
def validate(sys_path, sys_root=Path("/sys")):
if not sys_path.startswith("/devices/") or ".." in sys_path.split("/"):
raise ValueError("Invalid sysfs path")
root = (sys_root / sys_path.lstrip("/")).resolve(strict=True)
if not root.is_relative_to(sys_root / "devices") or not re.fullmatch(r"iio:device[0-9]+", root.name):
raise ValueError("Not an IIO device")
matched = False
for parent in root.parents:
if (parent / "idVendor").exists() and (parent / "idProduct").exists():
matched = (parent / "idVendor").read_text().strip() == "8086" and (
parent / "idProduct"
).read_text().strip() == "0b5c"
break
if not matched:
raise ValueError("Not an admitted D455")
return root
def grant(sys_path):
root = validate(sys_path)
group = grp.getgrnam("mission-core-sensors").gr_gid
for path in allowed_attributes(root):
os.chown(path, 0, group, follow_symlinks=False)
os.chmod(path, 0o660, follow_symlinks=False)
if __name__ == "__main__":
if os.geteuid() != 0 or len(sys.argv) != 2:
sys.exit(1)
grant(sys.argv[1])
+83 -6
View File
@@ -1,11 +1,14 @@
"""Fixed model job. No paths, packages, URLs or commands are accepted from clients."""
import hashlib
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import uuid
import zipfile
@@ -25,11 +28,12 @@ STEPS = [
def publish(value):
ROOT.mkdir(mode=0o755, exist_ok=True)
if ROOT.is_symlink() or ROOT.stat().st_uid != 0:
if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022:
raise RuntimeError("Небезопасный каталог драйверов")
ROOT.chmod(0o755)
tmp = ROOT / ".preparation.tmp"
with tmp.open("w") as f:
handle, name = tempfile.mkstemp(prefix=".preparation-", dir=ROOT)
tmp = Path(name)
with os.fdopen(handle, "w") as f:
os.fchmod(f.fileno(), 0o644)
json.dump(value, f, ensure_ascii=False)
f.flush()
@@ -57,12 +61,75 @@ def safe_members(archive):
yield info
def configure_imu_namespace():
# Isolated Python mode deliberately omits the script directory. This fixed,
# root-owned module is the same allowlist used by the udev grant.
import importlib.util
spec = importlib.util.spec_from_file_location(
"realsense_iio_access", Path(__file__).with_name("realsense_iio_access.py")
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = []
for root in Path("/sys/bus/iio/devices").glob("iio:device*"):
resolved = root.resolve()
try:
module.validate(str(resolved)[4:])
except ValueError:
continue
paths.extend(str(p) for p in module.allowed_attributes(resolved))
# No subtree write grant. Only concrete allowlisted IIO attribute files
# belonging to detected D455s are admitted to the service mount namespace.
if any(" " in p or "\n" in p or "%" in p for p in paths):
raise RuntimeError("Неподдерживаемый путь IMU")
content = (
"[Service]\nReadWritePaths=\n"
+ "".join("ReadWritePaths=-" + p + "\n" for p in sorted(paths))
).encode()
folder = Path("/etc/systemd/system/mission-core-realsense.service.d")
folder.mkdir(exist_ok=True, mode=0o755)
destination = folder / "70-imu-access.conf"
fingerprint = ROOT / "imu-config.sha256"
previous = fingerprint.read_text() if fingerprint.exists() else None
if destination.is_symlink() or fingerprint.is_symlink():
raise RuntimeError("Конфликт настроек доступа IMU")
if destination.exists():
old = destination.read_bytes()
if old == content:
return False
if hashlib.sha256(old).hexdigest() != previous:
raise RuntimeError("Настройки IMU изменены в системе. Чужой файл сохранён.")
# Read-only preflight; every entry point shares the board's capture owner.
sock = Path("/run/mission-core-sensors/driver.sock")
if sock.exists():
client = http.client.HTTPConnection("driver", timeout=5)
client.sock = socket.socket(socket.AF_UNIX)
client.sock.settimeout(5)
try:
client.sock.connect(str(sock))
client.request("GET", "/prepare-safe")
response = client.getresponse()
if response.status != 200 or not json.loads(response.read(1024)).get("safe"):
raise RuntimeError("Остановите захват всех камер перед подготовкой драйвера.")
finally:
client.close()
destination.write_bytes(content)
destination.chmod(0o644)
fingerprint.write_text(hashlib.sha256(content).hexdigest())
fingerprint.chmod(0o644)
return True
def prepare():
manifest = json.loads((SHARE / "bundle.json").read_text())
revision = manifest["revision"]
if not revision.isalnum():
raise RuntimeError("Некорректная версия драйвера")
target = ROOT / revision
if target.is_symlink() or (ROOT / "active.path").is_symlink():
raise RuntimeError("Конфликт установленного драйвера")
imu_changed = False
state = {
"schema": "missioncore.node.device-preparation/v1",
"model_id": "realsense.d455",
@@ -129,10 +196,14 @@ def prepare():
(ROOT / "active.path").write_text(str(target))
(ROOT / "active.path").chmod(0o644)
elif step["id"] == "access":
imu_changed = configure_imu_namespace()
source = SHARE / "70-mission-core-realsense.rules"
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
if dest.is_symlink() or (
dest.exists() and dest.read_bytes() != source.read_bytes()
dest.exists()
and dest.read_bytes() != source.read_bytes()
and hashlib.sha256(dest.read_bytes()).hexdigest()
!= "782eba7935400e688a7eaea53fe50d358a046eb6b2c99187a787cc03b0301449"
):
raise RuntimeError(
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
@@ -156,11 +227,17 @@ def prepare():
"--subsystem-match=video4linux",
)
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw")
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=iio")
run("/usr/bin/udevadm", "settle", "--timeout=10")
elif step["id"] == "service":
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
# Never restart a running acquisition on repeated preparation.
run("/usr/bin/systemctl", "start", "mission-core-realsense.service")
# The fixed job refuses running capture before changing its namespace.
run("/usr/bin/systemctl", "daemon-reload")
run(
"/usr/bin/systemctl",
"restart" if imu_changed else "start",
"mission-core-realsense.service",
)
run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service")
step["state"] = "complete"
publish(state)
@@ -0,0 +1,42 @@
import tempfile
import unittest
from pathlib import Path
from realsense_iio_access import allowed_attributes, validate
class IMUScopeTests(unittest.TestCase):
def test_only_capture_attributes_are_granted(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
for name in [
"buffer/enable",
"in_accel_hysteresis",
"scan_elements/in_accel_x_en",
"reset",
"power/control",
]:
path = root / name
path.parent.mkdir(exist_ok=True, parents=True)
path.touch()
(root / "buffer/length").symlink_to("/etc/passwd")
self.assertEqual(
{str(p.relative_to(root)) for p in allowed_attributes(root)},
{"buffer/enable", "in_accel_hysteresis", "scan_elements/in_accel_x_en"},
)
def test_foreign_usb_and_path_escape_are_rejected(self):
with tempfile.TemporaryDirectory() as folder:
sys = Path(folder).resolve()
usb = sys / "devices/usb/device"
root = usb / "hid/iio:device0"
root.mkdir(parents=True)
(usb / "idVendor").write_text("8086")
(usb / "idProduct").write_text("0b5c")
self.assertEqual(validate("/devices/usb/device/hid/iio:device0", sys), root)
(usb / "idProduct").write_text("ffff")
with self.assertRaises(ValueError):
validate("/devices/usb/device/hid/iio:device0", sys)
for path in ["/devices/../etc/passwd", "/class/iio:device0", "/devices/usb/device/hid"]:
with self.assertRaises(ValueError):
validate(path, sys)