services: wg-easy: image: ghcr.io/wg-easy/wg-easy:14 container_name: furtka-gateway-wg-easy # Host networking, not the "internal" bridge network: wg-quick creates # the wg0 interface and its `10.8.0.0/24 dev wg0` route only inside # whatever network namespace this container runs in. On the "internal" # bridge network that route existed only inside wg-easy's own isolated # namespace, so sibling containers (Traefik, in particular) had no path # to reach a WireGuard peer's address at all. With network_mode: host, # wg0 and its routes live in the VPS host's own namespace, which the # host can then route to/from its docker bridge interfaces normally # (given net.ipv4.ip_forward=1 — see ops/host-sysctls.sh; compose's own # `sysctls:` key can't set this here at all, see that script's comment). network_mode: host environment: - WG_HOST=${GATEWAY_PUBLIC_HOST} - PASSWORD_HASH=${WG_EASY_PASSWORD_HASH} - PORT=51821 - WG_PORT=51820 # wg-easy's session/CSRF handling assumes HTTPS by default; we talk to # it over plain HTTP from control-plane (see WG_EASY_URL below), which # is fine precisely because this port is never meant to be reachable # from the public internet — see the ops/firewall.sh note. - INSECURE=true # Default is "0.0.0.0/0, ::/0" — a full-tunnel config that would route # every box's entire internet traffic through this gateway. We only # want boxes reachable *from* the gateway for proxying, not routed # *through* it — split-tunnel, restricted to wg-easy's own peer # subnet (confirmed via config.js: WG_DEFAULT_ADDRESS defaults to # 10.8.0.x, i.e. this same /24). - WG_ALLOWED_IPS=10.8.0.0/24 # IPv6 isn't handled anywhere else in this repo yet (routes.py/ # traefikconf.py assume IPv4 peer addresses, ops/firewall.sh is # IPv4-only) — turn it off here too rather than leave a half-wired # IPv6 tunnel nothing else accounts for. - DISABLE_IPV6=true volumes: - wg_easy_data:/etc/wireguard cap_add: - NET_ADMIN - SYS_MODULE restart: unless-stopped # network_mode: host means wg-easy's admin UI/API (port 51821) binds # directly on the VPS's interfaces, including the public one — unlike # the old bridge-network setup, Docker itself can no longer keep this # off the internet. We can't fix this by binding wg-easy to a single # private address either (it would then also refuse the control-plane # container's own connections, which arrive via the docker bridge # interface, not loopback). The actual fix is a host firewall rule — # see ops/firewall.sh. RUN THAT SCRIPT (or an equivalent rule) BEFORE # this is reachable from the internet. traefik: image: traefik:v3.1 container_name: furtka-gateway-traefik command: - --configFile=/etc/traefik/traefik.yml volumes: - ./traefik/static/traefik.${GATEWAY_MODE:-single}.yml:/etc/traefik/traefik.yml:ro - ./traefik/dynamic:/etc/traefik/dynamic:ro - traefik_acme:/acme ports: - "80:80" - "443:443" restart: unless-stopped networks: - internal # Traefik's own dashboard/API is disabled in both static configs # (api.dashboard: false, api.insecure: false) — never reachable at all, # let alone publicly. control-plane: build: ./control_plane container_name: furtka-gateway-control-plane environment: - GATEWAY_MODE=${GATEWAY_MODE:-single} - GATEWAY_STATE_DIR=/data - GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} # 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: - traefik networks: internal: driver: bridge # Pinned (rather than Docker's usual dynamic allocation) so # ops/firewall.sh has a stable subnet to reference when it allows this # network through to wg-easy's admin API but blocks everyone else. ipam: config: - subnet: 172.28.0.0/24 volumes: wg_easy_data: traefik_acme: control_plane_data: