DC-050 harden dataDir + add image-layer migration
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.

1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
   zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
   Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
   copies non-empty content to bind mount with 'migrated-' prefix,
   gated by sentinel file. Survives set -e per-file failures.

19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).

Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
This commit is contained in:
Hermes Agent
2026-07-20 00:53:05 -07:00
parent 09efce2891
commit 894e091335
7 changed files with 465 additions and 0 deletions
+54
View File
@@ -14,6 +14,60 @@ HOST_IP="172.17.0.1"
DNS_PRIMARY="100.121.150.22" # Technitium (Tailscale IP) — resolves *.sami
DNS_FALLBACK="8.8.8.8"
# --- One-time migration from Docker image layer to bind mount --------------
# DC-039 follow-up. Before v1.14.10, certain modules (audit-logger, license-
# keygen, credential-manager) defaulted their files to /app/src/* via
# path.join(__dirname, 'foo.json'). Those writes landed in the Docker image
# layer and VANISHED on every container recreate. This step scans for any
# non-empty zombie files left over from a previous image (where /opt/dashcaddy/
# previously used /opt/dashcaddy/dashcaddy-api/src/... as the path root) and
# copies their contents into the bind-mounted data dir ONCE.
#
# Idempotent: bails out if the migration sentinel file already exists.
# Designed to be a no-op on every fresh install.
MIGRATION_SENTINEL="${DATA_DIR}/.migrated-from-image-layer"
IMAGE_LAYER_ZOMBIES=(
"/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json"
"/opt/dashcaddy/dashcaddy-api/src/security/.encryption-key"
"/opt/dashcaddy/dashcaddy-api/src/security/.encryption-key.bak"
"/opt/dashcaddy/dashcaddy-api/src/utils/error.log"
"/opt/dashcaddy/dashcaddy-api/src/managers/.license-secret"
"/opt/dashcaddy/dashcaddy-api/src/managers/.license-counter"
)
# Note: set -e is active at top of script. Each per-file step uses an
# explicit `|| true` (or guarded `if`) so a single unreadable zombie file
# can't take down the whole container. The sentinel write at the end is
# outside any conditional so it always runs once.
run_image_layer_migration() {
if [ -f "${MIGRATION_SENTINEL}" ]; then
return 0
fi
mkdir -p "${DATA_DIR}" || { echo "[start.sh] [migration] mkdir failed: ${DATA_DIR}"; return 0; }
local migrated=0
for src in "${IMAGE_LAYER_ZOMBIES[@]}"; do
if [ -f "${src}" ] && [ -s "${src}" ]; then
local dest_name dest
dest_name="$(basename "${src}")"
dest="${DATA_DIR}/migrated-${dest_name}"
if [ ! -f "${dest}" ]; then
echo "[start.sh] [migration] Recovering image-layer file: ${src} -> ${dest}"
if cp -a "${src}" "${dest}" 2>/dev/null; then
migrated=$((migrated + 1))
else
echo "[start.sh] [migration] WARN: failed to copy ${src} (continuing)"
fi
fi
fi
done
if [ "$migrated" -gt 0 ]; then
echo "[start.sh] [migration] Recovered ${migrated} file(s) from image layer."
echo "[start.sh] [migration] Review files prefixed 'migrated-' in ${DATA_DIR} and merge or delete."
fi
# Sentinel write MUST run regardless of any per-file failure above.
date -u +%Y-%m-%dT%H:%M:%SZ > "${MIGRATION_SENTINEL}" 2>/dev/null || echo "1" > "${MIGRATION_SENTINEL}"
}
run_image_layer_migration
# --- /etc/hosts overrides for the container ---------------------------------
# The base image (node:20-alpine) has no entries for *.sami. We must inject
# them via --add-host so health checks inside the container can resolve LAN