Some checks failed
Build control-plane image / build-image (push) Has been cancelled
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
169 lines
5.8 KiB
Bash
Executable file
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>"
|