[grade=A urn:ump:seyasvbntxsjibq5jiaqmfowc6xgchwe4jvcc54jd6355ujgm75q] DC-122: self-updater hardening + auto-update on + docker disk discipline (v1.16.0)
- _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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.15.0",
|
"version": "1.16.0",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -305,6 +305,11 @@ class SelfUpdater extends EventEmitter {
|
|||||||
JSON.stringify(trigger, null, 2)
|
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.
|
// The host-side systemd service will handle the rest.
|
||||||
// After container restart, checkPostUpdateResult() reads the result.
|
// After container restart, checkPostUpdateResult() reads the result.
|
||||||
this._addToHistory({
|
this._addToHistory({
|
||||||
@@ -317,6 +322,13 @@ class SelfUpdater extends EventEmitter {
|
|||||||
channel: this.config.channel,
|
channel: this.config.channel,
|
||||||
instanceId: this.instanceId,
|
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) {
|
} else if (isWindows) {
|
||||||
// Windows: frontend updated, API needs manual restart
|
// Windows: frontend updated, API needs manual restart
|
||||||
this._addToHistory({
|
this._addToHistory({
|
||||||
@@ -383,7 +395,18 @@ class SelfUpdater extends EventEmitter {
|
|||||||
|
|
||||||
if (historyIndex !== -1) {
|
if (historyIndex !== -1) {
|
||||||
const pending = history[historyIndex];
|
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;
|
pending.duration = result.duration;
|
||||||
if (result.error) pending.error = result.error;
|
if (result.error) pending.error = result.error;
|
||||||
if (result.version) pending.version = result.version;
|
if (result.version) pending.version = result.version;
|
||||||
@@ -469,8 +492,18 @@ class SelfUpdater extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const result = await this.checkForUpdate();
|
const result = await this.checkForUpdate();
|
||||||
if (result.available && result.remote) {
|
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 });
|
log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version });
|
||||||
await this.applyUpdate(result.remote);
|
await this.applyUpdate(result.remote);
|
||||||
|
this._lastAppliedRef = ref;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('updater', e, { phase: 'autoUpdate' });
|
log.error('updater', e, { phase: 'autoUpdate' });
|
||||||
@@ -561,12 +594,12 @@ class SelfUpdater extends EventEmitter {
|
|||||||
|
|
||||||
_isNewer(local, remote) {
|
_isNewer(local, remote) {
|
||||||
if (!remote || !remote.version) return false;
|
if (!remote || !remote.version) return false;
|
||||||
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
|
// DC-122: same version ⇒ NOT newer, period. Commit hashes are opaque
|
||||||
if (versionCompare < 0) return true;
|
// build labels (pipelines stamp different formats — short SHA vs
|
||||||
if (versionCompare > 0) return false;
|
// timestamp-prefixed), so any inequality would read as "newer" and made
|
||||||
// Same version — check commit hash
|
// same-version installs re-apply stale tarballs in a loop once
|
||||||
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
// DASHCADDY_UPDATE_ENABLED=true. A real release must bump semver.
|
||||||
return false;
|
return this._compareVersions(local.version || '0.0.0', remote.version) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_compareVersions(a, b) {
|
_compareVersions(a, b) {
|
||||||
|
|||||||
+284
-18
@@ -72,16 +72,64 @@ channel_allowed() {
|
|||||||
esac
|
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() {
|
write_result() {
|
||||||
local success="$1" version="$2" duration="$3"
|
local success="$1" version="$2" duration="$3"
|
||||||
shift 3
|
shift 3
|
||||||
local error="${1:-}"
|
local error="${1:-}"
|
||||||
|
|
||||||
|
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
|
if [[ "$success" == "true" ]]; then
|
||||||
cat > "$RESULT_FILE" <<EOF
|
cat > "$RESULT_FILE" <<EOF
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"version": "${version}",
|
"version": "${esc_version}",
|
||||||
"duration": ${duration},
|
"duration": ${duration},
|
||||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
}
|
}
|
||||||
@@ -90,13 +138,14 @@ EOF
|
|||||||
cat > "$RESULT_FILE" <<EOF
|
cat > "$RESULT_FILE" <<EOF
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"version": "${version}",
|
"version": "${esc_version}",
|
||||||
"duration": ${duration},
|
"duration": ${duration},
|
||||||
"error": "${error}",
|
"error": "${esc_error}",
|
||||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_old_backups() {
|
cleanup_old_backups() {
|
||||||
@@ -116,8 +165,15 @@ backup_data_dir() {
|
|||||||
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||||
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||||
mkdir -p "${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 \
|
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))"
|
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||||
else
|
else
|
||||||
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
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 backup_dir="$1"
|
||||||
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||||
if [[ -d "$data_backup" ]]; then
|
if [[ -d "$data_backup" ]]; then
|
||||||
log "Restoring data/ from backup..."
|
# DC-122: contents copy on the cp fallback — see backup_data_dir note.
|
||||||
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
if rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null; then
|
||||||
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
log "Data restored successfully (rsync --delete)"
|
||||||
log "Data restored successfully"
|
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
|
else
|
||||||
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||||
fi
|
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() {
|
wait_for_health() {
|
||||||
local port="${1:-3001}"
|
local port="${1:-3001}"
|
||||||
local timeout="$HEALTH_TIMEOUT"
|
local timeout="$HEALTH_TIMEOUT"
|
||||||
@@ -211,6 +409,7 @@ rollback_restore() {
|
|||||||
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||||
fi
|
fi
|
||||||
restore_data_dir "$backup_dir"
|
restore_data_dir "$backup_dir"
|
||||||
|
restore_frontend_dir "$backup_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Deployment mode ───────────────────────────────────────────────────────────
|
# ── Deployment mode ───────────────────────────────────────────────────────────
|
||||||
@@ -257,6 +456,7 @@ restart_container() {
|
|||||||
log "Recreating container via minimal docker run (fallback)..."
|
log "Recreating container via minimal docker run (fallback)..."
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
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 \
|
-p 127.0.0.1:3001:3001 \
|
||||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||||
-e SERVICES_FILE=/app/data/services.json \
|
-e SERVICES_FILE=/app/data/services.json \
|
||||||
@@ -327,6 +527,16 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ────────────────────────────────────────────────────────
|
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||||
if [[ "$action" == "rollback" ]]; then
|
if [[ "$action" == "rollback" ]]; then
|
||||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||||
@@ -340,17 +550,29 @@ main() {
|
|||||||
log "Performing rollback to v${version}..."
|
log "Performing rollback to v${version}..."
|
||||||
rollback_restore "$backup_dir"
|
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..."
|
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
|
restart_container
|
||||||
wait_for_health || log "WARNING: Health check failed after rollback"
|
if wait_for_health; then health_ok=true; fi
|
||||||
|
|
||||||
|
if [[ "$rebuild_ok" == "true" && "$health_ok" == "true" ]]; then
|
||||||
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||||
rm -f "${TRIGGER_FILE}.processing"
|
|
||||||
log "=== Rollback complete ==="
|
log "=== Rollback complete ==="
|
||||||
|
prune_docker
|
||||||
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
exit 0
|
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
|
fi
|
||||||
|
|
||||||
# ── Handle update ───────────────────────────────────────────────────────────
|
# ── Handle update ───────────────────────────────────────────────────────────
|
||||||
@@ -375,6 +597,18 @@ main() {
|
|||||||
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||||
backup_data_dir "$backup_dir"
|
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
|
# Backup updater state (trigger.json.processing + result.json) so post-mortem
|
||||||
# has a forensic trail tied to this exact version's backup.
|
# has a forensic trail tied to this exact version's backup.
|
||||||
backup_update_state "$backup_dir"
|
backup_update_state "$backup_dir"
|
||||||
@@ -460,6 +694,11 @@ main() {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
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"
|
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
|
||||||
mkdir -p "$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"
|
[[ -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"
|
mkdir -p "$frontend_target_dir/assets"
|
||||||
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# 4. Rebuild container
|
# 4. Rebuild container
|
||||||
@@ -486,10 +733,18 @@ main() {
|
|||||||
if [[ "$build_ok" != "true" ]]; then
|
if [[ "$build_ok" != "true" ]]; then
|
||||||
log "ERROR: Docker build failed — rolling back code + data"
|
log "ERROR: Docker build failed — rolling back code + data"
|
||||||
code_restore "$backup_dir"
|
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
|
restart_container
|
||||||
wait_for_health || true
|
if wait_for_health; then rb_health_ok=true; fi
|
||||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
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"
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -506,16 +761,27 @@ main() {
|
|||||||
local duration=$(( $(date +%s) - start_time ))
|
local duration=$(( $(date +%s) - start_time ))
|
||||||
log "ERROR: Health check failed after update — rolling back code + data"
|
log "ERROR: Health check failed after update — rolling back code + data"
|
||||||
rollback_restore "$backup_dir"
|
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
|
restart_container
|
||||||
wait_for_health || log "WARNING: Rollback health check also failed"
|
if wait_for_health; then rb_health_ok=true; fi
|
||||||
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
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
|
fi
|
||||||
|
|
||||||
# 7. Cleanup
|
# 7. Cleanup
|
||||||
rm -f "${TRIGGER_FILE}.processing"
|
rm -f "${TRIGGER_FILE}.processing"
|
||||||
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
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 ==="
|
log "=== Update process complete ==="
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+98
@@ -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 ==="
|
||||||
Executable
+67
@@ -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" <<EOF
|
||||||
|
{"target":"relative/not-absolute","indexExisted":true,"swExisted":true}
|
||||||
|
EOF
|
||||||
|
( cd "$WORK2/backup/frontend" && find . -type f -printf '%P\n' | sort ) > "$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"
|
||||||
@@ -149,26 +149,38 @@ fi
|
|||||||
echo "[start.sh] Creating container with full config..."
|
echo "[start.sh] Creating container with full config..."
|
||||||
|
|
||||||
# Sync the freshly-built dashboard bundle into the static directory Caddy
|
# Sync the freshly-built dashboard bundle into the static directory Caddy
|
||||||
# serves. The Docker image bakes dist/ from the source tree at build time,
|
# serves — decided by VERSION METADATA, not file mtimes (mtimes are not
|
||||||
# but DNS2 also serves /var/www/dashcaddy-status/dist/ (the original
|
# reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract:
|
||||||
# Windows installer mirror path). If we don't sync after every build, the
|
# - The self-updater writes a STAMP (update-stamp.json) into the live web
|
||||||
# served bundle keeps the OLD hash while the API responds with new code,
|
# root when it deploys a frontend; while that stamp is newer than the
|
||||||
# which shows up in the dashboard as "version unavailable" + "no data"
|
# source tree's VERSION file, start.sh must NOT touch the live bundle.
|
||||||
# widgets because the new API surface doesn't match the old widget code.
|
# - Normal builds: publishing bumps the source VERSION (mtime = build time)
|
||||||
# This step is idempotent and ~50ms — always safe to run.
|
# and clears any stale stamp, so source wins and the sync happens.
|
||||||
echo "[start.sh] Syncing dashboard bundle into static dir..."
|
echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..."
|
||||||
mkdir -p /var/www/dashcaddy-status/dist
|
mkdir -p /var/www/dashcaddy-status/dist
|
||||||
if [ -d /opt/dashcaddy/status/dist ]; then
|
if [ -d /opt/dashcaddy/status/dist ]; then
|
||||||
|
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/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/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true
|
||||||
cp /opt/dashcaddy/status/index.html /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)."
|
echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)."
|
echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
||||||
--memory=1g --memory-swap=2g --cpus=2 \
|
--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=get.dashcaddy.net:194.233.88.206 \
|
||||||
--add-host=get2.dashcaddy.net:194.233.88.206 \
|
--add-host=get2.dashcaddy.net:194.233.88.206 \
|
||||||
--dns ${DNS_PRIMARY} \
|
--dns ${DNS_PRIMARY} \
|
||||||
@@ -204,6 +216,6 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
|||||||
-e DASHCADDY_SELF_IPS="${SELF_IPS}" \
|
-e DASHCADDY_SELF_IPS="${SELF_IPS}" \
|
||||||
-e ASSETS_DIR=/app/assets \
|
-e ASSETS_DIR=/app/assets \
|
||||||
-e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \
|
-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 \
|
-e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \
|
||||||
${IMAGE}
|
${IMAGE}
|
||||||
Reference in New Issue
Block a user