From 73c96cab56c1e38d7a2e01fd6771cde2af19fe49 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Mon, 24 Aug 2026 12:51:22 +0200 Subject: [PATCH] Fix WireGuard peer routing and control-plane reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while walking through a real single-tenant deployment scenario, both fixed and verified against the actual running stack (not just unit tests): - wg-easy moves to network_mode: host so Traefik can actually route to WireGuard peer addresses (wg-quick's route only existed inside wg-easy's own isolated network namespace before). This forced host-level sysctls (ops/host-sysctls.sh — Docker/runc rejects namespaced sysctls under host networking) and a host firewall rule (ops/firewall.sh) to keep wg-easy's now-unisolated admin API off the public interface. - control-plane gets a real Traefik route (traefik/dynamic/control-plane.yml.example) instead of being reachable only at 127.0.0.1:8090 — box registration carries a WireGuard private key and needs TLS, not bare loopback HTTP. Along the way, running the actual pinned wg-easy image (ghcr.io/wg-easy/wg-easy:14) surfaced that it's a different, older, Express-based codebase than what wg-easy's current docs/master branch describe: auth is a plain Authorization header per request (no session cookie), routes live under /api/wireguard/client, and its default WG_ALLOWED_IPS is full-tunnel (0.0.0.0/0) rather than the split-tunnel this design assumed. wgeasy.py and docker-compose.yaml are corrected accordingly, verified end-to-end against the real container. Also fixed: Docker Compose silently truncates a bcrypt hash's `$` characters when read from .env — documented the required $$ escaping. --- .env.example | 29 ++++-- .gitignore | 3 + README.md | 75 +++++++++++--- control_plane/app.py | 5 +- control_plane/wgeasy.py | 114 ++++++++++------------ docker-compose.yaml | 73 +++++++++++--- ops/firewall.sh | 52 ++++++++++ ops/host-sysctls.sh | 34 +++++++ tests/test_wgeasy.py | 85 ++++++++++------ traefik/dynamic/control-plane.yml.example | 28 ++++++ 10 files changed, 374 insertions(+), 124 deletions(-) create mode 100755 ops/firewall.sh create mode 100755 ops/host-sysctls.sh create mode 100644 traefik/dynamic/control-plane.yml.example diff --git a/.env.example b/.env.example index 82c8e65..7cd7ef0 100644 --- a/.env.example +++ b/.env.example @@ -6,18 +6,24 @@ GATEWAY_MODE=single # peers as the WireGuard endpoint. Must be reachable on udp/51820. GATEWAY_PUBLIC_HOST=vpn.example.com -# bcrypt hash of the wg-easy admin UI password. Generate with: +# 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= -# Credentials the control-plane uses to log into wg-easy's own admin API -# (POST /api/auth/password -> session cookie; wg-easy has no separate -# bearer-token auth). WG_EASY_ADMIN_PASSWORD is the PLAINTEXT password -# corresponding to WG_EASY_PASSWORD_HASH above — wg-easy only ever sees the -# hash, but the control-plane needs the plaintext to log in the same way a -# human would through the UI. -WG_EASY_ADMIN_USERNAME=admin +# 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= # GATEWAY_MODE=single only: the one box token a single-tenant deployment @@ -25,6 +31,13 @@ WG_EASY_ADMIN_PASSWORD= # 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 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). +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 diff --git a/.gitignore b/.gitignore index b339c90..76c82a2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ .venv/ *.db acme.json +traefik/dynamic/route-*.yml +traefik/dynamic/control-plane.yml +traefik/dynamic/default-cert.yml diff --git a/README.md b/README.md index 5bd9768..82ee4ab 100644 --- a/README.md +++ b/README.md @@ -20,24 +20,75 @@ Supports two deployment modes from the same codebase: project) for people who don't want to run their own. Same schema, same code; `single` is just the one-account case. -See `docker-compose.yaml` and `control_plane/` for the moving parts. Status: -**Phase 1 (scaffold)** — wg-easy + Traefik + a stub control-plane exposing -only `/healthz`, enough to manually prove the wiring end-to-end before the -real control-plane (accounts/boxes/routes) lands. +Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy +peer provisioning, Traefik dynamic-config generation) is built and tested +for single-tenant mode. The Furtka-core side (the box-facing "connect to a +gateway" flow and per-app "expose to internet" toggle) is still just a +plan, not yet implemented — see the plan doc for the phased delivery. + +## Deploying on a VPS + +1. Get a VPS with a static public IP, point a domain's A/AAAA record at it + (e.g. `example.com`), and open `80/tcp`, `443/tcp`, `51820/udp` in + whatever firewall/security group sits in front of it. +2. `cp .env.example .env` and fill it in — in particular `GATEWAY_PUBLIC_HOST` + (the WireGuard endpoint hostname), a wg-easy admin password + (`WG_EASY_PASSWORD_HASH` + the matching plaintext `WG_EASY_ADMIN_PASSWORD`), + `GATEWAY_BOX_TOKEN` (`openssl rand -hex 32`), and + `GATEWAY_CONTROL_PLANE_HOST` (its own A/AAAA record — see step 5). + **Double every `$` in `WG_EASY_PASSWORD_HASH` as `$$`** — see that + variable's comment in `.env.example` for why; getting this wrong + silently truncates the hash instead of erroring, confirmed by hitting + it directly. +3. Run `sudo ops/host-sysctls.sh` on the VPS *before* bringing the stack up. + wg-easy needs `net.ipv4.ip_forward=1` (so the host actually routes + between the docker bridge and the WireGuard interface — see the + `network_mode: host` comment on the `wg-easy` service) and + `net.ipv4.conf.all.src_valid_mark`, and — confirmed by actually trying + it, not assumed — Docker's own `sysctls:` key can't set either one once + a service is host-networked; runc refuses to even start the container. +4. Bring the stack up: `docker compose up -d` (or `docker stack deploy` under + Swarm — see the caveats below, `build:` doesn't work there yet). +5. Copy `traefik/dynamic/control-plane.yml.example` to + `traefik/dynamic/control-plane.yml`, substituting your real + `GATEWAY_CONTROL_PLANE_HOST`. Without this, the registration API is only + reachable at `127.0.0.1:8090` on the VPS itself — a remote Furtka box has + no way to reach it, and the call needs TLS anyway since it carries a + WireGuard private key (see that file's comments, and + `control_plane/wgeasy.py`'s module docstring, for why). +6. Run `sudo ops/firewall.sh` on the VPS. wg-easy's admin API is no longer + kept off the public interface by Docker the way every other service + here is (a direct consequence of `network_mode: host` — see step 3). + This script closes that with an iptables rule. **Do this before + pointing DNS at the box for real** — read the script's own caveats + first, it hasn't been validated against a real multi-interface VPS yet. +7. `curl http://127.0.0.1:8090/healthz` from the VPS to confirm the stack + is actually up. + +### Docker Swarm caveats + +If deploying via `docker stack deploy` instead of `docker compose up`: +- `docker-compose.yaml`'s `build: ./control_plane` doesn't work under + Swarm at all — it only ever schedules pre-built, tagged images. Build + and tag the image yourself first (`docker build -t + furtka-gateway-control-plane:local ./control_plane`) and swap `build:` + for `image: furtka-gateway-control-plane:local` until this is packaged + as a versioned image in CI (the planned long-term fix). +- `docker stack deploy` does not read a `.env` file next to the compose + file the way `docker compose up` does — `set -a; source .env; set +a` + first, or the `${VAR}` substitutions come out empty. +- `depends_on` is silently ignored by Swarm — harmless here, since + `control_plane` only talks to wg-easy/Traefik lazily on the first real + request, not at startup. ## Local dev ```bash cp .env.example .env -# edit .env: set GATEWAY_PUBLIC_HOST to a real hostname you control (or a -# LAN-reachable IP for a first smoke test), and a PASSWORD_HASH for wg-easy -# (see https://github.com/wg-easy/wg-easy for how to generate one). +# 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 ``` -Traefik's dashboard/API and wg-easy's own UI are intentionally not published -on a host port — reach them via `docker compose exec` / port-forwarding -during development. Neither should ever be reachable from the internet in a -real deployment; only the control-plane (`8090`, bound to `127.0.0.1`) and -Traefik's `80`/`443` entrypoints are meant to be exposed. +`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/app.py b/control_plane/app.py index 09375e5..cc6aedf 100644 --- a/control_plane/app.py +++ b/control_plane/app.py @@ -16,9 +16,8 @@ def build_context(db: Database | None = None) -> Context: db = db or Database() wgeasy = WgEasyClient( - base_url=os.environ.get("WG_EASY_URL", "http://wg-easy:51821"), - username=os.environ.get("WG_EASY_ADMIN_USERNAME", "admin"), - password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""), + base_url=os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821"), + admin_password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""), ) single_account_id = None diff --git a/control_plane/wgeasy.py b/control_plane/wgeasy.py index 4258d42..32d4c8f 100644 --- a/control_plane/wgeasy.py +++ b/control_plane/wgeasy.py @@ -1,27 +1,36 @@ """Thin REST client for wg-easy's admin API. -Verified against wg-easy's actual source (github.com/wg-easy/wg-easy, -src/server/api/{auth,client}/*) rather than assumed — an earlier draft of -this plan guessed at a base path of /api/wireguard/client and assumed a -bearer-token auth scheme with bring-your-own-public-key support. Neither is -true. The real shape: +Verified directly against the actual running container for the pinned +image (`ghcr.io/wg-easy/wg-easy:14`) — `docker exec furtka-gateway-wg-easy +cat /app/lib/Server.js` — rather than trusted from wg-easy's GitHub +`master` branch, which turned out to describe a different, newer, +Nitro/h3-based rewrite this tag does not actually run. Two things that +draft got wrong, corrected here: this build's auth is a single shared +password sent as a plain `Authorization` header on every request (no +login call, no session cookie, no username), and its routes live under +`/api/wireguard/client`, not `/api/client`. Re-verify against the +container directly (not against docs) if this image tag is ever bumped. - POST /api/auth/password {username, password, remember} - -> sets a session cookie (h3's own - session mechanism; there is no - separate bearer-token auth) - POST /api/client {name} -> {success, clientId} - GET /api/client/{id} -> client record, includes .publicKey - GET /api/client/{id}/configuration -> raw wg-quick .conf text - (Content-Type: application/octet-stream) - DELETE /api/client/{id} -> remove the peer +Real routes (confirmed by reading the container's own source): -wg-easy always generates the WireGuard keypair itself; the private key is -only ever obtainable via the .conf download, never returned by the create -call. There is no supported way to hand wg-easy an externally-generated -public key. See the plan's "Correction found during implementation" note -for the resulting threat-model consequence (the gateway sees every box's -private key at registration time). + GET /api/wireguard/client -> [{id, name, enabled, + address, publicKey, + createdAt, ...}] + POST /api/wireguard/client {name} -> {success: true} + (no id in the + response — list and + match by name) + GET /api/wireguard/client/{id}/configuration -> plain-text wg-quick + .conf (the only place + the private key ever + appears) + DELETE /api/wireguard/client/{id} -> {success: true} + +wg-easy always generates the keypair itself; there is no supported way to +hand it an externally-generated public key. See the plan's "Correction +found during implementation" note for the resulting threat-model +consequence (the gateway sees every box's private key at registration +time). """ from __future__ import annotations @@ -30,7 +39,6 @@ import json import re import urllib.error import urllib.request -from http.cookiejar import CookieJar class WgEasyError(Exception): @@ -38,36 +46,39 @@ class WgEasyError(Exception): class WgEasyClient: - def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None: + def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None: self._base_url = base_url.rstrip("/") - self._username = username - self._password = password + self._admin_password = admin_password self._timeout = timeout - self._opener = urllib.request.build_opener( - urllib.request.HTTPCookieProcessor(CookieJar()) - ) - self._logged_in = False def create_client(self, name: str) -> dict: """Create a new WireGuard peer and return its full connection material. - wg-easy generates the keypair; we fetch the private key immediately - via the /configuration endpoint since create_client's own response - never includes it. + The create call itself never returns the new client's id, so we + immediately list clients and match by name (most recently created, + in case an old client happens to share the name) to find it, then + fetch its private key via /configuration — the only endpoint that + ever exposes it. """ - created = json.loads(self._authed_request("POST", "/api/client", {"name": name})) - client_id = created["clientId"] + self._request("POST", "/api/wireguard/client", {"name": name}) - info = json.loads(self._authed_request("GET", f"/api/client/{client_id}")) + clients = json.loads(self._request("GET", "/api/wireguard/client")) + matches = [c for c in clients if c["name"] == name] + if not matches: + raise WgEasyError( + f"created client {name!r} but it did not appear in the client list" + ) + client = max(matches, key=lambda c: c["createdAt"]) + client_id = client["id"] - conf_text = self._authed_request( - "GET", f"/api/client/{client_id}/configuration" + conf_text = self._request( + "GET", f"/api/wireguard/client/{client_id}/configuration" ).decode() conf = _parse_wg_conf(conf_text) return { "id": client_id, - "public_key": info["publicKey"], + "public_key": client["publicKey"], "private_key": conf["private_key"], "address": conf["address"], "server_public_key": conf["peer_public_key"], @@ -77,30 +88,22 @@ class WgEasyClient: def delete_client(self, client_id: str) -> None: try: - self._authed_request("DELETE", f"/api/client/{client_id}") + self._request("DELETE", f"/api/wireguard/client/{client_id}") except WgEasyError: # Already gone on wg-easy's side shouldn't block us cleaning up # our own box/route rows — deregistration must still succeed. pass - # -- transport ------------------------------------------------------ - - def _login(self) -> None: - self._request( - "POST", - "/api/auth/password", - {"username": self._username, "password": self._password, "remember": True}, - ) - self._logged_in = True - 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 = {"Content-Type": "application/json"} if data is not None else {} + headers = {"Authorization": self._admin_password} + 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 self._opener.open(req, timeout=self._timeout) as resp: + 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") @@ -108,17 +111,6 @@ class WgEasyClient: except urllib.error.URLError as e: raise WgEasyError(f"{method} {path} -> {e}") from e - def _authed_request(self, method: str, path: str, body: dict | None = None) -> bytes: - if not self._logged_in: - self._login() - try: - return self._request(method, path, body) - except WgEasyError: - # Session cookie may have expired between calls — retry once - # after a fresh login before giving up. - self._login() - return self._request(method, path, body) - def _parse_wg_conf(text: str) -> dict: def find(pattern: str) -> str: diff --git a/docker-compose.yaml b/docker-compose.yaml index 97beac5..729d9c5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,26 +2,54 @@ 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 - ports: - - "51820:51820/udp" cap_add: - NET_ADMIN - SYS_MODULE - sysctls: - - net.ipv4.ip_forward=1 - - net.ipv4.conf.all.src_valid_mark=1 restart: unless-stopped - networks: - - internal - # wg-easy's own admin UI/API (port 51821) is intentionally NOT published - # to the host. Only control-plane, on the internal network, talks to it. + # 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 @@ -51,24 +79,45 @@ services: - GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} - - WG_EASY_URL=http://wg-easy:51821 - - WG_EASY_ADMIN_USERNAME=${WG_EASY_ADMIN_USERNAME:-admin} + # 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. - 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: - - wg-easy - 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: diff --git a/ops/firewall.sh b/ops/firewall.sh new file mode 100755 index 0000000..a804afb --- /dev/null +++ b/ops/firewall.sh @@ -0,0 +1,52 @@ +#!/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. +# +# 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 + +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/ops/host-sysctls.sh b/ops/host-sysctls.sh new file mode 100755 index 0000000..05828ea --- /dev/null +++ b/ops/host-sysctls.sh @@ -0,0 +1,34 @@ +#!/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/tests/test_wgeasy.py b/tests/test_wgeasy.py index 61f531b..560f2ce 100644 --- a/tests/test_wgeasy.py +++ b/tests/test_wgeasy.py @@ -6,18 +6,28 @@ import pytest from control_plane.wgeasy import WgEasyClient, WgEasyError +ADMIN_PASSWORD = "hunter2" + FAKE_CONF = """[Interface] PrivateKey = client-private-key== -Address = 10.8.0.5/32 -DNS = 1.1.1.1 +Address = 10.8.0.5/24 [Peer] PublicKey = server-public-key== -Endpoint = gateway.example.com:51820 AllowedIPs = 10.8.0.0/24 PersistentKeepalive = 25 +Endpoint = gateway.example.com:51820 """ +CLIENT_RECORD = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "box-1", + "enabled": True, + "address": "10.8.0.5", + "publicKey": "client-public-key==", + "createdAt": "2026-01-01T00:00:00.000Z", +} + class FakeWgEasyHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): @@ -31,28 +41,32 @@ class FakeWgEasyHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _check_auth(self) -> bool: + ok = self.headers.get("Authorization") == ADMIN_PASSWORD + if not ok: + self._json(401, {"error": "Incorrect Password"}) + return ok + def do_POST(self): self.server.requests.append(("POST", self.path)) - if self.path == "/api/auth/password": - self.send_response(200) - self.send_header("Set-Cookie", "wg-easy-session=abc123; Path=/; HttpOnly") - self.send_header("Content-Length", "0") - self.end_headers() + if not self._check_auth(): return - if self.path == "/api/client": - self._json(200, {"success": True, "clientId": "client-1"}) + if self.path == "/api/wireguard/client": + self._json(200, {"success": True}) return self._json(404, {"error": "not found"}) def do_GET(self): self.server.requests.append(("GET", self.path)) - if self.path == "/api/client/client-1": - self._json(200, {"id": "client-1", "publicKey": "client-public-key=="}) + if not self._check_auth(): return - if self.path == "/api/client/client-1/configuration": + if self.path == "/api/wireguard/client": + self._json(200, [CLIENT_RECORD]) + return + if self.path == f"/api/wireguard/client/{CLIENT_RECORD['id']}/configuration": body = FAKE_CONF.encode() self.send_response(200) - self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -61,7 +75,9 @@ class FakeWgEasyHandler(BaseHTTPRequestHandler): def do_DELETE(self): self.server.requests.append(("DELETE", self.path)) - if self.path == "/api/client/client-1": + if not self._check_auth(): + return + if self.path == f"/api/wireguard/client/{CLIENT_RECORD['id']}": self._json(200, {"success": True}) return self._json(404, {"error": "not found"}) @@ -80,27 +96,39 @@ def fake_wgeasy(): thread.join() -def _client(server): +def _client(server, password=ADMIN_PASSWORD): port = server.server_address[1] - return WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2") + return WgEasyClient(f"http://127.0.0.1:{port}", password) -def test_create_client_logs_in_and_parses_configuration(fake_wgeasy): +def test_create_client_lists_and_parses_configuration(fake_wgeasy): client = _client(fake_wgeasy) peer = client.create_client("box-1") - assert peer["id"] == "client-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.5/32" + assert peer["address"] == "10.8.0.5/24" assert peer["server_public_key"] == "server-public-key==" assert peer["endpoint"] == "gateway.example.com:51820" assert peer["allowed_ips"] == "10.8.0.0/24" - methods_and_paths = [(m, p) for m, p in fake_wgeasy.requests] - assert ("POST", "/api/auth/password") in methods_and_paths - assert ("POST", "/api/client") in methods_and_paths + paths_hit = [(m, p) for m, p in fake_wgeasy.requests] + assert ("POST", "/api/wireguard/client") in paths_hit + assert ("GET", "/api/wireguard/client") in paths_hit + + +def test_every_request_carries_the_admin_password_header(fake_wgeasy): + client = _client(fake_wgeasy, password="wrong-password") + with pytest.raises(WgEasyError): + client.create_client("box-1") + + +def test_create_client_raises_if_not_found_after_creation(fake_wgeasy): + client = _client(fake_wgeasy) + with pytest.raises(WgEasyError): + client.create_client("some-other-name-not-in-fake-list") def test_delete_client_swallows_not_found(fake_wgeasy): @@ -110,12 +138,13 @@ def test_delete_client_swallows_not_found(fake_wgeasy): def test_delete_client_calls_endpoint(fake_wgeasy): client = _client(fake_wgeasy) - client.delete_client("client-1") - assert ("DELETE", "/api/client/client-1") in [(m, p) for m, p in fake_wgeasy.requests] + client.delete_client(CLIENT_RECORD["id"]) + assert ("DELETE", f"/api/wireguard/client/{CLIENT_RECORD['id']}") in [ + (m, p) for m, p in fake_wgeasy.requests + ] def test_bad_endpoint_raises_wgeasy_error(fake_wgeasy): - port = fake_wgeasy.server_address[1] - client = WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2") + client = _client(fake_wgeasy) with pytest.raises(WgEasyError): - client._authed_request("GET", "/api/does-not-exist") + client._request("GET", "/api/does-not-exist") diff --git a/traefik/dynamic/control-plane.yml.example b/traefik/dynamic/control-plane.yml.example new file mode 100644 index 0000000..7a87453 --- /dev/null +++ b/traefik/dynamic/control-plane.yml.example @@ -0,0 +1,28 @@ +# 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. +# +# GATEWAY_MODE=single: keep the certResolver line below (HTTP-01 per host, +# same as every app route — see traefik.single.yml). +# 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. +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"