272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""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
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
SHARE = Path("/usr/share/mission-core-node/realsense")
|
|
ROOT = Path("/var/lib/mission-core-node-drivers")
|
|
REPORT = ROOT / "preparation.json"
|
|
STEPS = [
|
|
("platform", "Проверка совместимости системы"),
|
|
("payload", "Проверка встроенного драйвера"),
|
|
("runtime", "Развёртывание драйвера"),
|
|
("access", "Настройка доступа к камере"),
|
|
("service", "Запуск службы камеры"),
|
|
]
|
|
|
|
|
|
def publish(value):
|
|
ROOT.mkdir(mode=0o755, exist_ok=True)
|
|
if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022:
|
|
raise RuntimeError("Небезопасный каталог драйверов")
|
|
ROOT.chmod(0o755)
|
|
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()
|
|
os.fsync(f.fileno())
|
|
tmp.replace(REPORT)
|
|
|
|
|
|
def run(*args):
|
|
result = subprocess.run(args, capture_output=True, timeout=90)
|
|
if result.returncode:
|
|
raise RuntimeError("Системный этап не завершён. Повторите подготовку устройства.")
|
|
|
|
|
|
def safe_members(archive):
|
|
for info in archive.infolist():
|
|
path = PurePosixPath(info.filename)
|
|
if (
|
|
path.is_absolute()
|
|
or ".." in path.parts
|
|
or (info.external_attr >> 16) & 0o170000 == 0o120000
|
|
):
|
|
raise RuntimeError("Недопустимое содержимое драйверного пакета")
|
|
if ".data" in info.filename or info.filename.endswith(".pth"):
|
|
raise RuntimeError("Пакет требует неподдерживаемый способ установки")
|
|
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",
|
|
"revision": revision,
|
|
"run_id": str(uuid.uuid4()),
|
|
"started_at": time.time(),
|
|
"state": "running",
|
|
"steps": [{"id": k, "label": v, "state": "pending"} for k, v in STEPS],
|
|
}
|
|
publish(state)
|
|
try:
|
|
for step in state["steps"]:
|
|
step["state"] = "running"
|
|
state["updated_at"] = time.time()
|
|
publish(state)
|
|
if step["id"] == "platform":
|
|
release = dict(
|
|
line.split("=", 1)
|
|
for line in Path("/etc/os-release").read_text().splitlines()
|
|
if "=" in line
|
|
)
|
|
if (
|
|
(release.get("ID", "").strip('"'), release.get("VERSION_ID", "").strip('"'))
|
|
!= ("ubuntu", "24.04")
|
|
or os.uname().machine != "x86_64"
|
|
or sys.version_info[:2] != (3, 12)
|
|
):
|
|
raise RuntimeError("Встроенный драйвер несовместим с этой системой")
|
|
elif step["id"] == "payload":
|
|
for entry in manifest["wheels"]:
|
|
path = SHARE / entry["name"]
|
|
if (
|
|
path.name != entry["name"]
|
|
or path.is_symlink()
|
|
or hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]
|
|
):
|
|
raise RuntimeError(
|
|
"Контрольная сумма драйвера не совпала. Переустановите пакет Node."
|
|
)
|
|
elif step["id"] == "runtime":
|
|
if not target.exists():
|
|
stage = ROOT / (revision + ".partial")
|
|
if stage.exists():
|
|
shutil.rmtree(stage)
|
|
stage.mkdir(mode=0o755)
|
|
for entry in manifest["wheels"]:
|
|
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
|
|
archive.extractall(stage, members=safe_members(archive))
|
|
for path in stage.rglob("*"):
|
|
path.chmod(0o755 if path.is_dir() else 0o644)
|
|
stage.rename(target)
|
|
# Wheel RECORD content is checked again against the bundled wheel;
|
|
# a previous successful report never substitutes for integrity.
|
|
for entry in manifest["wheels"]:
|
|
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
|
|
for info in safe_members(archive):
|
|
if not info.is_dir() and (
|
|
target / info.filename
|
|
).read_bytes() != archive.read(info):
|
|
raise RuntimeError(
|
|
"Установленный драйвер изменён. "
|
|
"Нужна переустановка пакета драйвера."
|
|
)
|
|
(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()
|
|
and hashlib.sha256(dest.read_bytes()).hexdigest()
|
|
!= "782eba7935400e688a7eaea53fe50d358a046eb6b2c99187a787cc03b0301449"
|
|
):
|
|
raise RuntimeError(
|
|
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
|
|
)
|
|
dest.write_bytes(source.read_bytes())
|
|
dest.chmod(0o644)
|
|
run("/usr/bin/udevadm", "control", "--reload-rules")
|
|
# Restrict trigger to the admitted product; no unrelated USB reset.
|
|
run(
|
|
"/usr/bin/udevadm",
|
|
"trigger",
|
|
"--action=change",
|
|
"--subsystem-match=usb",
|
|
"--attr-match=idVendor=8086",
|
|
"--attr-match=idProduct=0b5c",
|
|
)
|
|
run(
|
|
"/usr/bin/udevadm",
|
|
"trigger",
|
|
"--action=change",
|
|
"--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")
|
|
# 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)
|
|
state["state"] = "complete"
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
RuntimeError,
|
|
subprocess.SubprocessError,
|
|
zipfile.BadZipFile,
|
|
) as error:
|
|
step["state"] = "error"
|
|
step["message"] = (
|
|
str(error)[:300]
|
|
if isinstance(error, RuntimeError)
|
|
else "Не удалось подготовить драйвер. Повторите действие."
|
|
)
|
|
state["state"] = "error"
|
|
for item in state["steps"]:
|
|
if item["state"] == "pending":
|
|
item["state"] = "blocked"
|
|
state["updated_at"] = time.time()
|
|
publish(state)
|
|
return state["state"] == "complete"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
os.umask(0o022)
|
|
if os.geteuid() != 0 or len(sys.argv) != 1:
|
|
sys.exit(1)
|
|
sys.exit(0 if prepare() else 1)
|