furtka-gateway/control_plane/api.py

223 lines
7.5 KiB
Python
Raw 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
feat: ship control-plane standalone, bring your own Traefik/wg-easy Restructure the deployment model: instead of bundling and owning its own Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy the operator already runs (as sibling containers, or otherwise reachable). This avoids duplicating infra self-hosters commonly already have, and sidesteps having to run a second ACME/DNS setup entirely — the operator's own Traefik already has a working cert resolver. - New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live against a real running container (auth model, route shapes, and the new per-client PresharedKey all differ materially from v14's API). Threaded the preshared key through to the box side (furtka/wireguard.py in the furtka repo) so v15's handshake actually completes. - GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE- derived hardcoded "le", so it can mirror whatever resolver name (if any) the operator's own Traefik already has configured. - Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh asks how the operator's Traefik reaches it (same docker network, joined via `docker network connect`, or a separate host) instead of prompting for DNS-01 providers and port remaps single-parallel mode needed. - single-parallel mode is retired outright — nothing needs it once furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for. - The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to examples/starter-stack/ as the documented zero-infra on-ramp. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
from control_plane.wgeasy import WgEasyError, WgEasyProtocol
_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
feat: ship control-plane standalone, bring your own Traefik/wg-easy Restructure the deployment model: instead of bundling and owning its own Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy the operator already runs (as sibling containers, or otherwise reachable). This avoids duplicating infra self-hosters commonly already have, and sidesteps having to run a second ACME/DNS setup entirely — the operator's own Traefik already has a working cert resolver. - New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live against a real running container (auth model, route shapes, and the new per-client PresharedKey all differ materially from v14's API). Threaded the preshared key through to the box side (furtka/wireguard.py in the furtka repo) so v15's handshake actually completes. - GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE- derived hardcoded "le", so it can mirror whatever resolver name (if any) the operator's own Traefik already has configured. - Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh asks how the operator's Traefik reaches it (same docker network, joined via `docker network connect`, or a separate host) instead of prompting for DNS-01 providers and port remaps single-parallel mode needed. - single-parallel mode is retired outright — nothing needs it once furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for. - The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to examples/starter-stack/ as the documented zero-infra on-ramp. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00
wgeasy: WgEasyProtocol
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)