furtka-gateway/control_plane/api.py

223 lines
7.5 KiB
Python
Raw Permalink Normal View History

"""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)