furtka-gateway/control_plane/app.py
Robert Syrnicki e240f071d5
Some checks failed
Build control-plane image / build-image (push) Has been cancelled
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.

- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
  against a real running container (auth model, route shapes, and the new
  per-client PresharedKey all differ materially from v14's API). Threaded
  the preshared key through to the box side (furtka/wireguard.py in the
  furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
  derived hardcoded "le", so it can mirror whatever resolver name (if any)
  the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
  asks how the operator's Traefik reaches it (same docker network, joined
  via `docker network connect`, or a separate host) instead of prompting
  for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
  furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
  examples/starter-stack/ as the documented zero-infra on-ramp.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00

80 lines
2.9 KiB
Python

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
from control_plane.wgeasy_v15 import WgEasyV15Client
DEFAULT_BOX_LIMIT = 20
DEFAULT_ROUTE_LIMIT_PER_BOX = 20
DEFAULT_WG_ALLOWED_IPS = "10.8.0.0/24"
def build_context(db: Database | None = None) -> Context:
mode = os.environ.get("GATEWAY_MODE", "single")
db = db or Database()
wg_easy_url = os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821")
wg_easy_password = os.environ.get("WG_EASY_ADMIN_PASSWORD", "")
if os.environ.get("WG_EASY_API_VERSION", "14") == "15":
wgeasy = WgEasyV15Client(
base_url=wg_easy_url,
username=os.environ.get("WG_EASY_ADMIN_USERNAME", ""),
password=wg_easy_password,
)
# v15 defaults new clients to full-tunnel AllowedIPs and has no
# equivalent to v14's WG_ALLOWED_IPS container env var for an
# already-initialized instance (relevant for a BYO wg-easy that
# existed before this control-plane ever connected to it) — see
# wgeasy_v15.py's docstring. Enforced on every startup, not just
# once, since a BYO instance's config isn't otherwise this
# process's to assume.
wgeasy.restrict_default_allowed_ips(
[os.environ.get("GATEWAY_WG_ALLOWED_IPS", DEFAULT_WG_ALLOWED_IPS)]
)
else:
wgeasy = WgEasyClient(base_url=wg_easy_url, admin_password=wg_easy_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
)
# Independent of GATEWAY_MODE — mirrors whatever certResolver name (if
# any) the operator's own Traefik already has configured. Unset means
# no explicit resolver (`tls: {}` — see traefikconf.py), trusting that
# Traefik's own default TLS applies (e.g. shared mode's wildcard
# defaultGeneratedCert in traefik.shared.yml).
cert_resolver = os.environ.get("GATEWAY_CERT_RESOLVER") or 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()