forked from heavy-duty/rig
Issue #25 named this consumer when it introduced /etc/rig/role: 'rig <cmd> sanity warnings later (e.g. coolify install on a non-control-plane box)'. Both coolify verbs now read the marker through the lib's read_role_marker (RIG_ROLE_MARKER overrides the path for fixtures, repo precedent) and warn when it names any role but control-plane — the likeliest story is the wrong SSH session about to put a control plane on a workload box. The marker stays advisory: it may be absent (pre-marker boxes, hand-built boxes) and absence stays silent — warning there would nag every legitimate run — and a present-but-different marker warns and proceeds, because an advisory file must never outrank the operator (contrast close-root, where the marker IS the gate: shutting the root door blind is irreversible in a way an extra Coolify is not). The check sits after arg validation and before the root check, so exit codes are untouched (usage stays 2, the root refusal stays 1) and the harness proves it non-root. Tests drive the live matrix through fixture markers (warns on workload, silent on control-plane and on absence, still exits 1 at the root check) and pin the warning's presence in both shipped scripts for root-run environments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
276 lines
12 KiB
Bash
Executable file
276 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# rig coolify backup install — nightly age-encrypted dump of the Coolify
|
|
# control-plane database, as a systemd timer.
|
|
#
|
|
# rig installs the machinery and templates the bindings file. It never writes
|
|
# a credential and never reads the file back. Convergent: safe to re-run; an
|
|
# already-filled bindings file is left untouched.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
|
. "$HERE/lib/users-config.sh" # read_role_marker — the traits line bootstrap wrote
|
|
|
|
log() { printf 'rig-coolify-backup: %s\n' "$*"; }
|
|
warn() { printf 'rig-coolify-backup: WARNING: %s\n' "$*" >&2; }
|
|
die() { printf 'rig-coolify-backup: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
usage: rig coolify backup install [options]
|
|
|
|
--schedule <OnCalendar> systemd OnCalendar expression
|
|
(default: *-*-* 04:00:00 UTC)
|
|
--pg-container <name> Coolify's postgres container (default: coolify-db)
|
|
--pg-user <name> postgres role to dump as (default: coolify)
|
|
--pg-db <name> database to dump (default: coolify)
|
|
|
|
Installs a nightly dump of the Coolify control-plane database: pg_dump piped
|
|
straight into age (encrypted CLIENT-SIDE, on this box) and shipped to an
|
|
S3-compatible bucket. Control-plane box only. Run as root.
|
|
|
|
That database holds the GitHub App private key, every registered server's SSH
|
|
key, and every environment value for every environment this control plane
|
|
manages — which is why it is never written to disk or to S3 in the clear.
|
|
|
|
rig installs: age, awscli, /usr/local/sbin/coolify-dump.sh, a systemd service
|
|
+ timer, and an EMPTY 0600 bindings file at /etc/coolify-dump.env.
|
|
|
|
You supply, by filling that file and nothing else: the age recipient (a PUBLIC
|
|
key), the S3 bucket + endpoint, and the S3 credentials. Until you do, the unit
|
|
fails loudly on every run — the correct failure mode for a backup.
|
|
EOF
|
|
}
|
|
|
|
# --- args (validated before the root check, so errors stay testable) --------
|
|
SCHEDULE="*-*-* 04:00:00 UTC"
|
|
PG_CONTAINER="coolify-db"
|
|
PG_USER="coolify"
|
|
PG_DB="coolify"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--schedule)
|
|
[ $# -ge 2 ] || die "--schedule needs a value" 2
|
|
SCHEDULE="$2"; shift 2 ;;
|
|
--pg-container)
|
|
[ $# -ge 2 ] || die "--pg-container needs a value" 2
|
|
PG_CONTAINER="$2"; shift 2 ;;
|
|
--pg-user)
|
|
[ $# -ge 2 ] || die "--pg-user needs a value" 2
|
|
PG_USER="$2"; shift 2 ;;
|
|
--pg-db)
|
|
[ $# -ge 2 ] || die "--pg-db needs a value" 2
|
|
PG_DB="$2"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown flag: $1" 2 ;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$SCHEDULE" ] || die "--schedule must not be empty" 2
|
|
[ -n "$PG_CONTAINER" ] || die "--pg-container must not be empty" 2
|
|
[ -n "$PG_USER" ] || die "--pg-user must not be empty" 2
|
|
[ -n "$PG_DB" ] || die "--pg-db must not be empty" 2
|
|
|
|
# --- role-marker sanity (issue #25) ------------------------------------------
|
|
# Same advisory check as `rig coolify install`, same reasoning: this command
|
|
# dumps the CONTROL PLANE's database, so a marker naming any other role almost
|
|
# certainly means the wrong SSH session — but the marker is advisory and may be
|
|
# absent, so WARN, never die, and warn before the root check so the harness can
|
|
# prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent).
|
|
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
|
case "$MARKER_LINE" in
|
|
""|"role=control-plane "*) ;;
|
|
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane; if this is the wrong box, stop here and re-check your SSH session." ;;
|
|
esac
|
|
|
|
# --- guards ----------------------------------------------------------------
|
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
if [ -r /etc/os-release ]; then
|
|
# Sourced in a subshell — /etc/os-release defines VERSION and would clobber
|
|
# a caller's variables (see test/cli.sh's regression check).
|
|
# shellcheck source=/dev/null
|
|
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
|
|
case "$OS_FAMILY" in
|
|
*debian*) ;;
|
|
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
|
|
esac
|
|
else
|
|
warn "cannot read /etc/os-release; proceeding anyway"
|
|
fi
|
|
command -v docker >/dev/null \
|
|
|| die "docker not found — this is a control-plane box command (run rig coolify install first)"
|
|
command -v systemctl >/dev/null || die "systemd is required"
|
|
|
|
SCRIPT_PATH="/usr/local/sbin/coolify-dump.sh"
|
|
ENV_FILE="/etc/coolify-dump.env"
|
|
UNIT_DIR="/etc/systemd/system"
|
|
|
|
# --- packages ---------------------------------------------------------------
|
|
# age encrypts the dump before it leaves the box. awscli is used ONLY as an
|
|
# S3 protocol client (--endpoint-url); no AWS account is involved.
|
|
log "installing age + awscli"
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update -qq
|
|
apt-get install -y -qq age awscli >/dev/null
|
|
log "age $(age --version 2>/dev/null || echo '?') · $(aws --version 2>&1 | cut -d' ' -f1)"
|
|
|
|
# --- the dump script --------------------------------------------------------
|
|
log "writing ${SCRIPT_PATH}"
|
|
cat > "$SCRIPT_PATH" <<'DUMP_SCRIPT'
|
|
#!/usr/bin/env bash
|
|
# Nightly age-encrypted dump of the Coolify control-plane database.
|
|
# Installed by `rig coolify backup install` — edit rig, not this copy.
|
|
#
|
|
# Forensics, not a restore path: a lost control plane is rebuilt fresh and
|
|
# reconciled from its manifest, never restored from this artifact. It exists
|
|
# to answer "what WAS the state" and to recover a credential otherwise lost.
|
|
set -euo pipefail
|
|
|
|
die() { printf 'coolify-dump: ERROR: %s\n' "$1" >&2; exit 1; }
|
|
|
|
: "${AGE_RECIPIENT:?not set — fill /etc/coolify-dump.env (age PUBLIC key)}"
|
|
: "${S3_BUCKET:?not set — fill /etc/coolify-dump.env (e.g. s3://backups/coolify-db)}"
|
|
: "${S3_ENDPOINT:?not set — fill /etc/coolify-dump.env (e.g. https://hel1.your-objectstorage.com)}"
|
|
|
|
# Validate the bindings HERE, before spending a pg_dump on them. A bare bucket
|
|
# name reads to `aws` as a LOCAL path, so it fails deep in the upload with
|
|
# "Invalid argument type" and a usage dump — after the database has been read
|
|
# and encrypted, and with nothing pointing at the actual mistake.
|
|
case "$S3_BUCKET" in
|
|
s3://?*) ;;
|
|
*) die "S3_BUCKET must be an s3:// URI (got: '${S3_BUCKET}') — aws reads a bare bucket name as a local path" ;;
|
|
esac
|
|
case "$S3_ENDPOINT" in
|
|
http://?*|https://?*) ;;
|
|
*) die "S3_ENDPOINT needs a scheme (got: '${S3_ENDPOINT}') — e.g. https://hel1.your-objectstorage.com" ;;
|
|
esac
|
|
|
|
# NOTE: no check can tell you the recipient is the RIGHT key. age's X25519
|
|
# header does not reveal who it encrypts to, so a valid-but-wrong recipient
|
|
# (staging's key instead of prod's) produces a perfect backup nobody can open.
|
|
# Only decrypting an artifact proves that. Do it once, from a machine that
|
|
# holds the private key — never on this box.
|
|
|
|
PG_CONTAINER="${PG_CONTAINER:-coolify-db}"
|
|
PG_USER="${PG_USER:-coolify}"
|
|
PG_DB="${PG_DB:-coolify}"
|
|
|
|
WORKDIR="$(mktemp -d)"
|
|
trap 'rm -rf "$WORKDIR"' EXIT
|
|
OUT="${WORKDIR}/coolify-db-$(date -u +%Y%m%dT%H%M%SZ).sql.age"
|
|
|
|
# `set -o pipefail` (above) is load-bearing: without it a failing pg_dump still
|
|
# exits 0 through the pipe, and age faithfully encrypts the truncated output.
|
|
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" "$PG_DB" \
|
|
| age -r "$AGE_RECIPIENT" -o "$OUT"
|
|
|
|
# A failed dump piped into age still yields a valid, tiny, encrypted file. That
|
|
# would upload cleanly every night and look exactly like a working backup.
|
|
[ -s "$OUT" ] || { printf 'coolify-dump: refusing to upload an empty artifact\n' >&2; exit 1; }
|
|
|
|
aws s3 cp "$OUT" "${S3_BUCKET}/" --endpoint-url "$S3_ENDPOINT"
|
|
printf 'coolify-dump: uploaded %s (%s bytes)\n' "$(basename "$OUT")" "$(stat -c %s "$OUT")"
|
|
DUMP_SCRIPT
|
|
chmod 700 "$SCRIPT_PATH"
|
|
|
|
# --- bindings file (templated empty; NEVER clobbered) ------------------------
|
|
if [ -e "$ENV_FILE" ]; then
|
|
log "${ENV_FILE} exists — leaving it alone (rig never reads or rewrites it)"
|
|
else
|
|
log "templating ${ENV_FILE} (empty, 0600)"
|
|
install -m 0600 /dev/null "$ENV_FILE"
|
|
cat > "$ENV_FILE" <<'ENV_TEMPLATE'
|
|
# Coolify control-plane dump — bindings.
|
|
#
|
|
# rig installed the machinery; these values are yours. rig wrote this file
|
|
# empty and never reads it back. Nothing here is committed anywhere.
|
|
#
|
|
# AGE_RECIPIENT is a PUBLIC key (age1...) — safe to hold on this box. Its
|
|
# PRIVATE half must never live here: whoever holds it can read every secret
|
|
# this control plane manages. Keep it wherever your strictest key lives.
|
|
AGE_RECIPIENT=
|
|
S3_BUCKET=
|
|
S3_ENDPOINT=
|
|
AWS_ACCESS_KEY_ID=
|
|
AWS_SECRET_ACCESS_KEY=
|
|
AWS_DEFAULT_REGION=
|
|
ENV_TEMPLATE
|
|
chmod 600 "$ENV_FILE"
|
|
fi
|
|
|
|
# --- systemd service + timer -------------------------------------------------
|
|
log "writing ${UNIT_DIR}/coolify-dump.service"
|
|
cat > "$UNIT_DIR/coolify-dump.service" <<UNIT
|
|
[Unit]
|
|
Description=Age-encrypted dump of the Coolify control-plane database
|
|
Documentation=https://github.com/heavy-duty/rig
|
|
Requires=docker.service
|
|
After=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
# Defaults rig owns. Listed BEFORE EnvironmentFile so the operator's file wins
|
|
# on any key it sets, while a file that omits them still gets sane values.
|
|
#
|
|
# aws-cli >= 2.23 turns on new default upload checksums that S3-compatible
|
|
# backends (Hetzner, MinIO, Ceph) reject; \`when_required\` restores the older
|
|
# behavior. Debian 13 ships 2.23. Harmless where the backend does support them.
|
|
Environment=AWS_REQUEST_CHECKSUM_CALCULATION=when_required
|
|
Environment=AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
|
|
Environment=PG_CONTAINER=${PG_CONTAINER}
|
|
Environment=PG_USER=${PG_USER}
|
|
Environment=PG_DB=${PG_DB}
|
|
EnvironmentFile=${ENV_FILE}
|
|
ExecStart=${SCRIPT_PATH}
|
|
UNIT
|
|
|
|
log "writing ${UNIT_DIR}/coolify-dump.timer (${SCHEDULE})"
|
|
cat > "$UNIT_DIR/coolify-dump.timer" <<UNIT
|
|
[Unit]
|
|
Description=Nightly Coolify control-plane dump
|
|
Documentation=https://github.com/heavy-duty/rig
|
|
|
|
[Timer]
|
|
OnCalendar=${SCHEDULE}
|
|
# Catch a run missed while the box was down, rather than silently skipping a night.
|
|
Persistent=true
|
|
RandomizedDelaySec=15m
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
UNIT
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now coolify-dump.timer >/dev/null 2>&1
|
|
log "timer enabled — next run: $(systemctl show -P NextElapseUSecRealtime coolify-dump.timer 2>/dev/null || echo '?')"
|
|
|
|
# --- what rig deliberately did NOT do ----------------------------------------
|
|
cat <<EOF
|
|
|
|
rig-coolify-backup: installed. The timer is live but the backup does NOT work yet —
|
|
rig has no credentials and cannot verify an upload. Two steps remain, both yours:
|
|
|
|
1. Fill in the bindings: nano ${ENV_FILE}
|
|
(age recipient = a PUBLIC key; S3 bucket, endpoint, access key, secret, region)
|
|
S3_BUCKET must be an s3:// URI, not a bare bucket name.
|
|
|
|
2. Run it once by hand — do NOT wait for the timer to find out:
|
|
systemctl start coolify-dump.service
|
|
journalctl -u coolify-dump.service -n 20 --no-pager
|
|
|
|
3. Prove you can OPEN it. A successful upload only proves the file ARRIVED.
|
|
If the recipient is the wrong key, every run succeeds forever and produces
|
|
an artifact nobody can decrypt — and you cannot tell by looking at it.
|
|
From a machine holding the private key (NEVER this box), stream it down
|
|
and decrypt; you want PostgreSQL SQL out the other end:
|
|
|
|
ssh root@$(hostname) 'set -a; . ${ENV_FILE}; set +a; \\
|
|
line=\$(aws s3 ls "\$S3_BUCKET/" --endpoint-url "\$S3_ENDPOINT" | sort | tail -1); \\
|
|
aws s3 cp "\$S3_BUCKET/\${line##* }" - --endpoint-url "\$S3_ENDPOINT"' \\
|
|
| age -d -i <your-key-file> | { head -5; cat >/dev/null; }
|
|
|
|
A backup you have never read back is not yet a backup.
|
|
|
|
Until step 1 is done the unit fails loudly on every run. That is deliberate — a
|
|
silent backup is worse than a missing one.
|
|
EOF
|