furtka-gateway/control_plane/traefikconf.py
Robert Syrnicki 3a9d18fcd5 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.
2026-08-24 11:50:25 +02:00

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")
}