feat: rebuild the new-server stack on wg-easy v15 with a shared netns
wg-easy and Traefik now share one network namespace owned by a tiny gateway-net container, so Traefik reaches box tunnel addresses directly. This drops host networking, the host sysctl and firewall scripts, and keeps wg-easy's admin UI on loopback. wg-easy v15 is set up unattended via INIT_* variables, and deploy.sh needs only domain, hostnames and an ACME email; secrets are generated. Verified locally with a simulated box: register, tunnel, publish, and restarts of wg-easy, Traefik and gateway-net. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6b8cc6e006
commit
53ff30f273
6 changed files with 195 additions and 336 deletions
|
|
@ -1,33 +1,36 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Interactive setup for the zero-infra starter stack: bundles its own
|
# Sets up furtka-gateway on an empty server: wg-easy + Traefik +
|
||||||
# Traefik + wg-easy + control-plane. If you already run Traefik and/or
|
# control-plane, all from this folder's docker-compose.yaml. See README.md
|
||||||
# wg-easy, use the main repo's root docker-compose.yaml + ops/deploy.sh
|
# here for the server and DNS requirements.
|
||||||
# instead — it plugs into what you already have.
|
#
|
||||||
|
# Safe to re-run — existing .env values become the defaults for each
|
||||||
|
# prompt (press Enter to keep them), and secrets are generated once.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./deploy.sh full setup: prompts, render, sysctls,
|
# ./deploy.sh prompts, writes .env + dynamic/, starts
|
||||||
# compose up, firewall, health check
|
# the stack, health-checks it
|
||||||
# ./deploy.sh --render-only only (re)write .env and the rendered
|
# ./deploy.sh --non-interactive same, but no prompts: every value comes
|
||||||
# Traefik files — skips sysctls/compose/
|
# from .env (fails if one is missing)
|
||||||
# firewall/health-check.
|
# ./deploy.sh --render-only only (re)write .env and
|
||||||
|
# dynamic/control-plane.yml; starts nothing
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
RENDER_ONLY=0
|
RENDER_ONLY=0
|
||||||
if [[ "${1:-}" == "--render-only" ]]; then
|
INTERACTIVE=1
|
||||||
RENDER_ONLY=1
|
for arg in "$@"; do
|
||||||
fi
|
case "$arg" in
|
||||||
|
--render-only) RENDER_ONLY=1 ;;
|
||||||
|
--non-interactive) INTERACTIVE=0 ;;
|
||||||
|
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
|
||||||
# ---- load existing .env (if any) so prompts default to current values ----
|
# Deliberately not `source`d: bash would expand `$` in values.
|
||||||
# Deliberately NOT `source`d: bash expands `$$` (its own PID) and `$VAR`
|
|
||||||
# during sourcing, which would corrupt WG_EASY_PASSWORD_HASH's doubled-`$`
|
|
||||||
# escaping (that escaping is for docker compose's own .env parser, not
|
|
||||||
# bash's) — confirmed by hitting this while building the main ops/deploy.sh.
|
|
||||||
# Plain string splitting instead, so a value is never re-interpreted.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local file="$1" line key value
|
local file="$1" line key value
|
||||||
[[ -f "$file" ]] || return 0
|
[[ -f "$file" ]] || return 0
|
||||||
|
|
@ -39,68 +42,47 @@ load_env_file() {
|
||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
load_env_file "$ENV_FILE"
|
load_env_file "$ENV_FILE"
|
||||||
# Normalize back to the raw (single-`$`) hash for the rest of this script;
|
|
||||||
# re-escaped once, right before writing, further down.
|
|
||||||
WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH:-}"
|
|
||||||
WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH//\$\$/\$}"
|
|
||||||
|
|
||||||
ask() {
|
ask() {
|
||||||
|
# ask VARNAME "Prompt text" "fallback-default"
|
||||||
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
||||||
local __current="${!__var:-}"
|
local __current="${!__var:-}"
|
||||||
local __default="${__current:-$__fallback}"
|
local __default="${__current:-$__fallback}"
|
||||||
local __ans
|
local __ans=""
|
||||||
if [[ -n "$__default" ]]; then
|
if [[ "$INTERACTIVE" -eq 1 ]]; then
|
||||||
read -r -p "$__prompt [$__default]: " __ans
|
if [[ -n "$__default" ]]; then
|
||||||
else
|
read -r -p "$__prompt [$__default]: " __ans
|
||||||
read -r -p "$__prompt: " __ans
|
else
|
||||||
|
read -r -p "$__prompt: " __ans
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
printf -v "$__var" '%s' "${__ans:-$__default}"
|
printf -v "$__var" '%s' "${__ans:-$__default}"
|
||||||
}
|
if [[ -z "${!__var}" ]]; then
|
||||||
|
echo "$__var is required" >&2
|
||||||
ask_secret() {
|
exit 1
|
||||||
local __var="$1" __prompt="$2"
|
|
||||||
local __current="${!__var:-}"
|
|
||||||
local __ans
|
|
||||||
if [[ -n "$__current" ]]; then
|
|
||||||
read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans
|
|
||||||
else
|
|
||||||
read -r -s -p "$__prompt: " __ans
|
|
||||||
fi
|
fi
|
||||||
echo
|
|
||||||
printf -v "$__var" '%s' "${__ans:-$__current}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require_cmd() {
|
require_cmd() {
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_cmd openssl
|
||||||
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
||||||
require_cmd docker
|
require_cmd docker
|
||||||
require_cmd openssl
|
require_cmd curl
|
||||||
require_cmd sudo
|
docker compose version >/dev/null 2>&1 || { echo "missing: docker compose plugin" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== furtka-gateway starter-stack deploy =="
|
echo "== furtka-gateway: new server setup =="
|
||||||
echo
|
echo
|
||||||
|
ask GATEWAY_DOMAIN "Your domain (boxes publish apps as <name>.<domain>)" ""
|
||||||
|
ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname" "vpn.$GATEWAY_DOMAIN"
|
||||||
|
ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" "gw.$GATEWAY_DOMAIN"
|
||||||
|
ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt certificate notices" ""
|
||||||
|
|
||||||
ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname (A/AAAA record for this VPS)" "vpn.example.com"
|
# Secrets: generated once, kept on re-run.
|
||||||
ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com"
|
WG_EASY_ADMIN_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-$(openssl rand -hex 16)}"
|
||||||
ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt registration" "admin@example.com"
|
|
||||||
|
|
||||||
echo
|
|
||||||
_PREV_WG_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-}"
|
|
||||||
ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password"
|
|
||||||
if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then
|
|
||||||
echo "wg-easy admin password is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$WG_EASY_ADMIN_PASSWORD" != "$_PREV_WG_PASSWORD" || -z "${WG_EASY_PASSWORD_HASH:-}" ]]; then
|
|
||||||
echo "Hashing wg-easy admin password..."
|
|
||||||
WG_EASY_PASSWORD_HASH="$(docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
|
|
||||||
"console.log(require('bcryptjs').hashSync(process.argv[1], 10))" "$WG_EASY_ADMIN_PASSWORD")"
|
|
||||||
fi
|
|
||||||
WG_EASY_PASSWORD_HASH_ESCAPED="${WG_EASY_PASSWORD_HASH//\$/\$\$}"
|
|
||||||
|
|
||||||
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
|
||||||
|
|
@ -108,62 +90,97 @@ echo
|
||||||
echo "Writing $ENV_FILE"
|
echo "Writing $ENV_FILE"
|
||||||
{
|
{
|
||||||
echo "# Generated by deploy.sh — re-run it to change any of these."
|
echo "# Generated by deploy.sh — re-run it to change any of these."
|
||||||
|
echo "GATEWAY_DOMAIN=$GATEWAY_DOMAIN"
|
||||||
echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST"
|
echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST"
|
||||||
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
||||||
echo "GATEWAY_ACME_EMAIL=$GATEWAY_ACME_EMAIL"
|
echo "GATEWAY_ACME_EMAIL=$GATEWAY_ACME_EMAIL"
|
||||||
echo "WG_EASY_PASSWORD_HASH=$WG_EASY_PASSWORD_HASH_ESCAPED"
|
|
||||||
echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
||||||
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
||||||
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
||||||
|
[[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE"
|
||||||
} > "$ENV_FILE"
|
} > "$ENV_FILE"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
echo "Rendering traefik/static/traefik.active.yml"
|
echo "Writing dynamic/control-plane.yml"
|
||||||
cp traefik/static/traefik.single.yml traefik/static/traefik.active.yml
|
mkdir -p dynamic
|
||||||
sed -i "s|admin@example.com|$GATEWAY_ACME_EMAIL|" traefik/static/traefik.active.yml
|
cat > dynamic/control-plane.yml <<EOF
|
||||||
|
# Generated by deploy.sh — Traefik route for the gateway's own API.
|
||||||
echo "Rendering traefik/dynamic/control-plane.yml"
|
http:
|
||||||
# Only the http: block onward — the .example's header comment mentions the
|
routers:
|
||||||
# placeholder by name too (for a human reading that template), which would
|
control-plane:
|
||||||
# otherwise also get corrupted by this same substitution.
|
rule: "Host(\`$GATEWAY_CONTROL_PLANE_HOST\`)"
|
||||||
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > traefik/dynamic/control-plane.yml
|
entryPoints: [websecure]
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" traefik/dynamic/control-plane.yml
|
service: control-plane
|
||||||
|
tls:
|
||||||
|
certResolver: le
|
||||||
|
services:
|
||||||
|
control-plane:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://control-plane:8090"
|
||||||
|
EOF
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
||||||
echo
|
echo
|
||||||
echo "--render-only: stopping before sysctls/compose/firewall/health-check."
|
echo "--render-only: nothing started."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---- DNS check (warn only — records may still be propagating) ----
|
||||||
echo
|
echo
|
||||||
echo "== sudo ops/host-sysctls.sh =="
|
echo "== DNS =="
|
||||||
sudo "$SCRIPT_DIR/ops/host-sysctls.sh"
|
SERVER_IP="$(curl -4 -s -m 10 https://ifconfig.me || true)"
|
||||||
|
for host in "$GATEWAY_PUBLIC_HOST" "$GATEWAY_CONTROL_PLANE_HOST" "check-wildcard.$GATEWAY_DOMAIN"; do
|
||||||
|
resolved="$(getent ahostsv4 "$host" 2>/dev/null | awk 'NR==1{print $1}')"
|
||||||
|
if [[ -z "$resolved" ]]; then
|
||||||
|
echo " WARNING: $host does not resolve yet"
|
||||||
|
elif [[ -n "$SERVER_IP" && "$resolved" != "$SERVER_IP" ]]; then
|
||||||
|
echo " WARNING: $host -> $resolved, but this server is $SERVER_IP"
|
||||||
|
else
|
||||||
|
echo " ok: $host -> $resolved"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== docker compose up -d =="
|
echo "== docker compose up -d --build =="
|
||||||
docker compose up -d
|
docker compose up -d --build
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== sudo ops/firewall.sh =="
|
|
||||||
sudo "$SCRIPT_DIR/ops/firewall.sh"
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== health check =="
|
echo "== health check =="
|
||||||
for _ in $(seq 1 10); do
|
healthy=0
|
||||||
if curl -sf 127.0.0.1:8090/healthz >/dev/null; then
|
for _ in $(seq 1 60); do
|
||||||
echo "control-plane is up."
|
if curl -sf http://127.0.0.1:8090/healthz >/dev/null 2>&1; then
|
||||||
|
healthy=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 2
|
||||||
done
|
done
|
||||||
if ! curl -sf 127.0.0.1:8090/healthz >/dev/null; then
|
if [[ "$healthy" -ne 1 ]]; then
|
||||||
echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2
|
echo "control-plane did not become healthy — check: docker compose logs control-plane wg-easy" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo " ok: control-plane is up"
|
||||||
|
|
||||||
|
public_ok=0
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
if curl -sf -m 10 "https://$GATEWAY_CONTROL_PLANE_HOST/healthz" >/dev/null 2>&1; then
|
||||||
|
public_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [[ "$public_ok" -eq 1 ]]; then
|
||||||
|
echo " ok: https://$GATEWAY_CONTROL_PLANE_HOST/healthz (valid certificate)"
|
||||||
|
else
|
||||||
|
echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable yet."
|
||||||
|
echo " Usually DNS or the certificate is still pending — check: docker compose logs traefik"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== done =="
|
echo "== done =="
|
||||||
echo "Still needed before a real box can connect:"
|
echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter"
|
||||||
echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST and $GATEWAY_PUBLIC_HOST -> this VPS's public IP"
|
echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST"
|
||||||
echo " - Open udp/51820, tcp/80, tcp/443 in your firewall/security group"
|
echo " Registration token: the GATEWAY_BOX_TOKEN value in $SCRIPT_DIR/.env"
|
||||||
echo " - On the box: furtka gateway connect <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
|
echo
|
||||||
|
echo "wg-easy admin UI (optional): ssh -L 51821:127.0.0.1:51821 <this server>,"
|
||||||
|
echo "then open http://localhost:51821 — user 'admin', password WG_EASY_ADMIN_PASSWORD in .env."
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,131 @@
|
||||||
|
# furtka-gateway on an empty server: wg-easy + Traefik + control-plane.
|
||||||
|
# Configure via .env (deploy.sh writes it — see README.md in this folder).
|
||||||
|
#
|
||||||
|
# The one trick: wg-easy and Traefik share one network namespace, owned
|
||||||
|
# by the tiny `gateway-net` container. wg0 and its 10.8.0.0/24 route live
|
||||||
|
# in that namespace, so Traefik has a direct path to every box's tunnel
|
||||||
|
# address — no host routes, NAT rules, host sysctls, or host-networked
|
||||||
|
# wg-easy needed. `gateway-net` does nothing else and never restarts, so
|
||||||
|
# wg-easy and Traefik can each restart without cutting the other off
|
||||||
|
# (a namespace owned by wg-easy itself would vanish on every wg-easy
|
||||||
|
# restart, leaving Traefik with no network at all).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
gateway-net:
|
||||||
|
image: alpine:3.20
|
||||||
|
container_name: furtka-gateway-net
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
# WireGuard
|
||||||
|
- "51820:51820/udp"
|
||||||
|
# Traefik
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
# wg-easy admin UI, loopback only: ssh -L 51821:127.0.0.1:51821 <server>
|
||||||
|
- "127.0.0.1:51821:51821"
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ip_forward=1
|
||||||
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
wg-easy:
|
wg-easy:
|
||||||
image: ghcr.io/wg-easy/wg-easy:14
|
image: ghcr.io/wg-easy/wg-easy:15
|
||||||
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}
|
# Unattended first-run setup (only applied while wg-easy's database
|
||||||
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
|
# is still empty — change the password later in its UI, then update
|
||||||
- PORT=51821
|
# WG_EASY_ADMIN_PASSWORD in .env to match).
|
||||||
- WG_PORT=51820
|
- INIT_ENABLED=true
|
||||||
# wg-easy's session/CSRF handling assumes HTTPS by default; we talk to
|
- INIT_USERNAME=admin
|
||||||
# it over plain HTTP from control-plane (see WG_EASY_URL below), which
|
- INIT_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
||||||
# is fine precisely because this port is never meant to be reachable
|
- INIT_HOST=${GATEWAY_PUBLIC_HOST}
|
||||||
# from the public internet — see the ops/firewall.sh note.
|
- INIT_PORT=51820
|
||||||
- INSECURE=true
|
# Split tunnel: boxes only route the gateway's peer subnet through
|
||||||
# Default is "0.0.0.0/0, ::/0" — a full-tunnel config that would route
|
# the tunnel, never their whole internet traffic.
|
||||||
# every box's entire internet traffic through this gateway. We only
|
- INIT_ALLOWED_IPS=10.8.0.0/24
|
||||||
# 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
|
- DISABLE_IPV6=true
|
||||||
|
# The admin UI is only published on the server's loopback (see
|
||||||
|
# gateway-net) and reached over an SSH tunnel, so plain HTTP is fine.
|
||||||
|
- INSECURE=true
|
||||||
volumes:
|
volumes:
|
||||||
- wg_easy_data:/etc/wireguard
|
- wg_easy_data:/etc/wireguard
|
||||||
|
- /lib/modules:/lib/modules:ro
|
||||||
|
network_mode: service:gateway-net
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
|
depends_on:
|
||||||
|
gateway-net:
|
||||||
|
condition: service_started
|
||||||
|
restart: true
|
||||||
restart: unless-stopped
|
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:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.7
|
||||||
container_name: furtka-gateway-traefik
|
container_name: furtka-gateway-traefik
|
||||||
|
network_mode: service:gateway-net
|
||||||
command:
|
command:
|
||||||
- --configFile=/etc/traefik/traefik.yml
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --providers.file.directory=/dynamic
|
||||||
|
- --providers.file.watch=true
|
||||||
|
- --certificatesresolvers.le.acme.email=${GATEWAY_ACME_EMAIL}
|
||||||
|
- --certificatesresolvers.le.acme.storage=/acme/acme.json
|
||||||
|
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||||
|
- --log.level=INFO
|
||||||
volumes:
|
volumes:
|
||||||
# Rendered by deploy.sh from traefik/static/traefik.single.yml with
|
# control-plane.yml (written by deploy.sh) + one route-*.yml per
|
||||||
# the ACME email substituted in — see that script. Gitignored, not
|
# published app (written by the control-plane).
|
||||||
# the tracked template.
|
- ./dynamic:/dynamic:ro
|
||||||
- ./traefik/static/traefik.active.yml:/etc/traefik/traefik.yml:ro
|
|
||||||
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
|
||||||
- traefik_acme:/acme
|
- traefik_acme:/acme
|
||||||
ports:
|
depends_on:
|
||||||
- "80:80"
|
gateway-net:
|
||||||
- "443:443"
|
condition: service_started
|
||||||
|
restart: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
# Traefik's own dashboard/API is disabled (api.dashboard: false,
|
|
||||||
# api.insecure: false) — never reachable at all, let alone publicly.
|
|
||||||
|
|
||||||
control-plane:
|
control-plane:
|
||||||
build: ../../control_plane
|
build: ../../control_plane
|
||||||
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
|
# Always build from this checkout — never run a registry image that may
|
||||||
# the registry on every push to master, tagged `latest` plus the short
|
# be older than the code you just pulled. (The image name only matters
|
||||||
# commit sha. `docker compose up` builds fresh from `build:` above and
|
# for Docker Swarm, which can't build; see README.md.)
|
||||||
# tags the result as this name, ignoring this being a registry ref;
|
pull_policy: build
|
||||||
# `docker stack deploy` (Swarm ignores `build:` entirely) 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}
|
image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest}
|
||||||
container_name: furtka-gateway-control-plane
|
container_name: furtka-gateway-control-plane
|
||||||
environment:
|
environment:
|
||||||
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
- GATEWAY_MODE=single
|
||||||
- GATEWAY_STATE_DIR=/data
|
- GATEWAY_STATE_DIR=/data
|
||||||
- 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}
|
||||||
- GATEWAY_CERT_RESOLVER=le
|
- GATEWAY_CERT_RESOLVER=le
|
||||||
# wg-easy is host-networked now (see above), so it's no longer a
|
- WG_EASY_URL=http://gateway-net:51821
|
||||||
# fellow member of the "internal" bridge network reachable by service
|
- WG_EASY_API_VERSION=15
|
||||||
# name — host.docker.internal (mapped below) reaches the VPS host
|
- WG_EASY_ADMIN_USERNAME=admin
|
||||||
# itself, where wg-easy's admin API is listening on 51821.
|
|
||||||
- WG_EASY_URL=http://host.docker.internal:51821
|
|
||||||
- WG_EASY_API_VERSION=14
|
|
||||||
# This pinned image (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:
|
- GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
volumes:
|
volumes:
|
||||||
- control_plane_data:/data
|
- control_plane_data:/data
|
||||||
- ./traefik/dynamic:/dynamic
|
- ./dynamic:/dynamic
|
||||||
ports:
|
ports:
|
||||||
# Loopback-only convenience for local health checks/debugging
|
# Loopback-only, for health checks from the server itself. Boxes
|
||||||
# (`curl 127.0.0.1:8090/healthz` from the VPS itself). This is NOT
|
# reach the API through Traefik over HTTPS (dynamic/control-plane.yml).
|
||||||
# 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
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
depends_on:
|
depends_on:
|
||||||
- traefik
|
# The control-plane configures wg-easy on startup.
|
||||||
|
wg-easy:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
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:
|
volumes:
|
||||||
wg_easy_data:
|
wg_easy_data:
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Restrict wg-easy's admin API (tcp/51821, or GATEWAY_WG_ADMIN_PORT if
|
|
||||||
# overridden) 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.
|
|
||||||
#
|
|
||||||
# `sudo` does not inherit your shell's env or read .env for you — if you
|
|
||||||
# overrode GATEWAY_WG_ADMIN_PORT, run:
|
|
||||||
# set -a; source .env; set +a; sudo -E ops/firewall.sh
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Must match GATEWAY_WG_ADMIN_PORT in .env if you overrode it (e.g.
|
|
||||||
# GATEWAY_MODE=single-parallel, where 51821 is already taken by another
|
|
||||||
# wg-easy on this host).
|
|
||||||
ADMIN_PORT="${GATEWAY_WG_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."
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# Keep the certResolver line below as-is — this stack always runs
|
|
||||||
# GATEWAY_MODE=single with HTTP-01, same as every app route (see
|
|
||||||
# traefik/static/traefik.single.yml).
|
|
||||||
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"
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Single-tenant mode: one operator's own domain, HTTP-01 challenge per
|
|
||||||
# subdomain. Fine at self-hosted scale — nowhere near Let's Encrypt's
|
|
||||||
# ~50-certs/registered-domain/week limit with a handful of published apps.
|
|
||||||
|
|
||||||
entryPoints:
|
|
||||||
web:
|
|
||||||
address: ":80"
|
|
||||||
http:
|
|
||||||
redirections:
|
|
||||||
entryPoint:
|
|
||||||
to: websecure
|
|
||||||
scheme: https
|
|
||||||
websecure:
|
|
||||||
address: ":443"
|
|
||||||
|
|
||||||
providers:
|
|
||||||
file:
|
|
||||||
directory: /etc/traefik/dynamic
|
|
||||||
watch: true
|
|
||||||
|
|
||||||
certificatesResolvers:
|
|
||||||
le:
|
|
||||||
acme:
|
|
||||||
email: admin@example.com # overridden per-deployment; see deploy.sh
|
|
||||||
storage: /acme/acme.json
|
|
||||||
httpChallenge:
|
|
||||||
entryPoint: web
|
|
||||||
|
|
||||||
api:
|
|
||||||
dashboard: false
|
|
||||||
insecure: false
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: INFO
|
|
||||||
Loading…
Add table
Reference in a new issue