Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Recover only an existing binding over a previously authenticated tailnet address.
|
||||
|
||||
No peer discovery, invitation secrets, trust replacement, or provider API. The
|
||||
Node pins the Core CA public key through mTLS; Core pins the saved Node key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||
|
||||
from .trust import PairingError, atomic_private, endpoint, pem, verify_node_certificate
|
||||
|
||||
SCHEMA = "missioncore.node-channel-recovery/v1"
|
||||
|
||||
|
||||
def tailnet_address(value):
|
||||
try:
|
||||
return ipaddress.IPv4Address(value) in ipaddress.ip_network("100.64.0.0/10")
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def node_addresses(row):
|
||||
addresses = set()
|
||||
for network in (row.get("inventory") or {}).get("networks", [])[:64]:
|
||||
if not isinstance(network, dict) or not network.get("up"):
|
||||
continue
|
||||
for alias in network.get("addresses", [])[:32]:
|
||||
if isinstance(alias, str) and tailnet_address(alias.split("/")[0]):
|
||||
addresses.add(alias.split("/")[0])
|
||||
return sorted(addresses)[:2]
|
||||
|
||||
|
||||
def client_context(trust):
|
||||
# Keep the pinned Core key, but distinguish the leaf from its issuer.
|
||||
# Go rejects same-subject/same-SPKI certificates without SAN as a chain loop.
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mission Core channel recovery")])
|
||||
cert = (
|
||||
trust.builder(subject, trust.key.public_key(), 1)
|
||||
.issuer_name(trust.ca.subject)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False)
|
||||
.sign(trust.key, None)
|
||||
)
|
||||
cert_path, key_path = trust.root / "recovery-client.pem", trust.root / "recovery-key.pem"
|
||||
atomic_private(cert_path, (pem(cert) + pem(trust.ca)).encode())
|
||||
atomic_private(
|
||||
key_path,
|
||||
trust.key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
),
|
||||
)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
# Bootstrap certificate is self-signed by the already pinned Node key.
|
||||
# verify_node_certificate must succeed before any application request.
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
context.load_cert_chain(cert_path, key_path)
|
||||
return context
|
||||
|
||||
|
||||
def request(connection, path, body):
|
||||
connection.request("POST", path, json.dumps(body), {"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
raw = response.read(16385)
|
||||
if response.status != 200 or len(raw) > 16384:
|
||||
raise PairingError("БК пока не подтвердил восстановление канала.")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise PairingError("Некорректное подтверждение канала.")
|
||||
return value
|
||||
|
||||
|
||||
def recover(registry, row, address, context):
|
||||
if not tailnet_address(address) or address not in node_addresses(row):
|
||||
raise PairingError("Нет подтверждённого адреса БК.")
|
||||
connection = http.client.HTTPSConnection(address, 8781, timeout=4, context=context)
|
||||
try:
|
||||
connection.connect()
|
||||
verify_node_certificate(connection.sock.getpeercert(binary_form=True), row["node_id"])
|
||||
local = connection.sock.getsockname()[0]
|
||||
if not tailnet_address(local):
|
||||
raise PairingError("Обратный адрес Core не относится к частному каналу.")
|
||||
body = {"schema": SCHEMA, "binding_id": row["binding"]["binding_id"]}
|
||||
value = request(connection, "/v1/channel/inspect", body)
|
||||
if (
|
||||
value.get("schema") != SCHEMA
|
||||
or value.get("node_id") != row["node_id"]
|
||||
or value.get("core_id") != registry.trust.core_id
|
||||
or value.get("binding_id") != body["binding_id"]
|
||||
or type(value.get("endpoint_revision")) is not int
|
||||
or not 0 <= value["endpoint_revision"] < 1000000000
|
||||
):
|
||||
raise PairingError("Подтверждена другая привязка БК.")
|
||||
endpoint(value.get("endpoint"), 8782)
|
||||
with registry.lock:
|
||||
current = registry.find(row["id"])
|
||||
if (
|
||||
registry.stop.is_set()
|
||||
or current["enrollment"] != "paired"
|
||||
or current["binding"]["binding_id"] != body["binding_id"]
|
||||
):
|
||||
return
|
||||
registry.listen(local)
|
||||
desired = f"https://{local}:8782"
|
||||
if value["endpoint"] != desired:
|
||||
request(
|
||||
connection,
|
||||
"/v1/channel/migrate",
|
||||
{
|
||||
**body,
|
||||
"expected_endpoint": value["endpoint"],
|
||||
"expected_revision": value["endpoint_revision"],
|
||||
"endpoint": desired,
|
||||
},
|
||||
)
|
||||
# Only a subsequent authenticated heartbeat proves the new endpoint.
|
||||
# Lost migration acknowledgements are resolved by inspect, never replay.
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def reconcile(registry):
|
||||
attempts = {}
|
||||
context, refresh = None, 0
|
||||
while not registry.stop.is_set():
|
||||
with registry.lock:
|
||||
rows = [r for r in registry.rows() if r["enrollment"] == "paired"]
|
||||
attempts = {key: value for key, value in attempts.items() if key in {r["id"] for r in rows}}
|
||||
for row in rows:
|
||||
if registry.stop.is_set():
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now < attempts.get(row["id"], 0):
|
||||
continue
|
||||
attempts[row["id"]] = now + 30
|
||||
if tailnet_address(row["core_address"]) and (row.get("last_seen") or 0) >= max(
|
||||
registry.started_at, time.time() - 20
|
||||
):
|
||||
continue
|
||||
for address in node_addresses(row):
|
||||
try:
|
||||
if context is None or now >= refresh:
|
||||
context, refresh = client_context(registry.trust), now + 12 * 3600
|
||||
recover(registry, row, address, context)
|
||||
break
|
||||
except Exception:
|
||||
# Bounded retry, no untrusted peer data or private addresses in logs.
|
||||
# The ordinary heartbeat remains the connectivity authority.
|
||||
continue
|
||||
registry.stop.wait(2)
|
||||
@@ -54,6 +54,7 @@ class FleetRegistry:
|
||||
self.listeners: dict[str, object] = {}
|
||||
self.stop = threading.Event()
|
||||
self.worker: threading.Thread | None = None
|
||||
self.recovery_worker: threading.Thread | None = None
|
||||
|
||||
def rows(self):
|
||||
return [
|
||||
@@ -86,11 +87,22 @@ class FleetRegistry:
|
||||
target=self.reconcile, name="mission-core-fleet", daemon=True
|
||||
)
|
||||
self.worker.start()
|
||||
from .recovery import reconcile as recover_channels
|
||||
|
||||
self.recovery_worker = threading.Thread(
|
||||
target=recover_channels,
|
||||
args=(self,),
|
||||
name="mission-core-channel-recovery",
|
||||
daemon=True,
|
||||
)
|
||||
self.recovery_worker.start()
|
||||
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
if self.worker:
|
||||
self.worker.join(timeout=20)
|
||||
if self.recovery_worker:
|
||||
self.recovery_worker.join(timeout=30)
|
||||
for server in self.listeners.values():
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@@ -341,7 +353,7 @@ class FleetRegistry:
|
||||
self.save(row)
|
||||
self.stop.wait(2)
|
||||
|
||||
def receive(self, certificate: bytes, path: str, value: dict):
|
||||
def receive(self, certificate: bytes, path: str, value: dict, *, address: str | None = None):
|
||||
cert = x509.load_der_x509_certificate(certificate)
|
||||
key = cert.public_key()
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
@@ -380,6 +392,25 @@ class FleetRegistry:
|
||||
return 200, {"ok": True}
|
||||
if path != "/v1/node/heartbeat":
|
||||
return 404, {"error": "Unknown operation"}
|
||||
# Endpoint revisions prevent an old in-flight heartbeat or lost ack
|
||||
# from reversing a migration. Use the actual listener, not a claimed IP.
|
||||
from .recovery import tailnet_address
|
||||
|
||||
revision = value.get("endpoint_revision", 0)
|
||||
if type(revision) is not int or not 0 <= revision <= 1000000000:
|
||||
return 400, {"error": "Invalid endpoint revision"}
|
||||
previous_revision = row["binding"].get("endpoint_revision", 0)
|
||||
if revision < previous_revision:
|
||||
return 409, {"error": "Endpoint superseded"}
|
||||
if revision > previous_revision:
|
||||
if (
|
||||
not tailnet_address(address)
|
||||
or value.get("core_endpoint") != f"https://{address}:8782"
|
||||
):
|
||||
return 400, {"error": "Endpoint does not match authenticated channel"}
|
||||
row["core_address"] = address
|
||||
row["binding"]["endpoint"] = value["core_endpoint"]
|
||||
row["binding"]["endpoint_revision"] = revision
|
||||
binding = ExecutionBinding.model_validate(value["execution_binding"])
|
||||
if binding.node_id != node_id or binding.platform.value != "linux":
|
||||
return 400, {"error": "Invalid Node inventory"}
|
||||
@@ -417,4 +448,10 @@ class FleetRegistry:
|
||||
except (ValueError, TypeError, KeyError, sqlite3.Error):
|
||||
# Telemetry persistence failure must not block device control.
|
||||
pass
|
||||
return 200, {"monitor_ack": monitor_ack, "ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response}
|
||||
return 200, {
|
||||
"monitor_ack": monitor_ack,
|
||||
"ok": True,
|
||||
"client_pem": row["binding"]["client_pem"],
|
||||
**sensor_response,
|
||||
**enrollment_response,
|
||||
}
|
||||
|
||||
@@ -20,12 +20,22 @@ ACTIONS = {
|
||||
"option",
|
||||
"offer",
|
||||
"close-peer",
|
||||
"preview.start",
|
||||
"preview.stop",
|
||||
"record.start",
|
||||
"record.stop",
|
||||
"photo.capture",
|
||||
"settings.read",
|
||||
"settings.apply",
|
||||
"files.list",
|
||||
}
|
||||
MAX_INVENTORY_ITEMS = 500
|
||||
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
||||
|
||||
|
||||
def validate_inventory(value, node_id):
|
||||
items = value.get("devices", [])
|
||||
if not isinstance(items, list) or len(items) > 16:
|
||||
if not isinstance(items, list) or len(items) > MAX_INVENTORY_ITEMS:
|
||||
raise ValueError("Invalid device inventory")
|
||||
seen = set()
|
||||
for item in items:
|
||||
@@ -38,7 +48,11 @@ def validate_inventory(value, node_id):
|
||||
raise ValueError("Invalid device execution binding")
|
||||
seen.add(item["id"])
|
||||
state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None})
|
||||
if state.get("items") != items or len(json.dumps(state)) > 262144:
|
||||
if (
|
||||
not isinstance(state, dict)
|
||||
or state.get("items") != items
|
||||
or len(json.dumps(state, ensure_ascii=False).encode()) > MAX_SENSOR_STATE_BYTES
|
||||
):
|
||||
raise ValueError("Invalid sensor state")
|
||||
return state
|
||||
|
||||
|
||||
@@ -57,14 +57,19 @@ class NodeChannelHandler(BaseHTTPRequestHandler):
|
||||
if (
|
||||
self.headers.get("Content-Type") != "application/json"
|
||||
or self.headers.get("Origin")
|
||||
or not 0 < size <= 1048576
|
||||
# A bounded 500-camera summary is carried both in devices and
|
||||
# sensor_state for the existing paired heartbeat contract.
|
||||
or not 0 < size <= 8 * 1024 * 1024
|
||||
):
|
||||
raise ValueError
|
||||
value = json.loads(self.rfile.read(size))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError
|
||||
status, data = self.server.registry.receive(
|
||||
self.connection.getpeercert(binary_form=True), self.path, value
|
||||
self.connection.getpeercert(binary_form=True),
|
||||
self.path,
|
||||
value,
|
||||
address=self.server.address,
|
||||
)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
self.close_connection = True
|
||||
|
||||
+17
-10
@@ -206,6 +206,20 @@ class CoreTrust:
|
||||
return context
|
||||
|
||||
|
||||
def verify_node_certificate(raw: bytes, node_id: str) -> Ed25519PublicKey:
|
||||
cert = x509.load_der_x509_certificate(raw)
|
||||
key = cert.public_key()
|
||||
now = datetime.now(UTC)
|
||||
if (
|
||||
not isinstance(key, Ed25519PublicKey)
|
||||
or public_id("node_", key) != node_id
|
||||
or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc
|
||||
):
|
||||
raise PairingError("Идентичность БК не совпала с сохранённой.")
|
||||
key.verify(cert.signature, cert.tbs_certificate_bytes)
|
||||
return key
|
||||
|
||||
|
||||
def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25519PublicKey, str]:
|
||||
address, port = endpoint(invitation["endpoint"], 8781)
|
||||
# The one-use secret is sent only AFTER authenticating the Node identity.
|
||||
@@ -217,16 +231,9 @@ def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25
|
||||
connection = http.client.HTTPSConnection(address, port, timeout=5, context=context)
|
||||
try:
|
||||
connection.connect()
|
||||
cert = x509.load_der_x509_certificate(connection.sock.getpeercert(binary_form=True))
|
||||
key = cert.public_key()
|
||||
now = datetime.now(UTC)
|
||||
if (
|
||||
not isinstance(key, Ed25519PublicKey)
|
||||
or public_id("node_", key) != invitation["node_id"]
|
||||
or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc
|
||||
):
|
||||
raise PairingError("Идентичность БК не совпала с приглашением.")
|
||||
key.verify(cert.signature, cert.tbs_certificate_bytes)
|
||||
key = verify_node_certificate(
|
||||
connection.sock.getpeercert(binary_form=True), invitation["node_id"]
|
||||
)
|
||||
local = connection.sock.getsockname()[0]
|
||||
if not private_address(local):
|
||||
raise PairingError("Core не получил частный обратный адрес.")
|
||||
|
||||
Reference in New Issue
Block a user