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:
@@ -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).
|
||||
# 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 \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
"$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"
|
||||
# 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
|
||||
if [[ -d "$staging_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user