From e240f071d527bbf0216ff63969e290d8547eecf4 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Sat, 19 Sep 2026 13:34:29 +0200 Subject: [PATCH] feat: ship control-plane standalone, bring your own Traefik/wg-easy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the deployment model: instead of bundling and owning its own Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy the operator already runs (as sibling containers, or otherwise reachable). This avoids duplicating infra self-hosters commonly already have, and sidesteps having to run a second ACME/DNS setup entirely — the operator's own Traefik already has a working cert resolver. - New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live against a real running container (auth model, route shapes, and the new per-client PresharedKey all differ materially from v14's API). Threaded the preshared key through to the box side (furtka/wireguard.py in the furtka repo) so v15's handshake actually completes. - GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE- derived hardcoded "le", so it can mirror whatever resolver name (if any) the operator's own Traefik already has configured. - Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh asks how the operator's Traefik reaches it (same docker network, joined via `docker network connect`, or a separate host) instead of prompting for DNS-01 providers and port remaps single-parallel mode needed. - single-parallel mode is retired outright — nothing needs it once furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for. - The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to examples/starter-stack/ as the documented zero-infra on-ramp. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem --- .env.example | 110 +++++--- .gitignore | 1 + README.md | 133 +++++---- control_plane/api.py | 4 +- control_plane/app.py | 35 ++- control_plane/boxes.py | 11 +- control_plane/wgeasy.py | 12 + control_plane/wgeasy_v15.py | 159 +++++++++++ docker-compose.yaml | 152 +++-------- examples/starter-stack/.env.example | 59 ++++ examples/starter-stack/README.md | 73 +++++ examples/starter-stack/deploy.sh | 169 ++++++++++++ examples/starter-stack/docker-compose.yaml | 137 ++++++++++ .../starter-stack/ops}/firewall.sh | 16 +- .../starter-stack/ops}/host-sysctls.sh | 0 .../traefik/dynamic/control-plane.yml.example | 26 ++ .../traefik}/static/traefik.single.yml | 2 +- ops/deploy.sh | 256 ++++++++++++++++++ tests/test_wgeasy_v15.py | 191 +++++++++++++ traefik/dynamic/control-plane.yml.example | 44 ++- 20 files changed, 1373 insertions(+), 217 deletions(-) create mode 100644 control_plane/wgeasy_v15.py create mode 100644 examples/starter-stack/.env.example create mode 100644 examples/starter-stack/README.md create mode 100755 examples/starter-stack/deploy.sh create mode 100644 examples/starter-stack/docker-compose.yaml rename {ops => examples/starter-stack/ops}/firewall.sh (78%) rename {ops => examples/starter-stack/ops}/host-sysctls.sh (100%) create mode 100644 examples/starter-stack/traefik/dynamic/control-plane.yml.example rename {traefik => examples/starter-stack/traefik}/static/traefik.single.yml (98%) create mode 100755 ops/deploy.sh create mode 100644 tests/test_wgeasy_v15.py diff --git a/.env.example b/.env.example index da40171..93cfc95 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,93 @@ -# Which cert/tenancy strategy to run: "single" or "shared". -# See traefik/static/traefik.single.yml vs traefik.shared.yml. +# Filling this file in by hand? Run `ops/deploy.sh` instead — it prompts +# for the values below, generates the tokens, and renders +# traefik/dynamic/control-plane.yml from its tracked template. Read the +# README's Prerequisites checklist first either way. + +# Tenancy switch only — "single" (one operator, the common case) or +# "shared" (multi-tenant, out of scope for most deployments). Has no +# effect on ports, certs, or infrastructure — that's all your own +# Traefik/wg-easy now, configured below. GATEWAY_MODE=single -# The hostname (or IP, for a first local smoke test) wg-easy advertises to -# peers as the WireGuard endpoint. Must be reachable on udp/51820. -GATEWAY_PUBLIC_HOST=vpn.example.com +# YOUR wg-easy's admin API — reachable at whatever host:port makes sense +# for your setup. If it's a sibling container that joined the same docker +# network as this control-plane (see GATEWAY_TRAEFIK_NETWORK below), this +# is usually http://:51821. +WG_EASY_URL=http://wg-easy:51821 -# bcrypt hash of the wg-easy admin password. Generate with: -# docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \ -# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password' -# -# IMPORTANT: bcrypt hashes contain literal `$` characters (e.g. -# "$2a$10$..."), and Docker Compose's own .env-file parser treats `$word` -# as a variable reference to substitute — confirmed by actually hitting -# this: an unescaped hash silently got truncated to "$2a$10" with -# everything after dropped. Every `$` in the value below MUST be doubled -# as `$$`, e.g. WG_EASY_PASSWORD_HASH=$$2a$$10$$abc123... -WG_EASY_PASSWORD_HASH= +# "14" or "15" — these are genuinely different, incompatible wg-easy APIs +# (see control_plane/wgeasy.py vs wgeasy_v15.py's module docstrings, each +# independently verified against a real running container). Check your +# wg-easy image's actual tag. +WG_EASY_API_VERSION=14 -# PLAINTEXT password matching the hash above. wg-easy's admin API (this -# specific pinned image, ghcr.io/wg-easy/wg-easy:14 — confirmed by reading -# its actual source, which turned out to differ from what wg-easy's current -# docs describe) has no login call or session cookie at all: every request -# just carries this password as a plain `Authorization` header, checked -# with bcrypt against WG_EASY_PASSWORD_HASH. No username concept exists. +# v15 only: the admin user's username. v14 has no username concept — see +# WG_EASY_ADMIN_PASSWORD below, used alone in that case. +WG_EASY_ADMIN_USERNAME= + +# PLAINTEXT password for wg-easy's admin API — never a hash, on either +# version. v14 sends this as a bare `Authorization` header (no username, +# no session — confirmed by reading the actual pinned image's source, +# which differs from what wg-easy's current docs describe). v15 sends it +# as HTTP Basic auth alongside WG_EASY_ADMIN_USERNAME. WG_EASY_ADMIN_PASSWORD= -# GATEWAY_MODE=single only: the one box token a single-tenant deployment -# accepts at /v1/boxes/register, skipping full account/registration-token -# issuance. Generate with: openssl rand -hex 32 -GATEWAY_BOX_TOKEN= +# v15 only: restricts new WireGuard clients to this subnet instead of +# v15's full-tunnel default (0.0.0.0/0, ::/0) — see wgeasy_v15.py's +# restrict_default_allowed_ips(), applied on every control-plane startup. +# Should match your wg-easy's actual WireGuard peer subnet. +GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24 + +# Host directory YOUR Traefik's file provider 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). This +# gets bind-mounted into the control-plane container. +GATEWAY_DYNAMIC_DIR_HOST=./dynamic + +# Your Traefik's certResolver name — mirrored into every route +# furtka-gateway publishes. Blank means no explicit resolver (`tls: {}`), +# trusting your Traefik to apply its own default cert automatically (e.g. +# a wildcard defaultGeneratedCert). Set this if your Traefik doesn't have +# a default and needs an explicit resolver named per-route. +GATEWAY_CERT_RESOLVER= # Hostname a Furtka box actually reaches the control-plane API on — needs -# its own A/AAAA record pointing at this VPS. Not read by docker-compose -# itself; it's a reminder of what to substitute into -# traefik/dynamic/control-plane.yml (copied from the .example file — see -# that file for why this route has to exist at all). +# its own A/AAAA record pointing at wherever your Traefik is reachable. +# Not read by docker-compose itself; it's what ops/deploy.sh substitutes +# into traefik/dynamic/control-plane.yml. 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`). +# 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 +# private key in its registration responses. +GATEWAY_CONTROL_PLANE_BIND=127.0.0.1:8090 +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. +GATEWAY_TRAEFIK_NETWORK= + +# The one box token a single-tenant deployment accepts at +# /v1/boxes/register, skipping full account/registration-token issuance. +# Generate with: openssl rand -hex 32 +GATEWAY_BOX_TOKEN= + # Bearer token required to create accounts via POST /v1/accounts. # Only meaningful once account endpoints exist (Phase 2+); harmless to set # now. Generate with: openssl rand -hex 32 GATEWAY_ADMIN_TOKEN= -# GATEWAY_MODE=shared only: base domain subdomains are issued under, and the -# DNS provider Traefik's DNS-01 challenge should use for the wildcard cert. -# See traefik/static/traefik.shared.yml. -GATEWAY_BASE_DOMAIN=boxes.example.com - # Optional: override the control-plane image `docker stack deploy` pulls # (Swarm ignores docker-compose.yaml's `build:` key — see README's Swarm # caveats). Defaults to the `latest` tag CI publishes on every push to diff --git a/.gitignore b/.gitignore index 76c82a2..f6217ca 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ acme.json traefik/dynamic/route-*.yml traefik/dynamic/control-plane.yml traefik/dynamic/default-cert.yml +traefik/static/traefik.active.yml diff --git a/README.md b/README.md index d850823..5c56883 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,34 @@ 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 +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. -Supports two deployment modes from the same codebase: +## What this actually is -- **`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. +The control-plane (`control_plane/`, one small stdlib-only Docker image) is +all this repo ships. It does two things: talks to wg-easy's admin API to +provision WireGuard peers for boxes, and writes plain Traefik file-provider +YAML for the routes those boxes publish. It does not own or bundle a Traefik +or wg-easy of its own — **you plug it into a Traefik and wg-easy you already +run** (a very common self-hosting pair), the same way you'd plug any other +self-hosted app into your existing reverse proxy. This also means your +Traefik's own cert resolver (whatever you already use) just works for these +new routes too — furtka-gateway doesn't set up its own ACME/DNS story at all. + +Don't already run Traefik or wg-easy? See +[`examples/starter-stack/`](examples/starter-stack/) — a zero-infra on-ramp +that bundles both. + +`GATEWAY_MODE` is purely a tenancy switch: `single` (one operator, the +common case) or `shared` (one larger, multi-tenant instance — same schema, +same code, out of scope for the walkthrough below). It has no effect on +ports, certs, or which Traefik/wg-easy you use — that's all just your own +infrastructure now. Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy peer provisioning, Traefik dynamic-config generation) is built and tested @@ -26,44 +39,61 @@ 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 +## Prerequisites checklist -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. +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 @@ -79,18 +109,21 @@ If deploying via `docker stack deploy` instead of `docker compose up`: - `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 + `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. +# 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 ``` - -`ops/firewall.sh` and the `control-plane.yml` Traefik route are about -reaching this gateway from the real internet — skip both for local dev. diff --git a/control_plane/api.py b/control_plane/api.py index 6cae095..ca2f639 100644 --- a/control_plane/api.py +++ b/control_plane/api.py @@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler from control_plane import boxes, routes from control_plane.db import Database, row_to_dict -from control_plane.wgeasy import WgEasyClient, WgEasyError +from control_plane.wgeasy import WgEasyError, WgEasyProtocol _BOX_ACTION_RE = re.compile(r"^/v1/boxes/([^/]+)/(heartbeat|rotate-token|deregister)$") _ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$") @@ -32,7 +32,7 @@ _ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$") @dataclass class Context: db: Database - wgeasy: WgEasyClient + wgeasy: WgEasyProtocol mode: str cert_resolver: str | None single_account_id: str | None diff --git a/control_plane/app.py b/control_plane/app.py index cc6aedf..9b5db43 100644 --- a/control_plane/app.py +++ b/control_plane/app.py @@ -6,19 +6,37 @@ from control_plane import accounts from control_plane.api import Context, Handler from control_plane.db import Database from control_plane.wgeasy import WgEasyClient +from control_plane.wgeasy_v15 import WgEasyV15Client DEFAULT_BOX_LIMIT = 20 DEFAULT_ROUTE_LIMIT_PER_BOX = 20 +DEFAULT_WG_ALLOWED_IPS = "10.8.0.0/24" def build_context(db: Database | None = None) -> Context: mode = os.environ.get("GATEWAY_MODE", "single") db = db or Database() - wgeasy = WgEasyClient( - base_url=os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821"), - admin_password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""), - ) + wg_easy_url = os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821") + wg_easy_password = os.environ.get("WG_EASY_ADMIN_PASSWORD", "") + if os.environ.get("WG_EASY_API_VERSION", "14") == "15": + wgeasy = WgEasyV15Client( + base_url=wg_easy_url, + username=os.environ.get("WG_EASY_ADMIN_USERNAME", ""), + password=wg_easy_password, + ) + # v15 defaults new clients to full-tunnel AllowedIPs and has no + # equivalent to v14's WG_ALLOWED_IPS container env var for an + # already-initialized instance (relevant for a BYO wg-easy that + # existed before this control-plane ever connected to it) — see + # wgeasy_v15.py's docstring. Enforced on every startup, not just + # once, since a BYO instance's config isn't otherwise this + # process's to assume. + wgeasy.restrict_default_allowed_ips( + [os.environ.get("GATEWAY_WG_ALLOWED_IPS", DEFAULT_WG_ALLOWED_IPS)] + ) + else: + wgeasy = WgEasyClient(base_url=wg_easy_url, admin_password=wg_easy_password) single_account_id = None if mode == "single": @@ -26,9 +44,12 @@ def build_context(db: Database | None = None) -> Context: db, DEFAULT_BOX_LIMIT, DEFAULT_ROUTE_LIMIT_PER_BOX ) - # Shared mode uses one wildcard defaultGeneratedCert for every router - # (see traefik.shared.yml) rather than a per-router certResolver. - cert_resolver = "le" if mode == "single" else None + # Independent of GATEWAY_MODE — mirrors whatever certResolver name (if + # any) the operator's own Traefik already has configured. Unset means + # no explicit resolver (`tls: {}` — see traefikconf.py), trusting that + # Traefik's own default TLS applies (e.g. shared mode's wildcard + # defaultGeneratedCert in traefik.shared.yml). + cert_resolver = os.environ.get("GATEWAY_CERT_RESOLVER") or None return Context( db=db, diff --git a/control_plane/boxes.py b/control_plane/boxes.py index 8a0da98..1a57bec 100644 --- a/control_plane/boxes.py +++ b/control_plane/boxes.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime from control_plane import routes, tokens from control_plane.db import Database from control_plane.passwd import hash_password, verify_password -from control_plane.wgeasy import WgEasyClient +from control_plane.wgeasy import WgEasyProtocol class BoxLimitExceeded(Exception): @@ -23,7 +23,7 @@ def _box_count(db: Database, account_id: str) -> int: def register_box( db: Database, - wgeasy: WgEasyClient, + wgeasy: WgEasyProtocol, account_id: str, box_name: str, box_limit: int, @@ -71,6 +71,11 @@ def register_box( "server_public_key": peer["server_public_key"], "endpoint": peer["endpoint"], "allowed_ips": peer["allowed_ips"], + # Only present for wg-easy v15 (see wgeasy_v15.py) — v15 + # generates a preshared key per client by default and the + # server enforces it even if the client's own conf omits it, + # so this must reach the box or the handshake fails silently. + "preshared_key": peer.get("preshared_key"), }, } @@ -107,7 +112,7 @@ def rotate_box_token(db: Database, box_id: str) -> str | None: return box_token -def deregister_box(db: Database, wgeasy: WgEasyClient, box_id: str) -> bool: +def deregister_box(db: Database, wgeasy: WgEasyProtocol, box_id: str) -> bool: row = db.query_one("SELECT wg_peer_id FROM boxes WHERE id = ?", (box_id,)) if row is None: return False diff --git a/control_plane/wgeasy.py b/control_plane/wgeasy.py index 32d4c8f..2418180 100644 --- a/control_plane/wgeasy.py +++ b/control_plane/wgeasy.py @@ -39,12 +39,24 @@ import json import re import urllib.error import urllib.request +from typing import Protocol class WgEasyError(Exception): pass +class WgEasyProtocol(Protocol): + """Shape both WgEasyClient (v14) and WgEasyV15Client (v15, see + wgeasy_v15.py) satisfy structurally — the two images' actual REST APIs + are different enough (auth model, route shapes) to need separate + clients, but callers (boxes.py, api.py's Context) only ever need these + two operations.""" + + def create_client(self, name: str) -> dict: ... + def delete_client(self, client_id: str) -> None: ... + + class WgEasyClient: def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None: self._base_url = base_url.rstrip("/") diff --git a/control_plane/wgeasy_v15.py b/control_plane/wgeasy_v15.py new file mode 100644 index 0000000..b42bc49 --- /dev/null +++ b/control_plane/wgeasy_v15.py @@ -0,0 +1,159 @@ +"""REST client for wg-easy v15's admin API — the Nitro/h3-based rewrite +wgeasy.py's module docstring warned about, confirmed here to be a real, +materially different API rather than a compatible evolution of v14's. + +Verified directly against a real running container +(`ghcr.io/wg-easy/wg-easy:15`, booted headlessly via its `INIT_ENABLED=true` +bootstrap env vars, every endpoint below hit with live `curl` calls) rather +than trusted from reading the bundled source alone — the compiled Nitro +output's chunk filenames turned out to be a false trail (e.g. +`routes/api/client/index.post.mjs`'s handler body reads a router-param +`clientId` that doesn't exist on that literal path — it's not actually the +client-creation endpoint despite the name), so treat only the behavior +below as trustworthy, not the source layout. + +What's different from v14 (see wgeasy.py): + + - Auth: HTTP Basic (`Authorization: Basic base64(username:password)`) + against a real user account, not a bare plaintext-password header. An + admin user must already exist — create one headlessly by setting + `INIT_ENABLED=true`/`INIT_USERNAME`/`INIT_PASSWORD`/`INIT_HOST`/ + `INIT_PORT` on the wg-easy container itself (confirmed live: this + fully skips the interactive setup wizard). Basic auth is rejected for + a user with TOTP (2FA) enabled — keep the automation account's 2FA off. + - `POST /api/client` requires `{"name": ..., "expiresAt": null}` (v15 + added per-client expiry; `null` means never) and its response DOES + carry `{"clientId": ...}` directly — no more list-and-match-by-name. + The public key isn't in that response though, so `create_client()` + still does one follow-up `GET /api/client` list call, matched by the + now-unique numeric id (far less fragile than v14's match-by-name). + - `GET /api/client/{id}/configuration` returns a wg-quick .conf that now + also includes a `PresharedKey` line (v15 generates one per client by + default) — callers must thread this through to the box's own + wg-quick config, or the handshake fails silently (the server enforces + the PSK even if the client's own conf omits it). + - `DELETE /api/client/{id}` (id in the URL path — confirmed live; DELETE + on the bare `/api/client` collection 404s). + - New clients default to full-tunnel AllowedIPs (`0.0.0.0/0, ::/0`) + unless the instance's interface-wide default is restricted first via + `POST /api/admin/userconfig` — confirmed live that endpoint requires + the FULL current config object back (a partial/PATCH-style body + 400s), so `restrict_default_allowed_ips()` GETs the current config + before POSTing the change. + +Re-verify against the running container directly (not upstream docs) if +this image tag is ever bumped — the same lesson wgeasy.py's docstring +already learned once for v14. +""" + +from __future__ import annotations + +import base64 +import json +import re +import urllib.error +import urllib.request + + +class WgEasyV15Error(Exception): + pass + + +class WgEasyV15Client: + def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None: + self._base_url = base_url.rstrip("/") + self._auth_header = "Basic " + base64.b64encode( + f"{username}:{password}".encode() + ).decode() + self._timeout = timeout + + def restrict_default_allowed_ips(self, allowed_ips: list[str]) -> None: + """Set the interface-wide default AllowedIPs new clients get (e.g. + split-tunnel `["10.8.0.0/24"]`) instead of v15's full-tunnel + default — the same concern v14's WG_ALLOWED_IPS container env var + addresses, but v15 has no equivalent env var that applies to an + already-initialized instance, so this calls the admin API + directly. Idempotent — safe to call on every control-plane + startup. + """ + current = json.loads(self._request("GET", "/api/admin/userconfig")) + current.pop("id", None) + current.pop("createdAt", None) + current.pop("updatedAt", None) + current["defaultAllowedIps"] = allowed_ips + self._request("POST", "/api/admin/userconfig", current) + + def create_client(self, name: str) -> dict: + """Create a new WireGuard peer and return its full connection material.""" + created = json.loads( + self._request("POST", "/api/client", {"name": name, "expiresAt": None}) + ) + client_id = created["clientId"] + + clients = json.loads(self._request("GET", "/api/client")) + matches = [c for c in clients if c["id"] == client_id] + if not matches: + raise WgEasyV15Error( + f"created client id {client_id!r} but it did not appear in the client list" + ) + public_key = matches[0]["publicKey"] + + conf_text = self._request( + "GET", f"/api/client/{client_id}/configuration" + ).decode() + conf = _parse_wg_conf(conf_text) + + return { + "id": client_id, + "public_key": public_key, + "private_key": conf["private_key"], + "address": conf["address"], + "server_public_key": conf["peer_public_key"], + "endpoint": conf["endpoint"], + "allowed_ips": conf["allowed_ips"], + "preshared_key": conf.get("preshared_key"), + } + + def delete_client(self, client_id: str) -> None: + try: + self._request("DELETE", f"/api/client/{client_id}") + except WgEasyV15Error: + # Already gone on wg-easy's side shouldn't block us cleaning up + # our own box/route rows — deregistration must still succeed. + pass + + def _request(self, method: str, path: str, body: dict | None = None) -> bytes: + data = json.dumps(body).encode() if body is not None else None + headers = {"Authorization": self._auth_header} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request( + f"{self._base_url}{path}", data=data, method=method, headers=headers + ) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return resp.read() + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise WgEasyV15Error(f"{method} {path} -> HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise WgEasyV15Error(f"{method} {path} -> {e}") from e + + +def _parse_wg_conf(text: str) -> dict: + def find(pattern: str, required: bool = True) -> str | None: + m = re.search(pattern, text, re.MULTILINE) + if not m: + if required: + raise WgEasyV15Error(f"could not find {pattern!r} in wg-easy client configuration") + return None + return m.group(1).strip() + + return { + "private_key": find(r"^PrivateKey\s*=\s*(.+)$"), + "address": find(r"^Address\s*=\s*(.+)$"), + "peer_public_key": find(r"^PublicKey\s*=\s*(.+)$"), + "preshared_key": find(r"^PresharedKey\s*=\s*(.+)$", required=False), + "endpoint": find(r"^Endpoint\s*=\s*(.+)$"), + "allowed_ips": find(r"^AllowedIPs\s*=\s*(.+)$"), + } diff --git a/docker-compose.yaml b/docker-compose.yaml index 151c6ce..8af38aa 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,75 +1,4 @@ services: - wg-easy: - image: ghcr.io/wg-easy/wg-easy:14 - 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. - - DISABLE_IPV6=true - volumes: - - wg_easy_data:/etc/wireguard - cap_add: - - NET_ADMIN - - SYS_MODULE - 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 - container_name: furtka-gateway-traefik - command: - - --configFile=/etc/traefik/traefik.yml - volumes: - - ./traefik/static/traefik.${GATEWAY_MODE:-single}.yml:/etc/traefik/traefik.yml:ro - - ./traefik/dynamic:/etc/traefik/dynamic:ro - - traefik_acme:/acme - ports: - - "80:80" - - "443:443" - restart: unless-stopped - networks: - - internal - # Traefik's own dashboard/API is disabled in both static configs - # (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 @@ -87,47 +16,56 @@ services: - GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} - # 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 port 51821 is listening. - - WG_EASY_URL=http://host.docker.internal:51821 - # This build of wg-easy (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. + # Blank means no explicit resolver (`tls: {}`) — Traefik applies + # whatever default TLS is already configured on YOUR Traefik. Set + # this to your Traefik's certResolver name if it doesn't have one. + - GATEWAY_CERT_RESOLVER=${GATEWAY_CERT_RESOLVER:-} + # Your existing wg-easy's admin API — reachable at whatever + # host:port makes sense for your setup (a sibling container on the + # network this joins — see `networks:` below — or a real host:port + # if it's elsewhere). See the README's Prerequisites checklist for + # the network-reachability requirement this doesn't (can't) + # automate away. + - WG_EASY_URL=${WG_EASY_URL} - WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD} - extra_hosts: - - "host.docker.internal:host-gateway" + # "14" (default) or "15" — these are different, incompatible APIs + # (see control_plane/wgeasy.py vs wgeasy_v15.py's module docstrings). + # Check which one your wg-easy image tag actually is. + - WG_EASY_API_VERSION=${WG_EASY_API_VERSION:-14} + # v15 only: the admin user's username (v14 has no username concept — + # see WG_EASY_ADMIN_PASSWORD above). + - WG_EASY_ADMIN_USERNAME=${WG_EASY_ADMIN_USERNAME:-} + # v15 only: restricts new clients' AllowedIPs to your wg-easy peer + # subnet (v15 defaults new clients to full-tunnel 0.0.0.0/0, ::/0 + # otherwise — see wgeasy_v15.py's restrict_default_allowed_ips()). + - GATEWAY_WG_ALLOWED_IPS=${GATEWAY_WG_ALLOWED_IPS:-10.8.0.0/24} volumes: - control_plane_data:/data - - ./traefik/dynamic:/dynamic + # Bind-mount this at whatever host path YOUR Traefik's file provider + # already watches (or add a second `providers.file` directory to + # your Traefik's static config pointed at this same path — additive, + # doesn't touch your existing dynamic config). Default here is just + # a placeholder; ops/deploy.sh asks for the real path. + - ${GATEWAY_DYNAMIC_DIR_HOST:-./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. - - "127.0.0.1:8090:8090" + # Loopback-only by default. This is how a remote Furtka box reaches + # the registration API in the COMMON case (your own Traefik, on the + # network below, proxies to it — see + # traefik/dynamic/control-plane.yml.example) — a public bind here + # isn't needed. Only widen GATEWAY_CONTROL_PLANE_BIND if your + # Traefik genuinely can't join this container's docker network (a + # separate-host setup) — ops/deploy.sh flags the access-control + # implication of doing that when you choose it. + - "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090" restart: unless-stopped - networks: - - internal - depends_on: - - traefik - -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 + # 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. volumes: - wg_easy_data: - traefik_acme: control_plane_data: diff --git a/examples/starter-stack/.env.example b/examples/starter-stack/.env.example new file mode 100644 index 0000000..89ec9cc --- /dev/null +++ b/examples/starter-stack/.env.example @@ -0,0 +1,59 @@ +# Zero-infra starter stack: bundles its own Traefik + wg-easy alongside the +# 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 rather than running a second copy. +# +# Filling this file in by hand? Run `deploy.sh` instead — it prompts for +# the values below, generates the secrets/tokens, handles the `$$` +# escaping gotcha noted under WG_EASY_PASSWORD_HASH for you, and renders +# traefik/static/traefik.active.yml + traefik/dynamic/control-plane.yml. + +# The hostname (or IP, for a first local smoke test) wg-easy advertises to +# peers as the WireGuard endpoint. Must be reachable on udp/51820. +GATEWAY_PUBLIC_HOST=vpn.example.com + +# ACME email for Let's Encrypt registration (HTTP-01, 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). +GATEWAY_ACME_EMAIL=admin@example.com + +# bcrypt hash of the wg-easy admin password. Generate with: +# docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \ +# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password' +# +# IMPORTANT: bcrypt hashes contain literal `$` characters (e.g. +# "$2a$10$..."), and Docker Compose's own .env-file parser treats `$word` +# as a variable reference to substitute — confirmed by actually hitting +# this: an unescaped hash silently got truncated to "$2a$10" with +# everything after dropped. Every `$` in the value below MUST be doubled +# as `$$`, e.g. WG_EASY_PASSWORD_HASH=$$2a$$10$$abc123... +WG_EASY_PASSWORD_HASH= + +# PLAINTEXT password matching the hash above. wg-easy's admin API (this +# specific pinned image, ghcr.io/wg-easy/wg-easy:14 — confirmed by reading +# its actual source, which turned out to differ from what wg-easy's current +# docs describe) has no login call or session cookie at all: every request +# just carries this password as a plain `Authorization` header, checked +# with bcrypt against WG_EASY_PASSWORD_HASH. No username concept exists. +WG_EASY_ADMIN_PASSWORD= + +# The one box token this stack accepts at /v1/boxes/register, skipping +# full account/registration-token issuance. Generate with: openssl rand -hex 32 +GATEWAY_BOX_TOKEN= + +# Hostname a Furtka box actually reaches the control-plane API on — needs +# its own A/AAAA record pointing at this VPS. Not read by docker-compose +# itself; it's what deploy.sh substitutes into traefik/dynamic/control-plane.yml. +GATEWAY_CONTROL_PLANE_HOST=gw.example.com + +# Bearer token required to create accounts via POST /v1/accounts. +# Only meaningful once account endpoints exist (Phase 2+); harmless to set +# now. Generate with: openssl rand -hex 32 +GATEWAY_ADMIN_TOKEN= + +# Optional: override the control-plane image `docker stack deploy` pulls +# (Swarm ignores docker-compose.yaml's `build:` key). Defaults to the +# `latest` tag CI publishes on every push to master; pin to a specific +# commit-sha tag for a reproducible deploy. Not read by plain +# `docker compose up`, which always builds locally. +GATEWAY_CONTROL_PLANE_IMAGE= diff --git a/examples/starter-stack/README.md b/examples/starter-stack/README.md new file mode 100644 index 0000000..6978641 --- /dev/null +++ b/examples/starter-stack/README.md @@ -0,0 +1,73 @@ +# furtka-gateway starter stack + +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. + +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 +``` + +`ops/firewall.sh` and the `control-plane.yml` Traefik route are about +reaching this gateway from the real internet — skip both for local dev. + +## 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 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. diff --git a/examples/starter-stack/deploy.sh b/examples/starter-stack/deploy.sh new file mode 100755 index 0000000..cc428e5 --- /dev/null +++ b/examples/starter-stack/deploy.sh @@ -0,0 +1,169 @@ +#!/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. +# +# 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. +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 + +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. +load_env_file() { + local file="$1" line key value + [[ -f "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" || "$line" == \#* ]] && continue + key="${line%%=*}" + value="${line#*=}" + printf -v "$key" '%s' "$value" + 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() { + 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 + 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 + 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; } +} + +if [[ "$RENDER_ONLY" -eq 0 ]]; then + require_cmd docker + require_cmd openssl + require_cmd sudo +fi + +echo "== furtka-gateway starter-stack deploy ==" +echo + +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//\$/\$\$}" + +GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}" +GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}" + +echo +echo "Writing $ENV_FILE" +{ + echo "# Generated by deploy.sh — re-run it to change any of these." + 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" +} > "$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 + +if [[ "$RENDER_ONLY" -eq 1 ]]; then + echo + echo "--render-only: stopping before sysctls/compose/firewall/health-check." + exit 0 +fi + +echo +echo "== sudo ops/host-sysctls.sh ==" +sudo "$SCRIPT_DIR/ops/host-sysctls.sh" + +echo +echo "== docker compose up -d ==" +docker compose up -d + +echo +echo "== sudo ops/firewall.sh ==" +sudo "$SCRIPT_DIR/ops/firewall.sh" + +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." + break + fi + sleep 1 +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 + exit 1 +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 " diff --git a/examples/starter-stack/docker-compose.yaml b/examples/starter-stack/docker-compose.yaml new file mode 100644 index 0000000..8530269 --- /dev/null +++ b/examples/starter-stack/docker-compose.yaml @@ -0,0 +1,137 @@ +services: + wg-easy: + image: ghcr.io/wg-easy/wg-easy:14 + 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. + - DISABLE_IPV6=true + volumes: + - wg_easy_data:/etc/wireguard + cap_add: + - NET_ADMIN + - SYS_MODULE + 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 + container_name: furtka-gateway-traefik + command: + - --configFile=/etc/traefik/traefik.yml + 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 + - traefik_acme:/acme + ports: + - "80:80" + - "443:443" + 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. + 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_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_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - control_plane_data:/data + - ./traefik/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. + - "127.0.0.1:8090:8090" + restart: unless-stopped + networks: + - internal + depends_on: + - traefik + +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: + traefik_acme: + control_plane_data: diff --git a/ops/firewall.sh b/examples/starter-stack/ops/firewall.sh similarity index 78% rename from ops/firewall.sh rename to examples/starter-stack/ops/firewall.sh index a804afb..c65771b 100755 --- a/ops/firewall.sh +++ b/examples/starter-stack/ops/firewall.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash -# Restrict wg-easy's admin API (tcp/51821) 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. +# 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 @@ -24,7 +29,10 @@ set -euo pipefail -ADMIN_PORT=51821 +# 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" diff --git a/ops/host-sysctls.sh b/examples/starter-stack/ops/host-sysctls.sh similarity index 100% rename from ops/host-sysctls.sh rename to examples/starter-stack/ops/host-sysctls.sh diff --git a/examples/starter-stack/traefik/dynamic/control-plane.yml.example b/examples/starter-stack/traefik/dynamic/control-plane.yml.example new file mode 100644 index 0000000..181e830 --- /dev/null +++ b/examples/starter-stack/traefik/dynamic/control-plane.yml.example @@ -0,0 +1,26 @@ +# 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/traefik/static/traefik.single.yml b/examples/starter-stack/traefik/static/traefik.single.yml similarity index 98% rename from traefik/static/traefik.single.yml rename to examples/starter-stack/traefik/static/traefik.single.yml index 0079ab8..718e7c6 100644 --- a/traefik/static/traefik.single.yml +++ b/examples/starter-stack/traefik/static/traefik.single.yml @@ -21,7 +21,7 @@ providers: certificatesResolvers: le: acme: - email: admin@example.com # overridden per-deployment; see ops/deploy.sh + email: admin@example.com # overridden per-deployment; see deploy.sh storage: /acme/acme.json httpChallenge: entryPoint: web diff --git a/ops/deploy.sh b/ops/deploy.sh new file mode 100755 index 0000000..0412f6f --- /dev/null +++ b/ops/deploy.sh @@ -0,0 +1,256 @@ +#!/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. +# +# 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. +# +# 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. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +RENDER_ONLY=0 +if [[ "${1:-}" == "--render-only" ]]; then + RENDER_ONLY=1 +fi + +ENV_FILE=.env + +# ---- load existing .env (if any) so prompts default to current values ---- +# Deliberately NOT `source`d: bash expands `$VAR`/`$$` during sourcing, +# which is wrong for values meant to be taken literally. Plain string +# splitting instead. +load_env_file() { + local file="$1" line key value + [[ -f "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" || "$line" == \#* ]] && continue + key="${line%%=*}" + value="${line#*=}" + printf -v "$key" '%s' "$value" + done < "$file" +} +load_env_file "$ENV_FILE" + +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 + 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 + 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; } +} + +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 + +# ---- 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 + +# ---- 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 +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" +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 + +# ---- 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" + +echo +echo "How does YOUR Traefik reach this control-plane container?" +echo " 1) 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 + +# ---- tokens (generated once, kept on re-run) ---- +GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}" +GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}" + +# ---- write .env ---- +echo +echo "Writing $ENV_FILE" +{ + echo "# Generated by ops/deploy.sh — re-run it to change any of these." + echo "GATEWAY_MODE=$GATEWAY_MODE" + echo "WG_EASY_URL=$WG_EASY_URL" + echo "WG_EASY_API_VERSION=$WG_EASY_API_VERSION" + [[ "$WG_EASY_API_VERSION" == "15" ]] && echo "WG_EASY_ADMIN_USERNAME=$WG_EASY_ADMIN_USERNAME" + [[ "$WG_EASY_API_VERSION" == "15" ]] && echo "GATEWAY_WG_ALLOWED_IPS=$GATEWAY_WG_ALLOWED_IPS" + echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD" + 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_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" + 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" +else + sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" +fi + +if [[ "$RENDER_ONLY" -eq 1 ]]; then + echo + echo "--render-only: stopping before compose up / network join / health check." + exit 0 +fi + +# ---- bring the control-plane up ---- +echo +echo "== docker compose up -d ==" +docker compose up -d + +# ---- 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 + if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then + echo "control-plane is up." + break + fi + sleep 1 +done +if ! curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then + echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2 + exit 1 +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 " diff --git a/tests/test_wgeasy_v15.py b/tests/test_wgeasy_v15.py new file mode 100644 index 0000000..4392d3a --- /dev/null +++ b/tests/test_wgeasy_v15.py @@ -0,0 +1,191 @@ +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from control_plane.wgeasy_v15 import WgEasyV15Client, WgEasyV15Error + +USERNAME = "admin" +PASSWORD = "hunter2" +_AUTH_HEADER = "Basic " + base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() + +FAKE_CONF = """[Interface] +PrivateKey = client-private-key== +Address = 10.8.0.2/32 +MTU = 1420 +DNS = 1.1.1.1 + +[Peer] +PublicKey = server-public-key== +PresharedKey = preshared-key== +AllowedIPs = 10.8.0.0/24 +PersistentKeepalive = 0 +Endpoint = gateway.example.com:51820 +""" + +CLIENT_RECORD = { + "id": 1, + "name": "box-1", + "enabled": True, + "publicKey": "client-public-key==", +} + +USERCONFIG = { + "id": "wg0", + "defaultMtu": 1420, + "defaultAllowedIps": ["0.0.0.0/0", "::/0"], + "host": "gateway.example.com", + "port": 51820, + "createdAt": "2026-01-01 00:00:00", + "updatedAt": "2026-01-01 00:00:00", +} + + +class FakeWgEasyV15Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def _json(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _check_auth(self) -> bool: + ok = self.headers.get("Authorization") == _AUTH_HEADER + if not ok: + self._json(401, {"error": True, "statusMessage": "Not authenticated"}) + return ok + + def _body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else {} + + def do_POST(self): + self.server.requests.append(("POST", self.path)) + if not self._check_auth(): + return + if self.path == "/api/client": + body = self._body() + assert body["expiresAt"] is None + self._json(200, {"success": True, "clientId": CLIENT_RECORD["id"]}) + return + if self.path == "/api/admin/userconfig": + self.server.last_userconfig_post = self._body() + self._json(200, {"success": True}) + return + self._json(404, {"error": True, "statusMessage": "not found"}) + + def do_GET(self): + self.server.requests.append(("GET", self.path)) + if not self._check_auth(): + return + if self.path == "/api/client": + self._json(200, [CLIENT_RECORD]) + return + if self.path == f"/api/client/{CLIENT_RECORD['id']}/configuration": + body = FAKE_CONF.encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if self.path == "/api/admin/userconfig": + self._json(200, USERCONFIG) + return + self._json(404, {"error": True, "statusMessage": "not found"}) + + def do_DELETE(self): + self.server.requests.append(("DELETE", self.path)) + if not self._check_auth(): + return + if self.path == f"/api/client/{CLIENT_RECORD['id']}": + self._json(200, {"success": True}) + return + self._json(404, {"error": True, "statusMessage": "not found"}) + + +@pytest.fixture +def fake_wgeasy(): + server = ThreadingHTTPServer(("127.0.0.1", 0), FakeWgEasyV15Handler) + server.requests = [] + server.last_userconfig_post = None + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + thread.join() + + +def _client(server, username=USERNAME, password=PASSWORD): + port = server.server_address[1] + return WgEasyV15Client(f"http://127.0.0.1:{port}", username, password) + + +def test_create_client_returns_full_connection_material(fake_wgeasy): + client = _client(fake_wgeasy) + + peer = client.create_client("box-1") + + assert peer["id"] == CLIENT_RECORD["id"] + assert peer["public_key"] == "client-public-key==" + assert peer["private_key"] == "client-private-key==" + assert peer["address"] == "10.8.0.2/32" + assert peer["server_public_key"] == "server-public-key==" + assert peer["preshared_key"] == "preshared-key==" + assert peer["endpoint"] == "gateway.example.com:51820" + assert peer["allowed_ips"] == "10.8.0.0/24" + + paths_hit = [(m, p) for m, p in fake_wgeasy.requests] + assert ("POST", "/api/client") in paths_hit + assert ("GET", "/api/client") in paths_hit + assert ("GET", f"/api/client/{CLIENT_RECORD['id']}/configuration") in paths_hit + + +def test_every_request_uses_basic_auth_and_wrong_password_fails(fake_wgeasy): + client = _client(fake_wgeasy, password="wrong-password") + with pytest.raises(WgEasyV15Error): + client.create_client("box-1") + + +def test_delete_client_calls_endpoint(fake_wgeasy): + client = _client(fake_wgeasy) + client.delete_client(str(CLIENT_RECORD["id"])) + assert ("DELETE", f"/api/client/{CLIENT_RECORD['id']}") in [ + (m, p) for m, p in fake_wgeasy.requests + ] + + +def test_delete_client_swallows_not_found(fake_wgeasy): + client = _client(fake_wgeasy) + client.delete_client("999") # 404 from fake server — must not raise + + +def test_restrict_default_allowed_ips_sends_full_config_back(fake_wgeasy): + client = _client(fake_wgeasy) + + client.restrict_default_allowed_ips(["10.8.0.0/24"]) + + sent = fake_wgeasy.last_userconfig_post + assert sent["defaultAllowedIps"] == ["10.8.0.0/24"] + # The full current config must be echoed back, not a partial patch — + # confirmed live against v15 that a partial body 400s. + assert sent["defaultMtu"] == USERCONFIG["defaultMtu"] + assert sent["host"] == USERCONFIG["host"] + # Server-managed fields must not be sent back. + assert "id" not in sent + assert "createdAt" not in sent + assert "updatedAt" not in sent + + +def test_bad_endpoint_raises_wgeasy_v15_error(fake_wgeasy): + client = _client(fake_wgeasy) + with pytest.raises(WgEasyV15Error): + client._request("GET", "/api/does-not-exist") diff --git a/traefik/dynamic/control-plane.yml.example b/traefik/dynamic/control-plane.yml.example index 7a87453..7c1a2f5 100644 --- a/traefik/dynamic/control-plane.yml.example +++ b/traefik/dynamic/control-plane.yml.example @@ -1,15 +1,43 @@ -# 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). +# 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: +# +# 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`). +# If instead you exposed it on a separate +# host (GATEWAY_CONTROL_PLANE_BIND widened +# beyond 127.0.0.1 in docker-compose.yaml), +# this is that host's real reachable +# `host:port` — see ops/deploy.sh's warning +# about the access-control implication of +# that path. # # 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 +# 127.0.0.1:8090 on this 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. # -# GATEWAY_MODE=single: keep the certResolver line below (HTTP-01 per host, -# same as every app route — see traefik.single.yml). +# GATEWAY_MODE=single: keep the certResolver line below, set to whatever +# resolver name YOUR Traefik already has configured (blank/omit the `tls:` +# block entirely if your Traefik applies a default cert automatically — +# see GATEWAY_CERT_RESOLVER in .env.example, which controls this same +# choice for every *app* route furtka-gateway publishes; keep both +# consistent). # GATEWAY_MODE=shared: delete the `tls:` block entirely — the shared # wildcard defaultGeneratedCert (traefik/dynamic/default-cert.yml) already # covers every hostname under GATEWAY_BASE_DOMAIN, this included. @@ -25,4 +53,4 @@ http: control-plane: loadBalancer: servers: - - url: "http://control-plane:8090" + - url: "http://GATEWAY_CONTROL_PLANE_TARGET"