clusev/update.sh

167 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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; }
# Detail-Log fuer laute Schritte: git pull hier, apt/build/up/migrate in install.sh — via
# CLUSEV_INSTALL_LOG geerbt landet alles in EINEM install.log am Repo-Root (nicht run/ —
# container-beschreibbarer Bind-Mount, siehe watch.sh). Bei Fehlern werden die letzten
# Zeilen gezeigt; das vollstaendige Log bleibt liegen. (Der Re-Exec nach einem
# Self-Update leert das Log erneut — unkritisch, der Pull ist dann schon dokumentiert.)
# Symlink-sicheres Anlegen (Repo-Root gehoert dem unprivilegierten clusev-User; ein plain
# `: >` als root wuerde einem vorgelegten Symlink folgen): mktemp + atomares rename ersetzt
# einen Link selbst und folgt ihm nie.
init_log_file() {
local path="$1" dir tmp
dir="$(dirname -- "$path")"
tmp="$(mktemp "${dir}/.install-log.XXXXXX" 2>/dev/null)" || return 1
chmod 0644 "$tmp" 2>/dev/null || true
mv -fT -- "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
}
LOG_FILE="${REPO_DIR}/install.log"
init_log_file "$LOG_FILE" || LOG_FILE=/dev/null
export CLUSEV_INSTALL_LOG="$LOG_FILE"
[ "$(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 13 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[@]}" >> "$LOG_FILE" 2>&1 \
|| { tail -n 40 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2; 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. Details: ${LOG_FILE}"; }
info "Codestand aktuell ($(git rev-parse --short=7 HEAD 2>/dev/null || echo '?'))"
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 >> "$LOG_FILE" 2>&1 \
|| 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. Details: ${LOG_FILE}"
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