.env was written only on the first run. All four values reach Caddy solely
through compose interpolating .env into the container environment -- the
Caddyfile is installed verbatim every run and carries Caddy's own
{$ISSUER_URL} / {$BASE_DOMAIN} / {$REDIRECT_URL} / {$ACME_EMAIL} placeholders,
resolved at config load. So .env is the only thing that decides what is served.
Both paths were wrong. Interactively, the prompts are required and have no
default, so a re-run made the operator retype all four -- and then discarded
every one, because a value read into a nameref is not exported and .env was
not rewritten. Via automations.sh the values ARE exported, so compose
preferred them for that run only and the documented later
`docker compose up -d` reverted to stale .env.
Either way the run exits 0 and the DEPLOYED banner prints this shell's
values, not what was deployed. Moving the IdP from auth.example.com to
id.example.com leaves the endpoint serving "href": the old issuer, pointing
every discovering client at a decommissioned issuer, with the deploy that was
meant to fix it reporting success.
Same shape as d654299: record which keys arrived in the environment before the
":=" defaults, pre-load .env into keys not passed so the prompts stop
demanding a full retype (and SKIP_PROMPTS=1 no longer dies over values .env
already has), then write only the passed keys through with set_env.
Verified: a corrected ISSUER_URL containing a & query string lands in .env
intact (the old escaping would have corrupted it); a re-run passing nothing
leaves .env byte-identical and does not die.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
352 lines
15 KiB
Bash
352 lines
15 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# deploy.sh -- deploy the webfinger stack (a single Caddy container) on
|
|
# Alpine. Serves /.well-known/webfinger at $BASE_DOMAIN for OIDC discovery
|
|
# and redirects everything else to $REDIRECT_URL.
|
|
#
|
|
# Single-node: runs as root.
|
|
#
|
|
# What this does:
|
|
# 1. Installs docker + docker-cli-compose if missing.
|
|
# 2. Lays down docker-compose.yml, Caddyfile, .env.example in $STACK_DIR.
|
|
# 3. Generates .env on first run. On a re-run an existing .env is kept,
|
|
# except for values passed explicitly that run, which are written
|
|
# through -- .env is what compose interpolates into the container.
|
|
# 4. Prompts for required values not preset (BASE_DOMAIN, ISSUER_URL,
|
|
# REDIRECT_URL, ACME_EMAIL).
|
|
# 5. Opens TCP 80/443 in UFW if active.
|
|
# 6. Pulls images, brings the stack up, waits for healthchecks.
|
|
#
|
|
# Idempotent: re-run to apply config changes / pull new images.
|
|
#
|
|
# Self-contained: docker-compose.yml, Caddyfile, .env.example are embedded
|
|
# as a base64-encoded tar.gz at the bottom of this file. Rebuild with
|
|
# build.sh after editing the loose source files.
|
|
#
|
|
# Usage:
|
|
# bash deploy.sh # interactive prompts
|
|
# BASE_DOMAIN=example.com ISSUER_URL=https://auth.example.com \
|
|
# REDIRECT_URL=https://example.org ACME_EMAIL=me@example.com \
|
|
# FORCE=1 bash deploy.sh
|
|
# STACK_DIR=/opt/webfinger bash deploy.sh
|
|
# SKIP_DOCKER_INSTALL=1 bash deploy.sh
|
|
|
|
set -euo pipefail
|
|
|
|
: "${STACK_DIR:=/srv/webfinger}"
|
|
: "${SKIP_DOCKER_INSTALL:=0}"
|
|
: "${FORCE:=0}"
|
|
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
|
|
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1
|
|
ENV_FILE="$STACK_DIR/.env"
|
|
|
|
# All four values reach Caddy only by compose interpolating .env, so .env has to absorb
|
|
# what this run passed or the run deploys the old values while reporting the
|
|
# new ones. Which keys arrived in the ENVIRONMENT must be recorded BEFORE the
|
|
# ":=" defaults below, which make an unset variable look like an empty one.
|
|
RUNTIME_KEYS=(BASE_DOMAIN ISSUER_URL REDIRECT_URL ACME_EMAIL)
|
|
KEYS_FROM_ENV=()
|
|
for _k in "${RUNTIME_KEYS[@]}"; do
|
|
if [[ -n "${!_k+x}" ]]; then KEYS_FROM_ENV+=("$_k"); fi
|
|
done
|
|
|
|
: "${BASE_DOMAIN:=}"
|
|
: "${ISSUER_URL:=}"
|
|
: "${REDIRECT_URL:=}"
|
|
: "${ACME_EMAIL:=}"
|
|
|
|
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ $EUID -eq 0 ]] || die "Run as root."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is
|
|
# self-contained (scp'd standalone), so the OS logic is inlined here instead
|
|
# of sourced from scripts/oslib.sh.
|
|
# ---------------------------------------------------------------------------
|
|
osfam() {
|
|
local id="" like=""
|
|
if [[ -r /etc/os-release ]]; then
|
|
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
|
|
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
|
|
fi
|
|
case " $id $like " in
|
|
*" alpine "*) echo alpine ;;
|
|
*" debian "*|*" ubuntu "*) echo debian ;;
|
|
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
|
|
*) echo "${id:-unknown}" ;;
|
|
esac
|
|
}
|
|
|
|
install_docker() {
|
|
if command -v docker >/dev/null 2>&1; then
|
|
log "Docker already installed: $(docker --version)"
|
|
else
|
|
log "Installing Docker (OS: $(osfam))..."
|
|
case "$(osfam)" in
|
|
alpine) apk add -q docker docker-cli-compose openrc ;;
|
|
debian|rhel) command -v curl >/dev/null 2>&1 || \
|
|
{ command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \
|
|
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; }
|
|
curl -fsSL https://get.docker.com | sh ;;
|
|
*) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;;
|
|
esac
|
|
fi
|
|
if command -v rc-update >/dev/null 2>&1; then
|
|
rc-update add docker default >/dev/null 2>&1 || true
|
|
rc-service docker status >/dev/null 2>&1 || rc-service docker start
|
|
elif command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true
|
|
fi
|
|
}
|
|
|
|
open_web_ports() {
|
|
# Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh)
|
|
# when present: drop in a rule file and re-apply. Else fall back to ufw/
|
|
# firewalld if active (no-op when neither is).
|
|
#
|
|
# NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the
|
|
# nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless
|
|
# belt-and-braces for any host-bound bind and self-documents the stack ports.
|
|
if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then
|
|
log "Registering 80,443/tcp with host firewall..."
|
|
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/webfinger.rule
|
|
/usr/local/sbin/firewall-apply
|
|
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
|
|
log "ufw active -- allowing 80,443/tcp..."
|
|
ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null
|
|
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
log "firewalld active -- allowing http,https..."
|
|
firewall-cmd -q --add-service=http --permanent
|
|
firewall-cmd -q --add-service=https --permanent
|
|
firewall-cmd -q --reload
|
|
fi
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Extract embedded archive
|
|
# ----------------------------------------------------------------------------
|
|
SCRIPT_DIR=$(mktemp -d -t webfinger-deploy.XXXXXX)
|
|
trap 'rm -rf "$SCRIPT_DIR"' EXIT
|
|
|
|
extract_archive() {
|
|
grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \
|
|
| tail -n +2 \
|
|
| base64 -d \
|
|
| tar -xz -C "$SCRIPT_DIR"
|
|
}
|
|
|
|
if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then
|
|
log "Extracting embedded deployment files..."
|
|
extract_archive
|
|
else
|
|
die "No embedded archive found. Run build.sh to embed deployment files."
|
|
fi
|
|
|
|
for f in docker-compose.yml Caddyfile .env.example; do
|
|
[[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f"
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Prompt for required vars
|
|
# ----------------------------------------------------------------------------
|
|
prompt() {
|
|
local varname="$1" message="$2"
|
|
local -n ref="$varname"
|
|
if [[ -z "${ref:-}" ]]; then
|
|
[[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)."
|
|
read -r -p "$message: " ref
|
|
[[ -n "$ref" ]] || die "$varname required."
|
|
fi
|
|
}
|
|
|
|
# On a re-run the deployed values live in .env. Load them into any key NOT
|
|
# passed this run, so the prompts below do not force the operator to retype
|
|
# everything (and so SKIP_PROMPTS=1 does not die demanding values .env has).
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
_preloaded=()
|
|
for _k in "${RUNTIME_KEYS[@]}"; do
|
|
if [[ " ${KEYS_FROM_ENV[*]-} " == *" ${_k} "* ]]; then continue; fi
|
|
_v=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1)
|
|
if [[ -z "$_v" ]]; then continue; fi
|
|
printf -v "$_k" '%s' "$_v"
|
|
_preloaded+=("$_k")
|
|
done
|
|
if (( ${#_preloaded[@]} > 0 )); then
|
|
log "Reusing ${ENV_FILE} values for: ${_preloaded[*]}"
|
|
log " (pass VAR=... to change one; it is then written back to .env)"
|
|
fi
|
|
fi
|
|
|
|
prompt BASE_DOMAIN "Apex domain to serve from (e.g. example.com)"
|
|
prompt ISSUER_URL "OIDC issuer URL (e.g. https://auth.example.com)"
|
|
prompt REDIRECT_URL "Where to redirect non-webfinger traffic (e.g. https://example.org)"
|
|
prompt ACME_EMAIL "Let's Encrypt email"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Docker
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then
|
|
install_docker
|
|
fi
|
|
|
|
# Open 80/443 on whichever host firewall is active.
|
|
open_web_ports
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stack directory + files
|
|
# ----------------------------------------------------------------------------
|
|
log "Setting up $STACK_DIR..."
|
|
install -d -m 0750 "$STACK_DIR"
|
|
install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
|
|
install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile"
|
|
|
|
set_env() { # <KEY> <value>: update KEY in .env, or append if absent
|
|
# The value goes through the ENVIRONMENT, never interpolated into a sed
|
|
# script. Interpolating it corrupts any value containing & (sed expands it to
|
|
# the whole match) and aborts the run on one containing the s||| delimiter --
|
|
# which is reachable for an OIDC issuer URL or a redirect query string.
|
|
local key="$1" val="$2" tmp
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
printf '%s=%s
|
|
' "$key" "$val" >> "$ENV_FILE"
|
|
return 0
|
|
fi
|
|
tmp="$(mktemp)"
|
|
_SE_KEY="$key" _SE_VAL="$val" awk '
|
|
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
|
|
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
|
|
{ print }
|
|
END { if (!seen) print k "=" v }
|
|
' "$ENV_FILE" > "$tmp"
|
|
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
ENV_FILE="$STACK_DIR/.env"
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
log "Seeding $ENV_FILE..."
|
|
install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE"
|
|
sed -i \
|
|
-e "s|^BASE_DOMAIN=.*|BASE_DOMAIN=${BASE_DOMAIN}|" \
|
|
-e "s|^ISSUER_URL=.*|ISSUER_URL=${ISSUER_URL}|" \
|
|
-e "s|^REDIRECT_URL=.*|REDIRECT_URL=${REDIRECT_URL}|" \
|
|
-e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \
|
|
"$ENV_FILE"
|
|
else
|
|
# Keep the file, but absorb what was passed this run: compose reads .env,
|
|
# so letting it drift means deploying the old values while the banner
|
|
# prints the new ones. Keys not passed are left untouched.
|
|
log ".env exists; keeping it (only values passed this run are updated)."
|
|
for _k in ${KEYS_FROM_ENV[@]+"${KEYS_FROM_ENV[@]}"}; do
|
|
_cur=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1)
|
|
if [[ "$_cur" == "${!_k}" ]]; then continue; fi
|
|
set_env "$_k" "${!_k}"
|
|
log " ${_k}: ${_cur:-<unset>} -> ${!_k:-<empty>}"
|
|
done
|
|
fi
|
|
|
|
# Validate required values are present.
|
|
missing=()
|
|
for var in BASE_DOMAIN ISSUER_URL REDIRECT_URL ACME_EMAIL; do
|
|
grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var")
|
|
done
|
|
(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}"
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Bring up the stack
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$FORCE" != "1" ]]; then
|
|
cat <<EOF
|
|
|
|
About to pull images and start the stack from $STACK_DIR.
|
|
|
|
Caddy will request a Let's Encrypt cert for ${BASE_DOMAIN}. DNS for that
|
|
name must already point at this host, and ports 80/443 must be reachable
|
|
from the internet, or the cert request will fail.
|
|
|
|
Continue? [y/N]
|
|
EOF
|
|
read -r ans
|
|
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; }
|
|
fi
|
|
|
|
cd "$STACK_DIR"
|
|
log "Pulling images..."
|
|
docker compose pull
|
|
|
|
log "Starting stack..."
|
|
docker compose up -d --remove-orphans
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Wait for health
|
|
# ----------------------------------------------------------------------------
|
|
log "Waiting for services to become healthy (up to 120s)..."
|
|
deadline=$(( $(date +%s) + 120 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
status=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true)
|
|
unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}')
|
|
if [[ -z "$unhealthy" && -n "$status" ]]; then
|
|
log "All services healthy."
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
echo
|
|
log "Stack status:"
|
|
docker compose ps
|
|
echo
|
|
cat <<EOF
|
|
================================================================
|
|
DEPLOYED
|
|
|
|
WebFinger: https://${BASE_DOMAIN}/.well-known/webfinger
|
|
Redirects: https://${BASE_DOMAIN}/ -> ${REDIRECT_URL}
|
|
Stack dir: $STACK_DIR
|
|
|
|
Manage (run from $STACK_DIR):
|
|
docker compose logs -f
|
|
docker compose pull && docker compose up -d # update
|
|
docker compose down # stop, keep volumes
|
|
|
|
Or just re-run this script -- it's idempotent.
|
|
================================================================
|
|
EOF
|
|
|
|
# IMPORTANT: do not put any code below this exit. Everything after the
|
|
# __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64).
|
|
exit 0
|
|
__ARCHIVE_BELOW__
|
|
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
|