No description
Find a file
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
.forgejo/workflows ci: publish control-plane image for Swarm deploys 2026-08-26 12:20:16 +02:00
control_plane feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
examples/starter-stack feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
ops feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
tests feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
traefik feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
.env.example feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
.gitignore feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
docker-compose.yaml feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
pyproject.toml Scaffold gateway stack and single-tenant control plane 2026-08-24 11:50:25 +02:00
README.md feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00

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. Your 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.

What this actually is

The control-plane (control_plane/, one small stdlib-only Docker image) is all this repo ships. It does two things: talks to wg-easy's admin API to provision WireGuard peers for boxes, and writes plain Traefik file-provider YAML for the routes those boxes publish. It does not own or bundle a Traefik or wg-easy of its own — you plug it into a Traefik and wg-easy you already run (a very common self-hosting pair), the same way you'd plug any other self-hosted app into your existing reverse proxy. This also means your Traefik's own cert resolver (whatever you already use) just works for these new routes too — furtka-gateway doesn't set up its own ACME/DNS story at all.

Don't already run Traefik or wg-easy? See examples/starter-stack/ — a zero-infra on-ramp that bundles both.

GATEWAY_MODE is purely a tenancy switch: single (one operator, the common case) or shared (one larger, multi-tenant instance — same schema, same code, out of scope for the walkthrough below). It has no effect on ports, certs, or which Traefik/wg-easy you use — that's all just your own infrastructure now.

Status: Phase 2 — the control plane (accounts/boxes/routes, wg-easy peer provisioning, Traefik dynamic-config generation) is built and tested for single-tenant mode. The Furtka-core side (the box-facing "connect to a gateway" flow and per-app "expose to internet" toggle) is still just a plan, not yet implemented — see the plan doc for the phased delivery.

Prerequisites checklist

Read this before running ops/deploy.sh — a couple of these can't be verified from the script itself, and a deploy that skips them fails in confusing ways later.

  • A running Traefik, with a file-provider directory you can point GATEWAY_DYNAMIC_DIR_HOST at (either one it already watches, or a new one you add as an additional providers.file directory in its static config — additive, doesn't touch your existing dynamic config).
  • A running wg-easy. Know its version — ghcr.io/wg-easy/wg-easy:14 and :15 are confirmed supported (control_plane/wgeasy.py and wgeasy_v15.py respectively — these are genuinely different, incompatible APIs, not just version numbers; each module's own docstring documents exactly what was verified, live, against a real container). Anything else is unverified.
  • Traefik must have an actual routed network path to wg-easy's WireGuard peer subnet (default 10.8.0.0/24). This is the one thing that can't be automated or checked from ops/deploy.sh — it depends entirely on how you've deployed the two. A plain bridge-networked wg-easy container (Docker's default) will not work: wg0's route only exists inside wg-easy's own network namespace unless wg-easy runs with network_mode: host, a macvlan, or explicit host routing that bridges its WireGuard interface out to wherever Traefik is. Verify this yourself before running ops/deploy.sh: register a test box (or use any existing WireGuard peer's address), then from Traefik's container, wget -qO- http://10.8.0.x:<some-port> (or ping 10.8.0.x) should succeed. If it doesn't, fix your wg-easy/Traefik network setup first — nothing in this repo can route around it.
  • How Traefik reaches the control-plane container itself. The recommended path is joining your Traefik's own docker network ( ops/deploy.sh does this for you via docker network connect after docker compose up); a separate-host setup is also supported but needs its own access control — see the script's prompts.

Deploying

ops/deploy.sh prompts for all of the above, generates GATEWAY_BOX_TOKEN/GATEWAY_ADMIN_TOKEN, writes .env, renders traefik/dynamic/control-plane.yml from its tracked template, brings the control-plane up, joins your Traefik's network (if that's your choice), and health-checks it. Safe to re-run — an existing .env's values become the defaults for each prompt.

  1. Work through the Prerequisites checklist above.
  2. Run ops/deploy.sh and answer its prompts.
  3. Add GATEWAY_DYNAMIC_DIR_HOST (whatever path you gave the script) as a providers.file directory on your Traefik, if it wasn't already watching it.
  4. Point DNS: an A/AAAA record for GATEWAY_CONTROL_PLANE_HOST at wherever your Traefik is reachable.

Run ops/deploy.sh --render-only to just (re)write .env and the rendered control-plane.yml without touching anything live — useful to inspect what would be generated, or after hand-editing .env.

Docker Swarm caveats

If deploying via docker stack deploy instead of docker compose up:

  • docker-compose.yaml's build: ./control_plane doesn't work under Swarm at all — it only ever schedules pre-built, tagged images. Swarm falls back to the image: key on the same service instead, which .forgejo/workflows/build-image.yml keeps published to this instance's container registry on every push to master (tagged latest plus the short commit sha) — no manual build/tag step needed. Set GATEWAY_CONTROL_PLANE_IMAGE in .env first if you want a specific commit-sha tag rather than whatever latest currently points to.
  • docker stack deploy does not read a .env file next to the compose file the way docker compose up does — set -a; source .env; set +a first, or the ${VAR} substitutions come out empty.
  • docker network connect (what ops/deploy.sh uses to join your Traefik's network) is a plain compose/Docker concept, not Swarm-specific — under Swarm, attach the service to your Traefik's network directly in the stack file's own networks: section instead.
  • depends_on is silently ignored by Swarm — harmless here, since control-plane only talks to wg-easy/Traefik lazily on the first real request, not at startup.

Local dev

cp .env.example .env
# fill in WG_EASY_URL etc. for whatever Traefik/wg-easy you're testing
# against locally, or use examples/starter-stack/ for a fully bundled
# local smoke test.
docker compose up -d
curl http://127.0.0.1:8090/healthz