furtka-gateway/examples/starter-stack/deploy.sh
Robert Syrnicki 53ff30f273 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>
2026-10-07 15:49:49 +02:00

186 lines
5.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# 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 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
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
# Deliberately not `source`d: bash would expand `$` in values.
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"
ask() {
# ask VARNAME "Prompt text" "fallback-default"
local __var="$1" __prompt="$2" __fallback="${3:-}"
local __current="${!__var:-}"
local __default="${__current:-$__fallback}"
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}"
if [[ -z "${!__var}" ]]; then
echo "$__var is required" >&2
exit 1
fi
}
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 curl
docker compose version >/dev/null 2>&1 || { echo "missing: docker compose plugin" >&2; exit 1; }
fi
echo "== furtka-gateway: new server setup =="
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" ""
# 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)}"
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_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 "Writing dynamic/control-plane.yml"
mkdir -p dynamic
cat > dynamic/control-plane.yml <<EOF
# Generated by deploy.sh — Traefik route for the gateway's own API.
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"
EOF
if [[ "$RENDER_ONLY" -eq 1 ]]; then
echo
echo "--render-only: nothing started."
exit 0
fi
# ---- DNS check (warn only — records may still be propagating) ----
echo
echo "== DNS =="
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 "== docker compose up -d --build =="
docker compose up -d --build
echo
echo "== health check =="
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 2
done
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 "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 <this server>,"
echo "then open http://localhost:51821 — user 'admin', password WG_EASY_ADMIN_PASSWORD in .env."