From 4a11d772970b816d284dbc2d868f36534f988764 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Wed, 7 Oct 2026 15:49:49 +0200 Subject: [PATCH] docs: document new-server and existing Traefik/wg-easy setups Root README now lets you choose between the two paths, with requirements, the three ways to route Traefik to WireGuard peers (including the bridge-network recipe verified on a live Traefik v3.7 + wg-easy v15), box connection, troubleshooting and security notes. The starter-stack README lists server, firewall and DNS requirements. Co-Authored-By: Claude Opus 5.5 --- README.md | 309 +++++++++++++++++++------------ examples/starter-stack/README.md | 178 +++++++++++------- 2 files changed, 304 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 5c56883..6bfdd1b 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,206 @@ # 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. +Lets a Furtka box — typically at home behind NAT or CGNAT, with no public IP — +publish individual apps on the internet under a real domain, with automatic +HTTPS. -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. +``` +internet ──443──▶ Traefik ──WireGuard tunnel──▶ Furtka box (behind NAT) ──▶ app + ▲ +Furtka box ──51820/udp──▶ wg-easy (box dials out; no port forwarding at home) +``` -## What this actually is +A box connects as a WireGuard peer of wg-easy. Traefik then reverse-proxies +HTTPS traffic through that tunnel to the apps the box owner has explicitly +published. Only apps whose Furtka manifest declares `internet.viable: true` can +be published at all, and each one is a separate opt-in on the box — the +gateway never exposes anything by itself. -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. +This repo's own code is the **control-plane** (`control_plane/`, one small +stdlib-only Docker image). It creates WireGuard peers in wg-easy when a box +registers, and writes Traefik file-provider routes when a box publishes an app. -Don't already run Traefik or wg-easy? See -[`examples/starter-stack/`](examples/starter-stack/) — a zero-infra on-ramp -that bundles both. +## Choose your setup -`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. +| | [Option A: New server](#option-a-new-server) | [Option B: Existing Traefik + wg-easy](#option-b-existing-traefik--wg-easy) | +|---|---|---| +| For | An empty VPS | A server that already runs Traefik and wg-easy | +| Runs | wg-easy + Traefik + control-plane | control-plane only | +| Setup | `examples/starter-stack/deploy.sh`, 4 prompts | `ops/deploy.sh`, plus one routing step you do yourself | -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. +## Option A: New server -## Prerequisites checklist +Full guide, including server and DNS requirements: +**[examples/starter-stack/README.md](examples/starter-stack/README.md)**. -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:` (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 +In short: a Linux VPS with Docker, ports `80/tcp`, `443/tcp`, `51820/udp` +open, and a wildcard DNS record `*.example.com` pointing at it. Then: ```bash -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 +git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git +cd furtka-gateway/examples/starter-stack && ./deploy.sh ``` + +## Option B: Existing Traefik + wg-easy + +### Requirements + +- **Traefik v3** with a **file provider directory** (`providers.file.directory`). + The control-plane writes one file per published app into it. If your Traefik + uses `providers.file.filename` today, switch it to a directory and move your + existing file into it — Traefik allows only one file provider. +- **wg-easy 14 or 15.** Their admin APIs differ; you tell the deploy script + which one you run. **On v15, the control-plane sets wg-easy's default + "Allowed IPs" for new clients to your peer subnet (split tunnel)** on every + start. Existing clients aren't touched, but new non-Furtka clients (phones, + laptops) also get split tunnel unless you edit them. +- **Docker Compose v2.24+** on the server. +- **A route from Traefik to wg-easy's peer subnet** (default `10.8.0.0/24`) — + see the next section. This is the one step you do yourself. +- **DNS:** an A record for the gateway API hostname (e.g. `gw.example.com`) + pointing at your Traefik, plus one per published app — or simply a wildcard + `*.example.com`. Any registrar works; certificates come from your Traefik's + existing resolver. +- **No new ports.** Traefik's 443 and wg-easy's 51820/udp are already open. + +### Routing: Traefik must reach the WireGuard peers + +Published routes point Traefik at a box's tunnel address (`10.8.0.x`). That +address only exists inside wg-easy's network namespace, so a standard setup +(wg-easy on a Docker bridge network) can't reach it. Pick whichever fits: + +**1. wg-easy on a bridge network (most common)** — route the subnet through +wg-easy and NAT it. Verified on a live Traefik v3.7 + wg-easy v15 setup. + +a. Pin wg-easy's address on Traefik's network (in wg-easy's compose file), + so the route below stays valid: + + ```yaml + networks: + traefik: + ipv4_address: 172.19.0.5 # any free address in that network's subnet + ``` + +b. Route the peer subnet from the host to wg-easy, permanently + (`/etc/systemd/system/furtka-gateway-route.service`): + + ```ini + [Unit] + Description=Route WireGuard peer subnet via wg-easy (furtka-gateway) + Requires=docker.service + After=docker.service + PartOf=docker.service + + [Service] + Type=oneshot + RemainAfterExit=yes + ExecStart=/bin/sh -c 'for i in $(seq 90); do ip route replace 10.8.0.0/24 via 172.19.0.5 && exit 0; sleep 2; done; exit 1' + ExecStop=/sbin/ip route del 10.8.0.0/24 via 172.19.0.5 + + [Install] + WantedBy=multi-user.target + ``` + + ```bash + sudo systemctl daemon-reload && sudo systemctl enable --now furtka-gateway-route + ``` + +c. Make replies come back through the tunnel: in wg-easy's admin UI + (**Admin → Hooks**), append to **PostUp** + + ``` + iptables -t nat -A POSTROUTING -s 172.19.0.0/16 -o wg0 -j MASQUERADE; + ``` + + and to **PostDown** the same line with `-D` instead of `-A` (use your + Traefik network's subnet). Then restart wg-easy once. + +**2. Traefik in wg-easy's network namespace** — no routes or NAT at all; this +is what [Option A](examples/starter-stack/) does. Only practical if you can +restructure both containers. + +**3. wg-easy with `network_mode: host`** — wg0 then lives on the host. Needs +`net.ipv4.ip_forward=1` on the host, and a firewall rule keeping wg-easy's +admin port (51821) off the public interface, because host networking publishes +it. + +**Check it**, once a box is connected: + +```bash +docker exec ping -c1 10.8.0. +``` + +### Deploy + +```bash +git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git +cd furtka-gateway +ops/deploy.sh --render-only # optional: inspect .env and rendered/control-plane.yml first +ops/deploy.sh +``` + +It asks for your wg-easy URL, version and login, your Traefik's dynamic +directory, cert resolver and docker network, and the API hostname. It then +builds and starts the control-plane on your Traefik's network, installs its +route into the dynamic directory, and checks `https:///healthz`. + +Finding the answers: + +| Prompt | Where to look | +|---|---| +| wg-easy URL | `http://:51821` if it's on your Traefik's network | +| Version | The tag of your wg-easy image (`docker ps`) | +| Dynamic directory | The **host** path mounted at your Traefik's `providers.file.directory` | +| Cert resolver | `certificatesResolvers.` in your Traefik's static config | +| Docker network | `docker inspect --format '{{json .NetworkSettings.Networks}}'` | + +Update later with `git pull && ops/deploy.sh --non-interactive`. The image is +always rebuilt from your checkout. + +## Connect a box + +Same for both options. On the Furtka box: **Settings → Gateway**, enter the +gateway URL (`https://gw.example.com`), the registration token +(`GATEWAY_BOX_TOKEN` in the server's `.env`) and any box name. Then on the Apps +page, open an app's **⋯** menu → **Expose to Internet**, and give it a hostname +covered by your DNS. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| Box: `Unit wg-quick@furtka-gw.service does not exist` | Box image lacks `wireguard-tools`. Install it (`pacman -S wireguard-tools`) or reinstall from a newer ISO, then click Connect again — the existing registration is reused. | +| Box says connected, but wg-easy shows no handshake | Older box builds don't write wg-easy v15's preshared key into the tunnel config. Update the box. | +| App URL gives 502 / timeout, but the box's tunnel is up | Traefik can't reach `10.8.0.x` — see [Routing](#routing-traefik-must-reach-the-wireguard-peers). | +| No "Expose to Internet" in an app's menu | That app's manifest lacks `internet.viable: true`. | +| `deploy.sh`: control-plane not healthy | `docker compose logs control-plane` — usually a wrong wg-easy URL or password. | +| `https:///healthz` fails after deploy | DNS not pointing at Traefik yet, certificate still being issued, or Traefik isn't watching the dynamic directory. | + +## Security notes + +- A box's registration response contains its WireGuard private key, so the API + is only exposed through Traefik over HTTPS. The direct port is bound to + `127.0.0.1:8090`. +- `GATEWAY_BOX_TOKEN` lets anyone register a box. Treat it like a password; + rotate it by deleting it from `.env` and re-running the deploy script. +- Keep wg-easy's admin UI (port 51821) off the public internet. + +## Docker Swarm (Option B only) + +- `build:` doesn't work under Swarm. It uses the `image:` CI publishes on every + push to `master` (`latest` plus the short commit sha); set + `GATEWAY_CONTROL_PLANE_IMAGE` in `.env` to pin one. +- `docker stack deploy` doesn't read `.env` — `set -a; source .env; set +a` first. +- Attach the service to your Traefik's network in the stack file's own + `networks:` section (the generated `docker-compose.override.yaml` isn't used). +- Option A's stack can't run under Swarm (`network_mode: service:`). + +## Development + +```bash +python -m pytest # control-plane tests +ruff check . && ruff format --check . +``` + +`GATEWAY_MODE` is a tenancy switch: `single` (one operator — everything above) +or `shared` (multi-tenant; same code, not covered here). diff --git a/examples/starter-stack/README.md b/examples/starter-stack/README.md index 6978641..d217b3f 100644 --- a/examples/starter-stack/README.md +++ b/examples/starter-stack/README.md @@ -1,73 +1,117 @@ -# furtka-gateway starter stack +# furtka-gateway on a new server -Zero-infra on-ramp: bundles its own Traefik + wg-easy alongside the -control-plane, for an operator who doesn't already run either. This is a -secondary path — if you already have Traefik and/or wg-easy running -(a common self-hosting setup), use the main repo's root `docker-compose.yaml` -+ `ops/deploy.sh` instead, which plugs into what you already have rather -than running a second copy. See the root `README.md`'s Prerequisites -checklist. +Everything in one Docker Compose stack: **wg-easy** (WireGuard), **Traefik** +(HTTPS reverse proxy with Let's Encrypt), and the **furtka-gateway +control-plane**. Use this on a server that doesn't run anything else on +ports 80/443. If you already run Traefik and wg-easy, see +[the root README](../../README.md#option-b-existing-traefik--wg-easy) instead. -This stack always runs in `GATEWAY_MODE=single`, owns ports `80/tcp`, -`443/tcp`, and `51820/udp` on the host directly, and uses HTTP-01 certs -(no DNS provider needed) — the tradeoffs that make `single-parallel` mode -necessary elsewhere don't apply here, because this stack assumes nothing -else is already using those ports. - -## Deploying on a VPS - -1. Get a VPS with a static public IP, and point two A/AAAA records at it: - one for `GATEWAY_PUBLIC_HOST` (the WireGuard endpoint) and one for - `GATEWAY_CONTROL_PLANE_HOST` (the registration API — needs its own - hostname since the response carries a WireGuard private key in - plaintext JSON, so that call must go over HTTPS). -2. Run `./deploy.sh` from this directory and answer its prompts. It - generates the wg-easy admin password hash, `GATEWAY_BOX_TOKEN`, and - `GATEWAY_ADMIN_TOKEN`, writes `.env`, renders - `traefik/static/traefik.active.yml` and - `traefik/dynamic/control-plane.yml` from their tracked templates, runs - `sudo ops/host-sysctls.sh` (wg-easy needs `net.ipv4.ip_forward=1` and - `net.ipv4.conf.all.src_valid_mark` — see that script's comments for why - Docker's own `sysctls:` key can't set these under `network_mode: host`), - brings the stack up, runs `sudo ops/firewall.sh` (locks wg-easy's admin - API off the public interface — `network_mode: host` means Docker itself - can no longer do that automatically), and health-checks it. -3. Open `80/tcp`, `443/tcp`, `51820/udp` in whatever firewall/security - group sits in front of the VPS. - -Run `./deploy.sh --render-only` to just (re)write `.env` and the rendered -Traefik files without touching sysctls/compose/firewall — useful to inspect -what would be generated, or after hand-editing `.env`. - -## Local dev - -```bash -./deploy.sh --render-only -# 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 +``` +internet ──443──▶ Traefik ──WireGuard tunnel──▶ Furtka box (behind NAT) ──▶ app + ▲ +Furtka box ──51820/udp──▶ wg-easy (box dials out; no port forwarding at home) ``` -`ops/firewall.sh` and the `control-plane.yml` Traefik route are about -reaching this gateway from the real internet — skip both for local dev. +## Server requirements -## Docker Swarm caveats +| | | +|---|---| +| Machine | Any Linux VPS, x86_64 or arm64. 1 vCPU / 512 MB RAM is plenty. | +| Public IP | A static public IPv4 address. | +| Kernel | WireGuard support (Linux 5.6+ — every current distro). | +| Software | Docker Engine with the Compose plugin **v2.24 or newer** (`docker compose version`), plus `git`, `curl`, `openssl`. | +| Free ports | `80/tcp`, `443/tcp`, `51820/udp` — nothing else may use them. | +| Firewall | Allow those three ports inbound, in the server's firewall *and* any provider firewall/security group. Nothing else needs to be open. | -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 CI - keeps published to the registry on every push to `master` (tagged - `latest` plus the short commit sha). 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. **Do not `source` - `.env` directly if `WG_EASY_PASSWORD_HASH` is set** — bash expands `$$` - (its own PID) while sourcing, silently corrupting the hash's doubled-`$` - escaping. Export that one variable separately, unescaped, or set it - directly on the `docker stack deploy` command line. -- `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. +Installing Docker on a fresh Debian/Ubuntu server: + +```bash +curl -fsSL https://get.docker.com | sh +``` + +## DNS requirements + +Easiest: **one wildcard record** for your domain, pointing at the server. + +| Type | Name | Value | +|---|---|---| +| A | `*.example.com` | your server's IPv4 | + +That covers everything at once: + +- `vpn.example.com` — the WireGuard endpoint boxes dial +- `gw.example.com` — the gateway API you enter on a box +- `.example.com` — every app a box publishes later, with no DNS change per app + +No wildcard possible? Create A records for `vpn.` and `gw.`, plus one per app +before you publish it. Certificates use Let's Encrypt's HTTP challenge, so no +DNS provider API is needed — any registrar works. + +If you also add AAAA (IPv6) records, the server must be reachable over IPv6 on +the same three ports. + +## Install + +```bash +git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git +cd furtka-gateway/examples/starter-stack +./deploy.sh +``` + +It asks for four things — your domain, the two hostnames (defaults +`vpn.` and `gw.`), and an email for Let's Encrypt — then +generates all passwords and tokens into `.env`, starts the stack, checks DNS +and health, and prints what to enter on a box. + +`./deploy.sh --render-only` writes `.env` and `dynamic/control-plane.yml` +without starting anything. `./deploy.sh --non-interactive` takes every value +from an existing `.env`, for scripted installs. + +## Connect a box + +On the Furtka box: **Settings → Gateway**, enter + +- **Gateway URL:** `https://gw.example.com` +- **Registration token:** `GATEWAY_BOX_TOKEN` from `.env` +- **Box name:** anything + +Then publish an app from the Apps page: **⋯ → Expose to Internet**, and give it +a hostname like `tools.example.com`. + +## Day to day + +| Task | Command (in this folder) | +|---|---| +| Update | `git pull && ./deploy.sh --non-interactive` | +| Logs | `docker compose logs -f control-plane` (or `wg-easy`, `traefik`) | +| Restart | `docker compose restart` | +| wg-easy admin UI | `ssh -L 51821:127.0.0.1:51821 `, then open http://localhost:51821 — user `admin`, password `WG_EASY_ADMIN_PASSWORD` from `.env` | +| Backup | `.env`, `dynamic/`, and the Docker volumes `*_wg_easy_data`, `*_control_plane_data`, `*_traefik_acme` | + +Changed the wg-easy password in its UI? Put the new one in `.env` as +`WG_EASY_ADMIN_PASSWORD` and run `./deploy.sh --non-interactive`. + +## How it works + +wg-easy creates the `wg0` interface and the route to the boxes +(`10.8.0.0/24`) inside its own network namespace. Traefik must be in that same +namespace to reach a box's tunnel address. So both join one namespace owned by +`gateway-net`, a do-nothing container whose only job is to hold the namespace +and publish the ports. Because it never restarts, wg-easy and Traefik can each +restart independently without cutting the other off. No host routes, NAT rules, +sysctls or host networking are needed, and wg-easy's admin UI is never +published to the internet. + +Two consequences: + +- **Restart through Compose.** `docker compose restart` handles everything. + If `furtka-gateway-net` itself was restarted with a bare `docker restart`, + wg-easy and Traefik lose their network — fix with `docker compose restart` + (plain `docker compose up -d` does *not* repair it). +- **Compose only, not Swarm.** `network_mode: service:` isn't supported by + `docker stack deploy`. + +Verified with a simulated box: route publishing, plus restarts of wg-easy, +Traefik and `gateway-net`, and a full stack stop/start. A real server reboot +hasn't been tested yet — after your first reboot, check that +`https://gw.example.com/healthz` answers.