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.
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""Bounded private HTTP over Unix sockets, never a public device endpoint."""
|
|
|
|
import http.client
|
|
import json
|
|
import socket
|
|
import socketserver
|
|
import struct
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from http.server import BaseHTTPRequestHandler
|
|
|
|
LIMIT = 65536
|
|
INVENTORY_LIMIT = 2 * 1024 * 1024
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Binary:
|
|
data: bytes
|
|
|
|
|
|
def request(path, route, value=None, timeout=25, binary=False):
|
|
client = http.client.HTTPConnection("driver", timeout=timeout)
|
|
client.sock = socket.socket(socket.AF_UNIX)
|
|
client.sock.settimeout(timeout)
|
|
try:
|
|
client.sock.connect(str(path))
|
|
body = None if value is None else json.dumps(value, allow_nan=False).encode()
|
|
client.request(
|
|
"GET" if value is None else "POST", route, body, {"Content-Type": "application/json"}
|
|
)
|
|
reply = client.getresponse()
|
|
limit = (
|
|
4 * 1024 * 1024 + 1028
|
|
if binary
|
|
else INVENTORY_LIMIT
|
|
if route == "/inventory"
|
|
else LIMIT
|
|
)
|
|
raw = reply.read(limit + 1)
|
|
if len(raw) > limit or reply.status != 200:
|
|
raise RuntimeError("Camera service request failed")
|
|
if binary:
|
|
if reply.getheader("Content-Type") != "application/octet-stream":
|
|
raise RuntimeError("Invalid video response")
|
|
return raw
|
|
return json.loads(raw)
|
|
except http.client.HTTPException as error:
|
|
raise RuntimeError("Camera service response is incomplete") from error
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
|
daemon_threads = True
|
|
block_on_close = False
|
|
|
|
def __init__(self, path, dispatch, allowed_uids):
|
|
self.dispatch = dispatch
|
|
self.allowed_uids = frozenset(allowed_uids)
|
|
self.slots = threading.BoundedSemaphore(12)
|
|
super().__init__(str(path), Handler)
|
|
|
|
def verify_request(self, request, address):
|
|
_, uid, _ = struct.unpack(
|
|
"3i", request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
|
)
|
|
return uid in self.allowed_uids
|
|
|
|
def process_request(self, request, address):
|
|
if not self.slots.acquire(blocking=False):
|
|
self.shutdown_request(request)
|
|
return
|
|
try:
|
|
super().process_request(request, address)
|
|
except BaseException:
|
|
self.slots.release()
|
|
raise
|
|
|
|
def process_request_thread(self, request, address):
|
|
try:
|
|
super().process_request_thread(request, address)
|
|
finally:
|
|
self.slots.release()
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def setup(self):
|
|
self.request.settimeout(10)
|
|
super().setup()
|
|
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
self.handle_request()
|
|
|
|
def do_POST(self):
|
|
self.handle_request()
|
|
|
|
def handle_request(self):
|
|
try:
|
|
if self.headers.get("Transfer-Encoding"):
|
|
raise ValueError("Chunked commands are not supported")
|
|
value = None
|
|
if self.command == "POST":
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if (
|
|
not 0 < length <= LIMIT
|
|
or self.headers.get("Content-Type") != "application/json"
|
|
):
|
|
raise ValueError("Invalid command size")
|
|
raw = self.rfile.read(length)
|
|
if len(raw) != length:
|
|
raise ValueError("Truncated command")
|
|
value = json.loads(raw)
|
|
result = self.server.dispatch(self.command, self.path, value, self.headers)
|
|
status = 200
|
|
except (ValueError, KeyError, TypeError):
|
|
status, result = 400, {"error": "Некорректная команда камеры."}
|
|
except (RuntimeError, OSError, TimeoutError):
|
|
status, result = 409, {"error": "Камера не ответила. Проверьте подключение."}
|
|
binary = isinstance(result, Binary)
|
|
data = (
|
|
result.data
|
|
if binary
|
|
else json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
|
|
)
|
|
limit = (
|
|
4 * 1024 * 1024 + 1028
|
|
if binary
|
|
else INVENTORY_LIMIT
|
|
if self.path == "/inventory"
|
|
else LIMIT
|
|
)
|
|
if len(data) > limit:
|
|
binary = False
|
|
status, data = 500, b'{"error":"Camera response exceeds its limit"}'
|
|
self.send_response(status)
|
|
self.send_header(
|
|
"Content-Type", "application/octet-stream" if binary else "application/json"
|
|
)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|