furtka-gateway/docker-compose.yaml
Robert Syrnicki 8eebae8e1e ci: publish control-plane image for Swarm deploys
Swarm ignores docker-compose.yaml's build: key, so docker stack deploy
previously needed an operator to build and tag the control-plane image
by hand. CI now publishes it to this instance's container registry on
every push to master (or a tag), and docker-compose.yaml's image: key
(overridable via GATEWAY_CONTROL_PLANE_IMAGE) lets Swarm pull it directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142mP6EqCkQKmR1xCp7zfmZ
2026-08-26 12:20:16 +02:00

133 lines
6 KiB
YAML

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
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
# the registry on every push to master, tagged `latest` plus the short
# commit sha. `docker compose up` builds fresh from `build:` above and
# tags the result as this name, ignoring this being a registry ref;
# `docker stack deploy` (Swarm ignores `build:` entirely, see the
# Swarm caveats in README.md) pulls it instead — no manual
# `docker build -t ... ./control_plane` step needed there anymore.
image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest}
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: