#!/usr/bin/env python3 """Busfleet Hub — fleet registration + dashboard server. Listens on :8080. Routes: GET / — fleet dashboard (HTML) GET /health — health check GET /api/register/ — assign tunnel port, return JSON GET /api/fleet — all registered devices with tunnel status + access links POST /api/authorize-key — authorize device tunnel key """ import json import os import socket import subprocess import sys from http.server import HTTPServer, BaseHTTPRequestHandler REGISTER_SH = "/opt/busfleet-hub/register.sh" REGISTRY = "/opt/busfleet-hub/port-registry.json" DASHBOARD = "/opt/busfleet-hub/dashboard.html" HUB_IP = "162.243.83.36" # ── Helper: check if a TCP port is open (tunnel active) ────────── def _port_is_open(port): try: s = socket.create_connection(("127.0.0.1", port), timeout=2) s.close() return True except Exception: return False class HubHandler(BaseHTTPRequestHandler): """Handle hub registration, fleet status, and dashboard requests.""" def do_GET(self): # Fleet dashboard (homepage) if self.path in ("/", "/index.html"): self.serve_file(DASHBOARD, "text/html; charset=utf-8") return # Health check if self.path == "/health": self.send_json(200, '{"status":"ok","service":"busfleet-hub"}') return # Device registration if self.path.startswith("/api/register/"): device_id = self.path.split("/api/register/", 1)[1].strip("/") if not device_id: self.send_json_error(400, "Missing device ID") return self.call_register_script(device_id) return # Fleet status — all registered devices if self.path == "/api/fleet": self.serve_fleet_status() return self.send_json_error(404, "Not found") def do_POST(self): # Key authorization if self.path.startswith("/api/authorize-key"): content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length).decode() if content_length else "{}" try: data = json.loads(body) except json.JSONDecodeError: self.send_json_error(400, "Invalid JSON body") return device_id = data.get("device_id", "").strip() pubkey = data.get("pubkey", "").strip() if not device_id or not pubkey: self.send_json_error(400, "Missing device_id or pubkey") return if not pubkey.startswith(("ssh-", "ecdsa-", "sk-")) or len(pubkey) < 80: self.send_json_error(400, "Invalid SSH public key format") return try: result = subprocess.run( [REGISTER_SH, "--authorize-key", device_id, pubkey], capture_output=True, text=True, timeout=15, ) status = 200 if result.returncode == 0 else 500 self.send_json(status, result.stdout.strip()) except subprocess.TimeoutExpired: self.send_json_error(504, "Key authorization timed out") except Exception as exc: self.send_json_error(500, str(exc)) return self.send_json_error(404, "Not found") # ── Helpers ──────────────────────────────────────────────────── def serve_file(self, path, content_type): """Serve a static file.""" if not os.path.isfile(path): self.send_json_error(404, "File not found") return with open(path, "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def call_register_script(self, device_id): """Run register.sh and return its output.""" try: result = subprocess.run( [REGISTER_SH, device_id], capture_output=True, text=True, timeout=15, ) status = 200 if result.returncode == 0 else 500 self.send_json(status, result.stdout.strip()) except subprocess.TimeoutExpired: self.send_json_error(504, "Registration timed out") except Exception as exc: self.send_json_error(500, str(exc)) def serve_fleet_status(self): """Build and return fleet status from port-registry.json + live tunnel checks.""" devices = [] try: with open(REGISTRY) as f: registry = json.load(f) except Exception: registry = {} for dev_id, entry in registry.items(): port = entry.get("tunnel_port", 0) tunnel_up = _port_is_open(port) if port else False devices.append({ "device_id": dev_id, "tunnel_port": port, "assigned": entry.get("assigned", ""), "tunnel_up": tunnel_up, "ssh_command": f"ssh -p {port} kitadmin@{HUB_IP}", "dashboard_url": f"http://{HUB_IP}:{port}" if tunnel_up else None, }) # Sort: tunnel_up first, then by device_id devices.sort(key=lambda d: (not d["tunnel_up"], d["device_id"])) self.send_json(200, json.dumps({ "hub": HUB_IP, "device_count": len(devices), "devices": devices, })) def send_json(self, status, body): """Send a JSON response.""" self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body.encode() if isinstance(body, str) else body) def send_json_error(self, status, message): """Send a JSON error response.""" self.send_json(status, json.dumps({"error": message})) def log_message(self, fmt, *args): print(f"[hub] {args[0]}", file=sys.stderr) def main(): port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 server = HTTPServer(("0.0.0.0", port), HubHandler) print(f"Busfleet Hub listening on :{port}", flush=True) print(f" Dashboard: http://{HUB_IP}:{port}/", flush=True) try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down.", flush=True) server.shutdown() if __name__ == "__main__": main()