from __future__ import annotations import json import secrets import sqlite3 import threading import time from contextlib import suppress from datetime import UTC, datetime, timedelta from pathlib import Path from cryptography import x509 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from missioncore_plugin_sdk.v0alpha2.identity import ExecutionBinding from . import sensors from .trust import SCHEMA, CoreTrust, PairingError, node_request, parse_invitation, pem, public_id class FleetRegistry: """One writer lock and durable transactions; transport never owns vehicles.""" def __init__(self, root: Path): root.mkdir(parents=True, exist_ok=True, mode=0o700) if root.is_symlink() or root.stat().st_mode & 0o077: raise PairingError("Хранилище реестра требует закрытый каталог.") self.started_at = time.time() self.root = root self.lock = threading.RLock() from .events import FleetEvents self.events = FleetEvents() from .device_enrollment import DeviceEnrollment self.device_enrollment = DeviceEnrollment() self.trust = CoreTrust(root) path = root / "fleet.sqlite3" if path.is_symlink(): raise PairingError("Конфликт файла реестра.") self.db = sqlite3.connect(path, check_same_thread=False) path.chmod(0o600) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA synchronous=FULL") self.db.execute("PRAGMA secure_delete=ON") self.db.execute( "CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, " "node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)" ) self.db.commit() self.previews: dict[str, dict] = {} self.listeners: dict[str, object] = {} self.stop = threading.Event() self.worker: threading.Thread | None = None def rows(self): return [ json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id") ] def save(self, row): with self.db: self.db.execute( "INSERT INTO vehicles VALUES(?,?,?) " "ON CONFLICT(id) DO UPDATE SET body=excluded.body", (row["id"], row["node_id"], json.dumps(row)), ) self.events.notify() def find(self, identifier): result = self.db.execute("SELECT body FROM vehicles WHERE id=?", (identifier,)).fetchone() if not result: raise PairingError("Аппарат не найден.") return json.loads(result[0]) def start(self): with self.lock: for row in self.rows(): if row["enrollment"] in ("pending", "paired"): # Address absent: keep the binding and show offline. with suppress(OSError): self.listen(row["core_address"]) self.worker = threading.Thread( target=self.reconcile, name="mission-core-fleet", daemon=True ) self.worker.start() def close(self): self.stop.set() if self.worker: self.worker.join(timeout=20) for server in self.listeners.values(): server.shutdown() server.server_close() with self.lock: self.db.close() def listen(self, address): from .transport import NodeChannelServer if address not in self.listeners: server = NodeChannelServer(address, self) self.listeners[address] = server threading.Thread( target=server.serve_forever, name="mission-core-node-channel", daemon=True ).start() def preview(self, code: str) -> dict: invitation = parse_invitation(code.strip()) value, public_key, address = node_request( invitation, "/v1/pair/inspect", {"id": invitation["id"], "secret": invitation["secret"]} ) if value.get("schema") != SCHEMA or value.get("node_id") != invitation["node_id"]: raise PairingError("БК вернул несовместимое подтверждение.") with self.lock: now = time.time() self.previews = { key: item for key, item in self.previews.items() if item["expires"] > now } if len(self.previews) >= 16: raise PairingError("Завершите или закройте предыдущие приглашения.") identifier = secrets.token_urlsafe(32) self.previews[identifier] = { "invitation": invitation, "expires": min(now + 300, invitation["expires_at"]), "public_key": public_key, "core_address": address, "node": value, } return { "preview_id": identifier, "node_id": invitation["node_id"], "name": value["name"], "host": value["host"], "expires_at": invitation["expires_at"], "endpoint": invitation["endpoint"], } def add(self, preview_id: str, name: str, platform: str) -> dict: with self.lock: preview = self.previews.get(preview_id) if preview is None or preview["expires"] <= time.time(): raise PairingError("Проверка приглашения истекла. Вставьте код снова.") if preview.get("created_id"): return self.public(self.find(preview["created_id"])) invitation = preview["invitation"] existing = next( (row for row in self.rows() if row["node_id"] == invitation["node_id"]), None ) if existing and existing["enrollment"] in ("pending", "paired"): if existing.get("invitation_id") == invitation["id"]: return self.public(existing) raise PairingError("Этот БК уже добавлен. Сначала отзовите прежнюю привязку.") if ( not name.strip() or len(name.strip()) > 80 or any(ord(c) < 32 for c in name) or platform not in ("ugv", "uav", "stationary", "other") ): raise PairingError("Проверьте название и класс аппарата.") try: self.listen(preview["core_address"]) except OSError: raise PairingError( "Не удалось открыть частный канал Core. " "Проверьте сеть и доступность порта приложения." ) from None binding = { "binding_id": secrets.token_urlsafe(32), "core_id": self.trust.core_id, "core_name": "Mission Core", "endpoint": f"https://{preview['core_address']}:8782", "ca_pem": pem(self.trust.ca), "client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]), } row = { "id": existing["id"] if existing else secrets.token_urlsafe(16), "node_id": invitation["node_id"], "name": name.strip(), "platform": platform, "enrollment": "pending", "revision": (existing["revision"] + 1) if existing else 1, "binding": binding, "invitation_id": invitation["id"], "invitation": invitation, "receipt": None, "core_address": preview["core_address"], "last_seen": None, "inventory": None, "runtime": None, "certificate_previous": None, "notice": "Подтверждаем привязку с БК", "created_at": time.time(), } self.save(row) self.previews[preview_id] = {"created_id": row["id"], "expires": preview["expires"]} return self.public(row) def public(self, row): online = ( row["enrollment"] == "paired" and row["last_seen"] is not None and row["last_seen"] >= self.started_at and time.time() - row["last_seen"] < 20 ) return { "id": row["id"], "node_id": row["node_id"], "name": row["name"], "platform": row["platform"], "enrollment": row["enrollment"], "revision": row["revision"], "connectivity": "online" if online else "offline", "last_seen": row["last_seen"], "host": row["inventory"], "execution_binding": row["runtime"], "devices": row.get("sensor_state", {}).get("items", []), "sensor_state": row.get( "sensor_state", {"items": [], "operations": [], "preparation": None} ), "notice": row["notice"], "core_endpoint": row["binding"]["endpoint"], } def listing(self): with self.lock: return { "schema": "missioncore.fleet-registry/v1", "core_id": self.trust.core_id, "items": [self.public(row) for row in self.rows()], } def revoke(self, identifier: str): with self.lock: row = self.find(identifier) if row["enrollment"] == "revoked": return self.public(row) row.update( enrollment="revoked", revision=row["revision"] + 1, invitation=None, receipt=None, notice="Доверие отозвано", ) self.save(row) return self.public(row) def advance(self, identifier): # Hold no registry lock across network I/O. Every completion rechecks # the exact binding and enrollment to avoid resurrecting a revoked row. with self.lock: row = self.find(identifier) if row["enrollment"] != "pending": return invitation = row["invitation"] if invitation["expires_at"] <= time.time(): row.update( enrollment="failed", notice="Приглашение истекло. Создайте новое в Node.", invitation=None, receipt=None, ) self.save(row) return if row["receipt"] is None: value, _, _ = node_request( invitation, "/v1/pair/offer", {"id": invitation["id"], "secret": invitation["secret"], "binding": row["binding"]}, ) receipt = value.get("receipt") if ( not isinstance(receipt, str) or len(receipt) != 43 or value.get("node_id") != row["node_id"] ): raise PairingError("БК не подтвердил ожидаемую привязку.") with self.lock: current = self.find(identifier) if ( current["enrollment"] != "pending" or current["binding"]["binding_id"] != row["binding"]["binding_id"] ): return current["receipt"] = receipt self.save(current) row = current node_request( invitation, "/v1/pair/commit", {"id": row["binding"]["binding_id"], "receipt": row["receipt"]}, ) # An ack proves the binding. Online additionally needs the outgoing # authenticated heartbeat; a lost ack is recovered by that heartbeat. with self.lock: current = self.find(identifier) if ( current["enrollment"] == "pending" and current["binding"]["binding_id"] == row["binding"]["binding_id"] ): current.update( enrollment="paired", invitation=None, receipt=None, notice="Ожидаем исходящее соединение БК", ) self.save(current) def reconcile(self): while not self.stop.is_set(): with self.lock: active = [row for row in self.rows() if row["enrollment"] in ("pending", "paired")] for address in {row["core_address"] for row in active}: try: self.listen(address) server = self.listeners[address] if server.refresh_at <= time.time(): server.refresh_context() except OSError: pass identifiers = [row["id"] for row in active if row["enrollment"] == "pending"] self.previews = { k: v for k, v in self.previews.items() if v["expires"] > time.time() } for identifier in identifiers: if self.stop.is_set(): break try: self.advance(identifier) except (PairingError, OSError, ValueError): with self.lock: row = self.find(identifier) if row["enrollment"] == "pending": row["notice"] = ( "Подтверждение пока не получено. " "Повторяем до истечения приглашения." ) self.save(row) self.stop.wait(2) def receive(self, certificate: bytes, path: str, value: dict): cert = x509.load_der_x509_certificate(certificate) key = cert.public_key() if not isinstance(key, Ed25519PublicKey): return 403, {"error": "Unauthorized Node"} node_id = public_id("node_", key) with self.lock: row = next((item for item in self.rows() if item["node_id"] == node_id), None) if ( row is None or row["enrollment"] not in ("pending", "paired") or value.get("node_id") != node_id or value.get("binding_id") != row["binding"]["binding_id"] ): return 410, {"error": "Binding revoked"} allowed = [row["binding"]["client_pem"]] if ( row.get("certificate_previous") and row["certificate_previous"]["until"] > time.time() ): allowed.append(row["certificate_previous"]["pem"]) if cert.serial_number not in [ x509.load_pem_x509_certificate(item.encode()).serial_number for item in allowed ]: return 403, {"error": "Certificate superseded"} if value.get("schema") != SCHEMA: return 400, {"error": "Incompatible protocol"} if path == "/v1/node/unpair": row.update( enrollment="revoked", revision=row["revision"] + 1, invitation=None, receipt=None, notice="БК отозвал привязку", ) self.save(row) return 200, {"ok": True} if path != "/v1/node/heartbeat": return 404, {"error": "Unknown operation"} binding = ExecutionBinding.model_validate(value["execution_binding"]) if binding.node_id != node_id or binding.platform.value != "linux": return 400, {"error": "Invalid Node inventory"} sensor_state = sensors.validate_inventory(value, node_id) sensor_response = sensors.heartbeat(row, value) enrollment_response = self.device_enrollment.heartbeat(row, value) host = value.get("host") if ( not isinstance(host, dict) or len(host.get("usb", [])) > 256 or len(host.get("networks", [])) > 64 ): return 400, {"error": "Invalid host inventory"} row.update( enrollment="paired", invitation=None, receipt=None, last_seen=time.time(), inventory=host, sensor_state=sensor_state, runtime=binding.model_dump(mode="json"), notice="", ) current = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()) if current.not_valid_after_utc - datetime.now(UTC) < timedelta(days=7): row["certificate_previous"] = { "pem": row["binding"]["client_pem"], "until": time.time() + 3600, } row["binding"]["client_pem"] = self.trust.leaf(node_id, key) self.save(row) return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response}