diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index de90114..d521e59 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -290,9 +290,19 @@ class DeploymentService @mkdir($dir, 0775, true); } + // Sign the marker so the host updater (watch.sh) can reject a forged/limited write to the + // shared ./run dir before running a root update: line 1 = timestamp, line 2 = its HMAC. + // Falls back to the bare timestamp when no key is configured (dev / pre-HMAC installs), which + // the host accepts too. + $ts = now()->toIso8601String(); + $key = (string) config('clusev.update_hmac_key'); + $body = $key !== '' + ? $ts.PHP_EOL.hash_hmac('sha256', $ts, $key).PHP_EOL + : $ts.PHP_EOL; + // Returns false when the (bind-mounted) sentinel dir is not writable by the app user — // the caller surfaces that instead of showing a "running" state that never resolves. - $written = @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; + $written = @file_put_contents($path, $body) !== false; if ($written) { // Best-effort start marker (a failure here must never fail the update): lets the diff --git a/config/clusev.php b/config/clusev.php index 142f763..f90d19e 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -49,5 +49,11 @@ return [ // (terminal disabled). install.sh generates it alongside the other secrets. 'terminal_secret' => env('TERMINAL_SIDECAR_SECRET', ''), + // Shared secret used to AUTHENTICATE the update-request marker the app drops for the host updater. + // The app signs the marker (HMAC-SHA256) and the host-side watch.sh verifies it before running a + // root update, so a stray/limited write to the shared ./run dir cannot drive one. install.sh + // generates it alongside the other secrets; the host reads the SAME value from .env. + 'update_hmac_key' => env('UPDATE_HMAC_KEY', ''), + 'license' => 'AGPL-3.0', ]; diff --git a/docker/restart-sentinel/watch.sh b/docker/restart-sentinel/watch.sh index caa58f7..c3130c7 100755 --- a/docker/restart-sentinel/watch.sh +++ b/docker/restart-sentinel/watch.sh @@ -35,14 +35,42 @@ COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml" TAG="clusev-restart" log() { printf '%s %s: %s\n' "$(date -Is)" "$TAG" "$*"; } -# Publish an "error" stage to the update-progress feed (run/update-phase.json, bind-mounted into the -# app container) so the browser shows the failure AT ONCE instead of spinning to its 10-minute -# timeout. Best-effort — a write failure must never change the update's outcome. +# run_write_json PAYLOAD — write PAYLOAD to run/update-phase.json SYMLINK-SAFELY (as root, into a +# directory the app container can write). ./run is container-writable, so a compromised container +# could pre-plant update-phase.json as a symlink to an arbitrary host path; a plain `> file` would +# follow it and let this root write escape ./run. We write a temp in ./run and rename(2) over the +# target: rename replaces the path itself (link included) with our regular file and NEVER follows the +# link. Best-effort — a write failure must never change the update's outcome. +run_write_json() { + local d="${CLUSEV_DIR}/run" tmp + mkdir -p "$d" 2>/dev/null || true + tmp="$(mktemp "${d}/.phase.XXXXXX" 2>/dev/null)" || return 0 + printf '%s\n' "$1" > "$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 +} + +# Publish an "error" stage to the update-progress feed so the browser shows the failure AT ONCE +# instead of spinning to its 10-minute timeout. write_update_error() { - local f="${CLUSEV_DIR}/run/update-phase.json" - mkdir -p "${CLUSEV_DIR}/run" 2>/dev/null || true - printf '{"stage":"error","at":%s}\n' "$(date +%s 2>/dev/null || echo 0)" > "$f" 2>/dev/null || true - chmod 644 "$f" 2>/dev/null || true + run_write_json "$(printf '{"stage":"error","at":%s}' "$(date +%s 2>/dev/null || echo 0)")" +} + +# Authenticate an update request marker. The app (DeploymentService) signs it with UPDATE_HMAC_KEY +# (line 1 = ISO8601 timestamp, line 2 = HMAC-SHA256(timestamp)); we recompute and compare. Refusing +# an unsigned/forged marker stops a stray or limited write to ./run from driving a root update. +# (A full app-container compromise holds the key, so this raises the bar rather than being absolute — +# the standing guarantee is that a triggered update only re-installs the TRUSTED remote's code.) +verify_update_request() { + local f="$1" key ts mac expect + key="$(grep -m1 '^UPDATE_HMAC_KEY=' "${CLUSEV_DIR}/.env" 2>/dev/null | cut -d= -f2-)" + [ -n "$key" ] || { log "UPDATE_HMAC_KEY unset — skipping request authentication"; return 0; } + ts="$(sed -n '1p' "$f" 2>/dev/null)" + mac="$(sed -n '2p' "$f" 2>/dev/null)" + [ -n "$ts" ] && [ -n "$mac" ] || { log "update request unsigned/malformed — refusing"; return 1; } + expect="$(printf '%s' "$ts" | openssl dgst -sha256 -hmac "$key" 2>/dev/null | sed 's/^.*= *//')" + [ -n "$expect" ] && [ "$expect" = "$mac" ] || { log "update request HMAC mismatch — refusing"; return 1; } return 0 } @@ -89,14 +117,24 @@ do_update() { write_update_error return 1 fi + # Authenticate BEFORE consuming/running: refuse (and clear, so it does not loop the .path unit) a + # marker that carries no valid HMAC signature. + if ! verify_update_request "$CLUSEV_UPDATE_SIGNAL"; then + rm -f "$CLUSEV_UPDATE_SIGNAL" + write_update_error + return 1 + fi rm -f "$CLUSEV_UPDATE_SIGNAL" && log "update sentinel consumed: $CLUSEV_UPDATE_SIGNAL" log "update requested — running update.sh in ${CLUSEV_DIR}" # Tee the whole run to a host log the operator can inspect, and cap it with a generous timeout so # a truly-stuck step (e.g. a hung build) fails LOUDLY rather than pinning the progress page. On ANY # non-zero exit (update.sh or its exec'd install.sh — the exit code propagates through exec) publish # the error stage so the browser stops spinning and shows the failure. `timeout` exit 124 = capped. - local logf="${CLUSEV_DIR}/run/update.log" rc=0 - mkdir -p "${CLUSEV_DIR}/run" 2>/dev/null || true + # The log lives at the repo ROOT, NOT under ./run: ./run is the container-writable bind mount, so a + # log there could be pre-planted as a symlink and this root `tee` would follow it; the repo root is + # not mounted into any container, so a container cannot redirect this write. + local logf="${CLUSEV_DIR}/update.log" rc=0 + rm -f "$logf" 2>/dev/null || true timeout -k 30 1800 bash "$updater" 2>&1 | tee "$logf" || rc=${PIPESTATUS[0]} if [ "$rc" = 0 ]; then log "update applied" diff --git a/install.sh b/install.sh index e359d13..1ab2c12 100755 --- a/install.sh +++ b/install.sh @@ -39,9 +39,14 @@ set_stage() { # ── .env mutators ──────────────────────────────────────────────────── # force_kv: always write (config/derived). set_kv: write only when empty/placeholder. force_kv() { - local key="$1" val="$2" + local key="$1" val esc + # Strip stray CR/LF (a newline would split .env into two entries) and neutralise sed replacement + # metacharacters (& | \) so an operator-supplied domain/email/port can neither corrupt .env nor + # inject a sed command (values reach here unvalidated from CLUSEV_DOMAIN/ADMIN_EMAIL/HTTP_PORT). + val="$(printf '%s' "$2" | tr -d '\r\n')" + esc="$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g')" if grep -qE "^${key}=" "$ENV_FILE"; then - sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" + sed -i "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE" else printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" fi @@ -154,6 +159,13 @@ HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HO SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" [ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP" +# Reject a non-numeric / out-of-range HTTP port (an operator typo would otherwise reach compose as a +# bad ${APP_PORT} and fail cryptically). Fall back to 80. +case "$HTTP_PORT" in + ''|*[!0-9]*) warn "HTTP-Port '${HTTP_PORT}' nicht numerisch — nutze 80"; HTTP_PORT=80 ;; + *) if [ "$HTTP_PORT" -lt 1 ] || [ "$HTTP_PORT" -gt 65535 ]; then warn "HTTP-Port '${HTTP_PORT}' ausserhalb 1-65535 — nutze 80"; HTTP_PORT=80; fi ;; +esac + # ── domain DNS check (only when a domain is set) ───────────────────── DOMAIN_OK=0 if [ -n "$DOMAIN" ]; then @@ -202,6 +214,12 @@ fi phase 3/9 "Secrets generieren" if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt"; else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi +# .env holds EVERY secret: DB/root/redis passwords, APP_KEY (decrypts the whole fleet's SSH-credential +# vault + 2FA secrets), the terminal sidecar secret and the update HMAC key. Never leave it +# world-readable. Own it by the clusev user (the tree owner) and lock it to 0600 BEFORE any secret is +# written; the subsequent sed -i / >> writes preserve this mode. +chown clusev:clusev "$ENV_FILE" 2>/dev/null || true +chmod 600 "$ENV_FILE" set_kv DB_PASSWORD "$(rand_hex 24)" set_kv DB_ROOT_PASSWORD "$(rand_hex 24)" diff --git a/update.sh b/update.sh index 54906d0..c8fa800 100755 --- a/update.sh +++ b/update.sh @@ -32,9 +32,15 @@ export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-30}" # 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() { - mkdir -p "$REPO_DIR/run" 2>/dev/null || true - printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$REPO_DIR/run/update-phase.json" 2>/dev/null || true - chmod 644 "$REPO_DIR/run/update-phase.json" 2>/dev/null || true + # Symlink-safe write: ./run is the container-writable bind mount, so update-phase.json could be + # pre-planted as a symlink; write a temp in ./run and rename(2) over the target so this root write + # replaces the link itself and never follows it (mirror of run_write_json in the restart-sentinel). + local d="$REPO_DIR/run" tmp + mkdir -p "$d" 2>/dev/null || true + tmp="$(mktemp "${d}/.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