furtka-gateway/examples/starter-stack/deploy.sh
Robert Syrnicki e240f071d5
Some checks failed
Build control-plane image / build-image (push) Has been cancelled
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.

- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
  against a real running container (auth model, route shapes, and the new
  per-client PresharedKey all differ materially from v14's API). Threaded
  the preshared key through to the box side (furtka/wireguard.py in the
  furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
  derived hardcoded "le", so it can mirror whatever resolver name (if any)
  the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
  asks how the operator's Traefik reaches it (same docker network, joined
  via `docker network connect`, or a separate host) instead of prompting
  for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
  furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
  examples/starter-stack/ as the documented zero-infra on-ramp.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00

169 lines
5.8 KiB
Bash
Executable file

#!/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.
#
# 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.
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
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.
load_env_file() {
local file="$1" line key value
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" == \#* ]] && continue
key="${line%%=*}"
value="${line#*=}"
printf -v "$key" '%s' "$value"
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() {
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
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
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; }
}
if [[ "$RENDER_ONLY" -eq 0 ]]; then
require_cmd docker
require_cmd openssl
require_cmd sudo
fi
echo "== furtka-gateway starter-stack deploy =="
echo
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//\$/\$\$}"
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
echo
echo "Writing $ENV_FILE"
{
echo "# Generated by deploy.sh — re-run it to change any of these."
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"
} > "$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
if [[ "$RENDER_ONLY" -eq 1 ]]; then
echo
echo "--render-only: stopping before sysctls/compose/firewall/health-check."
exit 0
fi
echo
echo "== sudo ops/host-sysctls.sh =="
sudo "$SCRIPT_DIR/ops/host-sysctls.sh"
echo
echo "== docker compose up -d =="
docker compose up -d
echo
echo "== sudo ops/firewall.sh =="
sudo "$SCRIPT_DIR/ops/firewall.sh"
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."
break
fi
sleep 1
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
exit 1
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 <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"