From 53ff30f27354dde731fb66d064b375e03cc2e595 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Wed, 7 Oct 2026 15:49:49 +0200 Subject: [PATCH] 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 --- examples/starter-stack/deploy.sh | 197 ++++++++++-------- examples/starter-stack/docker-compose.yaml | 180 ++++++++-------- examples/starter-stack/ops/firewall.sh | 60 ------ examples/starter-stack/ops/host-sysctls.sh | 34 --- .../traefik/dynamic/control-plane.yml.example | 26 --- .../traefik/static/traefik.single.yml | 34 --- 6 files changed, 195 insertions(+), 336 deletions(-) delete mode 100755 examples/starter-stack/ops/firewall.sh delete mode 100755 examples/starter-stack/ops/host-sysctls.sh delete mode 100644 examples/starter-stack/traefik/dynamic/control-plane.yml.example delete mode 100644 examples/starter-stack/traefik/static/traefik.single.yml diff --git a/examples/starter-stack/deploy.sh b/examples/starter-stack/deploy.sh index cc428e5..50e3811 100755 --- a/examples/starter-stack/deploy.sh +++ b/examples/starter-stack/deploy.sh @@ -1,33 +1,36 @@ #!/usr/bin/env bash -# Interactive setup for the zero-infra starter stack: bundles its own -# Traefik + wg-easy + control-plane. If you already run Traefik and/or -# wg-easy, use the main repo's root docker-compose.yaml + ops/deploy.sh -# instead — it plugs into what you already have. +# Sets up furtka-gateway on an empty server: wg-easy + Traefik + +# control-plane, all from this folder's docker-compose.yaml. See README.md +# here for the server and DNS requirements. +# +# Safe to re-run — existing .env values become the defaults for each +# prompt (press Enter to keep them), and secrets are generated once. # # Usage: -# ./deploy.sh full setup: prompts, render, sysctls, -# compose up, firewall, health check -# ./deploy.sh --render-only only (re)write .env and the rendered -# Traefik files — skips sysctls/compose/ -# firewall/health-check. +# ./deploy.sh prompts, writes .env + dynamic/, starts +# the stack, health-checks it +# ./deploy.sh --non-interactive same, but no prompts: every value comes +# from .env (fails if one is missing) +# ./deploy.sh --render-only only (re)write .env and +# dynamic/control-plane.yml; starts nothing set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" RENDER_ONLY=0 -if [[ "${1:-}" == "--render-only" ]]; then - RENDER_ONLY=1 -fi +INTERACTIVE=1 +for arg in "$@"; do + case "$arg" in + --render-only) RENDER_ONLY=1 ;; + --non-interactive) INTERACTIVE=0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done ENV_FILE=.env -# ---- load existing .env (if any) so prompts default to current 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. +# Deliberately not `source`d: bash would expand `$` in values. load_env_file() { local file="$1" line key value [[ -f "$file" ]] || return 0 @@ -39,68 +42,47 @@ load_env_file() { done < "$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 VARNAME "Prompt text" "fallback-default" local __var="$1" __prompt="$2" __fallback="${3:-}" local __current="${!__var:-}" local __default="${__current:-$__fallback}" - local __ans - if [[ -n "$__default" ]]; then - read -r -p "$__prompt [$__default]: " __ans - else - read -r -p "$__prompt: " __ans + local __ans="" + if [[ "$INTERACTIVE" -eq 1 ]]; then + if [[ -n "$__default" ]]; then + read -r -p "$__prompt [$__default]: " __ans + else + read -r -p "$__prompt: " __ans + fi fi printf -v "$__var" '%s' "${__ans:-$__default}" -} - -ask_secret() { - 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 + if [[ -z "${!__var}" ]]; then + echo "$__var is required" >&2 + exit 1 fi - echo - printf -v "$__var" '%s' "${__ans:-$__current}" } require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; } } +require_cmd openssl if [[ "$RENDER_ONLY" -eq 0 ]]; then require_cmd docker - require_cmd openssl - require_cmd sudo + require_cmd curl + docker compose version >/dev/null 2>&1 || { echo "missing: docker compose plugin" >&2; exit 1; } fi -echo "== furtka-gateway starter-stack deploy ==" +echo "== furtka-gateway: new server setup ==" echo +ask GATEWAY_DOMAIN "Your domain (boxes publish apps as .)" "" +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" -ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com" -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//\$/\$\$}" - +# Secrets: generated once, kept on re-run. +WG_EASY_ADMIN_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-$(openssl rand -hex 16)}" GATEWAY_BOX_TOKEN="${GATEWAY_BOX_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 "# 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_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST" 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 "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN" echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN" + [[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE" } > "$ENV_FILE" chmod 600 "$ENV_FILE" -echo "Rendering traefik/static/traefik.active.yml" -cp traefik/static/traefik.single.yml traefik/static/traefik.active.yml -sed -i "s|admin@example.com|$GATEWAY_ACME_EMAIL|" traefik/static/traefik.active.yml - -echo "Rendering traefik/dynamic/control-plane.yml" -# Only the http: block onward — the .example's header comment mentions the -# placeholder by name too (for a human reading that template), which would -# otherwise also get corrupted by this same substitution. -sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > traefik/dynamic/control-plane.yml -sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" traefik/dynamic/control-plane.yml +echo "Writing dynamic/control-plane.yml" +mkdir -p dynamic +cat > dynamic/control-plane.yml </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 "== docker compose up -d ==" -docker compose up -d - -echo -echo "== sudo ops/firewall.sh ==" -sudo "$SCRIPT_DIR/ops/firewall.sh" +echo "== docker compose up -d --build ==" +docker compose up -d --build echo echo "== health check ==" -for _ in $(seq 1 10); do - if curl -sf 127.0.0.1:8090/healthz >/dev/null; then - echo "control-plane is up." +healthy=0 +for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:8090/healthz >/dev/null 2>&1; then + healthy=1 break fi - sleep 1 + sleep 2 done -if ! curl -sf 127.0.0.1:8090/healthz >/dev/null; then - echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2 +if [[ "$healthy" -ne 1 ]]; then + echo "control-plane did not become healthy — check: docker compose logs control-plane wg-easy" >&2 exit 1 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 "== done ==" -echo "Still needed before a real box can connect:" -echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST and $GATEWAY_PUBLIC_HOST -> this VPS's public IP" -echo " - Open udp/51820, tcp/80, tcp/443 in your firewall/security group" -echo " - On the box: furtka gateway connect " +echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter" +echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST" +echo " Registration token: the GATEWAY_BOX_TOKEN value in $SCRIPT_DIR/.env" +echo +echo "wg-easy admin UI (optional): ssh -L 51821:127.0.0.1:51821 ," +echo "then open http://localhost:51821 — user 'admin', password WG_EASY_ADMIN_PASSWORD in .env." diff --git a/examples/starter-stack/docker-compose.yaml b/examples/starter-stack/docker-compose.yaml index 8530269..72d530c 100644 --- a/examples/starter-stack/docker-compose.yaml +++ b/examples/starter-stack/docker-compose.yaml @@ -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: + 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 + - "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: - image: ghcr.io/wg-easy/wg-easy:14 + image: ghcr.io/wg-easy/wg-easy:15 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. + # Unattended first-run setup (only applied while wg-easy's database + # is still empty — change the password later in its UI, then update + # WG_EASY_ADMIN_PASSWORD in .env to match). + - INIT_ENABLED=true + - INIT_USERNAME=admin + - INIT_PASSWORD=${WG_EASY_ADMIN_PASSWORD} + - INIT_HOST=${GATEWAY_PUBLIC_HOST} + - INIT_PORT=51820 + # Split tunnel: boxes only route the gateway's peer subnet through + # the tunnel, never their whole internet traffic. + - INIT_ALLOWED_IPS=10.8.0.0/24 - 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: - wg_easy_data:/etc/wireguard + - /lib/modules:/lib/modules:ro + network_mode: service:gateway-net cap_add: - NET_ADMIN - SYS_MODULE + depends_on: + gateway-net: + condition: service_started + restart: true 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 + image: traefik:v3.7 container_name: furtka-gateway-traefik + network_mode: service:gateway-net 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: - # Rendered by deploy.sh from traefik/static/traefik.single.yml with - # the ACME email substituted in — see that script. Gitignored, not - # the tracked template. - - ./traefik/static/traefik.active.yml:/etc/traefik/traefik.yml:ro - - ./traefik/dynamic:/etc/traefik/dynamic:ro + # control-plane.yml (written by deploy.sh) + one route-*.yml per + # published app (written by the control-plane). + - ./dynamic:/dynamic:ro - traefik_acme:/acme - ports: - - "80:80" - - "443:443" + depends_on: + gateway-net: + condition: service_started + restart: true 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: 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) pulls it - # instead — no manual `docker build -t ... ./control_plane` step - # needed there anymore. + # Always build from this checkout — never run a registry image that may + # be older than the code you just pulled. (The image name only matters + # for Docker Swarm, which can't build; see README.md.) + pull_policy: build 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_MODE=single - GATEWAY_STATE_DIR=/data - GATEWAY_DYNAMIC_DIR=/dynamic - GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN} - GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN} - GATEWAY_CERT_RESOLVER=le - # 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 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_URL=http://gateway-net:51821 + - WG_EASY_API_VERSION=15 + - WG_EASY_ADMIN_USERNAME=admin - WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD} - extra_hosts: - - "host.docker.internal:host-gateway" + - GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24 volumes: - control_plane_data:/data - - ./traefik/dynamic:/dynamic + - ./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. + # Loopback-only, for health checks from the server itself. Boxes + # reach the API through Traefik over HTTPS (dynamic/control-plane.yml). - "127.0.0.1:8090:8090" - restart: unless-stopped networks: - internal depends_on: - - traefik + # The control-plane configures wg-easy on startup. + wg-easy: + condition: service_healthy + restart: unless-stopped 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: diff --git a/examples/starter-stack/ops/firewall.sh b/examples/starter-stack/ops/firewall.sh deleted file mode 100755 index c65771b..0000000 --- a/examples/starter-stack/ops/firewall.sh +++ /dev/null @@ -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." diff --git a/examples/starter-stack/ops/host-sysctls.sh b/examples/starter-stack/ops/host-sysctls.sh deleted file mode 100755 index 05828ea..0000000 --- a/examples/starter-stack/ops/host-sysctls.sh +++ /dev/null @@ -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 diff --git a/examples/starter-stack/traefik/dynamic/control-plane.yml.example b/examples/starter-stack/traefik/dynamic/control-plane.yml.example deleted file mode 100644 index 181e830..0000000 --- a/examples/starter-stack/traefik/dynamic/control-plane.yml.example +++ /dev/null @@ -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" diff --git a/examples/starter-stack/traefik/static/traefik.single.yml b/examples/starter-stack/traefik/static/traefik.single.yml deleted file mode 100644 index 718e7c6..0000000 --- a/examples/starter-stack/traefik/static/traefik.single.yml +++ /dev/null @@ -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