Scaffold gateway stack and single-tenant control plane
wg-easy + Traefik docker-compose stack (Phase 1) plus a stdlib-only control-plane API for box registration, WireGuard peer provisioning via wg-easy, and per-box route publish/unpublish backed by Traefik's file provider (Phase 2, single-tenant mode). SQLite holds accounts/boxes/routes so a later multi-tenant shared instance is the same schema with more rows, not a reshape. wg-easy's actual REST API was verified against its source rather than assumed: it has no bearer-token auth (session-cookie login via POST /api/auth/password) and no way to accept an externally-generated public key (it always mints the keypair itself, private key included) — both corrected from the original plan during implementation.
This commit is contained in:
commit
3a9d18fcd5
32 changed files with 2054 additions and 0 deletions
36
.env.example
Normal file
36
.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Which cert/tenancy strategy to run: "single" or "shared".
|
||||||
|
# See traefik/static/traefik.single.yml vs traefik.shared.yml.
|
||||||
|
GATEWAY_MODE=single
|
||||||
|
|
||||||
|
# The hostname (or IP, for a first local smoke test) wg-easy advertises to
|
||||||
|
# peers as the WireGuard endpoint. Must be reachable on udp/51820.
|
||||||
|
GATEWAY_PUBLIC_HOST=vpn.example.com
|
||||||
|
|
||||||
|
# bcrypt hash of the wg-easy admin UI password. Generate with:
|
||||||
|
# docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
|
||||||
|
# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password'
|
||||||
|
WG_EASY_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Credentials the control-plane uses to log into wg-easy's own admin API
|
||||||
|
# (POST /api/auth/password -> session cookie; wg-easy has no separate
|
||||||
|
# bearer-token auth). WG_EASY_ADMIN_PASSWORD is the PLAINTEXT password
|
||||||
|
# corresponding to WG_EASY_PASSWORD_HASH above — wg-easy only ever sees the
|
||||||
|
# hash, but the control-plane needs the plaintext to log in the same way a
|
||||||
|
# human would through the UI.
|
||||||
|
WG_EASY_ADMIN_USERNAME=admin
|
||||||
|
WG_EASY_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# GATEWAY_MODE=single only: the one box token a single-tenant deployment
|
||||||
|
# accepts at /v1/boxes/register, skipping full account/registration-token
|
||||||
|
# issuance. Generate with: openssl rand -hex 32
|
||||||
|
GATEWAY_BOX_TOKEN=
|
||||||
|
|
||||||
|
# Bearer token required to create accounts via POST /v1/accounts.
|
||||||
|
# Only meaningful once account endpoints exist (Phase 2+); harmless to set
|
||||||
|
# now. Generate with: openssl rand -hex 32
|
||||||
|
GATEWAY_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# GATEWAY_MODE=shared only: base domain subdomains are issued under, and the
|
||||||
|
# DNS provider Traefik's DNS-01 challenge should use for the wildcard cert.
|
||||||
|
# See traefik/static/traefik.shared.yml.
|
||||||
|
GATEWAY_BASE_DOMAIN=boxes.example.com
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
*.db
|
||||||
|
acme.json
|
||||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# furtka-gateway
|
||||||
|
|
||||||
|
WireGuard (wg-easy) + Traefik reverse-proxy that lets a Furtka box — typically
|
||||||
|
sitting behind NAT/CGNAT with no public IP — expose individual apps to the
|
||||||
|
internet under a real domain with automatic TLS.
|
||||||
|
|
||||||
|
A Furtka box becomes a WireGuard peer of this gateway. The gateway's Traefik
|
||||||
|
reverse-proxies public HTTPS traffic over that tunnel to whichever apps the
|
||||||
|
box owner has explicitly published. Only apps whose Furtka `manifest.json`
|
||||||
|
declares `internet.viable: true` can be published at all, and publishing is
|
||||||
|
always an explicit per-app opt-in on the box side — the gateway never exposes
|
||||||
|
anything on its own.
|
||||||
|
|
||||||
|
Supports two deployment modes from the same codebase:
|
||||||
|
|
||||||
|
- **`single`** — the common case: one person/operator runs this on their own
|
||||||
|
VPS with their own domain's A/AAAA records pointed at it, for their own
|
||||||
|
Furtka box(es).
|
||||||
|
- **`shared`** — one larger, multi-tenant instance (operated by the Furtka
|
||||||
|
project) for people who don't want to run their own. Same schema, same
|
||||||
|
code; `single` is just the one-account case.
|
||||||
|
|
||||||
|
See `docker-compose.yaml` and `control_plane/` for the moving parts. Status:
|
||||||
|
**Phase 1 (scaffold)** — wg-easy + Traefik + a stub control-plane exposing
|
||||||
|
only `/healthz`, enough to manually prove the wiring end-to-end before the
|
||||||
|
real control-plane (accounts/boxes/routes) lands.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: set GATEWAY_PUBLIC_HOST to a real hostname you control (or a
|
||||||
|
# LAN-reachable IP for a first smoke test), and a PASSWORD_HASH for wg-easy
|
||||||
|
# (see https://github.com/wg-easy/wg-easy for how to generate one).
|
||||||
|
docker compose up -d
|
||||||
|
curl http://127.0.0.1:8090/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik's dashboard/API and wg-easy's own UI are intentionally not published
|
||||||
|
on a host port — reach them via `docker compose exec` / port-forwarding
|
||||||
|
during development. Neither should ever be reachable from the internet in a
|
||||||
|
real deployment; only the control-plane (`8090`, bound to `127.0.0.1`) and
|
||||||
|
Traefik's `80`/`443` entrypoints are meant to be exposed.
|
||||||
7
control_plane/Dockerfile
Normal file
7
control_plane/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app/control_plane
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
CMD ["python", "-m", "control_plane.app", "--host", "0.0.0.0", "--port", "8090"]
|
||||||
0
control_plane/__init__.py
Normal file
0
control_plane/__init__.py
Normal file
46
control_plane/accounts.py
Normal file
46
control_plane/accounts.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Account bootstrap/lookup.
|
||||||
|
|
||||||
|
Full multi-tenant account issuance (``POST /v1/accounts``, registration-token
|
||||||
|
rotation, per-account management) is Phase 4 — see the plan's phased
|
||||||
|
delivery. Phase 2 only needs the single implicit account a
|
||||||
|
``GATEWAY_MODE=single`` deployment bootstraps itself with at startup: its
|
||||||
|
``registration_token_hash``/``account_token_hash`` are left blank because
|
||||||
|
single-tenant auth for box registration goes through the operator-set
|
||||||
|
``GATEWAY_BOX_TOKEN`` env var instead (see api.py), not through this row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
SINGLE_TENANT_ACCOUNT_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_single_tenant_account(
|
||||||
|
db: Database, box_limit: int, route_limit_per_box: int
|
||||||
|
) -> str:
|
||||||
|
"""Idempotently create the one account a single-tenant gateway uses.
|
||||||
|
|
||||||
|
Returns the account id every registered box on this gateway belongs to.
|
||||||
|
"""
|
||||||
|
row = db.query_one("SELECT id FROM accounts WHERE id = ?", (SINGLE_TENANT_ACCOUNT_ID,))
|
||||||
|
if row is not None:
|
||||||
|
return SINGLE_TENANT_ACCOUNT_ID
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts
|
||||||
|
(id, email, created_at, registration_token_hash, account_token_hash,
|
||||||
|
box_limit, route_limit_per_box)
|
||||||
|
VALUES (?, NULL, ?, '', '', ?, ?)
|
||||||
|
""",
|
||||||
|
(SINGLE_TENANT_ACCOUNT_ID, now, box_limit, route_limit_per_box),
|
||||||
|
)
|
||||||
|
return SINGLE_TENANT_ACCOUNT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def get_account(db: Database, account_id: str) -> sqlite3.Row | None:
|
||||||
|
return db.query_one("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||||
222
control_plane/api.py
Normal file
222
control_plane/api.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""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)
|
||||||
60
control_plane/app.py
Normal file
60
control_plane/app.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
from control_plane import accounts
|
||||||
|
from control_plane.api import Context, Handler
|
||||||
|
from control_plane.db import Database
|
||||||
|
from control_plane.wgeasy import WgEasyClient
|
||||||
|
|
||||||
|
DEFAULT_BOX_LIMIT = 20
|
||||||
|
DEFAULT_ROUTE_LIMIT_PER_BOX = 20
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(db: Database | None = None) -> Context:
|
||||||
|
mode = os.environ.get("GATEWAY_MODE", "single")
|
||||||
|
db = db or Database()
|
||||||
|
|
||||||
|
wgeasy = WgEasyClient(
|
||||||
|
base_url=os.environ.get("WG_EASY_URL", "http://wg-easy:51821"),
|
||||||
|
username=os.environ.get("WG_EASY_ADMIN_USERNAME", "admin"),
|
||||||
|
password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
single_account_id = None
|
||||||
|
if mode == "single":
|
||||||
|
single_account_id = accounts.ensure_single_tenant_account(
|
||||||
|
db, DEFAULT_BOX_LIMIT, DEFAULT_ROUTE_LIMIT_PER_BOX
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shared mode uses one wildcard defaultGeneratedCert for every router
|
||||||
|
# (see traefik.shared.yml) rather than a per-router certResolver.
|
||||||
|
cert_resolver = "le" if mode == "single" else None
|
||||||
|
|
||||||
|
return Context(
|
||||||
|
db=db,
|
||||||
|
wgeasy=wgeasy,
|
||||||
|
mode=mode,
|
||||||
|
cert_resolver=cert_resolver,
|
||||||
|
single_account_id=single_account_id,
|
||||||
|
box_limit=DEFAULT_BOX_LIMIT,
|
||||||
|
route_limit_per_box=DEFAULT_ROUTE_LIMIT_PER_BOX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(host: str = "0.0.0.0", port: int = 8090) -> None:
|
||||||
|
server = ThreadingHTTPServer((host, port), Handler)
|
||||||
|
server.ctx = build_context()
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="furtka-gateway control plane")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8090)
|
||||||
|
args = parser.parse_args()
|
||||||
|
serve(args.host, args.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
117
control_plane/boxes.py
Normal file
117
control_plane/boxes.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Box (WireGuard peer) lifecycle: registration, rotation, deregistration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from control_plane import routes, tokens
|
||||||
|
from control_plane.db import Database
|
||||||
|
from control_plane.passwd import hash_password, verify_password
|
||||||
|
from control_plane.wgeasy import WgEasyClient
|
||||||
|
|
||||||
|
|
||||||
|
class BoxLimitExceeded(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _box_count(db: Database, account_id: str) -> int:
|
||||||
|
row = db.query_one("SELECT COUNT(*) AS n FROM boxes WHERE account_id = ?", (account_id,))
|
||||||
|
return row["n"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_box(
|
||||||
|
db: Database,
|
||||||
|
wgeasy: WgEasyClient,
|
||||||
|
account_id: str,
|
||||||
|
box_name: str,
|
||||||
|
box_limit: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a wg-easy peer for a new box and persist it.
|
||||||
|
|
||||||
|
Returns everything the box needs to bring its tunnel up itself: a box
|
||||||
|
token for future gateway API calls, and the full WireGuard interface
|
||||||
|
config (including the private key — see wgeasy.py's module docstring
|
||||||
|
for why the gateway ends up handling that at all).
|
||||||
|
"""
|
||||||
|
if _box_count(db, account_id) >= box_limit:
|
||||||
|
raise BoxLimitExceeded(account_id)
|
||||||
|
|
||||||
|
peer = wgeasy.create_client(box_name)
|
||||||
|
|
||||||
|
box_id = uuid.uuid4().hex
|
||||||
|
box_token, box_secret = tokens.issue(box_id)
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO boxes
|
||||||
|
(id, account_id, name, wg_public_key, wg_peer_id, wg_allowed_ip,
|
||||||
|
box_token_hash, registered_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
box_id,
|
||||||
|
account_id,
|
||||||
|
box_name,
|
||||||
|
peer["public_key"],
|
||||||
|
peer["id"],
|
||||||
|
peer["address"],
|
||||||
|
hash_password(box_secret),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"box_id": box_id,
|
||||||
|
"box_token": box_token,
|
||||||
|
"wg": {
|
||||||
|
"private_key": peer["private_key"],
|
||||||
|
"public_key": peer["public_key"],
|
||||||
|
"address": peer["address"],
|
||||||
|
"server_public_key": peer["server_public_key"],
|
||||||
|
"endpoint": peer["endpoint"],
|
||||||
|
"allowed_ips": peer["allowed_ips"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_box_token(db: Database, token: str) -> sqlite3.Row | None:
|
||||||
|
parsed = tokens.split(token)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
box_id, secret = parsed
|
||||||
|
row = db.query_one("SELECT * FROM boxes WHERE id = ?", (box_id,))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if not verify_password(secret, row["box_token_hash"]):
|
||||||
|
return None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def touch_last_seen(db: Database, box_id: str) -> None:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE boxes SET last_seen_at = ? WHERE id = ?",
|
||||||
|
(datetime.now(UTC).isoformat(), box_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_box_token(db: Database, box_id: str) -> str | None:
|
||||||
|
row = db.query_one("SELECT id FROM boxes WHERE id = ?", (box_id,))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
box_token, box_secret = tokens.issue(box_id)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE boxes SET box_token_hash = ? WHERE id = ?",
|
||||||
|
(hash_password(box_secret), box_id),
|
||||||
|
)
|
||||||
|
return box_token
|
||||||
|
|
||||||
|
|
||||||
|
def deregister_box(db: Database, wgeasy: WgEasyClient, box_id: str) -> bool:
|
||||||
|
row = db.query_one("SELECT wg_peer_id FROM boxes WHERE id = ?", (box_id,))
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
routes.unpublish_all_for_box(db, box_id)
|
||||||
|
wgeasy.delete_client(row["wg_peer_id"])
|
||||||
|
db.execute("DELETE FROM boxes WHERE id = ?", (box_id,))
|
||||||
|
return True
|
||||||
55
control_plane/db.py
Normal file
55
control_plane/db.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Thin sqlite3 wrapper: one connection per process, guarded by a lock.
|
||||||
|
|
||||||
|
Why sqlite3 instead of furtka-core's flat-JSON-plus-flock convention: that
|
||||||
|
convention works because each file is effectively single-writer. This
|
||||||
|
gateway's defining requirement is many unrelated accounts/boxes registering
|
||||||
|
and publishing routes concurrently — a locked JSON file would serialize
|
||||||
|
every tenant's writes against every other tenant's. sqlite3 (stdlib, zero
|
||||||
|
extra dependencies) gives real concurrent relational access instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from control_plane import paths
|
||||||
|
|
||||||
|
_SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path | None = None) -> None:
|
||||||
|
path = db_path or paths.db_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._conn = sqlite3.connect(path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
with self._lock:
|
||||||
|
self._conn.executescript(_SCHEMA_PATH.read_text())
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: tuple = ()) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute(sql, params)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def query_one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._conn.execute(sql, params).fetchone()
|
||||||
|
|
||||||
|
def query_all(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||||
|
with self._lock:
|
||||||
|
return self._conn.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_dict(row: sqlite3.Row | None) -> dict | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return dict(zip(row.keys(), row, strict=True))
|
||||||
82
control_plane/passwd.py
Normal file
82
control_plane/passwd.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Stdlib-only secret hashing.
|
||||||
|
|
||||||
|
Vendored from furtka/furtka/furtka/passwd.py — kept byte-for-byte identical
|
||||||
|
to the source rather than reimplemented, the same "own a copy, keep it in
|
||||||
|
lockstep" approach furtka-apps/scripts/vendor/furtka_manifest.py already
|
||||||
|
uses for the manifest schema. Used here to hash registration/box/account
|
||||||
|
bearer tokens at rest, not just user passwords.
|
||||||
|
|
||||||
|
Format: ``<method>$<salt>$<hex digest>``
|
||||||
|
- ``pbkdf2:<hash>:<iterations>`` — what we generate by default here
|
||||||
|
- ``scrypt:<N>:<r>:<p>`` — accepted for parity with the furtka-core
|
||||||
|
copy; never produced by this module
|
||||||
|
Both are implemented via ``hashlib`` which has been stdlib since 3.6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_PBKDF2_HASH = "sha256"
|
||||||
|
_PBKDF2_ITERATIONS = 600_000
|
||||||
|
_SALT_LEN = 16
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Return a ``pbkdf2:sha256:<iter>$<salt>$<hex>`` hash of *password*.
|
||||||
|
|
||||||
|
PBKDF2-SHA256 over UTF-8. 600k iterations — same as werkzeug's
|
||||||
|
default in the 3.x series, roughly OWASP 2023's recommendation.
|
||||||
|
"""
|
||||||
|
if not isinstance(password, str):
|
||||||
|
raise TypeError("password must be str")
|
||||||
|
salt = secrets.token_urlsafe(_SALT_LEN)[:_SALT_LEN]
|
||||||
|
dk = hashlib.pbkdf2_hmac(
|
||||||
|
_PBKDF2_HASH, password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
|
||||||
|
)
|
||||||
|
return f"pbkdf2:{_PBKDF2_HASH}:{_PBKDF2_ITERATIONS}${salt}${dk.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
"""Constant-time verify *password* against a stored *hashed* value."""
|
||||||
|
if not isinstance(password, str) or not isinstance(hashed, str):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
method, salt, expected = hashed.split("$", 2)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
parts = method.split(":")
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
algo = parts[0]
|
||||||
|
pw_bytes = password.encode("utf-8")
|
||||||
|
salt_bytes = salt.encode("utf-8")
|
||||||
|
try:
|
||||||
|
if algo == "pbkdf2":
|
||||||
|
if len(parts) < 3:
|
||||||
|
return False
|
||||||
|
inner_hash = parts[1]
|
||||||
|
iterations = int(parts[2])
|
||||||
|
dk = hashlib.pbkdf2_hmac(inner_hash, pw_bytes, salt_bytes, iterations)
|
||||||
|
elif algo == "scrypt":
|
||||||
|
if len(parts) < 4:
|
||||||
|
return False
|
||||||
|
n = int(parts[1])
|
||||||
|
r = int(parts[2])
|
||||||
|
p = int(parts[3])
|
||||||
|
dk = hashlib.scrypt(
|
||||||
|
pw_bytes,
|
||||||
|
salt=salt_bytes,
|
||||||
|
n=n,
|
||||||
|
r=r,
|
||||||
|
p=p,
|
||||||
|
dklen=64,
|
||||||
|
maxmem=132 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(dk.hex(), expected)
|
||||||
21
control_plane/paths.py
Normal file
21
control_plane/paths.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_STATE_DIR = Path("/var/lib/furtka-gateway")
|
||||||
|
DEFAULT_DYNAMIC_DIR = Path("/var/lib/furtka-gateway/dynamic")
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir() -> Path:
|
||||||
|
return Path(os.environ.get("GATEWAY_STATE_DIR", DEFAULT_STATE_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def dynamic_dir() -> Path:
|
||||||
|
return Path(os.environ.get("GATEWAY_DYNAMIC_DIR", DEFAULT_DYNAMIC_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> Path:
|
||||||
|
return state_dir() / "gateway.db"
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_state_file() -> Path:
|
||||||
|
return state_dir() / "deploy-state.json"
|
||||||
32
control_plane/reconciler.py
Normal file
32
control_plane/reconciler.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Diff enabled routes in the DB against Traefik dynamic-config files on
|
||||||
|
disk and repair drift.
|
||||||
|
|
||||||
|
Structural echo of furtka/furtka/reconciler.py's "declared state -> drive
|
||||||
|
external state, self-heal on drift" shape. publish_route()/unpublish_route()
|
||||||
|
already write/remove the matching file synchronously, so this isn't on the
|
||||||
|
hot path for normal operation — it's the safety net for anything that made
|
||||||
|
the DB and the filesystem disagree (a crash mid-write, a file deleted by
|
||||||
|
hand, etc). Not yet wired into app.py's server loop; run it from a systemd
|
||||||
|
timer or an ad hoc `python -m control_plane.reconciler` invocation for now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from control_plane import traefikconf
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(db: Database, cert_resolver: str | None) -> None:
|
||||||
|
wanted = db.query_all(
|
||||||
|
"SELECT r.id, r.subdomain, r.target_port, b.wg_allowed_ip "
|
||||||
|
"FROM routes r JOIN boxes b ON b.id = r.box_id "
|
||||||
|
"WHERE r.enabled = 1"
|
||||||
|
)
|
||||||
|
wanted_ids = set()
|
||||||
|
for row in wanted:
|
||||||
|
wanted_ids.add(row["id"])
|
||||||
|
target_ip = row["wg_allowed_ip"].split("/")[0]
|
||||||
|
traefikconf.write_route(row["id"], row["subdomain"], target_ip, row["target_port"], cert_resolver)
|
||||||
|
|
||||||
|
for orphan_id in traefikconf.existing_route_ids() - wanted_ids:
|
||||||
|
traefikconf.remove_route(orphan_id)
|
||||||
122
control_plane/routes.py
Normal file
122
control_plane/routes.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Publish/unpublish routes: a DB row plus a Traefik dynamic-config file.
|
||||||
|
|
||||||
|
The gateway never sees a Furtka app's manifest — a publish call only ever
|
||||||
|
carries {app_name, subdomain, port} from the box. So this module is the
|
||||||
|
place that independently enforces what the manifest-side `internet.viable`
|
||||||
|
check can't: subdomain shape/reserved labels, port bounds, and per-box
|
||||||
|
route caps, regardless of what the calling box claims.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from control_plane import traefikconf
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
_SUBDOMAIN_RE = re.compile(
|
||||||
|
r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
|
||||||
|
)
|
||||||
|
_APP_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||||
|
_RESERVED_LABELS = {"www", "api", "admin", "traefik", "healthz", "gateway", "wg-easy"}
|
||||||
|
_MAX_SUBDOMAIN_LEN = 253
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRoute(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RouteLimitExceeded(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubdomainTaken(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(app_name: str, subdomain: str, target_port: int) -> None:
|
||||||
|
if not isinstance(app_name, str) or not _APP_NAME_RE.match(app_name):
|
||||||
|
raise InvalidRoute(f"invalid app_name {app_name!r}")
|
||||||
|
if (
|
||||||
|
not isinstance(subdomain, str)
|
||||||
|
or len(subdomain) > _MAX_SUBDOMAIN_LEN
|
||||||
|
or not _SUBDOMAIN_RE.match(subdomain)
|
||||||
|
):
|
||||||
|
raise InvalidRoute(f"invalid subdomain {subdomain!r}")
|
||||||
|
first_label = subdomain.split(".", 1)[0]
|
||||||
|
if first_label in _RESERVED_LABELS:
|
||||||
|
raise InvalidRoute(f"subdomain label {first_label!r} is reserved")
|
||||||
|
if not isinstance(target_port, int) or isinstance(target_port, bool):
|
||||||
|
raise InvalidRoute(f"port must be an integer, got {target_port!r}")
|
||||||
|
if not (1 <= target_port <= 65535):
|
||||||
|
raise InvalidRoute(f"port {target_port} out of range 1-65535")
|
||||||
|
|
||||||
|
|
||||||
|
def _route_count(db: Database, box_id: str) -> int:
|
||||||
|
row = db.query_one(
|
||||||
|
"SELECT COUNT(*) AS n FROM routes WHERE box_id = ? AND enabled = 1", (box_id,)
|
||||||
|
)
|
||||||
|
return row["n"]
|
||||||
|
|
||||||
|
|
||||||
|
def publish_route(
|
||||||
|
db: Database,
|
||||||
|
box_id: str,
|
||||||
|
account_id: str,
|
||||||
|
app_name: str,
|
||||||
|
subdomain: str,
|
||||||
|
target_port: int,
|
||||||
|
route_limit_per_box: int,
|
||||||
|
cert_resolver: str | None,
|
||||||
|
) -> dict:
|
||||||
|
_validate(app_name, subdomain, target_port)
|
||||||
|
|
||||||
|
if _route_count(db, box_id) >= route_limit_per_box:
|
||||||
|
raise RouteLimitExceeded(box_id)
|
||||||
|
|
||||||
|
if db.query_one("SELECT id FROM routes WHERE subdomain = ?", (subdomain,)) is not None:
|
||||||
|
raise SubdomainTaken(subdomain)
|
||||||
|
|
||||||
|
box = db.query_one("SELECT wg_allowed_ip FROM boxes WHERE id = ?", (box_id,))
|
||||||
|
if box is None:
|
||||||
|
raise InvalidRoute(f"unknown box {box_id!r}")
|
||||||
|
|
||||||
|
route_id = uuid.uuid4().hex
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO routes
|
||||||
|
(id, box_id, account_id, app_name, subdomain, target_port, enabled, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
""",
|
||||||
|
(route_id, box_id, account_id, app_name, subdomain, target_port, now),
|
||||||
|
)
|
||||||
|
target_ip = box["wg_allowed_ip"].split("/")[0]
|
||||||
|
traefikconf.write_route(route_id, subdomain, target_ip, target_port, cert_resolver)
|
||||||
|
return {"route_id": route_id, "public_url": f"https://{subdomain}/"}
|
||||||
|
|
||||||
|
|
||||||
|
def unpublish_route(db: Database, box_id: str, route_id: str) -> bool:
|
||||||
|
row = db.query_one("SELECT id FROM routes WHERE id = ? AND box_id = ?", (route_id, box_id))
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
db.execute("DELETE FROM routes WHERE id = ?", (route_id,))
|
||||||
|
traefikconf.remove_route(route_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def unpublish_all_for_box(db: Database, box_id: str) -> None:
|
||||||
|
for row in db.query_all("SELECT id FROM routes WHERE box_id = ?", (box_id,)):
|
||||||
|
traefikconf.remove_route(row["id"])
|
||||||
|
db.execute("DELETE FROM routes WHERE box_id = ?", (box_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def list_routes_for_box(db: Database, box_id: str) -> list[sqlite3.Row]:
|
||||||
|
return db.query_all(
|
||||||
|
"SELECT id, app_name, subdomain, target_port, enabled, created_at "
|
||||||
|
"FROM routes WHERE box_id = ?",
|
||||||
|
(box_id,),
|
||||||
|
)
|
||||||
43
control_plane/schema.sql
Normal file
43
control_plane/schema.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Applied idempotently on every startup (CREATE TABLE IF NOT EXISTS).
|
||||||
|
--
|
||||||
|
-- Single-tenant deployments (GATEWAY_MODE=single) use this exact schema
|
||||||
|
-- with one implicit account row auto-seeded at startup — see
|
||||||
|
-- accounts.ensure_single_tenant_account(). Multi-tenant is not a later
|
||||||
|
-- reshape of this; it's the same tables with more than one account row.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
registration_token_hash TEXT NOT NULL,
|
||||||
|
account_token_hash TEXT NOT NULL,
|
||||||
|
box_limit INTEGER NOT NULL DEFAULT 5,
|
||||||
|
route_limit_per_box INTEGER NOT NULL DEFAULT 5
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boxes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
wg_public_key TEXT NOT NULL UNIQUE,
|
||||||
|
wg_peer_id TEXT NOT NULL,
|
||||||
|
wg_allowed_ip TEXT NOT NULL,
|
||||||
|
box_token_hash TEXT NOT NULL,
|
||||||
|
registered_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS routes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
box_id TEXT NOT NULL REFERENCES boxes(id),
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
app_name TEXT NOT NULL,
|
||||||
|
subdomain TEXT NOT NULL UNIQUE,
|
||||||
|
target_port INTEGER NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_boxes_account ON boxes(account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_box ON routes(box_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_account ON routes(account_id);
|
||||||
27
control_plane/tokens.py
Normal file
27
control_plane/tokens.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Bearer-token helpers shared by accounts.py/boxes.py.
|
||||||
|
|
||||||
|
Tokens are minted as ``"<row-id>.<secret>"`` so authenticating one is an
|
||||||
|
O(1) primary-key lookup followed by a single hash comparison, rather than
|
||||||
|
scanning every row and hashing each one looking for a match.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def issue(row_id: str) -> tuple[str, str]:
|
||||||
|
"""Return ``(bearer_token_to_hand_out, secret_to_hash_and_store)``."""
|
||||||
|
secret = generate_secret()
|
||||||
|
return f"{row_id}.{secret}", secret
|
||||||
|
|
||||||
|
|
||||||
|
def split(token: str) -> tuple[str, str] | None:
|
||||||
|
if not isinstance(token, str) or "." not in token:
|
||||||
|
return None
|
||||||
|
row_id, _, secret = token.partition(".")
|
||||||
|
if not row_id or not secret:
|
||||||
|
return None
|
||||||
|
return row_id, secret
|
||||||
72
control_plane/traefikconf.py
Normal file
72
control_plane/traefikconf.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Write/remove per-route Traefik dynamic-config files.
|
||||||
|
|
||||||
|
Mirrors furtka/furtka/https.py's snippet-write pattern: write to a temp
|
||||||
|
file, then atomically rename over the target, so Traefik's file provider
|
||||||
|
never observes a half-written route. Unlike https.py's Caddy target, no
|
||||||
|
reload call is needed here — Traefik's file provider hot-reloads on change.
|
||||||
|
|
||||||
|
Config is built with plain string formatting rather than a YAML library,
|
||||||
|
to keep the control-plane's dependency footprint at zero (stdlib only, the
|
||||||
|
same choice the rest of this repo makes) — the shape here is small and
|
||||||
|
fixed enough that hand-formatting is simpler than it sounds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from control_plane import paths
|
||||||
|
|
||||||
|
|
||||||
|
def _route_file(route_id: str) -> Path:
|
||||||
|
return paths.dynamic_dir() / f"route-{route_id}.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def write_route(
|
||||||
|
route_id: str,
|
||||||
|
subdomain: str,
|
||||||
|
target_ip: str,
|
||||||
|
target_port: int,
|
||||||
|
cert_resolver: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""(Re)write the dynamic-config file that makes `subdomain` proxy to
|
||||||
|
`target_ip:target_port`. Idempotent — safe to call for an unchanged
|
||||||
|
route, which is what reconciler.py relies on.
|
||||||
|
"""
|
||||||
|
if cert_resolver:
|
||||||
|
tls_block = f" tls:\n certResolver: {cert_resolver}\n"
|
||||||
|
else:
|
||||||
|
tls_block = " tls: {}\n"
|
||||||
|
|
||||||
|
content = (
|
||||||
|
"http:\n"
|
||||||
|
" routers:\n"
|
||||||
|
f" route-{route_id}:\n"
|
||||||
|
f' rule: "Host(`{subdomain}`)"\n'
|
||||||
|
" entryPoints: [websecure]\n"
|
||||||
|
f" service: svc-{route_id}\n"
|
||||||
|
f"{tls_block}"
|
||||||
|
" services:\n"
|
||||||
|
f" svc-{route_id}:\n"
|
||||||
|
" loadBalancer:\n"
|
||||||
|
" servers:\n"
|
||||||
|
f' - url: "http://{target_ip}:{target_port}"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
target = _route_file(route_id)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = target.with_suffix(".tmp")
|
||||||
|
tmp.write_text(content)
|
||||||
|
os.replace(tmp, target)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_route(route_id: str) -> None:
|
||||||
|
_route_file(route_id).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_route_ids() -> set[str]:
|
||||||
|
return {
|
||||||
|
f.name.removeprefix("route-").removesuffix(".yml")
|
||||||
|
for f in paths.dynamic_dir().glob("route-*.yml")
|
||||||
|
}
|
||||||
136
control_plane/wgeasy.py
Normal file
136
control_plane/wgeasy.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Thin REST client for wg-easy's admin API.
|
||||||
|
|
||||||
|
Verified against wg-easy's actual source (github.com/wg-easy/wg-easy,
|
||||||
|
src/server/api/{auth,client}/*) rather than assumed — an earlier draft of
|
||||||
|
this plan guessed at a base path of /api/wireguard/client and assumed a
|
||||||
|
bearer-token auth scheme with bring-your-own-public-key support. Neither is
|
||||||
|
true. The real shape:
|
||||||
|
|
||||||
|
POST /api/auth/password {username, password, remember}
|
||||||
|
-> sets a session cookie (h3's own
|
||||||
|
session mechanism; there is no
|
||||||
|
separate bearer-token auth)
|
||||||
|
POST /api/client {name} -> {success, clientId}
|
||||||
|
GET /api/client/{id} -> client record, includes .publicKey
|
||||||
|
GET /api/client/{id}/configuration -> raw wg-quick .conf text
|
||||||
|
(Content-Type: application/octet-stream)
|
||||||
|
DELETE /api/client/{id} -> remove the peer
|
||||||
|
|
||||||
|
wg-easy always generates the WireGuard keypair itself; the private key is
|
||||||
|
only ever obtainable via the .conf download, never returned by the create
|
||||||
|
call. There is no supported way to hand wg-easy an externally-generated
|
||||||
|
public key. See the plan's "Correction found during implementation" note
|
||||||
|
for the resulting threat-model consequence (the gateway sees every box's
|
||||||
|
private key at registration time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
|
||||||
|
|
||||||
|
class WgEasyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WgEasyClient:
|
||||||
|
def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._timeout = timeout
|
||||||
|
self._opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(CookieJar())
|
||||||
|
)
|
||||||
|
self._logged_in = False
|
||||||
|
|
||||||
|
def create_client(self, name: str) -> dict:
|
||||||
|
"""Create a new WireGuard peer and return its full connection material.
|
||||||
|
|
||||||
|
wg-easy generates the keypair; we fetch the private key immediately
|
||||||
|
via the /configuration endpoint since create_client's own response
|
||||||
|
never includes it.
|
||||||
|
"""
|
||||||
|
created = json.loads(self._authed_request("POST", "/api/client", {"name": name}))
|
||||||
|
client_id = created["clientId"]
|
||||||
|
|
||||||
|
info = json.loads(self._authed_request("GET", f"/api/client/{client_id}"))
|
||||||
|
|
||||||
|
conf_text = self._authed_request(
|
||||||
|
"GET", f"/api/client/{client_id}/configuration"
|
||||||
|
).decode()
|
||||||
|
conf = _parse_wg_conf(conf_text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": client_id,
|
||||||
|
"public_key": info["publicKey"],
|
||||||
|
"private_key": conf["private_key"],
|
||||||
|
"address": conf["address"],
|
||||||
|
"server_public_key": conf["peer_public_key"],
|
||||||
|
"endpoint": conf["endpoint"],
|
||||||
|
"allowed_ips": conf["allowed_ips"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_client(self, client_id: str) -> None:
|
||||||
|
try:
|
||||||
|
self._authed_request("DELETE", f"/api/client/{client_id}")
|
||||||
|
except WgEasyError:
|
||||||
|
# Already gone on wg-easy's side shouldn't block us cleaning up
|
||||||
|
# our own box/route rows — deregistration must still succeed.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- transport ------------------------------------------------------
|
||||||
|
|
||||||
|
def _login(self) -> None:
|
||||||
|
self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/password",
|
||||||
|
{"username": self._username, "password": self._password, "remember": True},
|
||||||
|
)
|
||||||
|
self._logged_in = True
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, body: dict | None = None) -> bytes:
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._base_url}{path}", data=data, method=method, headers=headers
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self._opener.open(req, timeout=self._timeout) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode(errors="replace")
|
||||||
|
raise WgEasyError(f"{method} {path} -> HTTP {e.code}: {detail}") from e
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise WgEasyError(f"{method} {path} -> {e}") from e
|
||||||
|
|
||||||
|
def _authed_request(self, method: str, path: str, body: dict | None = None) -> bytes:
|
||||||
|
if not self._logged_in:
|
||||||
|
self._login()
|
||||||
|
try:
|
||||||
|
return self._request(method, path, body)
|
||||||
|
except WgEasyError:
|
||||||
|
# Session cookie may have expired between calls — retry once
|
||||||
|
# after a fresh login before giving up.
|
||||||
|
self._login()
|
||||||
|
return self._request(method, path, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_wg_conf(text: str) -> dict:
|
||||||
|
def find(pattern: str) -> str:
|
||||||
|
m = re.search(pattern, text, re.MULTILINE)
|
||||||
|
if not m:
|
||||||
|
raise WgEasyError(f"could not find {pattern!r} in wg-easy client configuration")
|
||||||
|
return m.group(1).strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"private_key": find(r"^PrivateKey\s*=\s*(.+)$"),
|
||||||
|
"address": find(r"^Address\s*=\s*(.+)$"),
|
||||||
|
"peer_public_key": find(r"^PublicKey\s*=\s*(.+)$"),
|
||||||
|
"endpoint": find(r"^Endpoint\s*=\s*(.+)$"),
|
||||||
|
"allowed_ips": find(r"^AllowedIPs\s*=\s*(.+)$"),
|
||||||
|
}
|
||||||
76
docker-compose.yaml
Normal file
76
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
services:
|
||||||
|
wg-easy:
|
||||||
|
image: ghcr.io/wg-easy/wg-easy:14
|
||||||
|
container_name: furtka-gateway-wg-easy
|
||||||
|
environment:
|
||||||
|
- WG_HOST=${GATEWAY_PUBLIC_HOST}
|
||||||
|
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
|
||||||
|
- PORT=51821
|
||||||
|
- WG_PORT=51820
|
||||||
|
volumes:
|
||||||
|
- wg_easy_data:/etc/wireguard
|
||||||
|
ports:
|
||||||
|
- "51820:51820/udp"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ip_forward=1
|
||||||
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
# wg-easy's own admin UI/API (port 51821) is intentionally NOT published
|
||||||
|
# to the host. Only control-plane, on the internal network, talks to it.
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.1
|
||||||
|
container_name: furtka-gateway-traefik
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
volumes:
|
||||||
|
- ./traefik/static/traefik.${GATEWAY_MODE:-single}.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
||||||
|
- traefik_acme:/acme
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
# Traefik's own dashboard/API is disabled in both static configs
|
||||||
|
# (api.dashboard: false, api.insecure: false) — never reachable at all,
|
||||||
|
# let alone publicly.
|
||||||
|
|
||||||
|
control-plane:
|
||||||
|
build: ./control_plane
|
||||||
|
container_name: furtka-gateway-control-plane
|
||||||
|
environment:
|
||||||
|
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
||||||
|
- GATEWAY_STATE_DIR=/data
|
||||||
|
- GATEWAY_DYNAMIC_DIR=/dynamic
|
||||||
|
- GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN}
|
||||||
|
- GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN}
|
||||||
|
- WG_EASY_URL=http://wg-easy:51821
|
||||||
|
- WG_EASY_ADMIN_USERNAME=${WG_EASY_ADMIN_USERNAME:-admin}
|
||||||
|
- WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- control_plane_data:/data
|
||||||
|
- ./traefik/dynamic:/dynamic
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8090:8090"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
- wg-easy
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
wg_easy_data:
|
||||||
|
traefik_acme:
|
||||||
|
control_plane_data:
|
||||||
6
pyproject.toml
Normal file
6
pyproject.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
13
tests/conftest.py
Normal file
13
tests/conftest.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gateway_paths(tmp_path, monkeypatch):
|
||||||
|
"""Redirect GATEWAY_STATE_DIR/GATEWAY_DYNAMIC_DIR to tmp_path, the same
|
||||||
|
env-var-override convention furtka-core's own tests use via FURTKA_*.
|
||||||
|
"""
|
||||||
|
state_dir = tmp_path / "state"
|
||||||
|
dynamic_dir = tmp_path / "dynamic"
|
||||||
|
monkeypatch.setenv("GATEWAY_STATE_DIR", str(state_dir))
|
||||||
|
monkeypatch.setenv("GATEWAY_DYNAMIC_DIR", str(dynamic_dir))
|
||||||
|
return {"state_dir": state_dir, "dynamic_dir": dynamic_dir}
|
||||||
28
tests/test_accounts.py
Normal file
28
tests/test_accounts.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from control_plane import accounts
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_single_tenant_account_is_idempotent(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
|
||||||
|
account_id1 = accounts.ensure_single_tenant_account(db, box_limit=5, route_limit_per_box=5)
|
||||||
|
account_id2 = accounts.ensure_single_tenant_account(db, box_limit=5, route_limit_per_box=5)
|
||||||
|
|
||||||
|
assert account_id1 == account_id2 == accounts.SINGLE_TENANT_ACCOUNT_ID
|
||||||
|
rows = db.query_all("SELECT id FROM accounts")
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_account(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
account_id = accounts.ensure_single_tenant_account(db, box_limit=7, route_limit_per_box=3)
|
||||||
|
|
||||||
|
row = accounts.get_account(db, account_id)
|
||||||
|
|
||||||
|
assert row["box_limit"] == 7
|
||||||
|
assert row["route_limit_per_box"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_account_missing(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
assert accounts.get_account(db, "nope") is None
|
||||||
219
tests/test_api.py
Normal file
219
tests/test_api.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from control_plane.api import Context, Handler
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWgEasy:
|
||||||
|
def __init__(self):
|
||||||
|
self._counter = 0
|
||||||
|
self.deleted = []
|
||||||
|
|
||||||
|
def create_client(self, name):
|
||||||
|
self._counter += 1
|
||||||
|
return {
|
||||||
|
"id": f"peer-{self._counter}",
|
||||||
|
"public_key": f"pubkey-{self._counter}==",
|
||||||
|
"private_key": f"privkey-{self._counter}==",
|
||||||
|
"address": f"10.8.0.{self._counter}/32",
|
||||||
|
"server_public_key": "server-pubkey==",
|
||||||
|
"endpoint": "gateway.example.com:51820",
|
||||||
|
"allowed_ips": "10.8.0.0/24",
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_client(self, client_id):
|
||||||
|
self.deleted.append(client_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(tmp_path, gateway_paths, monkeypatch):
|
||||||
|
monkeypatch.setenv("GATEWAY_BOX_TOKEN", "test-box-token")
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
ctx = Context(
|
||||||
|
db=db,
|
||||||
|
wgeasy=FakeWgEasy(),
|
||||||
|
mode="single",
|
||||||
|
cert_resolver="le",
|
||||||
|
single_account_id="default",
|
||||||
|
box_limit=5,
|
||||||
|
route_limit_per_box=5,
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('default', NULL, '2026-01-01T00:00:00', '', '', 5, 5)"
|
||||||
|
)
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
httpd.ctx = ctx
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield httpd
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def _url(server, path):
|
||||||
|
port = server.server_address[1]
|
||||||
|
return f"http://127.0.0.1:{port}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _request(server, method, path, body=None, token=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Content-Type": "application/json"} if data else {}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(_url(server, path), data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz(server):
|
||||||
|
status, body = _request(server, "GET", "/healthz")
|
||||||
|
assert status == 200
|
||||||
|
assert body == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _register(server, box_name="my-box"):
|
||||||
|
return _request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/v1/boxes/register",
|
||||||
|
{"registration_token": "test-box-token", "box_name": box_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_rejects_wrong_token(server):
|
||||||
|
status, _ = _request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/v1/boxes/register",
|
||||||
|
{"registration_token": "wrong", "box_name": "my-box"},
|
||||||
|
)
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_rejects_missing_fields(server):
|
||||||
|
status, _ = _request(server, "POST", "/v1/boxes/register", {"box_name": "my-box"})
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_box_and_route_lifecycle(server):
|
||||||
|
status, body = _register(server)
|
||||||
|
assert status == 201
|
||||||
|
box_token = body["box_token"]
|
||||||
|
assert body["wg"]["private_key"] == "privkey-1=="
|
||||||
|
|
||||||
|
# Unauthenticated calls are rejected.
|
||||||
|
status, _ = _request(server, "GET", "/v1/routes")
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
status, _ = _request(server, "GET", "/v1/routes", token=box_token)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
status, body = _request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/v1/routes",
|
||||||
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
||||||
|
token=box_token,
|
||||||
|
)
|
||||||
|
assert status == 201
|
||||||
|
route_id = body["route_id"]
|
||||||
|
assert body["public_url"] == "https://vault.example.com/"
|
||||||
|
|
||||||
|
status, body = _request(server, "GET", "/v1/routes", token=box_token)
|
||||||
|
assert status == 200
|
||||||
|
assert len(body["routes"]) == 1
|
||||||
|
assert body["routes"][0]["subdomain"] == "vault.example.com"
|
||||||
|
|
||||||
|
status, _ = _request(server, "POST", f"/v1/boxes/{_box_id(box_token)}/heartbeat", token=box_token)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
status, _ = _request(server, "DELETE", f"/v1/routes/{route_id}", token=box_token)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
status, body = _request(server, "GET", "/v1/routes", token=box_token)
|
||||||
|
assert body["routes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_ownership_is_enforced_across_boxes(server):
|
||||||
|
_, box_a = _register(server, "box-a")
|
||||||
|
_, box_b = _register(server, "box-b")
|
||||||
|
|
||||||
|
_, route = _request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/v1/routes",
|
||||||
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
||||||
|
token=box_a["box_token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# box-b's token must not be able to delete box-a's route.
|
||||||
|
status, _ = _request(
|
||||||
|
server, "DELETE", f"/v1/routes/{route['route_id']}", token=box_b["box_token"]
|
||||||
|
)
|
||||||
|
assert status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_action_rejects_mismatched_box_id(server):
|
||||||
|
_, box_a = _register(server, "box-a")
|
||||||
|
_, box_b = _register(server, "box-b")
|
||||||
|
|
||||||
|
status, _ = _request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
f"/v1/boxes/{_box_id(box_b['box_token'])}/heartbeat",
|
||||||
|
token=box_a["box_token"],
|
||||||
|
)
|
||||||
|
assert status == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_token_invalidates_old_token(server):
|
||||||
|
_, box = _register(server)
|
||||||
|
old_token = box["box_token"]
|
||||||
|
box_id = _box_id(old_token)
|
||||||
|
|
||||||
|
status, body = _request(server, "POST", f"/v1/boxes/{box_id}/rotate-token", token=old_token)
|
||||||
|
assert status == 200
|
||||||
|
new_token = body["box_token"]
|
||||||
|
|
||||||
|
status, _ = _request(server, "GET", "/v1/routes", token=old_token)
|
||||||
|
assert status == 401
|
||||||
|
status, _ = _request(server, "GET", "/v1/routes", token=new_token)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_removes_routes_and_wgeasy_peer(server):
|
||||||
|
_, box = _register(server)
|
||||||
|
box_token = box["box_token"]
|
||||||
|
box_id = _box_id(box_token)
|
||||||
|
_request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/v1/routes",
|
||||||
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
||||||
|
token=box_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, _ = _request(server, "POST", f"/v1/boxes/{box_id}/deregister", token=box_token)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
# The box token no longer authenticates anything after deregistration.
|
||||||
|
status, _ = _request(server, "GET", "/v1/routes", token=box_token)
|
||||||
|
assert status == 401
|
||||||
|
assert server.ctx.wgeasy.deleted == ["peer-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def _box_id(box_token: str) -> str:
|
||||||
|
return box_token.split(".", 1)[0]
|
||||||
130
tests/test_boxes.py
Normal file
130
tests/test_boxes.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from control_plane import boxes, routes
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWgEasy:
|
||||||
|
def __init__(self):
|
||||||
|
self.created = []
|
||||||
|
self.deleted = []
|
||||||
|
self._counter = 0
|
||||||
|
|
||||||
|
def create_client(self, name):
|
||||||
|
self._counter += 1
|
||||||
|
peer_id = f"peer-{self._counter}"
|
||||||
|
self.created.append(name)
|
||||||
|
return {
|
||||||
|
"id": peer_id,
|
||||||
|
"public_key": f"pubkey-{self._counter}==",
|
||||||
|
"private_key": f"privkey-{self._counter}==",
|
||||||
|
"address": f"10.8.0.{self._counter}/32",
|
||||||
|
"server_public_key": "server-pubkey==",
|
||||||
|
"endpoint": "gateway.example.com:51820",
|
||||||
|
"allowed_ips": "10.8.0.0/24",
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_client(self, client_id):
|
||||||
|
self.deleted.append(client_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_with_account(tmp_path, gateway_paths):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('acc1', NULL, '2026-01-01T00:00:00', '', '', 5, 5)"
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_box_success(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
|
||||||
|
assert "box_id" in result and "box_token" in result
|
||||||
|
assert result["wg"]["private_key"] == "privkey-1=="
|
||||||
|
assert wgeasy.created == ["my-box"]
|
||||||
|
|
||||||
|
row = db_with_account.query_one("SELECT * FROM boxes WHERE id = ?", (result["box_id"],))
|
||||||
|
assert row["name"] == "my-box"
|
||||||
|
assert row["wg_public_key"] == "pubkey-1=="
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_box_enforces_box_limit(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
boxes.register_box(db_with_account, wgeasy, "acc1", "box-a", box_limit=1)
|
||||||
|
with pytest.raises(boxes.BoxLimitExceeded):
|
||||||
|
boxes.register_box(db_with_account, wgeasy, "acc1", "box-b", box_limit=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_box_token_roundtrip(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
|
||||||
|
row = boxes.authenticate_box_token(db_with_account, result["box_token"])
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
assert row["id"] == result["box_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_token",
|
||||||
|
["not-a-real-token", "unknown-box-id.somesecret", ""],
|
||||||
|
)
|
||||||
|
def test_authenticate_box_token_rejects_bad_tokens(db_with_account, bad_token):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
|
||||||
|
assert boxes.authenticate_box_token(db_with_account, bad_token) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_box_token_rejects_wrong_secret(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
box_id = result["box_id"]
|
||||||
|
|
||||||
|
tampered = f"{box_id}.wrong-secret"
|
||||||
|
|
||||||
|
assert boxes.authenticate_box_token(db_with_account, tampered) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_box_token_invalidates_old_one(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
box_id = result["box_id"]
|
||||||
|
|
||||||
|
new_token = boxes.rotate_box_token(db_with_account, box_id)
|
||||||
|
|
||||||
|
assert boxes.authenticate_box_token(db_with_account, result["box_token"]) is None
|
||||||
|
assert boxes.authenticate_box_token(db_with_account, new_token)["id"] == box_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_last_seen(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
|
||||||
|
boxes.touch_last_seen(db_with_account, result["box_id"])
|
||||||
|
|
||||||
|
row = db_with_account.query_one("SELECT last_seen_at FROM boxes WHERE id = ?", (result["box_id"],))
|
||||||
|
assert row["last_seen_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_box_removes_peer_and_routes(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
result = boxes.register_box(db_with_account, wgeasy, "acc1", "my-box", box_limit=5)
|
||||||
|
box_id = result["box_id"]
|
||||||
|
routes.publish_route(db_with_account, box_id, "acc1", "app1", "app1.example.com", 80, 5, "le")
|
||||||
|
|
||||||
|
assert boxes.deregister_box(db_with_account, wgeasy, box_id) is True
|
||||||
|
|
||||||
|
assert db_with_account.query_one("SELECT id FROM boxes WHERE id = ?", (box_id,)) is None
|
||||||
|
assert db_with_account.query_all("SELECT id FROM routes WHERE box_id = ?", (box_id,)) == []
|
||||||
|
assert wgeasy.deleted == ["peer-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_box_unknown_id(db_with_account):
|
||||||
|
wgeasy = FakeWgEasy()
|
||||||
|
assert boxes.deregister_box(db_with_account, wgeasy, "nope") is False
|
||||||
33
tests/test_db.py
Normal file
33
tests/test_db.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from control_plane.db import Database, row_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_created_and_roundtrips(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('acc1', 'a@example.com', '2026-01-01T00:00:00', 'h1', 'h2', 5, 5)"
|
||||||
|
)
|
||||||
|
row = db.query_one("SELECT * FROM accounts WHERE id = ?", ("acc1",))
|
||||||
|
assert row["email"] == "a@example.com"
|
||||||
|
|
||||||
|
all_rows = db.query_all("SELECT id FROM accounts")
|
||||||
|
assert [r["id"] for r in all_rows] == ["acc1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_to_dict_converts_row(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('acc1', 'a@example.com', '2026-01-01T00:00:00', 'h1', 'h2', 5, 5)"
|
||||||
|
)
|
||||||
|
row = db.query_one("SELECT * FROM accounts WHERE id = ?", ("acc1",))
|
||||||
|
as_dict = row_to_dict(row)
|
||||||
|
assert as_dict["id"] == "acc1"
|
||||||
|
assert as_dict["email"] == "a@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_to_dict_none():
|
||||||
|
assert row_to_dict(None) is None
|
||||||
54
tests/test_reconciler.py
Normal file
54
tests/test_reconciler.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from control_plane import paths, reconciler, routes, traefikconf
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
def _seeded_db(tmp_path):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('acc1', NULL, '2026-01-01T00:00:00', '', '', 5, 5)"
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO boxes (id, account_id, name, wg_public_key, wg_peer_id, "
|
||||||
|
"wg_allowed_ip, box_token_hash, registered_at) "
|
||||||
|
"VALUES ('box1', 'acc1', 'my-box', 'pk==', 'peer-1', '10.8.0.5/32', 'h', "
|
||||||
|
"'2026-01-01T00:00:00')"
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_recreates_missing_file(tmp_path, gateway_paths):
|
||||||
|
db = _seeded_db(tmp_path)
|
||||||
|
result = routes.publish_route(
|
||||||
|
db, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
route_file = paths.dynamic_dir() / f"route-{result['route_id']}.yml"
|
||||||
|
route_file.unlink()
|
||||||
|
assert not route_file.exists()
|
||||||
|
|
||||||
|
reconciler.reconcile(db, cert_resolver="le")
|
||||||
|
|
||||||
|
assert route_file.exists()
|
||||||
|
assert "vault.example.com" in route_file.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_removes_orphan_file(tmp_path, gateway_paths):
|
||||||
|
db = _seeded_db(tmp_path)
|
||||||
|
traefikconf.write_route("orphan", "orphan.example.com", "10.8.0.9", 80, cert_resolver="le")
|
||||||
|
|
||||||
|
reconciler.reconcile(db, cert_resolver="le")
|
||||||
|
|
||||||
|
assert "orphan" not in traefikconf.existing_route_ids()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_ignores_disabled_routes(tmp_path, gateway_paths):
|
||||||
|
db = _seeded_db(tmp_path)
|
||||||
|
result = routes.publish_route(
|
||||||
|
db, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
db.execute("UPDATE routes SET enabled = 0 WHERE id = ?", (result["route_id"],))
|
||||||
|
|
||||||
|
reconciler.reconcile(db, cert_resolver="le")
|
||||||
|
|
||||||
|
assert traefikconf.existing_route_ids() == set()
|
||||||
105
tests/test_routes.py
Normal file
105
tests/test_routes.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from control_plane import paths, routes
|
||||||
|
from control_plane.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_with_box(tmp_path, gateway_paths):
|
||||||
|
db = Database(db_path=tmp_path / "gateway.db")
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
||||||
|
"account_token_hash, box_limit, route_limit_per_box) "
|
||||||
|
"VALUES ('acc1', NULL, '2026-01-01T00:00:00', '', '', 5, 5)"
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO boxes (id, account_id, name, wg_public_key, wg_peer_id, "
|
||||||
|
"wg_allowed_ip, box_token_hash, registered_at) "
|
||||||
|
"VALUES ('box1', 'acc1', 'my-box', 'pk==', 'peer-1', '10.8.0.5/32', 'h', "
|
||||||
|
"'2026-01-01T00:00:00')"
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_success(db_with_box):
|
||||||
|
result = routes.publish_route(
|
||||||
|
db_with_box, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["public_url"] == "https://vault.example.com/"
|
||||||
|
row = db_with_box.query_one("SELECT * FROM routes WHERE id = ?", (result["route_id"],))
|
||||||
|
assert row["subdomain"] == "vault.example.com"
|
||||||
|
assert (paths.dynamic_dir() / f"route-{result['route_id']}.yml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"app_name,subdomain,port",
|
||||||
|
[
|
||||||
|
("Bad Name", "vault.example.com", 8081),
|
||||||
|
("vaultwarden", "not a domain", 8081),
|
||||||
|
("vaultwarden", "www.example.com", 8081),
|
||||||
|
("vaultwarden", "vault.example.com", 0),
|
||||||
|
("vaultwarden", "vault.example.com", 70000),
|
||||||
|
("vaultwarden", "vault.example.com", "8081"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_publish_route_rejects_invalid_input(db_with_box, app_name, subdomain, port):
|
||||||
|
with pytest.raises(routes.InvalidRoute):
|
||||||
|
routes.publish_route(db_with_box, "box1", "acc1", app_name, subdomain, port, 5, "le")
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_rejects_duplicate_subdomain(db_with_box):
|
||||||
|
routes.publish_route(
|
||||||
|
db_with_box, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
with pytest.raises(routes.SubdomainTaken):
|
||||||
|
routes.publish_route(
|
||||||
|
db_with_box, "box1", "acc1", "jellyfin", "vault.example.com", 8096, 5, "le"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_enforces_route_limit(db_with_box):
|
||||||
|
routes.publish_route(db_with_box, "box1", "acc1", "app1", "app1.example.com", 80, 1, "le")
|
||||||
|
with pytest.raises(routes.RouteLimitExceeded):
|
||||||
|
routes.publish_route(db_with_box, "box1", "acc1", "app2", "app2.example.com", 80, 1, "le")
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_unknown_box(db_with_box):
|
||||||
|
with pytest.raises(routes.InvalidRoute):
|
||||||
|
routes.publish_route(
|
||||||
|
db_with_box, "nope", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpublish_route_removes_row_and_file(db_with_box):
|
||||||
|
result = routes.publish_route(
|
||||||
|
db_with_box, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
route_id = result["route_id"]
|
||||||
|
|
||||||
|
assert routes.unpublish_route(db_with_box, "box1", route_id) is True
|
||||||
|
assert db_with_box.query_one("SELECT id FROM routes WHERE id = ?", (route_id,)) is None
|
||||||
|
assert not (paths.dynamic_dir() / f"route-{route_id}.yml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpublish_route_ownership_check(db_with_box):
|
||||||
|
result = routes.publish_route(
|
||||||
|
db_with_box, "box1", "acc1", "vaultwarden", "vault.example.com", 8081, 5, "le"
|
||||||
|
)
|
||||||
|
assert routes.unpublish_route(db_with_box, "some-other-box", result["route_id"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpublish_all_for_box(db_with_box):
|
||||||
|
routes.publish_route(db_with_box, "box1", "acc1", "app1", "app1.example.com", 80, 5, "le")
|
||||||
|
routes.publish_route(db_with_box, "box1", "acc1", "app2", "app2.example.com", 80, 5, "le")
|
||||||
|
|
||||||
|
routes.unpublish_all_for_box(db_with_box, "box1")
|
||||||
|
|
||||||
|
assert db_with_box.query_all("SELECT id FROM routes WHERE box_id = 'box1'") == []
|
||||||
|
assert traefik_dir_empty(db_with_box)
|
||||||
|
|
||||||
|
|
||||||
|
def traefik_dir_empty(db) -> bool:
|
||||||
|
from control_plane import traefikconf
|
||||||
|
|
||||||
|
return traefikconf.existing_route_ids() == set()
|
||||||
47
tests/test_traefikconf.py
Normal file
47
tests/test_traefikconf.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from control_plane import paths, traefikconf
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_route_single_mode_content(gateway_paths):
|
||||||
|
traefikconf.write_route("r1", "app.example.com", "10.8.0.5", 8081, cert_resolver="le")
|
||||||
|
|
||||||
|
content = (paths.dynamic_dir() / "route-r1.yml").read_text()
|
||||||
|
|
||||||
|
assert "route-r1:" in content
|
||||||
|
assert 'rule: "Host(`app.example.com`)"' in content
|
||||||
|
assert "service: svc-r1" in content
|
||||||
|
assert "certResolver: le" in content
|
||||||
|
assert 'url: "http://10.8.0.5:8081"' in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_route_shared_mode_no_cert_resolver(gateway_paths):
|
||||||
|
traefikconf.write_route("r2", "app.boxes.example.com", "10.8.0.9", 8096, cert_resolver=None)
|
||||||
|
|
||||||
|
content = (paths.dynamic_dir() / "route-r2.yml").read_text()
|
||||||
|
|
||||||
|
assert "tls: {}" in content
|
||||||
|
assert "certResolver" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_route_is_idempotent(gateway_paths):
|
||||||
|
traefikconf.write_route("r1", "app.example.com", "10.8.0.5", 8081, cert_resolver="le")
|
||||||
|
traefikconf.write_route("r1", "app.example.com", "10.8.0.5", 8081, cert_resolver="le")
|
||||||
|
|
||||||
|
assert traefikconf.existing_route_ids() == {"r1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_route(gateway_paths):
|
||||||
|
traefikconf.write_route("r1", "app.example.com", "10.8.0.5", 8081, cert_resolver="le")
|
||||||
|
traefikconf.remove_route("r1")
|
||||||
|
|
||||||
|
assert not (paths.dynamic_dir() / "route-r1.yml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_route_missing_file_is_a_noop(gateway_paths):
|
||||||
|
traefikconf.remove_route("does-not-exist") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_route_ids(gateway_paths):
|
||||||
|
traefikconf.write_route("r1", "a.example.com", "10.8.0.1", 80, cert_resolver="le")
|
||||||
|
traefikconf.write_route("r2", "b.example.com", "10.8.0.2", 80, cert_resolver="le")
|
||||||
|
|
||||||
|
assert traefikconf.existing_route_ids() == {"r1", "r2"}
|
||||||
121
tests/test_wgeasy.py
Normal file
121
tests/test_wgeasy.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from control_plane.wgeasy import WgEasyClient, WgEasyError
|
||||||
|
|
||||||
|
FAKE_CONF = """[Interface]
|
||||||
|
PrivateKey = client-private-key==
|
||||||
|
Address = 10.8.0.5/32
|
||||||
|
DNS = 1.1.1.1
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = server-public-key==
|
||||||
|
Endpoint = gateway.example.com:51820
|
||||||
|
AllowedIPs = 10.8.0.0/24
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWgEasyHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _json(self, status, payload):
|
||||||
|
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 do_POST(self):
|
||||||
|
self.server.requests.append(("POST", self.path))
|
||||||
|
if self.path == "/api/auth/password":
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Set-Cookie", "wg-easy-session=abc123; Path=/; HttpOnly")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if self.path == "/api/client":
|
||||||
|
self._json(200, {"success": True, "clientId": "client-1"})
|
||||||
|
return
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.server.requests.append(("GET", self.path))
|
||||||
|
if self.path == "/api/client/client-1":
|
||||||
|
self._json(200, {"id": "client-1", "publicKey": "client-public-key=="})
|
||||||
|
return
|
||||||
|
if self.path == "/api/client/client-1/configuration":
|
||||||
|
body = FAKE_CONF.encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self.server.requests.append(("DELETE", self.path))
|
||||||
|
if self.path == "/api/client/client-1":
|
||||||
|
self._json(200, {"success": True})
|
||||||
|
return
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_wgeasy():
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeWgEasyHandler)
|
||||||
|
server.requests = []
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield server
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def _client(server):
|
||||||
|
port = server.server_address[1]
|
||||||
|
return WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_client_logs_in_and_parses_configuration(fake_wgeasy):
|
||||||
|
client = _client(fake_wgeasy)
|
||||||
|
|
||||||
|
peer = client.create_client("box-1")
|
||||||
|
|
||||||
|
assert peer["id"] == "client-1"
|
||||||
|
assert peer["public_key"] == "client-public-key=="
|
||||||
|
assert peer["private_key"] == "client-private-key=="
|
||||||
|
assert peer["address"] == "10.8.0.5/32"
|
||||||
|
assert peer["server_public_key"] == "server-public-key=="
|
||||||
|
assert peer["endpoint"] == "gateway.example.com:51820"
|
||||||
|
assert peer["allowed_ips"] == "10.8.0.0/24"
|
||||||
|
|
||||||
|
methods_and_paths = [(m, p) for m, p in fake_wgeasy.requests]
|
||||||
|
assert ("POST", "/api/auth/password") in methods_and_paths
|
||||||
|
assert ("POST", "/api/client") in methods_and_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_client_swallows_not_found(fake_wgeasy):
|
||||||
|
client = _client(fake_wgeasy)
|
||||||
|
client.delete_client("does-not-exist") # 404 from fake server — must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_client_calls_endpoint(fake_wgeasy):
|
||||||
|
client = _client(fake_wgeasy)
|
||||||
|
client.delete_client("client-1")
|
||||||
|
assert ("DELETE", "/api/client/client-1") in [(m, p) for m, p in fake_wgeasy.requests]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_endpoint_raises_wgeasy_error(fake_wgeasy):
|
||||||
|
port = fake_wgeasy.server_address[1]
|
||||||
|
client = WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2")
|
||||||
|
with pytest.raises(WgEasyError):
|
||||||
|
client._authed_request("GET", "/api/does-not-exist")
|
||||||
12
traefik/dynamic/default-cert.yml.example
Normal file
12
traefik/dynamic/default-cert.yml.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# GATEWAY_MODE=shared only. Copy to traefik/dynamic/default-cert.yml with
|
||||||
|
# GATEWAY_BASE_DOMAIN substituted (ops/deploy.sh does this) to request the
|
||||||
|
# one wildcard cert used as the default for every published subdomain.
|
||||||
|
tls:
|
||||||
|
stores:
|
||||||
|
default:
|
||||||
|
defaultGeneratedCert:
|
||||||
|
resolver: le-dns
|
||||||
|
domain:
|
||||||
|
main: "GATEWAY_BASE_DOMAIN"
|
||||||
|
sans:
|
||||||
|
- "*.GATEWAY_BASE_DOMAIN"
|
||||||
47
traefik/static/traefik.shared.yml
Normal file
47
traefik/static/traefik.shared.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Shared multi-tenant mode: one wildcard cert for *.<GATEWAY_BASE_DOMAIN>,
|
||||||
|
# issued once via DNS-01, used as the default cert for every router. This
|
||||||
|
# avoids Let's Encrypt's ~50-certs/registered-domain/week limit that
|
||||||
|
# per-subdomain HTTP-01 would blow through at real shared-tenant volume.
|
||||||
|
#
|
||||||
|
# DNS-01 needs a provider. Traefik reads provider credentials from
|
||||||
|
# provider-specific env vars (e.g. CF_DNS_API_TOKEN for Cloudflare) passed
|
||||||
|
# through to the traefik container — see
|
||||||
|
# https://doc.traefik.io/traefik/https/acme/#providers for the full list.
|
||||||
|
# `provider:` below is a placeholder; set it to whatever DNS host actually
|
||||||
|
# serves GATEWAY_BASE_DOMAIN for the real shared deployment.
|
||||||
|
#
|
||||||
|
# The wildcard cert itself is requested via a dynamic-config
|
||||||
|
# `tls.stores.default.defaultGeneratedCert` entry (traefik/dynamic/), since
|
||||||
|
# it needs GATEWAY_BASE_DOMAIN interpolated at deploy time — see
|
||||||
|
# ops/deploy.sh.
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
http:
|
||||||
|
redirections:
|
||||||
|
entryPoint:
|
||||||
|
to: websecure
|
||||||
|
scheme: https
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: /etc/traefik/dynamic
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
le-dns:
|
||||||
|
acme:
|
||||||
|
email: admin@example.com # overridden per-deployment; see ops/deploy.sh
|
||||||
|
storage: /acme/acme.json
|
||||||
|
dnsChallenge:
|
||||||
|
provider: cloudflare # placeholder — set to the real deployment's DNS provider
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: false
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
34
traefik/static/traefik.single.yml
Normal file
34
traefik/static/traefik.single.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Single-tenant mode: one operator's own domain, HTTP-01 challenge per
|
||||||
|
# subdomain. Fine at self-hosted scale — nowhere near Let's Encrypt's
|
||||||
|
# ~50-certs/registered-domain/week limit with a handful of published apps.
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
http:
|
||||||
|
redirections:
|
||||||
|
entryPoint:
|
||||||
|
to: websecure
|
||||||
|
scheme: https
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: /etc/traefik/dynamic
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
le:
|
||||||
|
acme:
|
||||||
|
email: admin@example.com # overridden per-deployment; see ops/deploy.sh
|
||||||
|
storage: /acme/acme.json
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: false
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
Loading…
Add table
Reference in a new issue