Fix WireGuard peer routing and control-plane reachability

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.
This commit is contained in:
Robert Syrnicki 2026-08-24 12:51:22 +02:00
parent 3a9d18fcd5
commit 73c96cab56
10 changed files with 374 additions and 124 deletions

View file

@ -6,18 +6,24 @@ GATEWAY_MODE=single
# peers as the WireGuard endpoint. Must be reachable on udp/51820. # peers as the WireGuard endpoint. Must be reachable on udp/51820.
GATEWAY_PUBLIC_HOST=vpn.example.com 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 \ # docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password' # "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= WG_EASY_PASSWORD_HASH=
# Credentials the control-plane uses to log into wg-easy's own admin API # PLAINTEXT password matching the hash above. wg-easy's admin API (this
# (POST /api/auth/password -> session cookie; wg-easy has no separate # specific pinned image, ghcr.io/wg-easy/wg-easy:14 — confirmed by reading
# bearer-token auth). WG_EASY_ADMIN_PASSWORD is the PLAINTEXT password # its actual source, which turned out to differ from what wg-easy's current
# corresponding to WG_EASY_PASSWORD_HASH above — wg-easy only ever sees the # docs describe) has no login call or session cookie at all: every request
# hash, but the control-plane needs the plaintext to log in the same way a # just carries this password as a plain `Authorization` header, checked
# human would through the UI. # with bcrypt against WG_EASY_PASSWORD_HASH. No username concept exists.
WG_EASY_ADMIN_USERNAME=admin
WG_EASY_ADMIN_PASSWORD= WG_EASY_ADMIN_PASSWORD=
# GATEWAY_MODE=single only: the one box token a single-tenant deployment # 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 # issuance. Generate with: openssl rand -hex 32
GATEWAY_BOX_TOKEN= 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. # Bearer token required to create accounts via POST /v1/accounts.
# Only meaningful once account endpoints exist (Phase 2+); harmless to set # Only meaningful once account endpoints exist (Phase 2+); harmless to set
# now. Generate with: openssl rand -hex 32 # now. Generate with: openssl rand -hex 32

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ __pycache__/
.venv/ .venv/
*.db *.db
acme.json acme.json
traefik/dynamic/route-*.yml
traefik/dynamic/control-plane.yml
traefik/dynamic/default-cert.yml

View file

@ -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 project) for people who don't want to run their own. Same schema, same
code; `single` is just the one-account case. code; `single` is just the one-account case.
See `docker-compose.yaml` and `control_plane/` for the moving parts. Status: Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy
**Phase 1 (scaffold)** — wg-easy + Traefik + a stub control-plane exposing peer provisioning, Traefik dynamic-config generation) is built and tested
only `/healthz`, enough to manually prove the wiring end-to-end before the for single-tenant mode. The Furtka-core side (the box-facing "connect to a
real control-plane (accounts/boxes/routes) lands. 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 ## Local dev
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env: set GATEWAY_PUBLIC_HOST to a real hostname you control (or a # GATEWAY_PUBLIC_HOST can be a LAN-reachable IP for a first smoke test.
# 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).
docker compose up -d docker compose up -d
curl http://127.0.0.1:8090/healthz curl http://127.0.0.1:8090/healthz
``` ```
Traefik's dashboard/API and wg-easy's own UI are intentionally not published `ops/firewall.sh` and the `control-plane.yml` Traefik route are about
on a host port — reach them via `docker compose exec` / port-forwarding reaching this gateway from the real internet — skip both for local dev.
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.

View file

@ -16,9 +16,8 @@ def build_context(db: Database | None = None) -> Context:
db = db or Database() db = db or Database()
wgeasy = WgEasyClient( wgeasy = WgEasyClient(
base_url=os.environ.get("WG_EASY_URL", "http://wg-easy:51821"), base_url=os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821"),
username=os.environ.get("WG_EASY_ADMIN_USERNAME", "admin"), admin_password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""),
password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""),
) )
single_account_id = None single_account_id = None

View file

