furtka-gateway/control_plane/reconciler.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

32 lines
1.3 KiB
Python

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