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
256 lines
9.9 KiB
Bash
Executable file
256 lines
9.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Interactive setup for furtka-gateway's control-plane, plugged into a
|
|
# Traefik and wg-easy YOU already run (as sibling containers, or otherwise
|
|
# reachable) — see the root README's Prerequisites checklist before
|
|
# running this. No zero-infra option here: for that, use
|
|
# examples/starter-stack/deploy.sh instead, which bundles its own
|
|
# Traefik+wg-easy.
|
|
#
|
|
# Safe to re-run — existing .env values become the defaults for each
|
|
# prompt (press Enter to keep them), and rendering is always fully
|
|
# regenerated from the current .env, never patched in place.
|
|
#
|
|
# Usage:
|
|
# ops/deploy.sh full setup: prompts, render, compose up,
|
|
# network join, health check
|
|
# ops/deploy.sh --render-only only (re)write .env and the rendered
|
|
# traefik/dynamic/control-plane.yml —
|
|
# skips compose up / network join /
|
|
# health check. Useful after hand-editing
|
|
# .env, or to inspect what would be
|
|
# generated before touching anything live.
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
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 `$VAR`/`$$` during sourcing,
|
|
# which is wrong for values meant to be taken literally. Plain string
|
|
# splitting instead.
|
|
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 [[ -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 curl
|
|
fi
|
|
|
|
echo "== furtka-gateway deploy (bring your own Traefik/wg-easy) =="
|
|
echo "Haven't read the README's Prerequisites checklist yet? Do that first —"
|
|
echo "this script can't verify your Traefik has a real network path to"
|
|
echo "wg-easy's WireGuard peer subnet, and a broken deploy from skipping"
|
|
echo "that is hard to debug after the fact."
|
|
echo
|
|
|
|
# ---- tenancy mode ----
|
|
while true; do
|
|
ask GATEWAY_MODE "Mode (single / shared)" "single"
|
|
case "$GATEWAY_MODE" in
|
|
single|shared) break ;;
|
|
*) echo "must be one of: single, shared" ;;
|
|
esac
|
|
done
|
|
|
|
# ---- wg-easy ----
|
|
echo
|
|
ask WG_EASY_URL "Your wg-easy's admin API URL (e.g. http://wg-easy:51821)" ""
|
|
if [[ -z "$WG_EASY_URL" ]]; then
|
|
echo "WG_EASY_URL is required" >&2
|
|
exit 1
|
|
fi
|
|
while true; do
|
|
ask WG_EASY_API_VERSION "Your wg-easy image's version (14 or 15 — check the tag)" "14"
|
|
case "$WG_EASY_API_VERSION" in
|
|
14|15) break ;;
|
|
*) echo "must be 14 or 15 — these are different, incompatible APIs (see control_plane/wgeasy.py vs wgeasy_v15.py)" ;;
|
|
esac
|
|
done
|
|
if [[ "$WG_EASY_API_VERSION" == "15" ]]; then
|
|
ask WG_EASY_ADMIN_USERNAME "wg-easy admin username" "admin"
|
|
ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (restricts new clients to split-tunnel)" "10.8.0.0/24"
|
|
fi
|
|
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
|
|
|
|
# ---- Traefik ----
|
|
echo
|
|
ask GATEWAY_DYNAMIC_DIR_HOST "Host directory YOUR Traefik's file provider watches (or a new one you'll add as an extra providers.file directory)" ""
|
|
if [[ -z "$GATEWAY_DYNAMIC_DIR_HOST" ]]; then
|
|
echo "GATEWAY_DYNAMIC_DIR_HOST is required" >&2
|
|
exit 1
|
|
fi
|
|
ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if it applies a default cert automatically)" ""
|
|
ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com"
|
|
|
|
echo
|
|
echo "How does YOUR Traefik reach this control-plane container?"
|
|
echo " 1) same docker network as my Traefik (recommended)"
|
|
echo " 2) separate host — I'll expose it at a reachable host:port myself"
|
|
while true; do
|
|
ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1"
|
|
case "$GATEWAY_REACHABILITY_MODE" in
|
|
1|2) break ;;
|
|
*) echo "must be 1 or 2" ;;
|
|
esac
|
|
done
|
|
if [[ "$GATEWAY_REACHABILITY_MODE" == "1" ]]; then
|
|
ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (docker network ls / docker inspect <traefik-container>)" ""
|
|
if [[ -z "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
|
echo "a network name is required for this choice" >&2
|
|
exit 1
|
|
fi
|
|
GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090"
|
|
GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090"
|
|
else
|
|
echo
|
|
echo "WARNING: this binds the control-plane API beyond loopback. Its"
|
|
echo "registration response carries a WireGuard private key in plaintext"
|
|
echo "JSON — put real access control (firewall/security group) around"
|
|
echo "whatever bind address you give it, not just this compose file."
|
|
ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" ""
|
|
ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as YOUR Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND"
|
|
GATEWAY_TRAEFIK_NETWORK=""
|
|
fi
|
|
|
|
# ---- tokens (generated once, kept on re-run) ----
|
|
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
|
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
|
|
|
# ---- write .env ----
|
|
echo
|
|
echo "Writing $ENV_FILE"
|
|
{
|
|
echo "# Generated by ops/deploy.sh — re-run it to change any of these."
|
|
echo "GATEWAY_MODE=$GATEWAY_MODE"
|
|
echo "WG_EASY_URL=$WG_EASY_URL"
|
|
echo "WG_EASY_API_VERSION=$WG_EASY_API_VERSION"
|
|
[[ "$WG_EASY_API_VERSION" == "15" ]] && echo "WG_EASY_ADMIN_USERNAME=$WG_EASY_ADMIN_USERNAME"
|
|
[[ "$WG_EASY_API_VERSION" == "15" ]] && echo "GATEWAY_WG_ALLOWED_IPS=$GATEWAY_WG_ALLOWED_IPS"
|
|
echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
|
echo "GATEWAY_DYNAMIC_DIR_HOST=$GATEWAY_DYNAMIC_DIR_HOST"
|
|
echo "GATEWAY_CERT_RESOLVER=$GATEWAY_CERT_RESOLVER"
|
|
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
|
echo "GATEWAY_CONTROL_PLANE_BIND=$GATEWAY_CONTROL_PLANE_BIND"
|
|
echo "GATEWAY_CONTROL_PLANE_TARGET=$GATEWAY_CONTROL_PLANE_TARGET"
|
|
[[ -n "$GATEWAY_TRAEFIK_NETWORK" ]] && echo "GATEWAY_TRAEFIK_NETWORK=$GATEWAY_TRAEFIK_NETWORK"
|
|
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
|
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
|
} > "$ENV_FILE"
|
|
chmod 600 "$ENV_FILE"
|
|
|
|
# ---- render traefik/dynamic/control-plane.yml ----
|
|
echo "Rendering traefik/dynamic/control-plane.yml"
|
|
mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST"
|
|
# Only the http: block onward — the .example's header comment explains the
|
|
# placeholders BY NAME (for a human reading that template), which would
|
|
# otherwise also get corrupted by these same substitutions.
|
|
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then
|
|
# No resolver configured on your Traefik — drop the tls: block entirely
|
|
# so its own default cert applies, mirroring what GATEWAY_CERT_RESOLVER
|
|
# blank means for every app route (see traefikconf.py).
|
|
sed -i "/^ tls:$/,/^ certResolver:.*$/d" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
else
|
|
sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
fi
|
|
|
|
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
|
echo
|
|
echo "--render-only: stopping before compose up / network join / health check."
|
|
exit 0
|
|
fi
|
|
|
|
# ---- bring the control-plane up ----
|
|
echo
|
|
echo "== docker compose up -d =="
|
|
docker compose up -d
|
|
|
|
# ---- join your Traefik's network, if that's the reachability choice ----
|
|
if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
|
echo
|
|
echo "== joining docker network: $GATEWAY_TRAEFIK_NETWORK =="
|
|
docker network connect "$GATEWAY_TRAEFIK_NETWORK" furtka-gateway-control-plane 2>/dev/null \
|
|
|| echo " (already connected, or connect failed — check \`docker network ls\` and the name above)"
|
|
fi
|
|
|
|
# ---- health check ----
|
|
echo
|
|
echo "== health check =="
|
|
for _ in $(seq 1 10); do
|
|
if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then
|
|
echo "control-plane is up."
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if ! curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; 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 " - Add $GATEWAY_DYNAMIC_DIR_HOST as a providers.file directory on"
|
|
echo " your Traefik, if it isn't already watching it."
|
|
echo " - Verify Traefik can actually reach wg-easy's WireGuard peer subnet"
|
|
echo " (the README's Prerequisites checklist has a command for this) —"
|
|
echo " this is the one thing that can't be checked from here."
|
|
echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST -> wherever your"
|
|
echo " Traefik is reachable."
|
|
echo " - On the box: furtka gateway connect <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
|