#!/usr/bin/env bash # DashCaddy Host-Side Updater # Triggered by systemd path unit when the container writes trigger.json. # Reads the trigger, backs up current API + data/, copies new files, rebuilds container. # Writes result.json so the new container knows the outcome. # # This runs on the HOST, outside the container. # # Channel selection: by default only "stable" releases are applied. Set # ALLOW_PRERELEASE=true in /opt/dashcaddy/updates/channel.conf to opt in to # prerelease/beta/rc channels. Useful for staging hosts, not production. set -euo pipefail readonly UPDATES_DIR="/opt/dashcaddy/updates" readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json" readonly RESULT_FILE="${UPDATES_DIR}/result.json" readonly BACKUPS_DIR="${UPDATES_DIR}/backups" readonly CONTAINER_NAME="dashcaddy-api" readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest" readonly MAX_BACKUPS=3 readonly HEALTH_TIMEOUT=60 readonly CHANNEL_CONF="${UPDATES_DIR}/channel.conf" # Data directory backup — stored alongside code backups so everything rolls back together readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data" readonly DATA_BACKUP_PREFIX="data-backup" # Updater state (trigger.json / result.json) backup — keeps the audit trail # (what version we were attempting, what the previous update's outcome was) tied # to the same versioned backup directory as code + data. After a failed update, # operators can inspect what was attempted without correlating timestamps, and # rollback tooling can reconstruct a "what just happened" view of the update # state machine. NOTE: we do NOT auto-restore trigger.json on rollback — the # rollback handler reads a fresh trigger.json written by the operator/container; # restoring the previous attempt's trigger would clobber the active rollback # request. Backups here are read-only forensic evidence. readonly UPDATE_STATE_BACKUP_PREFIX="update-state" readonly TRIGGER_PROCESSING="${TRIGGER_FILE}.processing" log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } # Decide if a given release channel is acceptable on this host. # Returns 0 (accept) or 1 (reject) and logs the reason. channel_allowed() { local channel="$1" local allow_prerelease="false" if [[ -f "$CHANNEL_CONF" ]]; then # shellcheck disable=SC1090 source "$CHANNEL_CONF" allow_prerelease="${ALLOW_PRERELEASE:-false}" fi case "${channel,,}" in stable|"") return 0 ;; prerelease|beta|rc|alpha) if [[ "${allow_prerelease,,}" == "true" ]]; then log "Channel '${channel}' accepted (ALLOW_PRERELEASE=true in ${CHANNEL_CONF})" return 0 else log "Channel '${channel}' rejected — set ALLOW_PRERELEASE=true in ${CHANNEL_CONF} to accept" return 1 fi ;; *) log "Channel '${channel}' rejected — unknown channel" return 1 ;; esac } # ── JSON string escaping (DC-122) ───────────────────────────────────────────── # Complete JSON string encoder for the rare no-python fallback path: mandatory # escapes (quote, backslash) plus ALL control bytes U+0000–U+001F as \uXXXX or # their short forms. json_escape() { local s="$1" out="" ch i hex for (( i=0; i<${#s}; i++ )); do ch="${s:i:1}" case "$ch" in \\) out+='\\' ;; \") out+='\"' ;; $'\b') out+='\b' ;; $'\f') out+='\f' ;; $'\n') out+='\n' ;; $'\r') out+='\r' ;; $'\t') out+='\t' ;; *) if [[ "$ch" < ' ' || "$ch" == $'\x7f' ]]; then printf -v hex '%02x' "'$ch" out+="\u00${hex}" else out+="$ch" fi ;; esac done printf '%s' "$out" } write_result() { local success="$1" version="$2" duration="$3" shift 3 local error="${1:-}" if command -v python3 >/dev/null 2>&1; then python3 - "$success" "$version" "$duration" "$error" > "$RESULT_FILE" <<'PY' import json, sys, datetime success, version, duration, error = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] obj = { "success": success == "true", "version": version, "duration": int(duration) if duration.isdigit() else 0, "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if error: obj["error"] = error print(json.dumps(obj, indent=2)) PY else # Safe fallback: escaped interpolation (no raw quote/backslash leakage). local esc_version esc_error esc_version=$(json_escape "$version") esc_error=$(json_escape "$error") if [[ "$success" == "true" ]]; then cat > "$RESULT_FILE" < "$RESULT_FILE" </dev/null | wc -l) if (( count > MAX_BACKUPS )); then log "Cleaning old backups (${count} > ${MAX_BACKUPS})" find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \ | sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \ | xargs rm -rf fi } # ── Data backup (rsync for efficiency + permissions) ────────────────────────── backup_data_dir() { local backup_dir="$1" if [[ -d "$DATA_SOURCE_DIR" ]]; then log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/" mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}" # DC-122: fallback must copy CONTENTS ("dir/.") — a bare "cp -a dir dest" # nests a data/ level inside the existing destination dir, which then made # restore_data_dir restore data/data/... (rollback restoring nothing). rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \ || { log "rsync unavailable — cp fallback (contents copy)"; cp -a "$DATA_SOURCE_DIR/." "${backup_dir}/${DATA_BACKUP_PREFIX}/"; } # Manifest of backed-up files — lets the no-rsync restore path mirror # rsync --delete semantics (remove live files that the backup lacks). ( cd "${backup_dir}/${DATA_BACKUP_PREFIX}" && find . -type f -printf '%P\n' | sort ) \ > "${backup_dir}/data.manifest" 2>/dev/null || true log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))" else log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup" fi } # ── Updater state backup (trigger.json.processing + result.json) ───────────── # Captures what was being attempted + the last result so post-mortem can answer # "why did this fail" without joining timestamps across files. Tolerates absent # files (first-ever run) and locked files (chattr +i). Idempotent — re-running # overwrites the previous backup. backup_update_state() { local backup_dir="$1" local state_dir="${backup_dir}/${UPDATE_STATE_BACKUP_PREFIX}" mkdir -p "$state_dir" local copied=0 for src in "$TRIGGER_PROCESSING" "$RESULT_FILE"; do if [[ -f "$src" ]]; then # Unlock temporarily if immutable, copy, re-lock. local was_locked=false if lsattr -d "$src" 2>/dev/null | awk '{exit !($1 ~ /i/)}'; then was_locked=true chattr -i "$src" 2>/dev/null || true fi cp -f "$src" "${state_dir}/$(basename "$src")" 2>/dev/null && copied=$(( copied + 1 )) if [[ "$was_locked" == "true" ]]; then chattr +i "$src" 2>/dev/null || true fi fi done if (( copied > 0 )); then log "Update-state backup: ${copied} file(s) -> ${state_dir}" else log "Update-state backup: nothing to back up (no trigger/result files)" rmdir "$state_dir" 2>/dev/null || true fi } # ── Data restore ────────────────────────────────────────────────────────────── restore_data_dir() { local backup_dir="$1" local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}" if [[ -d "$data_backup" ]]; then # DC-122: contents copy on the cp fallback — see backup_data_dir note. if rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null; then log "Data restored successfully (rsync --delete)" else log "rsync unavailable — cp fallback + manifest reconciliation" cp -a "$data_backup/." "$DATA_SOURCE_DIR/" # Mirror deletion semantics without rsync: remove live files that the # backup manifest says did not exist at backup time. local manifest="${backup_dir}/data.manifest" if [[ -f "$manifest" ]]; then local live_list deleted=0 live_list="$( cd "$DATA_SOURCE_DIR" && find . -type f -printf '%P\n' | sort )" while IFS= read -r rel; do [[ -z "$rel" ]] && continue # defense against path traversal in a corrupted manifest [[ "$rel" == ..* || "$rel" == */..* || "$rel" == *../* ]] && continue rm -f "$DATA_SOURCE_DIR/$rel" && deleted=$(( deleted + 1 )) done < <(comm -13 "$manifest" <(printf '%s\n' "$live_list")) # prune directories that became empty find "$DATA_SOURCE_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null || true log "Manifest reconciliation: removed ${deleted} post-backup file(s)" fi log "Data restored successfully (cp fallback)" fi else log "WARNING: No data backup found at ${data_backup} — data/ not restored" fi } # ── Frontend backup/restore (DC-122) ────────────────────────────────────────── # The frontend is synced to the live web root BEFORE the API rebuild + health # check. If the update then fails, the new frontend would pair with the rolled # back API. Snapshot exactly what we touch so rollback can restore it. FRONTEND_SUBDIRS="dist css vendor js assets" # Snapshot = files + a manifest + metadata, so restore is EXACT: # frontend.meta : JSON with target dir + whether index.html/sw.js existed # frontend.manifest : every file that existed at snapshot time (relative) # Restore deletes live files that the snapshot manifest does not know about # (an update-introduced asset dies with the update) and recreates absent # snapshot files. backup_frontend_dir() { local backup_dir="$1" target="$2" # DC-122: reset any stale snapshot from a previous update with the same # version key before writing this one. rm -rf "${backup_dir}/frontend" "${backup_dir}/frontend.manifest" "${backup_dir}/frontend.meta" mkdir -p "${backup_dir}/frontend" [[ -f "$target/index.html" ]] && cp -f "$target/index.html" "${backup_dir}/frontend/index.html" [[ -f "$target/sw.js" ]] && cp -f "$target/sw.js" "${backup_dir}/frontend/sw.js" for sub in $FRONTEND_SUBDIRS; do [[ -d "$target/$sub" ]] && cp -rf "$target/$sub" "${backup_dir}/frontend/$sub" done ( cd "${backup_dir}/frontend" && find . -type f -printf '%P\n' | sort ) \ > "${backup_dir}/frontend.manifest" 2>/dev/null || true # DC-122: use a validated target path for frontend.meta — json-escaped. local esc_target esc_target=$(json_escape "$target") printf '{"target":"%s","indexExisted":%s,"swExisted":%s}\n' \ "$esc_target" \ "$( [[ -f "$target/index.html" ]] && echo true || echo false )" \ "$( [[ -f "$target/sw.js" ]] && echo true || echo false )" \ > "${backup_dir}/frontend.meta" log "Frontend snapshot saved to ${backup_dir}/frontend ($(wc -l < "${backup_dir}/frontend.manifest" 2>/dev/null || echo 0) files)" } # ── Frontend target validation (DC-122) ─────────────────────────────────────── # A custom DASHCADDY_HOST_FRONTEND_DIR is supported, but ONLY if it validates: # absolute, exists, is a directory, no '..' components, not the filesystem root. # The SAME validator gates deploy and restore, so anything we deploy to is # something we can roll back, and nothing else is ever touched. validate_frontend_target() { local t="$1" [[ -n "$t" ]] || return 1 [[ "$t" = /* ]] || return 1 [[ "$t" != "/" ]] || return 1 [[ "$t" != *..* ]] || return 1 [[ -d "$t" ]] || return 1 return 0 } restore_frontend_dir() { local backup_dir="$1" local snap="${backup_dir}/frontend" [[ -d "$snap" ]] || { log "No frontend snapshot in this backup — skipping frontend restore"; return 0; } # DC-122: resolve the target FIRST, validate BEFORE any delete/copy this # function performs. A recorded custom target is honored — it was validated # by this same function's rules before deployment, and recorded in meta. local target="" if [[ -f "${backup_dir}/frontend.meta" ]]; then target=$(python3 -c "import json;print(json.load(open('${backup_dir}/frontend.meta')).get('target',''))" 2>/dev/null) # DC-122 (hardened after live-fire test caught it): if the recorded meta # target is present but INVALID, refuse outright. Never fall through to # discovery with an untrusted/invalid target — that could point the # restore at a directory the snapshot was never taken from. if [[ -n "$target" ]] && ! validate_frontend_target "$target"; then log "REFUSING frontend restore: recorded meta target is invalid: '${target}'" return 1 fi validate_frontend_target "$target" || target="" fi if [[ -z "$target" ]]; then for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do [[ -d "$candidate" ]] && target="$candidate" && break done fi validate_frontend_target "$target" || { log "Refusing frontend restore on unexpected/unknown target: '${target:-}'"; return 1; } log "Restoring frontend to ${target} (exact state, updater-owned scope)..." # DC-122: structural exact restore of the owned subtrees: # - snapshot has the subtree → rm -rf live + copy snapshot (exact) # - snapshot lacks the subtree but live has it → the update introduced it # ⇒ remove it. (Fixes rollback leaving behind e.g. a css/ dir the update # created when none existed before.) # index.html / sw.js are governed by frontend.meta existence flags. # Files outside FRONTEND_SUBDIRS are NEVER touched. for sub in $FRONTEND_SUBDIRS; do if [[ -d "$snap/$sub" ]]; then rm -rf "${target:?}/${sub:?}" cp -rf "$snap/$sub" "${target:?}/$sub" elif [[ -d "${target:?}/${sub}" ]]; then rm -rf "${target:?}/${sub:?}" log "Removed update-introduced subtree: ${sub}/" fi done # index.html / sw.js per frontend.meta existence flags (single pass). if grep -q '"indexExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/index.html" ]]; then cp -f "$snap/index.html" "${target:?}/index.html" elif grep -q '"indexExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then rm -f "${target:?}/index.html" fi if grep -q '"swExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/sw.js" ]]; then cp -f "$snap/sw.js" "${target:?}/sw.js" elif grep -q '"swExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then rm -f "${target:?}/sw.js" fi # Rollback removes the deployment stamp: the restored frontend is NOT a # self-updater deployment, so start.sh's source sync must resume authority. rm -f "${target:?}/update-stamp.json" # Empty dirs left behind by the removal pass. find "${target:?}/dist" "${target:?}/css" "${target:?}/js" "${target:?}/vendor" "${target:?}/assets" \ -mindepth 1 -type d -empty -delete 2>/dev/null || true log "Frontend restored" } # ── Docker space reclaim (DC-122) ───────────────────────────────────────────── # Every rebuild leaves the previous image dangling (~250MB); prune it so a # churn of auto-updates doesn't seize disk. Safe to call at any exit point. prune_docker() { docker image prune -f --filter "dangling=true" >/dev/null 2>&1 || true docker builder prune -f --keep-storage 500m >/dev/null 2>&1 || true } wait_for_health() { local port="${1:-3001}" local timeout="$HEALTH_TIMEOUT" local elapsed=0 log "Waiting for health check (timeout: ${timeout}s)..." while (( elapsed < timeout )); do if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then log "Health check passed after ${elapsed}s" return 0 fi sleep 2 elapsed=$(( elapsed + 2 )) done log "Health check FAILED after ${timeout}s" return 1 } # ── Shared rollback: restore code + data ──────────────────────────────────── rollback_restore() { local backup_dir="$1" log "Rolling back: restoring code files..." for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done if [[ -d "$backup_dir/routes" ]]; then rm -rf "$api_source_dir/routes" cp -rf "$backup_dir/routes" "$api_source_dir/routes" fi if [[ -d "$backup_dir/src" ]]; then rm -rf "$api_source_dir/src" cp -rf "$backup_dir/src" "$api_source_dir/src" fi if [[ -d "$backup_dir/dns-providers" ]]; then rm -rf "$api_source_dir/dns-providers" cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" fi restore_data_dir "$backup_dir" restore_frontend_dir "$backup_dir" } # ── Deployment mode ─────────────────────────────────────────────────────────── # Reproduce the SAME container the install created so an auto-update keeps every # volume + env var (docker socket, Caddyfile, config/credentials, updates mount), # not a minimal subset. Standard installs use docker-compose (compose file in the # api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a # bare docker run is the last resort. build_image() and restart_container() both # honor the detected mode so build and run stay consistent. deploy_mode() { if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then echo compose elif [[ -x /opt/dashcaddy/start.sh ]]; then echo startsh else echo run fi } # Build the API image using whatever the install is wired for. Returns the build # command's exit status so callers can detect failure. build_image() { cd "$api_source_dir" || return 1 case "$(deploy_mode)" in compose) docker compose build 2>&1 || docker-compose build 2>&1 ;; *) docker build -t "$IMAGE_TAG" . 2>&1 ;; esac } # ── Shared container restart — recreate with the full, install-defined spec ─── # Recreates (rm + run / compose up) so new code AND new env vars take effect. restart_container() { cd "$api_source_dir" 2>/dev/null || true case "$(deploy_mode)" in compose) log "Recreating container via docker compose (full compose spec)..." docker compose up -d 2>&1 || docker-compose up -d 2>&1 ;; startsh) log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..." bash /opt/dashcaddy/start.sh ;; *) log "Recreating container via minimal docker run (fallback)..." docker rm -f "$CONTAINER_NAME" 2>/dev/null || true docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ --log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \ -p 127.0.0.1:3001:3001 \ -v /opt/dashcaddy/dashcaddy-api/data:/app/data \ -e SERVICES_FILE=/app/data/services.json \ "$IMAGE_TAG" ;; esac log "Container recreated" } # ── Code-only restore (used after failed build when data hasn't changed yet) ── code_restore() { local backup_dir="$1" log "Restoring code files..." for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done if [[ -d "$backup_dir/routes" ]]; then rm -rf "$api_source_dir/routes" cp -rf "$backup_dir/routes" "$api_source_dir/routes" fi if [[ -d "$backup_dir/src" ]]; then rm -rf "$api_source_dir/src" cp -rf "$backup_dir/src" "$api_source_dir/src" fi if [[ -d "$backup_dir/dns-providers" ]]; then rm -rf "$api_source_dir/dns-providers" cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" fi } main() { local start_time start_time=$(date +%s) # 1. Read trigger if [[ ! -f "$TRIGGER_FILE" ]]; then log "No trigger file found — nothing to do" exit 0 fi # Parse trigger.json (uses python3 which is available on all supported distros) local action version from_version staging_dir api_source_dir commit channel local frontend_staging_dir frontend_target_dir action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])") version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])") from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])") staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])") api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])") commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')") frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')") frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')") channel=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('channel') or 'stable')") # Handle action=rollback (no new version to deploy) local to_version="${version}" log "=== ${action^^}: v${from_version} -> v${to_version} (channel: ${channel}) ===" log "Staging: ${staging_dir}" log "API source: ${api_source_dir}" # Consume the trigger immediately so we don't re-process on failure mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing" # Channel gate: refuse to apply prereleases unless explicitly opted-in. # Rollbacks always allowed (no new release channel involved). if [[ "${action}" != "rollback" ]] && ! channel_allowed "${channel}"; then write_result "false" "$to_version" "0" "Channel '${channel}' not allowed on this host" rm -f "${TRIGGER_FILE}.processing" exit 1 fi # DC-122: validate the frontend target BEFORE any backup/deploy mutation. # An invalid trigger-provided target is a hard failure of the whole update # (the release cannot be applied faithfully), not a silent frontend skip. if [[ "$action" != "rollback" && -n "$frontend_target_dir" ]] && ! validate_frontend_target "$frontend_target_dir"; then log "ERROR: frontend target '${frontend_target_dir}' failed validation — refusing update" write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Invalid frontendTargetDir in trigger: ${frontend_target_dir}" rm -f "${TRIGGER_FILE}.processing" exit 1 fi # ── Handle rollback ──────────────────────────────────────────────────────── if [[ "$action" == "rollback" ]]; then local backup_dir="${BACKUPS_DIR}/${version}" if [[ ! -d "$backup_dir" ]]; then log "ERROR: No backup found for version ${version}" write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}" rm -f "${TRIGGER_FILE}.processing" exit 1 fi log "Performing rollback to v${version}..." rollback_restore "$backup_dir" # Rebuild old code — DC-122: a rollback that cannot rebuild or cannot # recover health is a FAILED rollback and must be reported as such. log "Rebuilding container..." local rebuild_ok=false health_ok=false if build_image; then rebuild_ok=true; fi restart_container if wait_for_health; then health_ok=true; fi if [[ "$rebuild_ok" == "true" && "$health_ok" == "true" ]]; then write_result "true" "$version" "$(( $(date +%s) - start_time ))" log "=== Rollback complete ===" prune_docker rm -f "${TRIGGER_FILE}.processing" exit 0 else write_result "false" "$version" "$(( $(date +%s) - start_time ))" \ "Rollback incomplete (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED" log "=== Rollback FAILED (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED ===" prune_docker rm -f "${TRIGGER_FILE}.processing" exit 1 fi fi # ── Handle update ─────────────────────────────────────────────────────────── if [[ ! -d "$staging_dir" ]]; then log "ERROR: Staging directory not found: ${staging_dir}" write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found" rm -f "${TRIGGER_FILE}.processing" exit 1 fi # 2. Backup current API code + data/ local backup_dir="${BACKUPS_DIR}/${from_version}" mkdir -p "$backup_dir" log "Backing up current API files to ${backup_dir}" for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true done [[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/" [[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/" [[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/" # Backup data/ directory (services.json, config.json, credentials, etc.) backup_data_dir "$backup_dir" # DC-122: snapshot the live frontend so a failed update can roll it back # (frontend is synced to the web root before build+health check). # Use the TRIGGER-provided target when set (custom installs) — the snapshot # must cover exactly the dir the deploy will touch — else discover. local fe_target="${frontend_target_dir}" if [[ -z "$fe_target" ]]; then for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do [[ -d "$candidate" ]] && fe_target="$candidate" && break done fi [[ -n "$fe_target" && -d "$fe_target" ]] && backup_frontend_dir "$backup_dir" "$fe_target" # Backup updater state (trigger.json.processing + result.json) so post-mortem # has a forensic trail tied to this exact version's backup. backup_update_state "$backup_dir" cleanup_old_backups # 3. Copy new files from staging to API source log "Deploying new API files..." for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do [[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true done # Safety: only replace routes/src if staging has the dir AND it's non-empty. # An empty or partial staging dir used to cause live routes/src to be wiped # when a prior update cycle was interrupted. We also handle locked files # (chattr +i) by temporarily unlocking before replace and re-locking after. deploy_tree() { local rel="$1" # e.g. "routes" local src="${staging_dir}/${rel}" local dst="${api_source_dir}/${rel}" if [[ ! -d "$src" ]] || [[ -z "$(ls -A "$src" 2>/dev/null)" ]]; then [[ -d "$src" ]] && log "WARNING: staging ${rel}/ exists but is empty — leaving live ${rel}/ untouched" return 0 fi # Collect any locked files (chattr +i) in the destination. lsattr's # first field is the attribute flags ("i" at position 5 = immutable); # the second field is the filename. We unlock before rm -rf and re-lock # after so the locked state survives the update. local locked_files=() if [[ -d "$dst" ]]; then while IFS= read -r lf; do [[ -n "$lf" ]] && locked_files+=("$lf") done < <(find "$dst" -type f \( -name "*.js" -o -name "*.json" -o -name "*.sh" \) -print0 2>/dev/null \ | xargs -0 lsattr -a 2>/dev/null \ | awk '$1 ~ /i/ { print $2 }') fi for lf in "${locked_files[@]:-}"; do [[ -n "$lf" ]] && chattr -i "$lf" 2>/dev/null || true done rm -rf "$dst" cp -rf "$src" "$dst" local file_count file_count=$(find "$dst" -type f 2>/dev/null | wc -l) log "${rel}/ deployed (${file_count} files)" for lf in "${locked_files[@]:-}"; do [[ -n "$lf" ]] && [[ -f "$lf" ]] && chattr +i "$lf" 2>/dev/null || true done } deploy_tree "routes" deploy_tree "src" deploy_tree "dns-providers" if [[ -n "$commit" ]]; then echo "$commit" > "$api_source_dir/VERSION" fi # 3a. Apply post-deploy patches — fix upstream bugs in released tarballs # (e.g. v1.14.4 has broken require paths and missing license-keygen module). # Runs AFTER staging copy, BEFORE docker build. Idempotent. local patch_script="/opt/dashcaddy/scripts/dashcaddy-post-deploy-patches.sh" if [[ -x "$patch_script" ]]; then log "Applying post-deploy patches..." if "$patch_script" "$api_source_dir"; then log "Post-deploy patches applied successfully" else log "WARNING: Post-deploy patches exited non-zero — continuing build anyway" fi else log "NOTE: $patch_script not found or not executable — skipping post-deploy patches" fi # 3b. Sync frontend if [[ -z "$frontend_staging_dir" ]]; then parent_staging=$(dirname "$staging_dir") [[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status" fi if [[ -z "$frontend_target_dir" ]]; then for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do [[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break done fi if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then # DC-122: same validator gates deployment — if the trigger-provided custom # target doesn't validate, refuse before mutating anything. if ! validate_frontend_target "$frontend_target_dir"; then log "ERROR: frontend target '${frontend_target_dir}' failed validation — skipping frontend sync (update continues for API only)" else log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir" mkdir -p "$frontend_target_dir" [[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html" [[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js" for sub in dist css vendor js; do if [[ -d "$frontend_staging_dir/$sub" ]]; then mkdir -p "$frontend_target_dir/$sub" cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true fi done if [[ -d "$frontend_staging_dir/assets" ]]; then mkdir -p "$frontend_target_dir/assets" cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true fi # DC-122: host-side deployment stamp — start.sh treats a stamped, newer # deployment as authoritative and skips its source-bundle sync (this is # the only writer that can reach the web root with real host paths). local esc_ver esc_ver=$(json_escape "$to_version") printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ > "$frontend_target_dir/update-stamp.json" 2>/dev/null || true fi # DC-122 close: validated frontend-target branch fi # 4. Rebuild container log "Rebuilding container..." local build_ok=false if build_image; then build_ok=true fi if [[ "$build_ok" != "true" ]]; then log "ERROR: Docker build failed — rolling back code + data" code_restore "$backup_dir" restore_frontend_dir "$backup_dir" local rb_rebuild_ok=false rb_health_ok=false if build_image; then rb_rebuild_ok=true; fi restart_container if wait_for_health; then rb_health_ok=true; fi prune_docker if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed — rolled back cleanly" else write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" \ "Docker build failed AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED" fi rm -f "${TRIGGER_FILE}.processing" exit 1 fi # 5. Restart container (recreate so new code + env vars take effect) restart_container # 6. Health check if wait_for_health; then local duration=$(( $(date +%s) - start_time )) log "=== Update successful: v${to_version} in ${duration}s ===" write_result "true" "$to_version" "$duration" else local duration=$(( $(date +%s) - start_time )) log "ERROR: Health check failed after update — rolling back code + data" rollback_restore "$backup_dir" local rb_rebuild_ok=false rb_health_ok=false if build_image; then rb_rebuild_ok=true; fi restart_container if wait_for_health; then rb_health_ok=true; fi prune_docker if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then write_result "false" "$to_version" "$duration" "Health check failed after update — rolled back cleanly" else write_result "false" "$to_version" "$duration" \ "Health check failed after update AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED" fi fi # 7. Cleanup rm -f "${TRIGGER_FILE}.processing" rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true # 8. DC-122: reclaim docker space after every self-update rebuild (old # dangling image layers otherwise accumulate ~250MB per apply). prune_docker log "=== Update process complete ===" } main "$@"