diff --git a/.env.example b/.env.example index 93cfc95..10bda8c 100644 --- a/.env.example +++ b/.env.example @@ -58,12 +58,10 @@ GATEWAY_CERT_RESOLVER= GATEWAY_CONTROL_PLANE_HOST=gw.example.com # Where the control-plane's own API is bound (docker-compose.yaml's port -# publish) and where your Traefik reaches it, respectively. Same value -# for the recommended same-docker-network setup (ops/deploy.sh sets both -# to furtka-gateway-control-plane:8090 automatically, with the bind -# staying loopback-only — see docker-compose.yaml's comment on why this -# is more robust than the compose *service* name "control-plane" once the -# network is joined after the fact via `docker network connect`). +# publish) and where your Traefik reaches it, respectively. For the +# recommended same-docker-network setup, ops/deploy.sh keeps the bind +# loopback-only and sets the target to the fixed container name +# furtka-gateway-control-plane:8090. # Different values only for a separate-host setup, where # GATEWAY_CONTROL_PLANE_BIND must be widened beyond 127.0.0.1 — put real # access control around that bind address, this file carries a WireGuard @@ -74,8 +72,8 @@ GATEWAY_CONTROL_PLANE_TARGET=furtka-gateway-control-plane:8090 # Same-docker-network setup only (the recommended path — see the README's # Prerequisites checklist): your Traefik's docker network name # (`docker network ls` / `docker inspect `). -# ops/deploy.sh runs `docker network connect` with this after bringing the -# stack up. Leave blank for a separate-host setup. +# ops/deploy.sh writes a docker-compose.override.yaml that joins this +# network. Leave blank for a separate-host setup. GATEWAY_TRAEFIK_NETWORK= # The one box token a single-tenant deployment accepts at diff --git a/.gitignore b/.gitignore index f6217ca..95c99a1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ traefik/dynamic/route-*.yml traefik/dynamic/control-plane.yml traefik/dynamic/default-cert.yml traefik/static/traefik.active.yml +examples/starter-stack/dynamic/ +rendered/ +docker-compose.override.yaml 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/docker-compose.yaml b/docker-compose.yaml index 8af38aa..9f74b14 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,13 +1,12 @@ services: control-plane: build: ./control_plane - # CI (.forgejo/workflows/build-image.yml) publishes this same build to - # the registry on every push to master, tagged `latest` plus the short - # commit sha. `docker compose up` builds fresh from `build:` above and - # tags the result as this name, ignoring this being a registry ref; - # `docker stack deploy` (Swarm ignores `build:` entirely, see the - # Swarm caveats in README.md) pulls it instead — no manual - # `docker build -t ... ./control_plane` step needed there anymore. + # Always build from this checkout — never pull a registry image that may + # be older than the code you just pulled. The image name below only + # matters for `docker stack deploy` (Swarm can't build; see the Swarm + # caveats in README.md), which pulls what CI publishes on every push to + # master (`latest` plus the short commit sha). + pull_policy: build image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest} container_name: furtka-gateway-control-plane environment: @@ -58,14 +57,10 @@ services: # implication of doing that when you choose it. - "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090" restart: unless-stopped - # No custom `networks:` here on purpose — this stays on the default - # bridge network compose creates unless you also run YOUR Traefik in - # this SAME compose project. To reach a Traefik running elsewhere (its - # own compose project, or your own homelab stack), ops/deploy.sh joins - # this container to your Traefik's existing docker network after - # `up` via `docker network connect` — no external-network dependency - # baked into this file, so it works whether or not that network - # exists yet at compose-up time. + # No `networks:` here on purpose — your Traefik's network name differs + # per setup. ops/deploy.sh writes a gitignored + # docker-compose.override.yaml (auto-loaded by `docker compose`) that + # joins this container to it from the first second. volumes: control_plane_data: 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. diff --git a/examples/starter-stack/deploy.sh b/examples/starter-stack/deploy.sh index cc428e5..50e3811 100755 --- a/examples/starter-stack/deploy.sh +++ b/examples/starter-stack/deploy.sh @@ -1,33 +1,36 @@ #!/usr/bin/env bash -# Interactive setup for the zero-infra starter stack: bundles its own -# Traefik + wg-easy + control-plane. If you already run Traefik and/or -# wg-easy, use the main repo's root docker-compose.yaml + ops/deploy.sh -# instead — it plugs into what you already have. +# Sets up furtka-gateway on an empty server: wg-easy + Traefik + +# control-plane, all from this folder's docker-compose.yaml. See README.md +# here for the server and DNS requirements. +# +# Safe to re-run — existing .env values become the defaults for each +# prompt (press Enter to keep them), and secrets are generated once. # # Usage: -# ./deploy.sh full setup: prompts, render, sysctls, -# compose up, firewall, health check -# ./deploy.sh --render-only only (re)write .env and the rendered -# Traefik files — skips sysctls/compose/ -# firewall/health-check. +# ./deploy.sh prompts, writes .env + dynamic/, starts +# the stack, health-checks it +# ./deploy.sh --non-interactive same, but no prompts: every value comes +# from .env (fails if one is missing) +# ./deploy.sh --render-only only (re)write .env and +# dynamic/control-plane.yml; starts nothing set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" RENDER_ONLY=0 -if [[ "${1:-}" == "--render-only" ]]; then - RENDER_ONLY=1 -fi +INTERACTIVE=1 +for arg in "$@"; do + case "$arg" in + --render-only) RENDER_ONLY=1 ;; + --non-interactive) INTERACTIVE=0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done ENV_FILE=.env -# ---- load existing .env (if any) so prompts default to current values ---- -# Deliberately NOT `source`d: bash expands `$$` (its own PID) and `$VAR` -# during sourcing, which would corrupt WG_EASY_PASSWORD_HASH's doubled-`$` -# escaping (that escaping is for docker compose's own .env parser, not -# bash's) — confirmed by hitting this while building the main ops/deploy.sh. -# Plain string splitting instead, so a value is never re-interpreted. +# Deliberately not `source`d: bash would expand `$` in values. load_env_file() { local file="$1" line key value [[ -f "$file" ]] || return 0 @@ -39,68 +42,47 @@ load_env_file() { done < "$file" } load_env_file "$ENV_FILE" -# Normalize back to the raw (single-`$`) hash for the rest of this script; -# re-escaped once, right before writing, further down. -WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH:-}" -WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH//\$\$/\$}" ask() { + # ask VARNAME "Prompt text" "fallback-default" local __var="$1" __prompt="$2" __fallback="${3:-}" local __current="${!__var:-}" local __default="${__current:-$__fallback}" - local __ans - if [[ -n "$__default" ]]; then - read -r -p "$__prompt [$__default]: " __ans - else - read -r -p "$__prompt: " __ans + local __ans="" + if [[ "$INTERACTIVE" -eq 1 ]]; then + if [[ -n "$__default" ]]; then + read -r -p "$__prompt [$__default]: " __ans + else + read -r -p "$__prompt: " __ans + fi fi printf -v "$__var" '%s' "${__ans:-$__default}" -} - -ask_secret() { - local __var="$1" __prompt="$2" - local __current="${!__var:-}" - local __ans - if [[ -n "$__current" ]]; then - read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans - else - read -r -s -p "$__prompt: " __ans + if [[ -z "${!__var}" ]]; then + echo "$__var is required" >&2 + exit 1 fi - echo - printf -v "$__var" '%s' "${__ans:-$__current}" } require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; } } +require_cmd openssl if [[ "$RENDER_ONLY" -eq 0 ]]; then require_cmd docker - require_cmd openssl - require_cmd sudo + require_cmd curl + docker compose version >/dev/null 2>&1 || { echo "missing: docker compose plugin" >&2; exit 1; } fi -echo "== furtka-gateway starter-stack deploy ==" +echo "== furtka-gateway: new server setup ==" echo +ask GATEWAY_DOMAIN "Your domain (boxes publish apps as .)" "" +ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname" "vpn.$GATEWAY_DOMAIN" +ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" "gw.$GATEWAY_DOMAIN" +ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt certificate notices" "" -ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname (A/AAAA record for this VPS)" "vpn.example.com" -ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com" -ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt registration" "admin@example.com" - -echo -_PREV_WG_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-}" -ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password" -if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then - echo "wg-easy admin password is required" >&2 - exit 1 -fi -if [[ "$WG_EASY_ADMIN_PASSWORD" != "$_PREV_WG_PASSWORD" || -z "${WG_EASY_PASSWORD_HASH:-}" ]]; then - echo "Hashing wg-easy admin password..." - WG_EASY_PASSWORD_HASH="$(docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \ - "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" "$WG_EASY_ADMIN_PASSWORD")" -fi -WG_EASY_PASSWORD_HASH_ESCAPED="${WG_EASY_PASSWORD_HASH//\$/\$\$}" - +# Secrets: generated once, kept on re-run. +WG_EASY_ADMIN_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-$(openssl rand -hex 16)}" GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}" GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}" @@ -108,62 +90,97 @@ echo echo "Writing $ENV_FILE" { echo "# Generated by deploy.sh — re-run it to change any of these." + echo "GATEWAY_DOMAIN=$GATEWAY_DOMAIN" echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST" echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST" echo "GATEWAY_ACME_EMAIL=$GATEWAY_ACME_EMAIL" - echo "WG_EASY_PASSWORD_HASH=$WG_EASY_PASSWORD_HASH_ESCAPED" echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD" echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN" echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN" + [[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE" } > "$ENV_FILE" chmod 600 "$ENV_FILE" -echo "Rendering traefik/static/traefik.active.yml" -cp traefik/static/traefik.single.yml traefik/static/traefik.active.yml -sed -i "s|admin@example.com|$GATEWAY_ACME_EMAIL|" traefik/static/traefik.active.yml - -echo "Rendering traefik/dynamic/control-plane.yml" -# Only the http: block onward — the .example's header comment mentions the -# placeholder by name too (for a human reading that template), which would -# otherwise also get corrupted by this same substitution. -sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > traefik/dynamic/control-plane.yml -sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" traefik/dynamic/control-plane.yml +echo "Writing dynamic/control-plane.yml" +mkdir -p dynamic +cat > dynamic/control-plane.yml </dev/null | awk 'NR==1{print $1}')" + if [[ -z "$resolved" ]]; then + echo " WARNING: $host does not resolve yet" + elif [[ -n "$SERVER_IP" && "$resolved" != "$SERVER_IP" ]]; then + echo " WARNING: $host -> $resolved, but this server is $SERVER_IP" + else + echo " ok: $host -> $resolved" + fi +done echo -echo "== docker compose up -d ==" -docker compose up -d - -echo -echo "== sudo ops/firewall.sh ==" -sudo "$SCRIPT_DIR/ops/firewall.sh" +echo "== docker compose up -d --build ==" +docker compose up -d --build echo echo "== health check ==" -for _ in $(seq 1 10); do - if curl -sf 127.0.0.1:8090/healthz >/dev/null; then - echo "control-plane is up." +healthy=0 +for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:8090/healthz >/dev/null 2>&1; then + healthy=1 break fi - sleep 1 + sleep 2 done -if ! curl -sf 127.0.0.1:8090/healthz >/dev/null; then - echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2 +if [[ "$healthy" -ne 1 ]]; then + echo "control-plane did not become healthy — check: docker compose logs control-plane wg-easy" >&2 exit 1 fi +echo " ok: control-plane is up" + +public_ok=0 +for _ in $(seq 1 20); do + if curl -sf -m 10 "https://$GATEWAY_CONTROL_PLANE_HOST/healthz" >/dev/null 2>&1; then + public_ok=1 + break + fi + sleep 3 +done +if [[ "$public_ok" -eq 1 ]]; then + echo " ok: https://$GATEWAY_CONTROL_PLANE_HOST/healthz (valid certificate)" +else + echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable yet." + echo " Usually DNS or the certificate is still pending — check: docker compose logs traefik" +fi echo echo "== done ==" -echo "Still needed before a real box can connect:" -echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST and $GATEWAY_PUBLIC_HOST -> this VPS's public IP" -echo " - Open udp/51820, tcp/80, tcp/443 in your firewall/security group" -echo " - On the box: furtka gateway connect " +echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter" +echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST" +echo " Registration token: the GATEWAY_BOX_TOKEN value in $SCRIPT_DIR/.env" +echo +echo "wg-easy admin UI (optional): ssh -L 51821:127.0.0.1:51821 ," +echo "then open http://localhost:51821 — user 'admin', password WG_EASY_ADMIN_PASSWORD in .env." diff --git a/examples/starter-stack/docker-compose.yaml b/examples/starter-stack/docker-compose.yaml index 8530269..72d530c 100644 --- a/examples/starter-stack/docker-compose.yaml +++ b/examples/starter-stack/docker-compose.yaml @@ -1,135 +1,131 @@ +# furtka-gateway on an empty server: wg-easy + Traefik + control-plane. +# Configure via .env (deploy.sh writes it — see README.md in this folder). +# +# The one trick: wg-easy and Traefik share one network namespace, owned +# by the tiny `gateway-net` container. wg0 and its 10.8.0.0/24 route live +# in that namespace, so Traefik has a direct path to every box's tunnel +# address — no host routes, NAT rules, host sysctls, or host-networked +# wg-easy needed. `gateway-net` does nothing else and never restarts, so +# wg-easy and Traefik can each restart without cutting the other off +# (a namespace owned by wg-easy itself would vanish on every wg-easy +# restart, leaving Traefik with no network at all). + services: + gateway-net: + image: alpine:3.20 + container_name: furtka-gateway-net + command: ["sleep", "infinity"] + init: true + ports: + # WireGuard + - "51820:51820/udp" + # Traefik + - "80:80" + - "443:443" + # wg-easy admin UI, loopback only: ssh -L 51821:127.0.0.1:51821 + - "127.0.0.1:51821:51821" + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv4.conf.all.src_valid_mark=1 + networks: + - internal + restart: unless-stopped + wg-easy: - image: ghcr.io/wg-easy/wg-easy:14 + image: ghcr.io/wg-easy/wg-easy:15 container_name: furtka-gateway-wg-easy - # Host networking, not the "internal" bridge network: wg-quick creates - # the wg0 interface and its `10.8.0.0/24 dev wg0` route only inside - # whatever network namespace this container runs in. On the "internal" - # bridge network that route existed only inside wg-easy's own isolated - # namespace, so sibling containers (Traefik, in particular) had no path - # to reach a WireGuard peer's address at all. With network_mode: host, - # wg0 and its routes live in the VPS host's own namespace, which the - # host can then route to/from its docker bridge interfaces normally - # (given net.ipv4.ip_forward=1 — see ops/host-sysctls.sh; compose's own - # `sysctls:` key can't set this here at all, see that script's comment). - network_mode: host environment: - - WG_HOST=${GATEWAY_PUBLIC_HOST} - - PASSWORD_HASH=${WG_EASY_PASSWORD_HASH} - - PORT=51821 - - WG_PORT=51820 - # wg-easy's session/CSRF handling assumes HTTPS by default; we talk to - # it over plain HTTP from control-plane (see WG_EASY_URL below), which - # is fine precisely because this port is never meant to be reachable - # from the public internet — see the ops/firewall.sh note. - - INSECURE=true - # Default is "0.0.0.0/0, ::/0" — a full-tunnel config that would route - # every box's entire internet traffic through this gateway. We only - # want boxes reachable *from* the gateway for proxying, not routed - # *through* it — split-tunnel, restricted to wg-easy's own peer - # subnet (confirmed via config.js: WG_DEFAULT_ADDRESS defaults to - # 10.8.0.x, i.e. this same /24). - - WG_ALLOWED_IPS=10.8.0.0/24 - # IPv6 isn't handled anywhere else in this repo yet (routes.py/ - # traefikconf.py assume IPv4 peer addresses, ops/firewall.sh is - # IPv4-only) — turn it off here too rather than leave a half-wired - # IPv6 tunnel nothing else accounts for. + # Unattended first-run setup (only applied while wg-easy's database + # is still empty — change the password later in its UI, then update + # WG_EASY_ADMIN_PASSWORD in .env to match). + - INIT_ENABLED=true + - INIT_USERNAME=admin + - INIT_PASSWORD=${WG_EASY_ADMIN_PASSWORD} + - INIT_HOST=${GATEWAY_PUBLIC_HOST} + - INIT_PORT=51820 + # Split tunnel: boxes only route the gateway's peer subnet through + # the tunnel, never their whole internet traffic. + - INIT_ALLOWED_IPS=10.8.0.0/24 - DISABLE_IPV6=true + # The admin UI is only published on the server's loopback (see + # gateway-net) and reached over an SSH tunnel, so plain HTTP is fine. + - INSECURE=true volumes: - wg_easy_data:/etc/wireguard + - /lib/modules:/lib/modules:ro + network_mode: service:gateway-net cap_add: - NET_ADMIN - SYS_MODULE + depends_on: + gateway-net: + condition: service_started + restart: true restart: unless-stopped - # network_mode: host means wg-easy's admin UI/API (port 51821) binds - # directly on the VPS's interfaces, including the public one — unlike - # the old bridge-network setup, Docker itself can no longer keep this - # off the internet. We can't fix this by binding wg-easy to a single - # private address either (it would then also refuse the control-plane - # container's own connections, which arrive via the docker bridge - # interface, not loopback). The actual fix is a host firewall rule — - # see ops/firewall.sh. RUN THAT SCRIPT (or an equivalent rule) BEFORE - # this is reachable from the internet. traefik: - image: traefik:v3.1 + image: traefik:v3.7 container_name: furtka-gateway-traefik + network_mode: service:gateway-net command: - - --configFile=/etc/traefik/traefik.yml + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + - --providers.file.directory=/dynamic + - --providers.file.watch=true + - --certificatesresolvers.le.acme.email=${GATEWAY_ACME_EMAIL} + - --certificatesresolvers.le.acme.storage=/acme/acme.json + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + - --log.level=INFO volumes: - # Rendered by deploy.sh from traefik/static/traefik.single.yml with - # the ACME email substituted in — see that script. Gitignored, not - # the tracked template. - - ./traefik/static/traefik.active.yml:/etc/traefik/traefik.yml:ro - - ./traefik/dynamic:/etc/traefik/dynamic:ro + # control-plane.yml (written by deploy.sh) + one route-*.yml per + # published app (written by the control-plane). + - ./dynamic:/dynamic:ro - traefik_acme:/acme - ports: - - "80:80" - - "443:443" + depends_on: + gateway-net: + condition: service_started + restart: true restart: unless-stopped - networks: - - internal - # Traefik's own dashboard/API is disabled (api.dashboard: false, - # api.insecure: false) — never reachable at all, let alone publicly. control-plane: build: ../../control_plane - # CI (.forgejo/workflows/build-image.yml) publishes this same build to - # the registry on every push to master, tagged `latest` plus the short - # commit sha. `docker compose up` builds fresh from `build:` above and - # tags the result as this name, ignoring this being a registry ref; - # `docker stack deploy` (Swarm ignores `build:` entirely) pulls it - # instead — no manual `docker build -t ... ./control_plane` step - # needed there anymore. + # Always build from this checkout — never run a registry image that may + # be older than the code you just pulled. (The image name only matters + # for Docker Swarm, which can't build; see README.md.) + pull_policy: build image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest} container_name: furtka-gateway-control-plane environment: - - GATEWAY_MODE=${GATEWAY_MODE:-single} + - GATEWAY_MODE=single - GATEWAY_STATE_DIR=/data - GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} - GATEWAY_CERT_RESOLVER=le - # wg-easy is host-networked now (see above), so it's no longer a - # fellow member of the "internal" bridge network reachable by service - # name — host.docker.internal (mapped below) reaches the VPS host - # itself, where wg-easy's admin API is listening on 51821. - - WG_EASY_URL=http://host.docker.internal:51821 - - WG_EASY_API_VERSION=14 - # This pinned image (ghcr.io/wg-easy/wg-easy:14 — a legacy - # Express-based codebase, confirmed by reading the actual container's - # /app/lib/Server.js, NOT the newer rewrite wg-easy's GitHub `master` - # branch and docs describe) has no username/session-login API at - # all — every request just carries this plaintext password as a bare - # `Authorization` header, checked with bcrypt against - # WG_EASY_PASSWORD_HASH above. + - WG_EASY_URL=http://gateway-net:51821 + - WG_EASY_API_VERSION=15 + - WG_EASY_ADMIN_USERNAME=admin - WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD} - extra_hosts: - - "host.docker.internal:host-gateway" + - GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24 volumes: - control_plane_data:/data - - ./traefik/dynamic:/dynamic + - ./dynamic:/dynamic ports: - # Loopback-only convenience for local health checks/debugging - # (`curl 127.0.0.1:8090/healthz` from the VPS itself). This is NOT - # how a remote Furtka box reaches the registration API — that goes - # through Traefik over HTTPS; see traefik/dynamic/control-plane.yml.example. + # Loopback-only, for health checks from the server itself. Boxes + # reach the API through Traefik over HTTPS (dynamic/control-plane.yml). - "127.0.0.1:8090:8090" - restart: unless-stopped networks: - internal depends_on: - - traefik + # The control-plane configures wg-easy on startup. + wg-easy: + condition: service_healthy + restart: unless-stopped networks: internal: - driver: bridge - # Pinned (rather than Docker's usual dynamic allocation) so - # ops/firewall.sh has a stable subnet to reference when it allows this - # network through to wg-easy's admin API but blocks everyone else. - ipam: - config: - - subnet: 172.28.0.0/24 volumes: wg_easy_data: diff --git a/examples/starter-stack/ops/firewall.sh b/examples/starter-stack/ops/firewall.sh deleted file mode 100755 index c65771b..0000000 --- a/examples/starter-stack/ops/firewall.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# Restrict wg-easy's admin API (tcp/51821, or GATEWAY_WG_ADMIN_PORT if -# overridden) to loopback and the gateway's own docker bridge network. Run -# this once on the VPS after `docker compose up -d`, and again if the -# "internal" network's subnet ever changes. -# -# `sudo` does not inherit your shell's env or read .env for you — if you -# overrode GATEWAY_WG_ADMIN_PORT, run: -# set -a; source .env; set +a; sudo -E ops/firewall.sh -# -# Why this exists: wg-easy runs with network_mode: host (see -# docker-compose.yaml's comment on why — Traefik needs a route to -# WireGuard peer addresses that only exists in whatever network namespace -# wg-easy's wg0 interface lives in). Host networking means Docker itself -# can no longer keep wg-easy's admin port off the VPS's public interface -# the way it does for every other container here — this script is that -# missing piece, done with iptables instead. -# -# NOT yet validated against a real multi-interface VPS. Review -# DOCKER_BRIDGE_SUBNET against your actual `docker network inspect -# furtka-gateway_internal` output before relying on this as your only line -# of defense. A cloud provider security group that blocks 51821/tcp -# entirely is a good belt-and-suspenders addition on top of this — wg-easy's -# admin API has no legitimate reason to ever be reached from outside this -# host. -# -# IPv4 only. If this VPS also has a public IPv6 address, ip6tables needs -# the equivalent rules added by hand — not yet handled here. - -set -euo pipefail - -# Must match GATEWAY_WG_ADMIN_PORT in .env if you overrode it (e.g. -# GATEWAY_MODE=single-parallel, where 51821 is already taken by another -# wg-easy on this host). -ADMIN_PORT="${GATEWAY_WG_ADMIN_PORT:-51821}" -# Must match docker-compose.yaml's networks.internal.ipam.config subnet. -DOCKER_BRIDGE_SUBNET="172.28.0.0/24" - -if [[ $EUID -ne 0 ]]; then - echo "must be run as root" >&2 - exit 1 -fi - -# Insert ACCEPT rules at the head of the chain, then append DROP at the -# tail — this ordering holds regardless of how many times the script runs, -# since -I always lands above whatever's already there (including a -# previous run's DROP) and -A always lands below. -iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -s 127.0.0.1 -j ACCEPT 2>/dev/null \ - || iptables -I INPUT -p tcp --dport "$ADMIN_PORT" -s 127.0.0.1 -j ACCEPT - -iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -s "$DOCKER_BRIDGE_SUBNET" -j ACCEPT 2>/dev/null \ - || iptables -I INPUT -p tcp --dport "$ADMIN_PORT" -s "$DOCKER_BRIDGE_SUBNET" -j ACCEPT - -iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -j DROP 2>/dev/null \ - || iptables -A INPUT -p tcp --dport "$ADMIN_PORT" -j DROP - -echo "wg-easy admin API (tcp/$ADMIN_PORT) now restricted to loopback + $DOCKER_BRIDGE_SUBNET" -echo "NOTE: these rules do not persist across reboot on most distros —" -echo "install iptables-persistent (Debian/Ubuntu) or an equivalent, or add" -echo "this script to a boot-time hook." diff --git a/examples/starter-stack/ops/host-sysctls.sh b/examples/starter-stack/ops/host-sysctls.sh deleted file mode 100755 index 05828ea..0000000 --- a/examples/starter-stack/ops/host-sysctls.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Set the two kernel sysctls wg-easy needs, directly on the VPS host. -# -# Why this can't just be docker-compose.yaml's `sysctls:` key: that key -# sets namespaced (per-network-namespace) sysctls inside a container's own -# network namespace. wg-easy runs with network_mode: host (see the comment -# on that service), which means it has no network namespace of its own — -# runc flatly refuses to start the container if `sysctls:` is set at all -# under host networking ("not allowed in host network namespace"), -# confirmed by actually trying it, not assumed. These have to be host-level -# settings instead. -# -# Run this once on the VPS, before `docker compose up -d`. - -set -euo pipefail - -if [[ $EUID -ne 0 ]]; then - echo "must be run as root" >&2 - exit 1 -fi - -CONF_FILE=/etc/sysctl.d/99-furtka-gateway.conf - -cat > "$CONF_FILE" <<'EOF' -# Required by furtka-gateway's wg-easy service (network_mode: host) — see -# docker-compose.yaml and ops/host-sysctls.sh. -net.ipv4.ip_forward=1 -net.ipv4.conf.all.src_valid_mark=1 -EOF - -sysctl --system >/dev/null - -echo "Applied and persisted (via $CONF_FILE):" -sysctl net.ipv4.ip_forward net.ipv4.conf.all.src_valid_mark diff --git a/examples/starter-stack/traefik/dynamic/control-plane.yml.example b/examples/starter-stack/traefik/dynamic/control-plane.yml.example deleted file mode 100644 index 181e830..0000000 --- a/examples/starter-stack/traefik/dynamic/control-plane.yml.example +++ /dev/null @@ -1,26 +0,0 @@ -# Copy to traefik/dynamic/control-plane.yml with GATEWAY_CONTROL_PLANE_HOST -# substituted for a real hostname you control (e.g. gw.example.com, with its -# own A/AAAA record pointing at this VPS). -# -# Without this file, the control-plane API is only reachable at -# 127.0.0.1:8090 on the gateway host itself — fine for a local health -# check, useless for a real remote Furtka box, which needs this over HTTPS -# anyway: the registration response carries a WireGuard private key in -# plaintext JSON, so that call must never go out over bare HTTP. -# -# Keep the certResolver line below as-is — this stack always runs -# GATEWAY_MODE=single with HTTP-01, same as every app route (see -# traefik/static/traefik.single.yml). -http: - routers: - control-plane: - rule: "Host(`GATEWAY_CONTROL_PLANE_HOST`)" - entryPoints: [websecure] - service: control-plane - tls: - certResolver: le - services: - control-plane: - loadBalancer: - servers: - - url: "http://control-plane:8090" diff --git a/examples/starter-stack/traefik/static/traefik.single.yml b/examples/starter-stack/traefik/static/traefik.single.yml deleted file mode 100644 index 718e7c6..0000000 --- a/examples/starter-stack/traefik/static/traefik.single.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Single-tenant mode: one operator's own domain, HTTP-01 challenge per -# subdomain. Fine at self-hosted scale — nowhere near Let's Encrypt's -# ~50-certs/registered-domain/week limit with a handful of published apps. - -entryPoints: - web: - address: ":80" - http: - redirections: - entryPoint: - to: websecure - scheme: https - websecure: - address: ":443" - -providers: - file: - directory: /etc/traefik/dynamic - watch: true - -certificatesResolvers: - le: - acme: - email: admin@example.com # overridden per-deployment; see deploy.sh - storage: /acme/acme.json - httpChallenge: - entryPoint: web - -api: - dashboard: false - insecure: false - -log: - level: INFO diff --git a/ops/deploy.sh b/ops/deploy.sh index 0412f6f..ef6eade 100755 --- a/ops/deploy.sh +++ b/ops/deploy.sh @@ -1,24 +1,23 @@ #!/usr/bin/env bash -# Interactive setup for furtka-gateway's control-plane, plugged into a -# Traefik and wg-easy YOU already run (as sibling containers, or otherwise -# reachable) — see the root README's Prerequisites checklist before -# running this. No zero-infra option here: for that, use -# examples/starter-stack/deploy.sh instead, which bundles its own -# Traefik+wg-easy. +# Sets up furtka-gateway's control-plane, plugged into a Traefik and +# wg-easy YOU already run. Read the README's "Existing Traefik + wg-easy" +# section first — in particular the routing requirement, which this script +# can't set up or verify for you. On an empty server, use +# examples/starter-stack/ instead. # # Safe to re-run — existing .env values become the defaults for each -# prompt (press Enter to keep them), and rendering is always fully -# regenerated from the current .env, never patched in place. +# prompt (press Enter to keep them). # # Usage: -# ops/deploy.sh full setup: prompts, render, compose up, -# network join, health check -# ops/deploy.sh --render-only only (re)write .env and the rendered -# traefik/dynamic/control-plane.yml — -# skips compose up / network join / -# health check. Useful after hand-editing -# .env, or to inspect what would be -# generated before touching anything live. +# ops/deploy.sh prompts, writes .env, starts the +# control-plane, installs its Traefik +# route, health-checks it +# ops/deploy.sh --non-interactive same, but no prompts: every value +# comes from .env (fails if one is missing) +# ops/deploy.sh --render-only only (re)write .env and +# rendered/control-plane.yml — touches +# nothing live (not even your Traefik's +# dynamic directory) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,9 +25,14 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" RENDER_ONLY=0 -if [[ "${1:-}" == "--render-only" ]]; then - RENDER_ONLY=1 -fi +INTERACTIVE=1 +for arg in "$@"; do + case "$arg" in + --render-only) RENDER_ONLY=1 ;; + --non-interactive) INTERACTIVE=0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done ENV_FILE=.env @@ -53,11 +57,13 @@ ask() { local __var="$1" __prompt="$2" __fallback="${3:-}" local __current="${!__var:-}" local __default="${__current:-$__fallback}" - local __ans - if [[ -n "$__default" ]]; then - read -r -p "$__prompt [$__default]: " __ans - else - read -r -p "$__prompt: " __ans + local __ans="" + if [[ "$INTERACTIVE" -eq 1 ]]; then + if [[ -n "$__default" ]]; then + read -r -p "$__prompt [$__default]: " __ans + else + read -r -p "$__prompt: " __ans + fi fi printf -v "$__var" '%s' "${__ans:-$__default}" } @@ -65,105 +71,95 @@ ask() { ask_secret() { local __var="$1" __prompt="$2" local __current="${!__var:-}" - local __ans - if [[ -n "$__current" ]]; then - read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans - else - read -r -s -p "$__prompt: " __ans + local __ans="" + if [[ "$INTERACTIVE" -eq 1 ]]; then + if [[ -n "$__current" ]]; then + read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans + else + read -r -s -p "$__prompt: " __ans + fi + echo fi - echo printf -v "$__var" '%s' "${__ans:-$__current}" } +require() { + # require VARNAME — fail if empty + if [[ -z "${!1:-}" ]]; then + echo "$1 is required" >&2 + exit 1 + fi +} + require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; } } +require_cmd openssl if [[ "$RENDER_ONLY" -eq 0 ]]; then require_cmd docker - require_cmd openssl require_cmd curl fi -echo "== furtka-gateway deploy (bring your own Traefik/wg-easy) ==" -echo "Haven't read the README's Prerequisites checklist yet? Do that first —" -echo "this script can't verify your Traefik has a real network path to" -echo "wg-easy's WireGuard peer subnet, and a broken deploy from skipping" -echo "that is hard to debug after the fact." +echo "== furtka-gateway: connect to your existing Traefik + wg-easy ==" echo # ---- tenancy mode ---- -while true; do - ask GATEWAY_MODE "Mode (single / shared)" "single" - case "$GATEWAY_MODE" in - single|shared) break ;; - *) echo "must be one of: single, shared" ;; - esac -done +ask GATEWAY_MODE "Mode (single / shared)" "single" +case "$GATEWAY_MODE" in + single|shared) ;; + *) echo "GATEWAY_MODE must be single or shared" >&2; exit 1 ;; +esac # ---- wg-easy ---- echo -ask WG_EASY_URL "Your wg-easy's admin API URL (e.g. http://wg-easy:51821)" "" -if [[ -z "$WG_EASY_URL" ]]; then - echo "WG_EASY_URL is required" >&2 - exit 1 -fi -while true; do - ask WG_EASY_API_VERSION "Your wg-easy image's version (14 or 15 — check the tag)" "14" - case "$WG_EASY_API_VERSION" in - 14|15) break ;; - *) echo "must be 14 or 15 — these are different, incompatible APIs (see control_plane/wgeasy.py vs wgeasy_v15.py)" ;; - esac -done +ask WG_EASY_URL "Your wg-easy's admin API URL, as the control-plane will reach it (e.g. http://wg-easy:51821)" "" +require WG_EASY_URL +ask WG_EASY_API_VERSION "Your wg-easy image's major version (14 or 15 — check the tag)" "15" +case "$WG_EASY_API_VERSION" in + 14|15) ;; + *) echo "WG_EASY_API_VERSION must be 14 or 15 (different, incompatible APIs)" >&2; exit 1 ;; +esac if [[ "$WG_EASY_API_VERSION" == "15" ]]; then ask WG_EASY_ADMIN_USERNAME "wg-easy admin username" "admin" - ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (restricts new clients to split-tunnel)" "10.8.0.0/24" + ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (new clients get split-tunnel to this)" "10.8.0.0/24" fi ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password" -if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then - echo "wg-easy admin password is required" >&2 - exit 1 -fi +require WG_EASY_ADMIN_PASSWORD # ---- Traefik ---- echo -ask GATEWAY_DYNAMIC_DIR_HOST "Host directory YOUR Traefik's file provider watches (or a new one you'll add as an extra providers.file directory)" "" -if [[ -z "$GATEWAY_DYNAMIC_DIR_HOST" ]]; then - echo "GATEWAY_DYNAMIC_DIR_HOST is required" >&2 - exit 1 -fi -ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if it applies a default cert automatically)" "" -ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com" +ask GATEWAY_DYNAMIC_DIR_HOST "Host directory your Traefik's file provider watches" "" +require GATEWAY_DYNAMIC_DIR_HOST +ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if a default cert applies automatically)" "" +ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" "" +require GATEWAY_CONTROL_PLANE_HOST echo -echo "How does YOUR Traefik reach this control-plane container?" -echo " 1) same docker network as my Traefik (recommended)" +echo "How does your Traefik reach the control-plane container?" +echo " 1) it's on the same docker network as my Traefik (recommended)" echo " 2) separate host — I'll expose it at a reachable host:port myself" -while true; do - ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1" - case "$GATEWAY_REACHABILITY_MODE" in - 1|2) break ;; - *) echo "must be 1 or 2" ;; - esac -done -if [[ "$GATEWAY_REACHABILITY_MODE" == "1" ]]; then - ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (docker network ls / docker inspect )" "" - if [[ -z "$GATEWAY_TRAEFIK_NETWORK" ]]; then - echo "a network name is required for this choice" >&2 - exit 1 - fi - GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090" - GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090" -else - echo - echo "WARNING: this binds the control-plane API beyond loopback. Its" - echo "registration response carries a WireGuard private key in plaintext" - echo "JSON — put real access control (firewall/security group) around" - echo "whatever bind address you give it, not just this compose file." - ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" "" - ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as YOUR Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND" - GATEWAY_TRAEFIK_NETWORK="" -fi +ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1" +case "$GATEWAY_REACHABILITY_MODE" in + 1) + ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (see: docker inspect --format '{{json .NetworkSettings.Networks}}')" "" + require GATEWAY_TRAEFIK_NETWORK + GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090" + GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090" + ;; + 2) + echo + echo "WARNING: this binds the control-plane API beyond loopback. Its" + echo "registration response carries a WireGuard private key in plaintext" + echo "JSON — put real access control (firewall/security group) around" + echo "whatever bind address you give it." + ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" "" + require GATEWAY_CONTROL_PLANE_BIND + ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as your Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND" + GATEWAY_TRAEFIK_NETWORK="" + ;; + *) echo "GATEWAY_REACHABILITY_MODE must be 1 or 2" >&2; exit 1 ;; +esac # ---- tokens (generated once, kept on re-run) ---- GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}" @@ -183,74 +179,111 @@ echo "Writing $ENV_FILE" echo "GATEWAY_DYNAMIC_DIR_HOST=$GATEWAY_DYNAMIC_DIR_HOST" echo "GATEWAY_CERT_RESOLVER=$GATEWAY_CERT_RESOLVER" echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST" + echo "GATEWAY_REACHABILITY_MODE=$GATEWAY_REACHABILITY_MODE" echo "GATEWAY_CONTROL_PLANE_BIND=$GATEWAY_CONTROL_PLANE_BIND" echo "GATEWAY_CONTROL_PLANE_TARGET=$GATEWAY_CONTROL_PLANE_TARGET" [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]] && echo "GATEWAY_TRAEFIK_NETWORK=$GATEWAY_TRAEFIK_NETWORK" + [[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE" echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN" echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN" } > "$ENV_FILE" chmod 600 "$ENV_FILE" -# ---- render traefik/dynamic/control-plane.yml ---- -echo "Rendering traefik/dynamic/control-plane.yml" -mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST" -# Only the http: block onward — the .example's header comment explains the -# placeholders BY NAME (for a human reading that template), which would -# otherwise also get corrupted by these same substitutions. -sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" -sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" -sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" -if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then - # No resolver configured on your Traefik — drop the tls: block entirely - # so its own default cert applies, mirroring what GATEWAY_CERT_RESOLVER - # blank means for every app route (see traefikconf.py). - sed -i "/^ tls:$/,/^ certResolver:.*$/d" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" +# ---- join your Traefik's network from the compose file itself ---- +# An override (auto-loaded by `docker compose`, gitignored) rather than a +# `docker network connect` after startup: the control-plane talks to +# wg-easy as soon as it starts, so the network must be there from the +# first second, and it must survive container recreation. +if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then + echo "Writing docker-compose.override.yaml (joins network: $GATEWAY_TRAEFIK_NETWORK)" + cat > docker-compose.override.yaml < rendered/control-plane.yml +sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" rendered/control-plane.yml +sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" rendered/control-plane.yml +if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then + # No resolver on your Traefik — drop the tls: block so its default cert + # applies, mirroring what a blank GATEWAY_CERT_RESOLVER means for every + # app route (see traefikconf.py). + sed -i "/^ tls:$/,/^ certResolver:.*$/d" rendered/control-plane.yml +else + sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" rendered/control-plane.yml fi if [[ "$RENDER_ONLY" -eq 1 ]]; then echo - echo "--render-only: stopping before compose up / network join / health check." + echo "--render-only: nothing live was touched. Inspect .env and rendered/control-plane.yml." exit 0 fi -# ---- bring the control-plane up ---- +# ---- bring the control-plane up (always built from this checkout) ---- echo -echo "== docker compose up -d ==" -docker compose up -d +echo "== docker compose up -d --build ==" +docker compose up -d --build -# ---- join your Traefik's network, if that's the reachability choice ---- -if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then - echo - echo "== joining docker network: $GATEWAY_TRAEFIK_NETWORK ==" - docker network connect "$GATEWAY_TRAEFIK_NETWORK" furtka-gateway-control-plane 2>/dev/null \ - || echo " (already connected, or connect failed — check \`docker network ls\` and the name above)" -fi - -# ---- health check ---- echo echo "== health check ==" -for _ in $(seq 1 10); do +healthy=0 +for _ in $(seq 1 30); do if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then - echo "control-plane is up." + healthy=1 break fi - sleep 1 + sleep 2 done -if ! curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then +if [[ "$healthy" -ne 1 ]]; then echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2 + echo "(a wrong WG_EASY_URL or wg-easy password shows up there)" >&2 exit 1 fi +echo " ok: control-plane is up" + +# ---- install the route into your Traefik's dynamic directory ---- +mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST" +cp rendered/control-plane.yml "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" +echo " installed $GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" + +public_ok=0 +for _ in $(seq 1 20); do + if curl -sf -m 10 "https://$GATEWAY_CONTROL_PLANE_HOST/healthz" >/dev/null 2>&1; then + public_ok=1 + break + fi + sleep 3 +done +if [[ "$public_ok" -eq 1 ]]; then + echo " ok: https://$GATEWAY_CONTROL_PLANE_HOST/healthz" +else + echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable." + echo " Check that DNS points at your Traefik and that Traefik watches" + echo " $GATEWAY_DYNAMIC_DIR_HOST (providers.file.directory)." +fi echo echo "== done ==" -echo "Still needed before a real box can connect:" -echo " - Add $GATEWAY_DYNAMIC_DIR_HOST as a providers.file directory on" -echo " your Traefik, if it isn't already watching it." -echo " - Verify Traefik can actually reach wg-easy's WireGuard peer subnet" -echo " (the README's Prerequisites checklist has a command for this) —" -echo " this is the one thing that can't be checked from here." -echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST -> wherever your" -echo " Traefik is reachable." -echo " - On the box: furtka gateway connect " +echo "Before publishing apps, make sure your Traefik can reach wg-easy's peer" +echo "subnet — see the README's routing section. Quick check after a box connects:" +echo " docker exec ping -c1 10.8.0." +echo +echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter" +echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST" +echo " Registration token: the GATEWAY_BOX_TOKEN value in $REPO_ROOT/.env" diff --git a/traefik/dynamic/control-plane.yml.example b/traefik/dynamic/control-plane.yml.example index 7c1a2f5..f532667 100644 --- a/traefik/dynamic/control-plane.yml.example +++ b/traefik/dynamic/control-plane.yml.example @@ -1,23 +1,18 @@ -# Copy to traefik/dynamic/control-plane.yml (into whatever directory YOUR -# Traefik's file provider watches — see the root README's Prerequisites -# checklist) with two placeholders substituted: +# Template for the control-plane's own Traefik route. ops/deploy.sh renders +# it to rendered/control-plane.yml and installs it into the directory YOUR +# Traefik's file provider watches, with two placeholders substituted: # # GATEWAY_CONTROL_PLANE_HOST a real hostname you control (e.g. # gw.example.com), with its own A/AAAA record # pointing at this host. # GATEWAY_CONTROL_PLANE_TARGET where YOUR Traefik actually reaches the # control-plane container. If it joined your -# Traefik's docker network (ops/deploy.sh -# does this via `docker network connect` — -# the primary, recommended path), this is -# `furtka-gateway-control-plane:8090` — the -# container's fixed name, which Docker's -# embedded DNS resolves on any network it's -# joined to (unlike the compose *service* -# name `control-plane`, which is only -# guaranteed to resolve on networks compose -# itself manages — not one joined after the -# fact via a bare `docker network connect`). +# Traefik's docker network (the recommended +# path — ops/deploy.sh sets that up), this is +# `furtka-gateway-control-plane:8090`, the +# container's fixed name (unambiguous on a +# shared network, unlike a generic service +# name like `control-plane`). # If instead you exposed it on a separate # host (GATEWAY_CONTROL_PLANE_BIND widened # beyond 127.0.0.1 in docker-compose.yaml),