411 lines
17 KiB
Python
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}
|