#!/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 "