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>
186 lines
5.9 KiB
Bash
Executable file
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."
|