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