commit 3a9d18fcd5ec46803cb105ae2e6d4016c5135881 Author: Robert Syrnicki Date: Mon Aug 24 11:50:25 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..82c8e65 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b339c90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.venv/ +*.db +acme.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bd9768 --- /dev/null +++ b/README.md @@ -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. diff --git a/control_plane/Dockerfile b/control_plane/Dockerfile new file mode 100644 index 0000000..32204d6 --- /dev/null +++ b/control_plane/Dockerfile @@ -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"] diff --git a/control_plane/__init__.py b/control_plane/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/accounts.py b/control_plane/accounts.py new file mode 100644 index 0000000..6be4a8e --- /dev/null +++ b/control_plane/accounts.py @@ -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,)) diff --git a/control_plane/api.py b/control_plane/api.py new file mode 100644 index 0000000..6cae095 --- /dev/null +++ b/control_plane/api.py @@ -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) diff --git a/control_plane/app.py b/control_plane/app.py new file mode 100644 index 0000000..09375e5 --- /dev/null +++ b/control_plane/app.py @@ -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() diff --git a/control_plane/boxes.py b/control_plane/boxes.py new file mode 100644 index 0000000..8a0da98 --- /dev/null +++ b/control_plane/boxes.py @@ -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 diff --git a/control_plane/db.py b/control_plane/db.py new file mode 100644 index 0000000..de99c9f --- /dev/null +++ b/control_plane/db.py @@ -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)) diff --git a/control_plane/passwd.py b/control_plane/passwd.py new file mode 100644 index 0000000..c82f334 --- /dev/null +++ b/control_plane/passwd.py @@ -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: ``$$`` + - ``pbkdf2::`` — what we generate by default here + - ``scrypt:::

`` — 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:$$`` 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) diff --git a/control_plane/paths.py b/control_plane/paths.py new file mode 100644 index 0000000..0f8b591 --- /dev/null +++ b/control_plane/paths.py @@ -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" diff --git a/control_plane/reconciler.py b/control_plane/reconciler.py new file mode 100644 index 0000000..ff3b53d --- /dev/null +++ b/control_plane/reconciler.py @@ -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) diff --git a/control_plane/routes.py b/control_plane/routes.py new file mode 100644 index 0000000..25bbdb2 --- /dev/null +++ b/control_plane/routes.py @@ -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,), + ) diff --git a/control_plane/schema.sql b/control_plane/schema.sql new file mode 100644 index 0000000..9efb10f --- /dev/null +++ b/control_plane/schema.sql @@ -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); diff --git a/control_plane/tokens.py b/control_plane/tokens.py new file mode 100644 index 0000000..e37ea80 --- /dev/null +++ b/control_plane/tokens.py @@ -0,0 +1,27 @@ +"""Bearer-token helpers shared by accounts.py/boxes.py. + +Tokens are minted as ``"."`` 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 diff --git a/control_plane/traefikconf.py b/control_plane/traefikconf.py new file mode 100644 index 0000000..e860943 --- /dev/null +++ b/control_plane/traefikconf.py @@ -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") + } diff --git a/control_plane/wgeasy.py b/control_plane/wgeasy.py new file mode 100644 index 0000000..4258d42 --- /dev/null +++ b/control_plane/wgeasy.py @@ -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*(.+)$"), + } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..97beac5 --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6a23c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py312" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4329a3a --- /dev/null +++ b/tests/conftest.py @@ -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} diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..0a21c5f --- /dev/null +++ b/tests/test_accounts.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b542a2b --- /dev/null +++ b/tests/test_api.py @@ -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] diff --git a/tests/test_boxes.py b/tests/test_boxes.py new file mode 100644 index 0000000..dca59b4 --- /dev/null +++ b/tests/test_boxes.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..8b73456 --- /dev/null +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py new file mode 100644 index 0000000..7697ea5 --- /dev/null +++ b/tests/test_reconciler.py @@ -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() diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..8a524d7 --- /dev/null +++ b/tests/test_routes.py @@ -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() diff --git a/tests/test_traefikconf.py b/tests/test_traefikconf.py new file mode 100644 index 0000000..e63b7ef --- /dev/null +++ b/tests/test_traefikconf.py @@ -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"} diff --git a/tests/test_wgeasy.py b/tests/test_wgeasy.py new file mode 100644 index 0000000..61f531b --- /dev/null +++ b/tests/test_wgeasy.py @@ -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") diff --git a/traefik/dynamic/default-cert.yml.example b/traefik/dynamic/default-cert.yml.example new file mode 100644 index 0000000..e03ff1a --- /dev/null +++ b/traefik/dynamic/default-cert.yml.example @@ -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" diff --git a/traefik/static/traefik.shared.yml b/traefik/static/traefik.shared.yml new file mode 100644 index 0000000..0fb83f6 --- /dev/null +++ b/traefik/static/traefik.shared.yml @@ -0,0 +1,47 @@ +# Shared multi-tenant mode: one wildcard cert for *., +# 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 diff --git a/traefik/static/traefik.single.yml b/traefik/static/traefik.single.yml new file mode 100644 index 0000000..0079ab8 --- /dev/null +++ b/traefik/static/traefik.single.yml @@ -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