"""Authenticated stdlib HTTP server; ROS imports are optional and isolated.""" import argparse import hmac from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import ipaddress import json import os from pathlib import Path import signal import threading import time from urllib.parse import urlsplit from .backends import MockBackend, Ros1MoveBaseBackend from .gateway import Gateway, GatewayError, SafetyConfig def make_server(gateway, token, host="127.0.0.1", port=8766): if not isinstance(token, str) or len(token) < 16: raise ValueError("bearer token must contain at least 16 characters") class Handler(BaseHTTPRequestHandler): server_version = "NavigationGateway/1" def setup(self): super().setup() self.connection.settimeout(5.0) # HTTP resource bound, not a robot safety threshold. def log_message(self, format, *args): return # Never log bearer headers or raw task input. def respond(self, status, body): raw = json.dumps(body, allow_nan=False, separators=(",", ":")).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(raw) def body(self): if self.headers.get("Transfer-Encoding"): raise GatewayError("chunked bodies are unsupported") try: length = int(self.headers.get("Content-Length", "0")) except ValueError: raise GatewayError("invalid Content-Length") if not 0 < length <= 65536: raise GatewayError("body must contain 1..65536 bytes", 413) if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json": raise GatewayError("application/json required", 415) def duplicate_safe(pairs): obj = {} for key, value in pairs: if key in obj: raise GatewayError("duplicate JSON field") obj[key] = value return obj try: return json.loads(self.rfile.read(length), object_pairs_hook=duplicate_safe, parse_constant=lambda value: (_ for _ in ()).throw(GatewayError("non-finite JSON value"))) except (ValueError, UnicodeError): raise GatewayError("invalid JSON") def handle_request(self): try: expected = "Bearer " + token if not hmac.compare_digest(self.headers.get("Authorization", ""), expected): raise GatewayError("valid bearer authorization required", 401) parts = urlsplit(self.path) if parts.query or parts.fragment: raise GatewayError("query parameters unsupported") path = parts.path.rstrip("/") if self.command == "GET" and path == "/healthz": return self.respond(200, gateway.health()) if self.command == "POST" and path == "/v1/goals": return self.respond(202, gateway.submit(self.body())) split = path.split("/") if len(split) in (4, 5) and split[1:3] == ["v1", "goals"]: goal_id = split[3] if self.command == "GET" and len(split) == 4: return self.respond(200, gateway.poll(goal_id)) if self.command == "POST" and len(split) == 5 and split[4] == "cancel": if self.body() != {}: raise GatewayError("cancel body must be {}") return self.respond(202, gateway.cancel(goal_id)) raise GatewayError("route not found", 404) except GatewayError as exc: self.respond(exc.http_status, {"error": str(exc)}) except (TimeoutError, ConnectionError, BrokenPipeError): self.close_connection = True except Exception: self.respond(500, {"error": "internal error; query original UUID; never resubmit with a new UUID"}) do_GET = handle_request do_POST = handle_request class Server(ThreadingHTTPServer): daemon_threads = True request_queue_size = 16 return Server((host, port), Handler) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", required=True, help="JSON config with explicit safety thresholds") parser.add_argument("--journal", required=True, help="durable SQLite journal; one per physical robot") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8766) args = parser.parse_args() if not ipaddress.ip_address(args.host).is_loopback: parser.error("HTTP bearer transport listens only on loopback; use an authenticated SSH/TLS tunnel") token = os.environ.get("NAV_GATEWAY_TOKEN", "") if len(token) < 16: parser.error("set NAV_GATEWAY_TOKEN to at least 16 characters") config = json.loads(Path(args.config).read_text()) safety = SafetyConfig(**config["safety"]) if config["mode"] == "simulation": backend = MockBackend(map_id=config["map_id"], auto_complete_sec=config["simulation_complete_sec"]) elif config["mode"] == "ros1_move_base": if config.get("production_enable") is not True: parser.error("production_enable must be explicitly true after site acceptance") import rospy rospy.init_node("navigation_http_gateway", disable_signals=True) backend = Ros1MoveBaseBackend(map_id=config["map_id"], **config["ros1"]) else: parser.error("mode must be simulation or ros1_move_base") period = config["poll_interval_sec"] if isinstance(period, bool) or not isinstance(period, (int, float)) or not 0 < period <= safety.odom_max_age_sec: parser.error("poll_interval_sec must be positive and <= odom_max_age_sec") gateway = Gateway(args.journal, backend, safety) server = make_server(gateway, token, args.host, args.port) stop = threading.Event() def monitor(): while not stop.wait(period): try: gateway.poll_all() except Exception: # Persist/telemetry errors must not release resource ownership. stop.set() threading.Thread(target=server.shutdown, daemon=True).start() thread = threading.Thread(target=monitor, daemon=True); thread.start() def shutdown(signum, frame): stop.set() threading.Thread(target=server.shutdown, daemon=True).start() signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGINT, shutdown) try: server.serve_forever(poll_interval=0.1) finally: stop.set(); thread.join(timeout=2) # Best effort cancel cannot turn shutdown into stop confirmation. for goal_id in list(gateway.records): try: gateway.cancel(goal_id) except Exception: pass server.server_close(); gateway.close() if __name__ == "__main__": main()