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

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ __pycache__/
.venv/
*.db
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
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.

View file

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

View file

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

View file

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

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

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"