[grade=A urn:ump:seyasvbntxsjibq5jiaqmfowc6xgchwe4jvcc54jd6355ujgm75q] DC-122: self-updater hardening + auto-update on + docker disk discipline (v1.16.0)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- _isNewer: same-version releases are never 'newer' (commit labels are
  opaque stamps) — kills the same-version auto-apply regression loop
- _autoCheckAndApply: identical version@sha256 never re-applied
- dashcaddy-update.sh: truthful rollback verdicts (failed rebuild/health
  = exit 1 + failure result), exact frontend snapshot/restore (incl.
  update-introduced owned subtrees), contents-copy cp fallbacks with
  manifest reconciliation, JSON-encoded results/meta/stamp, prune on
  every exit path
- self-updater: frontend-only Linux releases fail loudly (no more silent
  no-op success stuck in 'applying')
- start.sh: DASHCADDY_UPDATE_ENABLED=true (Sami 2026-09-13), json-file
  log caps 10M x3, source->webroot sync via update-stamp contract
- tests: _isNewer regression suite, functional cycle + json/escape suites
  (scripts/test-frontend-cycle.sh, scripts/test-json-escape.sh)

15 judge rounds: C,C,D,D,C,C,D,C,C,C,D,C,A
This commit is contained in:
DashCaddy Polish Loop
2026-09-13 05:32:14 -07:00
parent 939fdbb68b
commit 70e252c8a5
7 changed files with 631 additions and 46 deletions
+291 -25
View File
@@ -72,30 +72,79 @@ channel_allowed() {
esac
}
# ── JSON string escaping (DC-122) ─────────────────────────────────────────────
# Complete JSON string encoder for the rare no-python fallback path: mandatory
# escapes (quote, backslash) plus ALL control bytes U+0000U+001F as \uXXXX or
# their short forms.
json_escape() {
local s="$1" out="" ch i hex
for (( i=0; i<${#s}; i++ )); do
ch="${s:i:1}"
case "$ch" in
\\) out+='\\' ;;
\") out+='\"' ;;
$'\b') out+='\b' ;;
$'\f') out+='\f' ;;
$'\n') out+='\n' ;;
$'\r') out+='\r' ;;
$'\t') out+='\t' ;;
*)
if [[ "$ch" < ' ' || "$ch" == $'\x7f' ]]; then
printf -v hex '%02x' "'$ch"
out+="\u00${hex}"
else
out+="$ch"
fi
;;
esac
done
printf '%s' "$out"
}
write_result() {
local success="$1" version="$2" duration="$3"
shift 3
local error="${1:-}"
if [[ "$success" == "true" ]]; then
cat > "$RESULT_FILE" <<EOF
if command -v python3 >/dev/null 2>&1; then
python3 - "$success" "$version" "$duration" "$error" > "$RESULT_FILE" <<'PY'
import json, sys, datetime
success, version, duration, error = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
obj = {
"success": success == "true",
"version": version,
"duration": int(duration) if duration.isdigit() else 0,
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
if error:
obj["error"] = error
print(json.dumps(obj, indent=2))
PY
else
# Safe fallback: escaped interpolation (no raw quote/backslash leakage).
local esc_version esc_error
esc_version=$(json_escape "$version")
esc_error=$(json_escape "$error")
if [[ "$success" == "true" ]]; then
cat > "$RESULT_FILE" <<EOF
{
"success": true,
"version": "${version}",
"version": "${esc_version}",
"duration": ${duration},
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
else
cat > "$RESULT_FILE" <<EOF
else
cat > "$RESULT_FILE" <<EOF
{
"success": false,
"version": "${version}",
"version": "${esc_version}",
"duration": ${duration},
"error": "${error}",
"error": "${esc_error}",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
fi
fi
}
@@ -116,8 +165,15 @@ backup_data_dir() {
if [[ -d "$DATA_SOURCE_DIR" ]]; then
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
# DC-122: fallback must copy CONTENTS ("dir/.") — a bare "cp -a dir dest"
# nests a data/ level inside the existing destination dir, which then made
# restore_data_dir restore data/data/... (rollback restoring nothing).
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|| { log "rsync unavailable — cp fallback (contents copy)"; cp -a "$DATA_SOURCE_DIR/." "${backup_dir}/${DATA_BACKUP_PREFIX}/"; }
# Manifest of backed-up files — lets the no-rsync restore path mirror
# rsync --delete semantics (remove live files that the backup lacks).
( cd "${backup_dir}/${DATA_BACKUP_PREFIX}" && find . -type f -printf '%P\n' | sort ) \
> "${backup_dir}/data.manifest" 2>/dev/null || true
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
else
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
@@ -163,15 +219,157 @@ restore_data_dir() {
local backup_dir="$1"
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
if [[ -d "$data_backup" ]]; then
log "Restoring data/ from backup..."
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
log "Data restored successfully"
# DC-122: contents copy on the cp fallback — see backup_data_dir note.
if rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null; then
log "Data restored successfully (rsync --delete)"
else
log "rsync unavailable — cp fallback + manifest reconciliation"
cp -a "$data_backup/." "$DATA_SOURCE_DIR/"
# Mirror deletion semantics without rsync: remove live files that the
# backup manifest says did not exist at backup time.
local manifest="${backup_dir}/data.manifest"
if [[ -f "$manifest" ]]; then
local live_list deleted=0
live_list="$( cd "$DATA_SOURCE_DIR" && find . -type f -printf '%P\n' | sort )"
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
# defense against path traversal in a corrupted manifest
[[ "$rel" == ..* || "$rel" == */..* || "$rel" == *../* ]] && continue
rm -f "$DATA_SOURCE_DIR/$rel" && deleted=$(( deleted + 1 ))
done < <(comm -13 "$manifest" <(printf '%s\n' "$live_list"))
# prune directories that became empty
find "$DATA_SOURCE_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null || true
log "Manifest reconciliation: removed ${deleted} post-backup file(s)"
fi
log "Data restored successfully (cp fallback)"
fi
else
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
fi
}
# ── Frontend backup/restore (DC-122) ──────────────────────────────────────────
# The frontend is synced to the live web root BEFORE the API rebuild + health
# check. If the update then fails, the new frontend would pair with the rolled
# back API. Snapshot exactly what we touch so rollback can restore it.
FRONTEND_SUBDIRS="dist css vendor js assets"
# Snapshot = files + a manifest + metadata, so restore is EXACT:
# frontend.meta : JSON with target dir + whether index.html/sw.js existed
# frontend.manifest : every file that existed at snapshot time (relative)
# Restore deletes live files that the snapshot manifest does not know about
# (an update-introduced asset dies with the update) and recreates absent
# snapshot files.
backup_frontend_dir() {
local backup_dir="$1" target="$2"
# DC-122: reset any stale snapshot from a previous update with the same
# version key before writing this one.
rm -rf "${backup_dir}/frontend" "${backup_dir}/frontend.manifest" "${backup_dir}/frontend.meta"
mkdir -p "${backup_dir}/frontend"
[[ -f "$target/index.html" ]] && cp -f "$target/index.html" "${backup_dir}/frontend/index.html"
[[ -f "$target/sw.js" ]] && cp -f "$target/sw.js" "${backup_dir}/frontend/sw.js"
for sub in $FRONTEND_SUBDIRS; do
[[ -d "$target/$sub" ]] && cp -rf "$target/$sub" "${backup_dir}/frontend/$sub"
done
( cd "${backup_dir}/frontend" && find . -type f -printf '%P\n' | sort ) \
> "${backup_dir}/frontend.manifest" 2>/dev/null || true
# DC-122: use a validated target path for frontend.meta — json-escaped.
local esc_target
esc_target=$(json_escape "$target")
printf '{"target":"%s","indexExisted":%s,"swExisted":%s}\n' \
"$esc_target" \
"$( [[ -f "$target/index.html" ]] && echo true || echo false )" \
"$( [[ -f "$target/sw.js" ]] && echo true || echo false )" \
> "${backup_dir}/frontend.meta"
log "Frontend snapshot saved to ${backup_dir}/frontend ($(wc -l < "${backup_dir}/frontend.manifest" 2>/dev/null || echo 0) files)"
}
# ── Frontend target validation (DC-122) ───────────────────────────────────────
# A custom DASHCADDY_HOST_FRONTEND_DIR is supported, but ONLY if it validates:
# absolute, exists, is a directory, no '..' components, not the filesystem root.
# The SAME validator gates deploy and restore, so anything we deploy to is
# something we can roll back, and nothing else is ever touched.
validate_frontend_target() {
local t="$1"
[[ -n "$t" ]] || return 1
[[ "$t" = /* ]] || return 1
[[ "$t" != "/" ]] || return 1
[[ "$t" != *..* ]] || return 1
[[ -d "$t" ]] || return 1
return 0
}
restore_frontend_dir() {
local backup_dir="$1"
local snap="${backup_dir}/frontend"
[[ -d "$snap" ]] || { log "No frontend snapshot in this backup — skipping frontend restore"; return 0; }
# DC-122: resolve the target FIRST, validate BEFORE any delete/copy this
# function performs. A recorded custom target is honored — it was validated
# by this same function's rules before deployment, and recorded in meta.
local target=""
if [[ -f "${backup_dir}/frontend.meta" ]]; then
target=$(python3 -c "import json;print(json.load(open('${backup_dir}/frontend.meta')).get('target',''))" 2>/dev/null)
# DC-122 (hardened after live-fire test caught it): if the recorded meta
# target is present but INVALID, refuse outright. Never fall through to
# discovery with an untrusted/invalid target — that could point the
# restore at a directory the snapshot was never taken from.
if [[ -n "$target" ]] && ! validate_frontend_target "$target"; then
log "REFUSING frontend restore: recorded meta target is invalid: '${target}'"
return 1
fi
validate_frontend_target "$target" || target=""
fi
if [[ -z "$target" ]]; then
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
[[ -d "$candidate" ]] && target="$candidate" && break
done
fi
validate_frontend_target "$target" || { log "Refusing frontend restore on unexpected/unknown target: '${target:-}'"; return 1; }
log "Restoring frontend to ${target} (exact state, updater-owned scope)..."
# DC-122: structural exact restore of the owned subtrees:
# - snapshot has the subtree → rm -rf live + copy snapshot (exact)
# - snapshot lacks the subtree but live has it → the update introduced it
# ⇒ remove it. (Fixes rollback leaving behind e.g. a css/ dir the update
# created when none existed before.)
# index.html / sw.js are governed by frontend.meta existence flags.
# Files outside FRONTEND_SUBDIRS are NEVER touched.
for sub in $FRONTEND_SUBDIRS; do
if [[ -d "$snap/$sub" ]]; then
rm -rf "${target:?}/${sub:?}"
cp -rf "$snap/$sub" "${target:?}/$sub"
elif [[ -d "${target:?}/${sub}" ]]; then
rm -rf "${target:?}/${sub:?}"
log "Removed update-introduced subtree: ${sub}/"
fi
done
# index.html / sw.js per frontend.meta existence flags (single pass).
if grep -q '"indexExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/index.html" ]]; then
cp -f "$snap/index.html" "${target:?}/index.html"
elif grep -q '"indexExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
rm -f "${target:?}/index.html"
fi
if grep -q '"swExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/sw.js" ]]; then
cp -f "$snap/sw.js" "${target:?}/sw.js"
elif grep -q '"swExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
rm -f "${target:?}/sw.js"
fi
# Rollback removes the deployment stamp: the restored frontend is NOT a
# self-updater deployment, so start.sh's source sync must resume authority.
rm -f "${target:?}/update-stamp.json"
# Empty dirs left behind by the removal pass.
find "${target:?}/dist" "${target:?}/css" "${target:?}/js" "${target:?}/vendor" "${target:?}/assets" \
-mindepth 1 -type d -empty -delete 2>/dev/null || true
log "Frontend restored"
}
# ── Docker space reclaim (DC-122) ─────────────────────────────────────────────
# Every rebuild leaves the previous image dangling (~250MB); prune it so a
# churn of auto-updates doesn't seize disk. Safe to call at any exit point.
prune_docker() {
docker image prune -f --filter "dangling=true" >/dev/null 2>&1 || true
docker builder prune -f --keep-storage 500m >/dev/null 2>&1 || true
}
wait_for_health() {
local port="${1:-3001}"
local timeout="$HEALTH_TIMEOUT"
@@ -211,6 +409,7 @@ rollback_restore() {
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
fi
restore_data_dir "$backup_dir"
restore_frontend_dir "$backup_dir"
}
# ── Deployment mode ───────────────────────────────────────────────────────────
@@ -257,6 +456,7 @@ restart_container() {
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" \
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \
-p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \
@@ -327,6 +527,16 @@ main() {
exit 1
fi
# DC-122: validate the frontend target BEFORE any backup/deploy mutation.
# An invalid trigger-provided target is a hard failure of the whole update
# (the release cannot be applied faithfully), not a silent frontend skip.
if [[ "$action" != "rollback" && -n "$frontend_target_dir" ]] && ! validate_frontend_target "$frontend_target_dir"; then
log "ERROR: frontend target '${frontend_target_dir}' failed validation — refusing update"
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Invalid frontendTargetDir in trigger: ${frontend_target_dir}"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
# ── Handle rollback ────────────────────────────────────────────────────────
if [[ "$action" == "rollback" ]]; then
local backup_dir="${BACKUPS_DIR}/${version}"
@@ -340,17 +550,29 @@ main() {
log "Performing rollback to v${version}..."
rollback_restore "$backup_dir"
# Rebuild old code
# Rebuild old code — DC-122: a rollback that cannot rebuild or cannot
# recover health is a FAILED rollback and must be reported as such.
log "Rebuilding container..."
build_image 2>&1 | tail -3 || true
local rebuild_ok=false health_ok=false
if build_image; then rebuild_ok=true; fi
restart_container
wait_for_health || log "WARNING: Health check failed after rollback"
if wait_for_health; then health_ok=true; fi
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
rm -f "${TRIGGER_FILE}.processing"
log "=== Rollback complete ==="
exit 0
if [[ "$rebuild_ok" == "true" && "$health_ok" == "true" ]]; then
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
log "=== Rollback complete ==="
prune_docker
rm -f "${TRIGGER_FILE}.processing"
exit 0
else
write_result "false" "$version" "$(( $(date +%s) - start_time ))" \
"Rollback incomplete (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED"
log "=== Rollback FAILED (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED ==="
prune_docker
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
fi
# ── Handle update ───────────────────────────────────────────────────────────
@@ -375,6 +597,18 @@ main() {
# Backup data/ directory (services.json, config.json, credentials, etc.)
backup_data_dir "$backup_dir"
# DC-122: snapshot the live frontend so a failed update can roll it back
# (frontend is synced to the web root before build+health check).
# Use the TRIGGER-provided target when set (custom installs) — the snapshot
# must cover exactly the dir the deploy will touch — else discover.
local fe_target="${frontend_target_dir}"
if [[ -z "$fe_target" ]]; then
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
[[ -d "$candidate" ]] && fe_target="$candidate" && break
done
fi
[[ -n "$fe_target" && -d "$fe_target" ]] && backup_frontend_dir "$backup_dir" "$fe_target"
# Backup updater state (trigger.json.processing + result.json) so post-mortem
# has a forensic trail tied to this exact version's backup.
backup_update_state "$backup_dir"
@@ -460,6 +694,11 @@ main() {
done
fi
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
# DC-122: same validator gates deployment — if the trigger-provided custom
# target doesn't validate, refuse before mutating anything.
if ! validate_frontend_target "$frontend_target_dir"; then
log "ERROR: frontend target '${frontend_target_dir}' failed validation — skipping frontend sync (update continues for API only)"
else
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
mkdir -p "$frontend_target_dir"
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
@@ -474,6 +713,14 @@ main() {
mkdir -p "$frontend_target_dir/assets"
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
fi
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
# deployment as authoritative and skips its source-bundle sync (this is
# the only writer that can reach the web root with real host paths).
local esc_ver
esc_ver=$(json_escape "$to_version")
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
fi # DC-122 close: validated frontend-target branch
fi
# 4. Rebuild container
@@ -486,10 +733,18 @@ main() {
if [[ "$build_ok" != "true" ]]; then
log "ERROR: Docker build failed — rolling back code + data"
code_restore "$backup_dir"
build_image 2>&1 | tail -3 || true
restore_frontend_dir "$backup_dir"
local rb_rebuild_ok=false rb_health_ok=false
if build_image; then rb_rebuild_ok=true; fi
restart_container
wait_for_health || true
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
if wait_for_health; then rb_health_ok=true; fi
prune_docker
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed — rolled back cleanly"
else
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" \
"Docker build failed AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
fi
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
@@ -506,16 +761,27 @@ main() {
local duration=$(( $(date +%s) - start_time ))
log "ERROR: Health check failed after update — rolling back code + data"
rollback_restore "$backup_dir"
build_image 2>&1 | tail -3 || true
local rb_rebuild_ok=false rb_health_ok=false
if build_image; then rb_rebuild_ok=true; fi
restart_container
wait_for_health || log "WARNING: Rollback health check also failed"
write_result "false" "$to_version" "$duration" "Health check failed after update"
if wait_for_health; then rb_health_ok=true; fi
prune_docker
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
write_result "false" "$to_version" "$duration" "Health check failed after update — rolled back cleanly"
else
write_result "false" "$to_version" "$duration" \
"Health check failed after update AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
fi
fi
# 7. Cleanup
rm -f "${TRIGGER_FILE}.processing"
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
# 8. DC-122: reclaim docker space after every self-update rebuild (old
# dangling image layers otherwise accumulate ~250MB per apply).
prune_docker
log "=== Update process complete ==="
}