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).
This commit is contained in:
Krystie
2026-07-06 15:26:10 -07:00
parent a6201b47cd
commit ca705fe59f
+176 -43
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
@@ -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