@ -1,27 +1,36 @@
"""Thin REST client for wg-easy's admin API. """Thin REST client for wg-easy's admin API.
Verified against wg-easy's actual source (github.com/wg-easy/wg-easy, Verified directly against the actual running container for the pinned
src/server/api/{auth,client}/*) rather than assumed an earlier draft of image (`ghcr.io/wg-easy/wg-easy:14`) `docker exec furtka-gateway-wg-easy
this plan guessed at a base path of /api/wireguard/client and assumed a cat /app/lib/Server.js` rather than trusted from wg-easy's GitHub
bearer-token auth scheme with bring-your-own-public-key support. Neither is `master` branch, which turned out to describe a different, newer,
true. The real shape: 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} Real routes (confirmed by reading the container's own source):
-> 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
wg-easy always generates the WireGuard keypair itself; the private key is GET /api/wireguard/client -> [{id, name, enabled,
only ever obtainable via the .conf download, never returned by the create address, publicKey,
call. There is no supported way to hand wg-easy an externally-generated createdAt, ...}]
public key. See the plan's "Correction found during implementation" note POST /api/wireguard/client {name} -> {success: true}
for the resulting threat-model consequence (the gateway sees every box's (no id in the
private key at registration time). 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 from __future__ import annotations
@ -30,7 +39,6 @@ import json
import re import re
import urllib.error import urllib.error
import urllib.request import urllib.request
from http.cookiejar import CookieJar
class WgEasyError(Exception): class WgEasyError(Exception):
@ -38,36 +46,39 @@ class WgEasyError(Exception):
class WgEasyClient: 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._base_url = base_url.rstrip("/")
self._username = username self._admin_password = admin_password
self._password = password
self._timeout = timeout self._timeout = timeout
self._opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar())
)
self._logged_in = False
def create_client(self, name: str) -> dict: def create_client(self, name: str) -> dict:
"""Create a new WireGuard peer and return its full connection material. """Create a new WireGuard peer and return its full connection material.
wg-easy generates the keypair; we fetch the private key immediately The create call itself never returns the new client's id, so we
via the /configuration endpoint since create_client's own response immediately list clients and match by name (most recently created,
never includes it. 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})) self._request("POST", "/api/wireguard/client", {"name": name})
client_id = created["clientId"]
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( conf_text = self._request(
"GET", f"/api/client/{client_id}/configuration" "GET", f"/api/wireguard/client/{client_id}/configuration"
).decode() ).decode()
conf = _parse_wg_conf(conf_text) conf = _parse_wg_conf(conf_text)
return { return {
"id": client_id, "id": client_id,
"public_key": info["publicKey"], "public_key": client["publicKey"],
"private_key": conf["private_key"], "private_key": conf["private_key"],
"address": conf["address"], "address": conf["address"],
"server_public_key": conf["peer_public_key"], "server_public_key": conf["peer_public_key"],
@ -77,30 +88,22 @@ class WgEasyClient:
def delete_client(self, client_id: str) -> None: def delete_client(self, client_id: str) -> None:
try: try:
self._authed_request("DELETE", f"/api/client/{client_id}") self._request("DELETE", f"/api/wireguard/client/{client_id}")
except WgEasyError: except WgEasyError:
# Already gone on wg-easy's side shouldn't block us cleaning up # Already gone on wg-easy's side shouldn't block us cleaning up
# our own box/route rows — deregistration must still succeed. # our own box/route rows — deregistration must still succeed.
pass 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: def _request(self, method: str, path: str, body: dict | None = None) -> bytes:
data = json.dumps(body).encode() if body is not None else None 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( req = urllib.request.Request(
f"{self._base_url}{path}", data=data, method=method, headers=headers f"{self._base_url}{path}", data=data, method=method, headers=headers
) )
try: try:
with self._opener.open(req, timeout=self._timeout) as resp: with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return resp.read() return resp.read()
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace") detail = e.read().decode(errors="replace")
@ -108,17 +111,6 @@ class WgEasyClient:
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise WgEasyError(f"{method} {path} -> {e}") from 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 _parse_wg_conf(text: str) -> dict:
def find(pattern: str) -> str: def find(pattern: str) -> str:

View file

@ -2,26 +2,54 @@ services:
wg-easy: wg-easy:
image: ghcr.io/wg-easy/wg-easy:14 image: ghcr.io/wg-easy/wg-easy:14
container_name: furtka-gateway-wg-easy 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: environment:
- WG_HOST=${GATEWAY_PUBLIC_HOST} - WG_HOST=${GATEWAY_PUBLIC_HOST}
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH} - PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
- PORT=51821 - PORT=51821
- WG_PORT=51820 - 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: volumes:
- wg_easy_data:/etc/wireguard - wg_easy_data:/etc/wireguard
ports:
- "51820:51820/udp"
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped restart: unless-stopped
networks: # network_mode: host means wg-easy's admin UI/API (port 51821) binds
- internal # directly on the VPS's interfaces, including the public one — unlike
# wg-easy's own admin UI/API (port 51821) is intentionally NOT published # the old bridge-network setup, Docker itself can no longer keep this
# to the host. Only control-plane, on the internal network, talks to it. # 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: traefik:
image: traefik:v3.1 image: traefik:v3.1
@ -51,24 +79,45 @@ services:
- GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_DYNAMIC_DIR=/dynamic
- GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN}
- GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN}
- WG_EASY_URL=http://wg-easy:51821 # wg-easy is host-networked now (see above), so it's no longer a
- WG_EASY_ADMIN_USERNAME=${WG_EASY_ADMIN_USERNAME:-admin} # 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} - WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- control_plane_data:/data - control_plane_data:/data
- ./traefik/dynamic:/dynamic - ./traefik/dynamic:/dynamic
ports: 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" - "127.0.0.1:8090:8090"
restart: unless-stopped restart: unless-stopped
networks: networks:
- internal - internal
depends_on: depends_on:
- wg-easy
- traefik - traefik
networks: networks:
internal: internal:
driver: bridge 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: volumes:
wg_easy_data: wg_easy_data:

52
ops/firewall.sh Executable file
View file

@ -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."

34
ops/host-sysctls.sh Executable file
View file

@ -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

View file

@ -6,18 +6,28 @@ import pytest
from control_plane.wgeasy import WgEasyClient, WgEasyError from control_plane.wgeasy import WgEasyClient, WgEasyError
ADMIN_PASSWORD = "hunter2"
FAKE_CONF = """[Interface] FAKE_CONF = """[Interface]
PrivateKey = client-private-key== PrivateKey = client-private-key==
Address = 10.8.0.5/32 Address = 10.8.0.5/24
DNS = 1.1.1.1
[Peer] [Peer]
PublicKey = server-public-key== PublicKey = server-public-key==
Endpoint = gateway.example.com:51820
AllowedIPs = 10.8.0.0/24 AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25 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): class FakeWgEasyHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
@ -31,28 +41,32 @@ class FakeWgEasyHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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): def do_POST(self):
self.server.requests.append(("POST", self.path)) self.server.requests.append(("POST", self.path))
if self.path == "/api/auth/password": if not self._check_auth():
self.send_response(200)
self.send_header("Set-Cookie", "wg-easy-session=abc123; Path=/; HttpOnly")
self.send_header("Content-Length", "0")
self.end_headers()
return return
if self.path == "/api/client": if self.path == "/api/wireguard/client":
self._json(200, {"success": True, "clientId": "client-1"}) self._json(200, {"success": True})
return return
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_GET(self): def do_GET(self):
self.server.requests.append(("GET", self.path)) self.server.requests.append(("GET", self.path))
if self.path == "/api/client/client-1": if not self._check_auth():
self._json(200, {"id": "client-1", "publicKey": "client-public-key=="})
return 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() body = FAKE_CONF.encode()
self.send_response(200) 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.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
@ -61,7 +75,9 @@ class FakeWgEasyHandler(BaseHTTPRequestHandler):
def do_DELETE(self): def do_DELETE(self):
self.server.requests.append(("DELETE", self.path)) 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}) self._json(200, {"success": True})
return return
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
@ -80,27 +96,39 @@ def fake_wgeasy():
thread.join() thread.join()
def _client(server): def _client(server, password=ADMIN_PASSWORD):
port = server.server_address[1] 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) client = _client(fake_wgeasy)
peer = client.create_client("box-1") 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["public_key"] == "client-public-key=="
assert peer["private_key"] == "client-private-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["server_public_key"] == "server-public-key=="
assert peer["endpoint"] == "gateway.example.com:51820" assert peer["endpoint"] == "gateway.example.com:51820"
assert peer["allowed_ips"] == "10.8.0.0/24" assert peer["allowed_ips"] == "10.8.0.0/24"
methods_and_paths = [(m, p) for m, p in fake_wgeasy.requests] paths_hit = [(m, p) for m, p in fake_wgeasy.requests]
assert ("POST", "/api/auth/password") in methods_and_paths assert ("POST", "/api/wireguard/client") in paths_hit
assert ("POST", "/api/client") in methods_and_paths 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): 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): def test_delete_client_calls_endpoint(fake_wgeasy):
client = _client(fake_wgeasy) client = _client(fake_wgeasy)
client.delete_client("client-1") client.delete_client(CLIENT_RECORD["id"])
assert ("DELETE", "/api/client/client-1") in [(m, p) for m, p in fake_wgeasy.requests] 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): def test_bad_endpoint_raises_wgeasy_error(fake_wgeasy):
port = fake_wgeasy.server_address[1] client = _client(fake_wgeasy)
client = WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2")
with pytest.raises(WgEasyError): with pytest.raises(WgEasyError):
client._authed_request("GET", "/api/does-not-exist") client._request("GET", "/api/does-not-exist")

View file

@ -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"