#!/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 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[@]}" >> "$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