147 lines
9.5 KiB
Bash
Executable File
147 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Clusev updater — pull the latest code and re-run the idempotent installer (root required).
|
||
# One command to update an existing install:
|
||
#
|
||
# sudo ./update.sh
|
||
#
|
||
# In order:
|
||
# 1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user,
|
||
# so a root-run `git pull` would otherwise refuse with "dubious ownership".
|
||
# 2. git pull --ff-only, PINNED to the recorded release branch. Aborts cleanly on any conflict /
|
||
# divergence — it NEVER discards local changes.
|
||
# 3. Verify the pulled HEAD's commit signature against a trusted maintainer key (opt-in via a
|
||
# gitignored allowed-signers file) BEFORE re-exec/build — fail-closed when configured.
|
||
# 4. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded
|
||
# against loops) so improvements to this very script take effect on the same run.
|
||
# 5. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates,
|
||
# refreshes the MOTD and fixes ownership. Run NON-interactively (stdin from /dev/null) so
|
||
# it never re-prompts; secrets and the configured domain / ACME e-mail are preserved.
|
||
set -euo pipefail
|
||
|
||
cd "$(dirname "$0")"
|
||
REPO_DIR="$(pwd)"
|
||
|
||
# Never let a private-repo credential miss HANG the update: git must fail fast instead of blocking on
|
||
# an interactive username/password prompt (the systemd updater has no TTY, so a prompt would wait
|
||
# forever — the classic "update spins with no error"). Also abort a stalled transfer instead of
|
||
# hanging on it. The pull itself is additionally wrapped in `timeout` below.
|
||
export GIT_TERMINAL_PROMPT=0
|
||
export GIT_HTTP_LOW_SPEED_LIMIT="${GIT_HTTP_LOW_SPEED_LIMIT:-1000}"
|
||
export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-30}"
|
||
|
||
# set_stage: publish a coarse update stage for the in-browser progress screen (best-effort — the
|
||
# app container serves run/update-phase.json to it). A write failure must never fail the update.
|
||
# Written first as "fetch" so the screen leaves the time-guess behind the moment the pull begins,
|
||
# and so a stale stage from a previous update is reset.
|
||
set_stage() {
|
||
# Symlink-safe write (mirror of run_write_json in the restart-sentinel): ./run is the
|
||
# container-writable bind mount, so update-phase.json could be pre-planted as a symlink. Build the
|
||
# temp at the REPO ROOT — same filesystem as ./run but not mounted into any container, so it can't
|
||
# be tampered mid-write (no TOCTOU) — then rename(2) it over the target: an atomic same-fs rename
|
||
# replaces the link itself with our regular file and never follows it.
|
||
local d="$REPO_DIR/run" tmp
|
||
mkdir -p "$d" 2>/dev/null || true
|
||
tmp="$(mktemp "${REPO_DIR}/.phase.XXXXXX" 2>/dev/null)" || return 0
|
||
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
|
||
chmod 644 "$tmp" 2>/dev/null || true
|
||
mv -f "$tmp" "${d}/update-phase.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
|
||
return 0
|
||
}
|
||
set_stage fetch
|
||
|
||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||
info() { printf ' %s\n' "$*"; }
|
||
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
|
||
|
||
[ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./update.sh"
|
||
[ -f docker-compose.prod.yml ] || die "Keine Clusev-Installation hier (docker-compose.prod.yml fehlt)."
|
||
[ -x ./install.sh ] || [ -f ./install.sh ] || die "install.sh fehlt — Repo unvollstaendig."
|
||
command -v git >/dev/null 2>&1 || die "git ist nicht installiert."
|
||
|
||
bold "Clusev Update"
|
||
echo "============="
|
||
|
||
# Steps 1–3 run only on the first pass; a re-exec (after a self-update) jumps straight to step 4.
|
||
if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then
|
||
# 1. let root operate on the clusev-owned working tree
|
||
git config --global --add safe.directory "$REPO_DIR" 2>/dev/null || true
|
||
|
||
# 2. fast-forward pull only — never auto-merge or throw away local edits.
|
||
# PIN the pull to the branch recorded at install time (install.sh writes CLUSEV_BUILD_BRANCH
|
||
# into .env from the then-current HEAD) instead of implicitly following whatever branch is
|
||
# checked out now — otherwise anyone able to `git checkout` in this tree (the unprivileged
|
||
# clusev user) could silently redirect the next ROOT update to an arbitrary branch/fork.
|
||
# Refuse when the checkout diverges from it; fall back to a plain pull only when no branch
|
||
# was recorded (an install predating this field).
|
||
pin_branch="$(grep -m1 '^CLUSEV_BUILD_BRANCH=' .env 2>/dev/null | cut -d= -f2-)"
|
||
pull_args=(--ff-only)
|
||
if [ -n "$pin_branch" ]; then
|
||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
|
||
[ "$current_branch" = "$pin_branch" ] \
|
||
|| die "Ausgecheckter Branch '${current_branch}' weicht vom Installations-Branch '${pin_branch}' ab — Update abgebrochen. Mit 'git checkout ${pin_branch}' zuruecksetzen und erneut versuchen."
|
||
pull_args=(--ff-only origin "$pin_branch")
|
||
fi
|
||
before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
|
||
info "Hole Aktualisierungen (git pull --ff-only) ..."
|
||
timeout 300 git pull "${pull_args[@]}" \
|
||
|| die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen."
|
||
after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
|
||
|
||
# 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it
|
||
# (the re-exec of this very script below, then the ROOT `docker compose build` in install.sh).
|
||
# The allowed-signers list lives in a GITIGNORED file — `.env` (CLUSEV_ALLOWED_SIGNERS, absolute
|
||
# path preferred) or the repo-root `.clusev-allowed-signers` — so a pulled or MITM'd tree can
|
||
# never swap the trusted key (git pull does not touch untracked/ignored files). This is the
|
||
# missing supply-chain control: the branch-pin above stops a local `git checkout` redirect, but
|
||
# only a signature stops a compromised ORIGIN / transport from feeding attacker commits to root.
|
||
# OPT-IN: with no signer list configured this is a loud no-op so existing installs keep updating;
|
||
# once configured, a bad OR absent signature FAILS CLOSED. Activate it by provisioning the
|
||
# maintainer public key + signing releases — see docs/…/self-update-signature-verification.md.
|
||
# Read the .env value BARE (no quote-stripping) — force_kv/set_kv write unquoted values, same as
|
||
# every other .env read here (CLUSEV_BUILD_BRANCH above, watch.sh); a quoted value simply won't
|
||
# resolve to a file and so fails closed below.
|
||
signers="$(grep -m1 '^CLUSEV_ALLOWED_SIGNERS=' .env 2>/dev/null | cut -d= -f2-)"
|
||
if [ -n "$signers" ]; then
|
||
# CLUSEV_ALLOWED_SIGNERS explicitly set => verification is REQUIRED. A missing/unreadable file
|
||
# must FAIL CLOSED, never silently downgrade to "not configured" (that fail-open would let an
|
||
# attacker who can remove the anchor skip verification entirely).
|
||
[ -f "$signers" ] && [ -r "$signers" ] \
|
||
|| die "CLUSEV_ALLOWED_SIGNERS ist gesetzt (${signers}), aber die Datei fehlt oder ist unlesbar — Update abgebrochen. Signaturpruefung darf nicht stillschweigend ausfallen."
|
||
elif [ -f "$REPO_DIR/.clusev-allowed-signers" ]; then
|
||
signers="$REPO_DIR/.clusev-allowed-signers"
|
||
fi
|
||
if [ -n "$signers" ]; then
|
||
# The trust anchor must NOT be repointable or swappable via a pull. Reject a SYMLINK first: `-f`
|
||
# follows the link and `git ls-files` checks the LINK path (not its target), so an untracked
|
||
# symlink to a tracked, pull-overwritable file would otherwise slip past the tracked-check below.
|
||
if [ -L "$signers" ]; then
|
||
die "Der allowed-signers Trust-Anchor (${signers}) ist ein Symlink — nicht erlaubt (koennte auf eine versionierte, per Pull austauschbare Datei zeigen). Bitte eine echte Datei verwenden."
|
||
fi
|
||
# And it must NOT be git-tracked (pull-overwritable): otherwise a malicious origin could ship its
|
||
# OWN allowed-signers file in the very commit being verified and pass the check against the
|
||
# attacker's key. Only untracked/gitignored or out-of-tree paths are trusted.
|
||
if git ls-files --error-unmatch -- "$signers" >/dev/null 2>&1; then
|
||
die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen."
|
||
fi
|
||
info "Verifiziere Signatur des gepullten Commits (${signers}) ..."
|
||
git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \
|
||
|| die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter."
|
||
info "Signatur ok."
|
||
else
|
||
info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen."
|
||
fi
|
||
|
||
# 4. self-update: relaunch the new (now signature-verified) update.sh if it changed
|
||
if [ "$before_hash" != "$after_hash" ]; then
|
||
info "update.sh wurde aktualisiert — starte die neue Version ..."
|
||
exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@"
|
||
fi
|
||
fi
|
||
|
||
# Refresh the update source from the (possibly changed) clone origin before handing off.
|
||
[ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true
|
||
|
||
# 5. apply via the idempotent installer, non-interactively (no prompts; config preserved)
|
||
info "Wende Update an (install.sh) ..."
|
||
exec ./install.sh </dev/null
|