deploy-paths #1

Merged
robert merged 3 commits from deploy-paths into master 2026-10-07 16:35:48 +02:00
5 changed files with 196 additions and 172 deletions
Showing only changes of commit 6b8cc6e006 - Show all commits

View file

@ -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 <your-traefik-container>`).
# 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

3
.gitignore vendored
View file

@ -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

View file

@ -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:

View file

@ -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,117 +57,109 @@ ask() {
local __var="$1" __prompt="$2" __fallback="${3:-}"
local __current="${!__var:-}"
local __default="${__current:-$__fallback}"
local __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}"
}
ask_secret() {
local __var="$1" __prompt="$2"
local __current="${!__var:-}"
local __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
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" ;;
single|shared) ;;
*) echo "GATEWAY_MODE must be single or shared" >&2; exit 1 ;;
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"
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) break ;;
*) echo "must be 14 or 15 — these are different, incompatible APIs (see control_plane/wgeasy.py vs wgeasy_v15.py)" ;;
14|15) ;;
*) echo "WG_EASY_API_VERSION must be 14 or 15 (different, incompatible APIs)" >&2; exit 1 ;;
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"
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 <traefik-container>)" ""
if [[ -z "$GATEWAY_TRAEFIK_NETWORK" ]]; then
echo "a network name is required for this choice" >&2
exit 1
fi
1)
ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (see: docker inspect <traefik> --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"
else
;;
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, not just this compose file."
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)" ""
ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as YOUR Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND"
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=""
fi
;;
*) 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 <<EOF
# Generated by ops/deploy.sh — joins your Traefik's docker network.
services:
control-plane:
networks:
- default
- traefik
networks:
traefik:
name: $GATEWAY_TRAEFIK_NETWORK
external: true
EOF
else
sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
rm -f docker-compose.override.yaml
fi
# ---- render the control-plane's Traefik route (not yet installed) ----
echo "Writing rendered/control-plane.yml"
mkdir -p rendered
# Only the http: block onward — the .example's header comment explains the
# placeholders BY NAME, which these substitutions would otherwise corrupt.
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > 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 <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
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 <your-traefik> ping -c1 10.8.0.<box>"
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"

View file

@ -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),