From ca705fe59f51e323b5e09f4a97ebf0c12bd7d0db Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 6 Jul 2026 15:26:10 -0700 Subject: [PATCH] DC-025: sync DC-025 hardening (channel gate, locked-file deploy_tree, deploy_mode, post-deploy patches, dns-providers handling) into canonical dashcaddy-api/scripts/dashcaddy-update.sh The host-side /opt/dashcaddy/scripts/dashcaddy-update.sh was hardened in DC-025 (commit bfa4ba5, 2026-07-05), but the canonical script at dashcaddy-api/scripts/dashcaddy-update.sh was never updated. This created a drift hazard: anyone running release.sh and rebuilding the install tarball would propagate the pre-hardening version, undoing DC-025 on fresh hosts. This commit syncs the hardening from the host-side script to the canonical, so the next release builds and ships the hardened version. Specifically adds: - channel_allowed() gate (refuse prereleases unless ALLOW_PRERELEASE=true) - deploy_mode() dispatch (compose / start.sh / bare docker run) - build_image() helper - deploy_tree() with chattr +i preservation and empty-staging-dir guard - Post-deploy patches invocation (dashcaddy-post-deploy-patches.sh) - dns-providers directory backup/restore Verified: bash -n passes on both scripts; canonical and host-side are now byte-identical (md5 a72e1dc37fb3487edc00e81ea37ac60b). Discovered while investigating a WIP on DNS2 that had silently reverted these features. That WIP was discarded (the BACKLOG entry it claimed to satisfy described an implementation that didn't exist in the diff). --- dashcaddy-api/scripts/dashcaddy-update.sh | 219 +++++++++++++++++----- 1 file changed, 176 insertions(+), 43 deletions(-) diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh index 6f028f9..1d3f1b3 100755 --- a/dashcaddy-api/scripts/dashcaddy-update.sh +++ b/dashcaddy-api/scripts/dashcaddy-update.sh @@ -5,6 +5,10 @@ # 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 @@ -13,8 +17,10 @@ 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" @@ -22,6 +28,38 @@ readonly DATA_BACKUP_PREFIX="data-backup" 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 +} + write_result() { local success="$1" version="$2" duration="$3" shift 3 @@ -122,30 +160,64 @@ rollback_restore() { 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" } -# ── Shared container restart (preserves SERVICES_FILE env var) ─────────────── -# Uses rm + run so new env vars (e.g. SERVICES_FILE) take effect. -# If docker-compose is not configured, falls back to docker start. -restart_container() { - local image="$1" - log "Restarting container (rm + run to pick up env vars)..." - # Stop and remove existing container so new env var is applied - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true +# ── 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 +} - # Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE + - # ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP - # secret from the bind-mounted host data dir (not image-local /app/credentials.json - # which gets a fresh encryption key on every container recreate = TOTP breaks). - docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ - -p 127.0.0.1:3001:3001 \ - -v /opt/dashcaddy/dashcaddy-api/data:/app/data \ - -e SERVICES_FILE=/app/data/services.json \ - -e CREDENTIALS_FILE=/app/d...son \ - -e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \ - "$image" - log "Container restarted with fresh env" +# 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" \ + -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) ── @@ -163,6 +235,10 @@ code_restore() { 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() { @@ -176,7 +252,7 @@ main() { fi # Parse trigger.json (uses python3 which is available on all supported distros) - local action version from_version staging_dir api_source_dir commit + 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'])") @@ -186,16 +262,25 @@ main() { 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} ===" + 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 + # ── Handle rollback ──────────────────────────────────────────────────────── if [[ "$action" == "rollback" ]]; then local backup_dir="${BACKUPS_DIR}/${version}" @@ -211,10 +296,9 @@ main() { # Rebuild old code log "Rebuilding container..." - cd "$api_source_dir" - docker build -t dashcaddy-dashcaddy-api:latest . 2>&1 | tail -1 || true + build_image 2>&1 | tail -3 || true - restart_container "dashcaddy-dashcaddy-api:latest" + restart_container wait_for_health || log "WARNING: Health check failed after rollback" write_result "true" "$version" "$(( $(date +%s) - start_time ))" @@ -240,6 +324,7 @@ main() { 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" @@ -251,18 +336,69 @@ main() { 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 - if [[ -d "$staging_dir/routes" ]]; then - rm -rf "$api_source_dir/routes" - cp -rf "$staging_dir/routes" "$api_source_dir/routes" - fi - if [[ -d "$staging_dir/src" ]]; then - rm -rf "$api_source_dir/src" - cp -rf "$staging_dir/src" "$api_source_dir/src" - fi + # 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") @@ -292,27 +428,24 @@ main() { # 4. Rebuild container log "Rebuilding container..." - cd "$api_source_dir" local build_ok=false - local image_tag="dashcaddy-dashcaddy-api:latest" - - if docker build -t "$image_tag" . 2>&1; then + 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" - docker build -t "$image_tag" . 2>&1 | tail -3 || true - restart_container "$image_tag" + build_image 2>&1 | tail -3 || true + restart_container wait_for_health || true write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed" rm -f "${TRIGGER_FILE}.processing" exit 1 fi - # 5. Restart container (rm + run so new env vars take effect) - restart_container "$image_tag" + # 5. Restart container (recreate so new code + env vars take effect) + restart_container # 6. Health check if wait_for_health; then @@ -323,8 +456,8 @@ main() { local duration=$(( $(date +%s) - start_time )) log "ERROR: Health check failed after update — rolling back code + data" rollback_restore "$backup_dir" - docker build -t "$image_tag" . 2>&1 | tail -3 || true - restart_container "$image_tag" + build_image 2>&1 | tail -3 || true + restart_container wait_for_health || log "WARNING: Rollback health check also failed" write_result "false" "$to_version" "$duration" "Health check failed after update" fi