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.
94 lines
4.9 KiB
Markdown
94 lines
4.9 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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.
|