DC-025: harden updater — channel gate + safe locked-file replacement
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
Krystie
2026-07-01 04:02:30 -07:00
parent b7624cc507
commit bfa4ba570e
2 changed files with 101 additions and 14 deletions
+92 -14
View File
@@ -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
+9
View File
@@ -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