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:
parent
3a9d18fcd5
commit
73c96cab56
10 changed files with 374 additions and 124 deletions
29
.env.example
29
.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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -6,3 +6,6 @@ __pycache__/
|
|||
.venv/
|
||||
*.db
|
||||
acme.json
|
||||
traefik/dynamic/route-*.yml
|
||||
traefik/dynamic/control-plane.yml
|
||||
traefik/dynamic/default-cert.yml
|
||||
|
|
|
|||
75
README.md
75
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
52
ops/firewall.sh
Executable 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
34
ops/host-sysctls.sh
Executable 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
28
traefik/dynamic/control-plane.yml.example
Normal file
28
traefik/dynamic/control-plane.yml.example
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue