furtka-gateway/README.md
Robert Syrnicki 73c96cab56 Fix WireGuard peer routing and control-plane reachability
Two gaps found while walking through a real single-tenant deployment
scenario, both fixed and verified against the actual running stack (not
just unit tests):

- wg-easy moves to network_mode: host so Traefik can actually route to
  WireGuard peer addresses (wg-quick's route only existed inside wg-easy's
  own isolated network namespace before). This forced host-level sysctls
  (ops/host-sysctls.sh — Docker/runc rejects namespaced sysctls under host
  networking) and a host firewall rule (ops/firewall.sh) to keep wg-easy's
  now-unisolated admin API off the public interface.
- control-plane gets a real Traefik route (traefik/dynamic/control-plane.yml.example)
  instead of being reachable only at 127.0.0.1:8090 — box registration
  carries a WireGuard private key and needs TLS, not bare loopback HTTP.

Along the way, running the actual pinned wg-easy image
(ghcr.io/wg-easy/wg-easy:14) surfaced that it's a different, older,
Express-based codebase than what wg-easy's current docs/master branch
describe: auth is a plain Authorization header per request (no session
cookie), routes live under /api/wireguard/client, and its default
WG_ALLOWED_IPS is full-tunnel (0.0.0.0/0) rather than the split-tunnel this
design assumed. wgeasy.py and docker-compose.yaml are corrected
accordingly, verified end-to-end against the real container.

Also fixed: Docker Compose silently truncates a bcrypt hash's `$`
characters when read from .env — documented the required $$ escaping.
2026-08-24 12:51:22 +02:00

4.9 KiB

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. The gateway's 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.

Supports two deployment modes from the same codebase:

  • single — the common case: one person/operator runs this on their own VPS with their own domain's A/AAAA records pointed at it, for their own Furtka box(es).
  • shared — one larger, multi-tenant instance (operated by the Furtka project) for people who don't want to run their own. Same schema, same code; single is just the one-account case.

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.

Deploying on a VPS

  1. Get a VPS with a static public IP, point a domain's A/AAAA record at it (e.g. example.com), and open 80/tcp, 443/tcp, 51820/udp in whatever firewall/security group sits in front of it.
  2. cp .env.example .env and fill it in — in particular GATEWAY_PUBLIC_HOST (the WireGuard endpoint hostname), a wg-easy admin password (WG_EASY_PASSWORD_HASH + the matching plaintext WG_EASY_ADMIN_PASSWORD), GATEWAY_BOX_TOKEN (openssl rand -hex 32), and GATEWAY_CONTROL_PLANE_HOST (its own A/AAAA record — see step 5). Double every $ in WG_EASY_PASSWORD_HASH as $$ — see that variable's comment in .env.example for why; getting this wrong silently truncates the hash instead of erroring, confirmed by hitting it directly.
  3. Run sudo ops/host-sysctls.sh on the VPS before bringing the stack up. wg-easy needs net.ipv4.ip_forward=1 (so the host actually routes between the docker bridge and the WireGuard interface — see the network_mode: host comment on the wg-easy service) and net.ipv4.conf.all.src_valid_mark, and — confirmed by actually trying it, not assumed — Docker's own sysctls: key can't set either one once a service is host-networked; runc refuses to even start the container.
  4. Bring the stack up: docker compose up -d (or docker stack deploy under Swarm — see the caveats below, build: doesn't work there yet).
  5. Copy traefik/dynamic/control-plane.yml.example to traefik/dynamic/control-plane.yml, substituting your real GATEWAY_CONTROL_PLANE_HOST. Without this, the registration API is only reachable at 127.0.0.1:8090 on the VPS itself — a remote Furtka box has no way to reach it, and the call needs TLS anyway since it carries a WireGuard private key (see that file's comments, and control_plane/wgeasy.py's module docstring, for why).
  6. Run sudo ops/firewall.sh on the VPS. wg-easy's admin API is no longer kept off the public interface by Docker the way every other service here is (a direct consequence of network_mode: host — see step 3). This script closes that with an iptables rule. Do this before pointing DNS at the box for real — read the script's own caveats first, it hasn't been validated against a real multi-interface VPS yet.
  7. curl http://127.0.0.1:8090/healthz from the VPS to confirm the stack is actually up.

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. Build and tag the image yourself first (docker build -t furtka-gateway-control-plane:local ./control_plane) and swap build: for image: furtka-gateway-control-plane:local until this is packaged as a versioned image in CI (the planned long-term fix).
  • 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.
  • 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
# GATEWAY_PUBLIC_HOST can be a LAN-reachable IP for a first smoke test.
docker compose up -d
curl http://127.0.0.1:8090/healthz

ops/firewall.sh and the control-plane.yml Traefik route are about reaching this gateway from the real internet — skip both for local dev.