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