223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
|
|
"""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)
|