Files
NODEDC_MISSION_CORE/src/k1link/fleet/registry.py
T

411 lines
17 KiB
Python

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}