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:
Robert Syrnicki 2026-08-24 11:50:25 +02:00
commit 3a9d18fcd5
32 changed files with 2054 additions and 0 deletions

36
.env.example Normal file
View 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
View file

@ -0,0 +1,8 @@
.env
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.venv/
*.db
acme.json

43
README.md Normal file
View 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
View 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"]

View file

46
control_plane/accounts.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.ruff]
line-length = 100
target-version = "py312"

13
tests/conftest.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")

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

View 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

View 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