140 lines
6.9 KiB
Python
140 lines
6.9 KiB
Python
"""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()
|