#!/usr/bin/env bash # Sets up furtka-gateway's control-plane, plugged into a Traefik and # wg-easy YOU already run. Read the README's "Existing Traefik + wg-easy" # section first — in particular the routing requirement, which this script # can't set up or verify for you. On an empty server, use # examples/starter-stack/ instead. # # Safe to re-run — existing .env values become the defaults for each # prompt (press Enter to keep them). # # Usage: # ops/deploy.sh prompts, writes .env, starts the # control-plane, installs its Traefik # route, health-checks it # ops/deploy.sh --non-interactive same, but no prompts: every value # comes from .env (fails if one is missing) # ops/deploy.sh --render-only only (re)write .env and # rendered/control-plane.yml — touches # nothing live (not even your Traefik's # dynamic directory) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" 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 # ---- 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 [[ "$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}" } ask_secret() { local __var="$1" __prompt="$2" local __current="${!__var:-}" local __ans="" if [[ "$INTERACTIVE" -eq 1 ]]; then if [[ -n "$__current" ]]; then read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans else read -r -s -p "$__prompt: " __ans fi echo fi printf -v "$__var" '%s' "${__ans:-$__current}" } require() { # require VARNAME — fail if empty if [[ -z "${!1:-}" ]]; then echo "$1 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 fi echo "== furtka-gateway: connect to your existing Traefik + wg-easy ==" echo # ---- tenancy mode ---- ask GATEWAY_MODE "Mode (single / shared)" "single" case "$GATEWAY_MODE" in single|shared) ;; *) echo "GATEWAY_MODE must be single or shared" >&2; exit 1 ;; esac # ---- wg-easy ---- echo ask WG_EASY_URL "Your wg-easy's admin API URL, as the control-plane will reach it (e.g. http://wg-easy:51821)" "" require WG_EASY_URL ask WG_EASY_API_VERSION "Your wg-easy image's major version (14 or 15 — check the tag)" "15" case "$WG_EASY_API_VERSION" in 14|15) ;; *) echo "WG_EASY_API_VERSION must be 14 or 15 (different, incompatible APIs)" >&2; exit 1 ;; esac 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 (new clients get split-tunnel to this)" "10.8.0.0/24" fi ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password" require WG_EASY_ADMIN_PASSWORD # ---- Traefik ---- echo ask GATEWAY_DYNAMIC_DIR_HOST "Host directory your Traefik's file provider watches" "" require GATEWAY_DYNAMIC_DIR_HOST ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if a default cert applies automatically)" "" ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" "" require GATEWAY_CONTROL_PLANE_HOST echo echo "How does your Traefik reach the control-plane container?" echo " 1) it's on the same docker network as my Traefik (recommended)" echo " 2) separate host — I'll expose it at a reachable host:port myself" ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1" case "$GATEWAY_REACHABILITY_MODE" in 1) ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (see: docker inspect --format '{{json .NetworkSettings.Networks}}')" "" require GATEWAY_TRAEFIK_NETWORK GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090" GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090" ;; 2) 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." ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" "" require GATEWAY_CONTROL_PLANE_BIND ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as your Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND" GATEWAY_TRAEFIK_NETWORK="" ;; *) echo "GATEWAY_REACHABILITY_MODE must be 1 or 2" >&2; exit 1 ;; esac # ---- 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_REACHABILITY_MODE=$GATEWAY_REACHABILITY_MODE" 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" [[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE" echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN" echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN" } > "$ENV_FILE" chmod 600 "$ENV_FILE" # ---- join your Traefik's network from the compose file itself ---- # An override (auto-loaded by `docker compose`, gitignored) rather than a # `docker network connect` after startup: the control-plane talks to # wg-easy as soon as it starts, so the network must be there from the # first second, and it must survive container recreation. if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then echo "Writing docker-compose.override.yaml (joins network: $GATEWAY_TRAEFIK_NETWORK)" cat > docker-compose.override.yaml < rendered/control-plane.yml sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" rendered/control-plane.yml sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" rendered/control-plane.yml if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then # No resolver on your Traefik — drop the tls: block so its default cert # applies, mirroring what a blank GATEWAY_CERT_RESOLVER means for every # app route (see traefikconf.py). sed -i "/^ tls:$/,/^ certResolver:.*$/d" rendered/control-plane.yml else sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" rendered/control-plane.yml fi if [[ "$RENDER_ONLY" -eq 1 ]]; then echo echo "--render-only: nothing live was touched. Inspect .env and rendered/control-plane.yml." exit 0 fi # ---- bring the control-plane up (always built from this checkout) ---- echo echo "== docker compose up -d --build ==" docker compose up -d --build echo echo "== health check ==" healthy=0 for _ in $(seq 1 30); do if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/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" >&2 echo "(a wrong WG_EASY_URL or wg-easy password shows up there)" >&2 exit 1 fi echo " ok: control-plane is up" # ---- install the route into your Traefik's dynamic directory ---- mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST" cp rendered/control-plane.yml "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" echo " installed $GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml" 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" else echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable." echo " Check that DNS points at your Traefik and that Traefik watches" echo " $GATEWAY_DYNAMIC_DIR_HOST (providers.file.directory)." fi echo echo "== done ==" echo "Before publishing apps, make sure your Traefik can reach wg-easy's peer" echo "subnet — see the README's routing section. Quick check after a box connects:" echo " docker exec ping -c1 10.8.0." echo 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 $REPO_ROOT/.env"