diff --git a/.env.example b/.env.example index 93cfc95..10bda8c 100644 --- a/.env.example +++ b/.env.example @@ -58,12 +58,10 @@ GATEWAY_CERT_RESOLVER= GATEWAY_CONTROL_PLANE_HOST=gw.example.com # Where the control-plane's own API is bound (docker-compose.yaml's port -# publish) and where your Traefik reaches it, respectively. Same value -# for the recommended same-docker-network setup (ops/deploy.sh sets both -# to furtka-gateway-control-plane:8090 automatically, with the bind -# staying loopback-only — see docker-compose.yaml's comment on why this -# is more robust than the compose *service* name "control-plane" once the -# network is joined after the fact via `docker network connect`). +# publish) and where your Traefik reaches it, respectively. For the +# recommended same-docker-network setup, ops/deploy.sh keeps the bind +# loopback-only and sets the target to the fixed container name +# furtka-gateway-control-plane:8090. # Different values only for a separate-host setup, where # GATEWAY_CONTROL_PLANE_BIND must be widened beyond 127.0.0.1 — put real # access control around that bind address, this file carries a WireGuard @@ -74,8 +72,8 @@ GATEWAY_CONTROL_PLANE_TARGET=furtka-gateway-control-plane:8090 # Same-docker-network setup only (the recommended path — see the README's # Prerequisites checklist): your Traefik's docker network name # (`docker network ls` / `docker inspect `). -# ops/deploy.sh runs `docker network connect` with this after bringing the -# stack up. Leave blank for a separate-host setup. +# ops/deploy.sh writes a docker-compose.override.yaml that joins this +# network. Leave blank for a separate-host setup. GATEWAY_TRAEFIK_NETWORK= # The one box token a single-tenant deployment accepts at diff --git a/.gitignore b/.gitignore index f6217ca..95c99a1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ traefik/dynamic/route-*.yml traefik/dynamic/control-plane.yml traefik/dynamic/default-cert.yml traefik/static/traefik.active.yml +examples/starter-stack/dynamic/ +rendered/ +docker-compose.override.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml index 8af38aa..9f74b14 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,13 +1,12 @@ services: control-plane: build: ./control_plane - # CI (.forgejo/workflows/build-image.yml) publishes this same build to - # the registry on every push to master, tagged `latest` plus the short - # commit sha. `docker compose up` builds fresh from `build:` above and - # tags the result as this name, ignoring this being a registry ref; - # `docker stack deploy` (Swarm ignores `build:` entirely, see the - # Swarm caveats in README.md) pulls it instead — no manual - # `docker build -t ... ./control_plane` step needed there anymore. + # Always build from this checkout — never pull a registry image that may + # be older than the code you just pulled. The image name below only + # matters for `docker stack deploy` (Swarm can't build; see the Swarm + # caveats in README.md), which pulls what CI publishes on every push to + # master (`latest` plus the short commit sha). + pull_policy: build image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest} container_name: furtka-gateway-control-plane environment: @@ -58,14 +57,10 @@ services: # implication of doing that when you choose it. - "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090" restart: unless-stopped - # No custom `networks:` here on purpose — this stays on the default - # bridge network compose creates unless you also run YOUR Traefik in - # this SAME compose project. To reach a Traefik running elsewhere (its - # own compose project, or your own homelab stack), ops/deploy.sh joins - # this container to your Traefik's existing docker network after - # `up` via `docker network connect` — no external-network dependency - # baked into this file, so it works whether or not that network - # exists yet at compose-up time. + # No `networks:` here on purpose — your Traefik's network name differs + # per setup. ops/deploy.sh writes a gitignored + # docker-compose.override.yaml (auto-loaded by `docker compose`) that + # joins this container to it from the first second. volumes: control_plane_data: diff --git a/ops/deploy.sh b/ops/deploy.sh index 0412f6f..ef6eade 100755 --- a/ops/deploy.sh +++ b/ops/deploy.sh @@ -1,24 +1,23 @@ #!/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. +# 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), and rendering is always fully -# regenerated from the current .env, never patched in place. +# prompt (press Enter to keep them). # # 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. +# 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)" @@ -26,9 +25,14 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" RENDER_ONLY=0 -if [[ "${1:-}" == "--render-only" ]]; then - RENDER_ONLY=1 -fi +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 @@ -53,11 +57,13 @@ ask() { 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 + 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}" } @@ -65,105 +71,95 @@ ask() { 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 + 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 - echo 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 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 "== furtka-gateway: connect to your existing Traefik + wg-easy ==" 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 +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 (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 +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 (restricts new clients to split-tunnel)" "10.8.0.0/24" + 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" -if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then - echo "wg-easy admin password is required" >&2 - exit 1 -fi +require WG_EASY_ADMIN_PASSWORD # ---- 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" +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 this control-plane container?" -echo " 1) same docker network as my Traefik (recommended)" +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" -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 +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)}" @@ -183,74 +179,111 @@ echo "Writing $ENV_FILE" 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" -# ---- 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" +# ---- 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: stopping before compose up / network join / health check." + echo "--render-only: nothing live was touched. Inspect .env and rendered/control-plane.yml." exit 0 fi -# ---- bring the control-plane up ---- +# ---- bring the control-plane up (always built from this checkout) ---- echo -echo "== docker compose up -d ==" -docker compose up -d +echo "== docker compose up -d --build ==" +docker compose up -d --build -# ---- 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 +healthy=0 +for _ in $(seq 1 30); do if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then - echo "control-plane is up." + healthy=1 break fi - sleep 1 + sleep 2 done -if ! curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then +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 "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 " +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" diff --git a/traefik/dynamic/control-plane.yml.example b/traefik/dynamic/control-plane.yml.example index 7c1a2f5..f532667 100644 --- a/traefik/dynamic/control-plane.yml.example +++ b/traefik/dynamic/control-plane.yml.example @@ -1,23 +1,18 @@ -# Copy to traefik/dynamic/control-plane.yml (into whatever directory YOUR -# Traefik's file provider watches — see the root README's Prerequisites -# checklist) with two placeholders substituted: +# Template for the control-plane's own Traefik route. ops/deploy.sh renders +# it to rendered/control-plane.yml and installs it into the directory YOUR +# Traefik's file provider watches, with two placeholders substituted: # # GATEWAY_CONTROL_PLANE_HOST a real hostname you control (e.g. # gw.example.com), with its own A/AAAA record # pointing at this host. # GATEWAY_CONTROL_PLANE_TARGET where YOUR Traefik actually reaches the # control-plane container. If it joined your -# Traefik's docker network (ops/deploy.sh -# does this via `docker network connect` — -# the primary, recommended path), this is -# `furtka-gateway-control-plane:8090` — the -# container's fixed name, which Docker's -# embedded DNS resolves on any network it's -# joined to (unlike the compose *service* -# name `control-plane`, which is only -# guaranteed to resolve on networks compose -# itself manages — not one joined after the -# fact via a bare `docker network connect`). +# Traefik's docker network (the recommended +# path — ops/deploy.sh sets that up), this is +# `furtka-gateway-control-plane:8090`, the +# container's fixed name (unambiguous on a +# shared network, unlike a generic service +# name like `control-plane`). # If instead you exposed it on a separate # host (GATEWAY_CONTROL_PLANE_BIND widened # beyond 127.0.0.1 in docker-compose.yaml),