fix(node): verify D455 access and share sensor progress and recording UI
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user