DC-025: harden updater — channel gate + safe locked-file replacement
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:
+92
-14
@@ -5,6 +5,10 @@
|
|||||||
# Writes result.json so the new container knows the outcome.
|
# Writes result.json so the new container knows the outcome.
|
||||||
#
|
#
|
||||||
# This runs on the HOST, outside the container.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ readonly CONTAINER_NAME="dashcaddy-api"
|
|||||||
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
||||||
readonly MAX_BACKUPS=3
|
readonly MAX_BACKUPS=3
|
||||||
readonly HEALTH_TIMEOUT=60
|
readonly HEALTH_TIMEOUT=60
|
||||||
|
readonly CHANNEL_CONF="${UPDATES_DIR}/channel.conf"
|
||||||
|
|
||||||
# Data directory backup — stored alongside code backups so everything rolls back together
|
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||||
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
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') $*"; }
|
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() {
|
write_result() {
|
||||||
local success="$1" version="$2" duration="$3"
|
local success="$1" version="$2" duration="$3"
|
||||||
shift 3
|
shift 3
|
||||||
@@ -215,7 +252,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse trigger.json (uses python3 which is available on all supported distros)
|
# 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
|
local frontend_staging_dir frontend_target_dir
|
||||||
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||||
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
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 '')")
|
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_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 '')")
|
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)
|
# Handle action=rollback (no new version to deploy)
|
||||||
local to_version="${version}"
|
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 "Staging: ${staging_dir}"
|
||||||
log "API source: ${api_source_dir}"
|
log "API source: ${api_source_dir}"
|
||||||
|
|
||||||
# Consume the trigger immediately so we don't re-process on failure
|
# Consume the trigger immediately so we don't re-process on failure
|
||||||
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
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 ────────────────────────────────────────────────────────
|
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||||
if [[ "$action" == "rollback" ]]; then
|
if [[ "$action" == "rollback" ]]; then
|
||||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
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
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
if [[ -d "$staging_dir/routes" ]]; then
|
# Safety: only replace routes/src if staging has the dir AND it's non-empty.
|
||||||
rm -rf "$api_source_dir/routes"
|
# An empty or partial staging dir used to cause live routes/src to be wiped
|
||||||
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
# when a prior update cycle was interrupted. We also handle locked files
|
||||||
fi
|
# (chattr +i) by temporarily unlocking before replace and re-locking after.
|
||||||
if [[ -d "$staging_dir/src" ]]; then
|
deploy_tree() {
|
||||||
rm -rf "$api_source_dir/src"
|
local rel="$1" # e.g. "routes"
|
||||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
local src="${staging_dir}/${rel}"
|
||||||
fi
|
local dst="${api_source_dir}/${rel}"
|
||||||
if [[ -d "$staging_dir/dns-providers" ]]; then
|
|
||||||
rm -rf "$api_source_dir/dns-providers"
|
if [[ ! -d "$src" ]] || [[ -z "$(ls -A "$src" 2>/dev/null)" ]]; then
|
||||||
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
[[ -d "$src" ]] && log "WARNING: staging ${rel}/ exists but is empty — leaving live ${rel}/ untouched"
|
||||||
fi
|
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
|
if [[ -n "$commit" ]]; then
|
||||||
echo "$commit" > "$api_source_dir/VERSION"
|
echo "$commit" > "$api_source_dir/VERSION"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user