No description
Find a file
Robert Syrnicki 6b8cc6e006 fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start
- Always build from the checkout (`up --build`, `pull_policy: build`)
  instead of reusing a stale local image or pulling an old registry one.
- --render-only writes to rendered/ and no longer touches the live
  Traefik dynamic directory; the route is installed after health check.
- Join the Traefik network via a generated docker-compose.override.yaml
  rather than `docker network connect` after startup, which raced the
  control-plane's startup call to wg-easy and was lost on recreate.
- Add --non-interactive, check the public HTTPS endpoint, and point the
  closing instructions at Settings -> Gateway (the suggested
  `furtka gateway connect` command doesn't exist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:49:49 +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 fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start 2026-10-07 15:49:49 +02:00
tests feat: ship control-plane standalone, bring your own Traefik/wg-easy 2026-09-19 13:34:29 +02:00
traefik fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start 2026-10-07 15:49:49 +02:00
.env.example fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start 2026-10-07 15:49:49 +02:00
.gitignore fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start 2026-10-07 15:49:49 +02:00
docker-compose.yaml fix: make ops/deploy.sh rebuild, dry-run safely, and join Traefik at start 2026-10-07 15:49:49 +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