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.
72 lines
2.2 KiB
Python
72 lines
2.2 KiB
Python
"""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")
|
|
}
|