feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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 .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()
|
||||
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)),
|
||||
)
|
||||
|
||||
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": [],
|
||||
"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"
|
||||
or value.get("devices") != []
|
||||
):
|
||||
return 400, {"error": "Invalid Node inventory"}
|
||||
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,
|
||||
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"]}
|
||||
Reference in New Issue
Block a user