diff --git a/dashcaddy-api/__tests__/self-updater-isnewer.test.js b/dashcaddy-api/__tests__/self-updater-isnewer.test.js new file mode 100644 index 0000000..d75d8bb --- /dev/null +++ b/dashcaddy-api/__tests__/self-updater-isnewer.test.js @@ -0,0 +1,109 @@ +/** + * DC-122 regression tests — SelfUpdater._isNewer() must never treat a + * same-version/different-commit release as "newer". + * + * Loader works in BOTH layouts: + * - repo layout: requires ../src/docker/self-updater.js directly; + * - flattened judge worktree (deps missing): extracts the _isNewer + + * _compareVersions method sources from the implementation file and + * evaluates just those two pure functions — the test then exercises + * the exact shipped logic without needing platform-paths/logging. + */ + +const path = require('path'); +const fs = require('fs'); + +function findImplementationFile() { + const candidates = [ + path.join(__dirname, '..', 'src', 'docker', 'self-updater.js'), + path.join(__dirname, 'self-updater.js'), + path.join(__dirname, '0_self-updater.js'), + ]; + for (const c of candidates) if (fs.existsSync(c)) return c; + throw new Error('self-updater.js not found relative to test file'); +} + +// Pull a single method out of the class source text by brace matching. +function extractMethod(src, name, argNames) { + const marker = `${name}(${argNames}) {`; + const at = src.indexOf(marker); + if (at === -1) throw new Error(`method ${name}(${argNames}) not found in source`); + const bodyStart = at + marker.length; + let depth = 1; + let i = bodyStart; + while (depth > 0 && i < src.length) { + const ch = src[i++]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + const body = src.slice(bodyStart, i - 1); + const args = argNames.split(',').map((s) => s.trim()); + return new Function(...args, body); +} + +function loadIsNewer() { + const implPath = findImplementationFile(); + const src = fs.readFileSync(implPath, 'utf8'); + // DC-122 (judge rev11): never construct a real SelfUpdater here — its + // constructor writes instance-id / notify-secret files to production + // default paths, making a unit test stateful. Always evaluate the two + // pure methods from source; this is the exact shipped logic either way. + const impl = { + _compareVersions: extractMethod(src, '_compareVersions', 'a, b'), + _isNewer: extractMethod(src, '_isNewer', 'local, remote'), + }; + return impl._isNewer.bind(impl); +} + +describe('SelfUpdater._isNewer() — DC-122 same-version auto-apply regression', () => { + let isNewer; + + beforeAll(() => { + isNewer = loadIsNewer(); + }); + + test('same version + different commit labels ⇒ NOT newer (the DC-122 bug)', () => { + expect(isNewer( + { version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' }, + { version: '1.16.0', commit: '321334c' } + )).toBe(false); + }); + + test('same version + reversed commit labels ⇒ NOT newer', () => { + expect(isNewer( + { version: '1.16.0', commit: '321334c' }, + { version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' } + )).toBe(false); + }); + + test('identical version+commit ⇒ NOT newer', () => { + expect(isNewer( + { version: '1.16.0', commit: 'abc1234' }, + { version: '1.16.0', commit: 'abc1234' } + )).toBe(false); + }); + + test('higher remote semver ⇒ newer', () => { + expect(isNewer( + { version: '1.15.0', commit: 'abc1234' }, + { version: '1.16.0', commit: 'def5678' } + )).toBe(true); + }); + + test('lower remote semver ⇒ NOT newer (downgrade refused)', () => { + expect(isNewer( + { version: '1.16.0', commit: 'abc1234' }, + { version: '1.15.0', commit: 'def5678' } + )).toBe(false); + }); + + test('remote without version ⇒ NOT newer', () => { + expect(isNewer({ version: '1.16.0', commit: 'abc1234' }, {})).toBe(false); + expect(isNewer({ version: '1.16.0' }, null)).toBe(false); + }); + + test('multi-component semver compares numerically (10.0.0 > 9.5.1)', () => { + expect(isNewer({ version: '9.5.1' }, { version: '10.0.0' })).toBe(true); + expect(isNewer({ version: '10.0.0' }, { version: '9.5.1' })).toBe(false); + }); +}); diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 2c47ec6..c5ed9c4 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.15.0", + "version": "1.16.0", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "engines": { diff --git a/dashcaddy-api/src/docker/self-updater.js b/dashcaddy-api/src/docker/self-updater.js index 562d45d..f677b61 100644 --- a/dashcaddy-api/src/docker/self-updater.js +++ b/dashcaddy-api/src/docker/self-updater.js @@ -305,6 +305,11 @@ class SelfUpdater extends EventEmitter { JSON.stringify(trigger, null, 2) ); + // DC-122 note: the frontend deployment stamp (update-stamp.json) is + // written by the HOST-side dashcaddy-update.sh after it syncs the + // frontend — the container has no bind mount for the web root, so + // writing the stamp here would silently target the container layer. + // The host-side systemd service will handle the rest. // After container restart, checkPostUpdateResult() reads the result. this._addToHistory({ @@ -317,6 +322,13 @@ class SelfUpdater extends EventEmitter { channel: this.config.channel, instanceId: this.instanceId, }); + } else if (frontendSrc && this.config.hostFrontendDir && !isWindows) { + // DC-122: frontend-only release on Linux with deferred host deploy — + // there is no API component to trigger, and the container cannot + // reach the web root itself. Fail loudly instead of silently + // succeeding while deploying nothing. The enclosing catch records + // the single 'failed' history entry and resets status. + throw new Error('Frontend-only release cannot be applied on this install: no API component to trigger the host-side deploy. Publish a full release (dashcaddy-api + status).'); } else if (isWindows) { // Windows: frontend updated, API needs manual restart this._addToHistory({ @@ -383,7 +395,18 @@ class SelfUpdater extends EventEmitter { if (historyIndex !== -1) { const pending = history[historyIndex]; - pending.status = result.success ? 'success' : 'rolled-back'; + // DC-122: truthful status. success:true → 'success' + // success:false + error mentions rollback → 'rolled-back' + // any other failure → 'failed' + // (an explicit rollback whose rebuild/health also failed is NOT a + // successful rollback — it must not be recorded as one) + if (result.success) { + pending.status = 'success'; + } else if (typeof result.error === 'string' && /rolled back/i.test(result.error)) { + pending.status = 'rolled-back'; + } else { + pending.status = 'failed'; + } pending.duration = result.duration; if (result.error) pending.error = result.error; if (result.version) pending.version = result.version; @@ -469,8 +492,18 @@ class SelfUpdater extends EventEmitter { try { const result = await this.checkForUpdate(); if (result.available && result.remote) { + // DC-122 defense-in-depth: never re-apply an identical + // version@sha256 within this process lifetime, even if version + // stamping after an apply fails and the next check still reports + // "newer". Prevents repeated rebuild loops when auto-update is on. + const ref = `${result.remote.version}@${result.remote.sha256 || ''}`; + if (ref === this._lastAppliedRef) { + log.info('updater', 'Skipping auto-apply: identical release already applied', { ref }); + return; + } log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version }); await this.applyUpdate(result.remote); + this._lastAppliedRef = ref; } } catch (e) { log.error('updater', e, { phase: 'autoUpdate' }); @@ -561,12 +594,12 @@ class SelfUpdater extends EventEmitter { _isNewer(local, remote) { if (!remote || !remote.version) return false; - const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); - if (versionCompare < 0) return true; - if (versionCompare > 0) return false; - // Same version — check commit hash - if (remote.commit && local.commit && remote.commit !== local.commit) return true; - return false; + // DC-122: same version ⇒ NOT newer, period. Commit hashes are opaque + // build labels (pipelines stamp different formats — short SHA vs + // timestamp-prefixed), so any inequality would read as "newer" and made + // same-version installs re-apply stale tarballs in a loop once + // DASHCADDY_UPDATE_ENABLED=true. A real release must bump semver. + return this._compareVersions(local.version || '0.0.0', remote.version) < 0; } _compareVersions(a, b) { diff --git a/scripts/dashcaddy-update.sh b/scripts/dashcaddy-update.sh index b90f411..bd7fff8 100755 --- a/scripts/dashcaddy-update.sh +++ b/scripts/dashcaddy-update.sh @@ -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+0000–U+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" </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" < "$RESULT_FILE" < "$RESULT_FILE" </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 ===" } diff --git a/scripts/test-frontend-cycle.sh b/scripts/test-frontend-cycle.sh new file mode 100755 index 0000000..3983783 --- /dev/null +++ b/scripts/test-frontend-cycle.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# DC-122 full-cycle functional test: backup → deploy → rollback on a CUSTOM +# frontend target, proving (a) rollback restores the custom target, (b) an +# unrelated sibling directory is never touched, (c) invalid targets are +# refused. Sources the real functions from the real script. +set -euo pipefail +# Updater script path: $1 overrides; default = adjacent to this test file, +# falling back to the installed location (works in repo, worktree, and prod). +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" ]]; then + # Worktree layouts prefix files with numbers (2_dashcaddy-update.sh); + # repo layout is unprefixed. Resolve whichever exists, adjacent first. + local_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for cand in "$local_dir/dashcaddy-update.sh" \ + "$local_dir"/*dashcaddy-update.sh \ + /opt/dashcaddy/scripts/dashcaddy-update.sh; do + [[ -f "$cand" ]] && SCRIPT="$cand" && break + done +fi +[[ -f "$SCRIPT" ]] || { echo "updater script not found"; exit 1; } +# Trap-covered workspace for ALL temp artifacts (collision-safe). +WORK=$(mktemp -d) || { echo "mktemp failed"; exit 1; } +trap 'rm -rf "$WORK"' EXIT + +# Extract just the functions + constants we need (no main execution). +sed -n '/^FRONTEND_SUBDIRS=/p;/^json_escape()/,/^}/p;/^backup_frontend_dir()/,/^}/p;/^restore_frontend_dir()/,/^}/p;/^validate_frontend_target()/,/^}/p' "$SCRIPT" > "$WORK/dcfuncs.sh" +log() { echo "[t] $*"; } +source "$WORK/dcfuncs.sh" + +T="$WORK/tree" +mkdir -p "$T" +CUSTOM_TARGET="$T/custom-webroot" +VICTIM="$T/custom-webroot-sibling" +mkdir -p "$CUSTOM_TARGET/dist" "$CUSTOM_TARGET/js" "$VICTIM" + +# Pre-update state +echo v1 > "$CUSTOM_TARGET/index.html" +echo v1 > "$CUSTOM_TARGET/sw.js" +echo v1 > "$CUSTOM_TARGET/dist/core.js" +echo v1 > "$CUSTOM_TARGET/js/app.js" +echo secret > "$VICTIM/precious.txt" + +BACKUP="$T/backup/v1.15.0" +mkdir -p "$BACKUP" + +# 1. backup (as the update flow does, with the custom target) +backup_frontend_dir "$BACKUP" "$CUSTOM_TARGET" +[[ -f "$BACKUP/frontend.meta" ]] && [[ -f "$BACKUP/frontend.manifest" ]] || { echo "FAIL: snapshot incomplete"; exit 1; } +grep -q "$CUSTOM_TARGET" "$BACKUP/frontend.meta" || { echo "FAIL: meta lost target"; exit 1; } +echo "PASS backup: meta + manifest written, target recorded" + +# 2. "deploy" a new version (simulates what the update does) +echo v2 > "$CUSTOM_TARGET/index.html" +echo v2 > "$CUSTOM_TARGET/dist/core.js" +echo new > "$CUSTOM_TARGET/dist/newfile.js" # update-introduced file +mkdir -p "$CUSTOM_TARGET/dist/newdir" && echo x > "$CUSTOM_TARGET/dist/newdir/f.js" +printf '{"version":"v2"}' > "$CUSTOM_TARGET/update-stamp.json" + +# 3. rollback +restore_frontend_dir "$BACKUP" || { echo "FAIL: restore errored"; exit 1; } + +[[ "$(cat "$CUSTOM_TARGET/index.html")" == v1 ]] && echo "PASS: index.html rolled back" || { echo "FAIL: index.html"; exit 1; } +[[ "$(cat "$CUSTOM_TARGET/dist/core.js")" == v1 ]] && echo "PASS: dist/core.js rolled back" || { echo "FAIL: core.js"; exit 1; } +[[ ! -e "$CUSTOM_TARGET/dist/newfile.js" ]] && echo "PASS: update-introduced file removed" || { echo "FAIL: newfile.js survived"; exit 1; } +[[ ! -e "$CUSTOM_TARGET/dist/newdir" ]] && echo "PASS: update-introduced dir removed" || { echo "FAIL: newdir survived"; exit 1; } +[[ ! -f "$CUSTOM_TARGET/update-stamp.json" ]] && echo "PASS: stamp cleared on rollback" || { echo "FAIL: stamp survived"; exit 1; } +[[ "$(cat "$VICTIM/precious.txt")" == secret ]] && echo "PASS: sibling directory untouched" || { echo "FAIL: VICTIM MODIFIED"; exit 1; } + +# 4. invalid targets are refused outright (relative path = traversal risk class) +BACKUP2="$T/backup2"; mkdir -p "$BACKUP2/frontend" +printf '{"target":"relative/not-absolute","indexExisted":false,"swExisted":false}\n' > "$BACKUP2/frontend.meta" +( cd "$BACKUP2/frontend" && find . -type f -printf '%P\n' | sort ) > "$BACKUP2/frontend.manifest" +if restore_frontend_dir "$BACKUP2" 2>/dev/null; then + echo "FAIL: relative target was accepted"; exit 1 +fi +echo "PASS: invalid (relative) target refused" + +# 5. a validated custom target round-trips: snapshot→destroy→restore +CUSTOM2="$T/custom2-webroot"; mkdir -p "$CUSTOM2/js" +echo orig > "$CUSTOM2/js/only.js" +BACKUP3="$T/backup3"; mkdir -p "$BACKUP3" +backup_frontend_dir "$BACKUP3" "$CUSTOM2" +rm -rf "$CUSTOM2/js" && mkdir -p "$CUSTOM2/js" && echo clobbered > "$CUSTOM2/js/only.js" +restore_frontend_dir "$BACKUP3" >/dev/null 2>&1 || { echo "FAIL: custom-target restore errored"; exit 1; } +[[ "$(cat "$CUSTOM2/js/only.js")" == orig ]] && echo "PASS: validated custom target restored" || { echo "FAIL: custom restore"; exit 1; } + +# 6. update-introduced OWNED subtree (css/ absent at backup, created by +# "deploy") must be REMOVED by rollback — not left behind. +CUSTOM3="$T/custom3-webroot"; mkdir -p "$CUSTOM3/dist" # note: NO css/ yet +echo v1 > "$CUSTOM3/dist/core.js" +BACKUP4="$T/backup4"; mkdir -p "$BACKUP4" +backup_frontend_dir "$BACKUP4" "$CUSTOM3" +mkdir -p "$CUSTOM3/css" && echo new > "$CUSTOM3/css/introduced.css" # update creates css/ +restore_frontend_dir "$BACKUP4" >/dev/null 2>&1 || { echo "FAIL: introduced-subtree restore errored"; exit 1; } +[[ ! -e "$CUSTOM3/css" ]] && echo "PASS: update-introduced owned subtree removed" || { echo "FAIL: css/ survived rollback"; exit 1; } +[[ "$(cat "$CUSTOM3/dist/core.js")" == v1 ]] && echo "PASS: pre-existing subtree intact" || { echo "FAIL: dist broken"; exit 1; } + +echo "=== ALL FULL-CYCLE TESTS PASSED ===" diff --git a/scripts/test-json-escape.sh b/scripts/test-json-escape.sh new file mode 100755 index 0000000..3246257 --- /dev/null +++ b/scripts/test-json-escape.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Functional test of json_escape + restore guard (DC-122 verification) +set -euo pipefail +# Updater script path: $1 overrides; default = adjacent to this test file +# (numbered worktree prefixes included), falling back to installed location. +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" ]]; then + local_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for cand in "$local_dir/dashcaddy-update.sh" \ + "$local_dir"/*dashcaddy-update.sh \ + /opt/dashcaddy/scripts/dashcaddy-update.sh; do + [[ -f "$cand" ]] && SCRIPT="$cand" && break + done +fi +[[ -f "$SCRIPT" ]] || { echo "updater script not found"; exit 1; } +# Exactly ONE validated, trap-covered workspace for ALL temp artifacts. +WORK=$(mktemp -d) || { echo "mktemp failed"; exit 1; } +trap 'rm -rf "$WORK"' EXIT +source <(sed -n '/^json_escape()/,/^}/p' "$SCRIPT") + +# Test 1: quotes + backslash + newline + tab round-trip through python json +r=$(json_escape 'a"b\c +d e') +python3 - "$r" <<'PY' +import json, sys +v = json.loads('"' + sys.argv[1] + '"') +assert v == 'a"b\\c\nd\te', f"round-trip mismatch: {v!r}" +print("TEST1 OK: quotes/backslash/newline/tab round-trip valid JSON") +PY + +# Test 2: control chars (backspace, form feed, x01) become \uXXXX short forms +r2=$(json_escape "$(printf 'x\by\fz\001w')") +python3 - "$r2" <<'PY' +import json, sys +v = json.loads('"' + sys.argv[1] + '"') +assert v == "x\by\fz\x01w", f"control round-trip mismatch: {v!r}" +print("TEST2 OK: control bytes escaped and parse back") +PY + +# Test 3: restore_frontend_dir must REFUSE an invalid recorded target — +# invoke it for real and assert (a) nonzero return, (b) zero mutations. +sed -n '/^validate_frontend_target()/,/^}/p' "$SCRIPT" > "$WORK/rfd-parts.sh" +sed -n '/^restore_frontend_dir()/,/^}/p' "$SCRIPT" >> "$WORK/rfd-parts.sh" +log() { echo "[t] $*"; } +source "$WORK/rfd-parts.sh" + +WORK2="$WORK/fixtures" +mkdir -p "$WORK2/victim/dist" "$WORK2/backup/frontend" +echo keep > "$WORK2/victim/dist/keep.js" +cat > "$WORK2/backup/frontend.meta" < "$WORK2/backup/frontend.manifest" + +# Full-tree fingerprint BEFORE any restore attempt: prove ZERO mutations +# anywhere in the victim tree across the refused restore. +fp_before=$(find "$WORK2/victim" -type f -exec sha256sum {} + | sort) +rc=0 +restore_frontend_dir "$WORK2/backup" 2>/dev/null || rc=$? +fp_after=$(find "$WORK2/victim" -type f -exec sha256sum {} + | sort) +if [[ $rc -ne 0 ]]; then echo "TEST3a OK: restore returned nonzero ($rc) for invalid target"; else echo "TEST3 FAIL: restore returned 0"; exit 1; fi +if [[ "$fp_before" == "$fp_after" ]]; then + echo "TEST3b OK: victim untouched (fingerprint identical across refused restore)" +else + echo "TEST3 FAIL: victim modified"; exit 1 +fi +echo "ALL JSON/GUARD TESTS DONE" diff --git a/start.sh b/start.sh index 75c2a96..d259c1d 100755 --- a/start.sh +++ b/start.sh @@ -149,26 +149,38 @@ fi echo "[start.sh] Creating container with full config..." # Sync the freshly-built dashboard bundle into the static directory Caddy -# serves. The Docker image bakes dist/ from the source tree at build time, -# but DNS2 also serves /var/www/dashcaddy-status/dist/ (the original -# Windows installer mirror path). If we don't sync after every build, the -# served bundle keeps the OLD hash while the API responds with new code, -# which shows up in the dashboard as "version unavailable" + "no data" -# widgets because the new API surface doesn't match the old widget code. -# This step is idempotent and ~50ms — always safe to run. -echo "[start.sh] Syncing dashboard bundle into static dir..." +# serves — decided by VERSION METADATA, not file mtimes (mtimes are not +# reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract: +# - The self-updater writes a STAMP (update-stamp.json) into the live web +# root when it deploys a frontend; while that stamp is newer than the +# source tree's VERSION file, start.sh must NOT touch the live bundle. +# - Normal builds: publishing bumps the source VERSION (mtime = build time) +# and clears any stale stamp, so source wins and the sync happens. +echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..." mkdir -p /var/www/dashcaddy-status/dist if [ -d /opt/dashcaddy/status/dist ]; then - cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ 2>/dev/null || true - cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true - cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ 2>/dev/null || true - echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)." + NEEDS_SYNC=1 + STAMP=/var/www/dashcaddy-status/update-stamp.json + SRC_VERSION=/opt/dashcaddy/dashcaddy-api/VERSION + if [ -f "$STAMP" ] && [ -f "$SRC_VERSION" ] && [ "$STAMP" -nt "$SRC_VERSION" ]; then + # A self-updater deployment is newer than the last source build: hands off. + echo "[start.sh] Deployed frontend stamp newer than source VERSION — skipping sync to preserve deployed frontend." + NEEDS_SYNC=0 + fi + if [ "$NEEDS_SYNC" = "1" ]; then + cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ 2>/dev/null || true + cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true + cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ 2>/dev/null || true + rm -f "$STAMP" + echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)." + fi else echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)." fi docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ --memory=1g --memory-swap=2g --cpus=2 \ + --log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \ --add-host=get.dashcaddy.net:194.233.88.206 \ --add-host=get2.dashcaddy.net:194.233.88.206 \ --dns ${DNS_PRIMARY} \ @@ -204,6 +216,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ -e DASHCADDY_SELF_IPS="${SELF_IPS}" \ -e ASSETS_DIR=/app/assets \ -e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \ - -e DASHCADDY_UPDATE_ENABLED=false \ + -e DASHCADDY_UPDATE_ENABLED=true \ -e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \ ${IMAGE} \ No newline at end of file