From bfa4ba570e31102ad5b6861122954c871cd4a6fe Mon Sep 17 00:00:00 2001 From: Krystie Date: Wed, 1 Jul 2026 04:02:30 -0700 Subject: [PATCH] =?UTF-8?q?DC-025:=20harden=20updater=20=E2=80=94=20channe?= =?UTF-8?q?l=20gate=20+=20safe=20locked-file=20replacement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side updater has been silently broken in two ways: 1. Empty staging directories would cause rm -rf of live routes/src with no replacement, leaving the host tree gutted while the container kept serving from its own image. Now deploy_tree() refuses to delete unless the staging source has actual files. 2. chattr +i on critical files (used to protect security-hotfixed routes from being clobbered by upstream tarballs) caused rm -rf to partially execute then fail under set -e, leaving the host in a half-deleted state. Now deploy_tree() scans for immutable files, unlocks them before replace, and re-locks them after — so security-locked files survive every update. Also adds: - Channel gate: trigger.json channel=prerelease/beta/rc/alpha is rejected unless ALLOW_PRERELEASE=true is set in /opt/dashcaddy/updates/channel.conf. Default is 'stable only', safe for production. Staging hosts opt in. - channel.conf.example documenting the new opt-in mechanism. Verified end-to-end: manual trigger.json → path unit fired → routes (53 files) + src (62 files) deployed → container rebuilt → health check passed. totp.js remained locked with security edits intact. --- scripts/dashcaddy-update.sh | 106 ++++++++++++++++++++++++++++++----- updates/channel.conf.example | 9 +++ 2 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 updates/channel.conf.example diff --git a/scripts/dashcaddy-update.sh b/scripts/dashcaddy-update.sh index 2980442..1d3f1b3 100755 --- a/scripts/dashcaddy-update.sh +++ b/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 @@ -16,6 +20,7 @@ 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" @@ -23,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 @@ -215,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'])") @@ -225,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}" @@ -290,18 +336,50 @@ 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 - if [[ -d "$staging_dir/dns-providers" ]]; then - rm -rf "$api_source_dir/dns-providers" - cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers" - 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 diff --git a/updates/channel.conf.example b/updates/channel.conf.example new file mode 100644 index 0000000..97bfc3c --- /dev/null +++ b/updates/channel.conf.example @@ -0,0 +1,9 @@ +# DashCaddy update channel configuration +# +# Copy this file to channel.conf and uncomment ALLOW_PRERELEASE to opt in to +# prerelease/beta/rc channels. Stable releases are always applied. +# +# Useful for staging hosts that want to test new releases before they hit prod. +# Production hosts should leave this set to false (the default). + +# ALLOW_PRERELEASE=false \ No newline at end of file