"""Control-plane HTTP API — v1 box/route endpoints plus /healthz. Route dispatch mirrors furtka/furtka/api.py's hand-rolled if/elif style (no framework — see the plan's "why stdlib http.server" note). Shared state (db, wg-easy client, mode, cert resolver, single-tenant account id) is attached to the running HTTPServer instance as `.ctx` by app.py, and read here via `self.server.ctx` — the standard way to share state across per-connection handler instances with stdlib http.server. Account-facing endpoints (POST /v1/accounts and friends) are Phase 4 — see the plan's phased delivery — so `_handle_register` only supports GATEWAY_MODE=single for now. """ from __future__ import annotations import json import os import re import secrets from dataclasses import dataclass from http.server import BaseHTTPRequestHandler from control_plane import boxes, routes from control_plane.db import Database, row_to_dict from control_plane.wgeasy import WgEasyClient, WgEasyError _BOX_ACTION_RE = re.compile(r"^/v1/boxes/([^/]+)/(heartbeat|rotate-token|deregister)$") _ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$") @dataclass class Context: db: Database wgeasy: WgEasyClient mode: str cert_resolver: str | None single_account_id: str | None box_limit: int route_limit_per_box: int class Handler(BaseHTTPRequestHandler): server_version = "furtka-gateway/0.1" # -- helpers ---------------------------------------------------------- @property def ctx(self) -> Context: return self.server.ctx # type: ignore[attr-defined] def log_message(self, format: str, *args: object) -> None: pass def _json(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json_body(self) -> dict: length = int(self.headers.get("Content-Length", 0) or 0) if length == 0: return {} try: data = json.loads(self.rfile.read(length)) except json.JSONDecodeError: return {} return data if isinstance(data, dict) else {} def _bearer_token(self) -> str | None: header = self.headers.get("Authorization", "") if not header.startswith("Bearer "): return None return header[len("Bearer ") :].strip() or None def _authenticate_box(self): """Return the authenticated box row, or None (having already written a 401 response).""" token = self._bearer_token() if token is None: self._json(401, {"error": "missing bearer token"}) return None box = boxes.authenticate_box_token(self.ctx.db, token) if box is None: self._json(401, {"error": "invalid or expired box token"}) return None return box # -- dispatch ----------------------------------------------------------- def do_GET(self) -> None: if self.path == "/healthz": self._json(200, {"status": "ok"}) return if self.path == "/v1/routes": box = self._authenticate_box() if box is None: return rows = routes.list_routes_for_box(self.ctx.db, box["id"]) self._json(200, {"routes": [row_to_dict(r) for r in rows]}) return self._json(404, {"error": "not found"}) def do_POST(self) -> None: if self.path == "/v1/boxes/register": self._handle_register() return m = _BOX_ACTION_RE.match(self.path) if m: self._handle_box_action(m.group(1), m.group(2)) return if self.path == "/v1/routes": self._handle_publish_route() return self._json(404, {"error": "not found"}) def do_DELETE(self) -> None: m = _ROUTE_ID_RE.match(self.path) if m: box = self._authenticate_box() if box is None: return if routes.unpublish_route(self.ctx.db, box["id"], m.group(1)): self._json(200, {"status": "ok"}) else: self._json(404, {"error": "route not found"}) return self._json(404, {"error": "not found"}) # -- handlers ----------------------------------------------------------- def _handle_register(self) -> None: body = self._read_json_body() registration_token = body.get("registration_token") box_name = body.get("box_name") if not registration_token or not box_name: self._json(400, {"error": "registration_token and box_name are required"}) return if self.ctx.mode != "single": self._json( 501, {"error": "shared-mode account registration is not yet implemented"} ) return expected = os.environ.get("GATEWAY_BOX_TOKEN", "") if not expected or not secrets.compare_digest(registration_token, expected): self._json(401, {"error": "invalid registration token"}) return try: result = boxes.register_box( self.ctx.db, self.ctx.wgeasy, self.ctx.single_account_id, box_name, self.ctx.box_limit, ) except boxes.BoxLimitExceeded: self._json(403, {"error": "box limit exceeded for this account"}) return except WgEasyError as e: self._json(502, {"error": f"wg-easy error: {e}"}) return self._json(201, result) def _handle_box_action(self, box_id: str, action: str) -> None: box = self._authenticate_box() if box is None: return if box["id"] != box_id: self._json(403, {"error": "token does not authorize this box"}) return if action == "heartbeat": boxes.touch_last_seen(self.ctx.db, box_id) self._json(200, {"status": "ok"}) elif action == "rotate-token": new_token = boxes.rotate_box_token(self.ctx.db, box_id) self._json(200, {"box_token": new_token}) elif action == "deregister": boxes.deregister_box(self.ctx.db, self.ctx.wgeasy, box_id) self._json(200, {"status": "ok"}) def _handle_publish_route(self) -> None: box = self._authenticate_box() if box is None: return body = self._read_json_body() app_name = body.get("app_name") subdomain = body.get("subdomain") target_port = body.get("port") if not app_name or not subdomain or target_port is None: self._json(400, {"error": "app_name, subdomain, and port are required"}) return try: result = routes.publish_route( self.ctx.db, box["id"], box["account_id"], app_name, subdomain, target_port, self.ctx.route_limit_per_box, self.ctx.cert_resolver, ) except routes.SubdomainTaken: self._json(409, {"error": "subdomain already in use"}) return except routes.RouteLimitExceeded: self._json(403, {"error": "route limit exceeded for this box"}) return except routes.InvalidRoute as e: self._json(400, {"error": str(e)}) return self._json(201, result)