Files
dashcaddy/start.sh
T
Hermes Agent 56f1a001f2
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
start.sh auto-sync dashboard bundle into /var/www/dashcaddy-status/
After every rebuild the freshly-baked dashboard bundle lives in
/opt/dashcaddy/status/dist/ + sw.js. DNS2 also serves files from
/var/www/dashcaddy-status/dist/ + sw.js (the original Windows-installer
mirror path). Without an explicit copy step between build and start.sh,
the served bundle stays on whatever hash was there before, while the API
responds with new code. That mismatch is what shows up in the dashboard
as "version unavailable" + "no data" widgets — saw it in the deploy
that followed DC-046/047 (fixed by a manual cp this time, never again).

Sync block runs before docker run:
  cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/
  cp /opt/dashcaddy/status/sw.js   /var/www/dashcaddy-status/
  cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/

All  guarded so set -e doesn't kill the container start on a
single per-file failure (e.g. read-only mount, missing dir). Missing
source dir is a WARN + no-op rather than a fatal — fresh installs
without status/dist/ don't get a stale-bundle problem, just a log line.

Test: scripts/test-start-sh-sync.sh — 7 assertions across 4 cases
(fresh-copy, missing-source, idempotent-re-sync, set-e-survives-permission-
denied). All pass.
2026-07-20 02:07:25 -07:00

170 lines
8.1 KiB
Bash
Executable File

#!/bin/bash
set -e
CONTAINER_NAME="dashcaddy-api"
IMAGE="dashcaddy-dashcaddy-api:latest"
DATA_DIR="/opt/dashcaddy/dashcaddy-api/data"
CADDYFILE="/etc/caddy/Caddyfile"
ASSETS_DIR="/var/www/dashcaddy-status/assets"
UPDATES_DIR="/opt/dashcaddy/updates"
BACKUPS_DIR="/opt/dashcaddy/backups"
HOST_IP="172.17.0.1"
# Local Technitium (binds 0.0.0.0:53) resolves *.sami + recurses for docker subnet
# external fallback. Without this the container only has 8.8.8.8 and every
# *.sami health-check probe fails with ENOTFOUND (uptime bars stay empty).
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
# and Tailscale IPs to the right destinations.
#
# IMPORTANT: Do NOT add `git.sami:100.81.59.99` (DNS3). DNS3 does not serve
# HTTPS on 443 — it only serves Gitea on :3030. Setting git.sami → DNS3 in
# the container would make health checks bypass Caddy and hit a closed port.
# Let Caddy (on DNS2:443) handle git.sami and route to DNS3:3030 internally.
#
# Layout:
# dns3.sami / gitea → DNS3 (Tailscale IP, used by tools inside the container
# that need to talk to Gitea directly, e.g. backups)
# dns3-wan.sami → DNS3 Contabo WAN fallback
# dns2.sami → DNS2 (this host) — for cross-service references
# dns1.sami → DNS1 (SAMI-CLOUD-U32)
# dc-contabo-de → DashCaddy Contabo test instance
# git.dashcaddy.net → DashCaddy upstream git
# ca.sami → local CA (DN2 + DN3 both have their own)
ADD_HOST_FLAGS=(
--add-host=dns3.sami:100.81.59.99
--add-host=gitea:100.81.59.99
--add-host=dns3-wan.sami:74.208.167.19
--add-host=dns2.sami:100.121.150.22
--add-host=dns1.sami:100.71.97.12
--add-host=dc-contabo-de:100.98.123.59
--add-host=git.dashcaddy.net:100.98.123.59
# ca.sami resolves via DNS to 100.121.150.22 (Caddy on DNS2). Don't pin
# to 127.0.0.1 — nothing listens on 443 inside the container, so the
# health checker would fail with ECONNREFUSED. The CA itself is a
# public-facing service that goes through Caddy just like every other *.sami.
)
# Always recreate to ensure env vars are correct (CONFIG_FILE defaults to /etc/dashcaddy/ which doesn't exist)
if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
echo "[start.sh] Recreating container to apply correct env vars..."
docker rm -f ${CONTAINER_NAME}
fi
# Tailscale CLI + control socket — lets the container invoke
# `tailscale status --json` to populate /api/v1/tailscale/status etc.
# The binary is statically linked (Go), so the bind-mount works under
# the container's Alpine libc without any library forwarding.
# Both mounts are read-only: `tailscale status --json` is a read query
# that the local tailscaled handles; we never need to mutate state
# from inside the container.
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..."
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)."
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} \
--add-host=get.dashcaddy.net:194.233.88.206 \
--add-host=get2.dashcaddy.net:194.233.88.206 \
--dns ${DNS_PRIMARY} \
--dns ${DNS_FALLBACK} \
"${ADD_HOST_FLAGS[@]}" \
-p 127.0.0.1:3001:3001 \
-v ${DATA_DIR}:/app/data \
-v ${BACKUPS_DIR}:/app/backups \
-v ${CADDYFILE}:/caddyfile \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${ASSETS_DIR}:/app/assets \
-v ${UPDATES_DIR}:/app/updates \
-v /opt/sami-files/logs:/opt/sami-files/logs:ro \
-v /usr/bin/tailscale:/usr/bin/tailscale:ro \
-v /var/run/tailscale:/var/run/tailscale:ro \
-v /etc/ssl/sami-ca:/etc/ssl/sami-ca:ro \
-e NODE_ENV=production \
-e SERVICES_FILE=/app/data/services.json \
-e CONFIG_FILE=/app/data/config.json \
-e BACKUP_DIR=/app/backups \
-e DNS_CREDENTIALS_FILE=/app/data/dns-credentials.json \
-e CREDENTIALS_FILE=/app/data/credentials.json \
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
-e HEALTH_HISTORY_FILE=/app/data/health-history.json \
-e HEALTH_CONFIG_FILE=/app/data/health-config.json \
-e CADDYFILE_PATH=/caddyfile \
-e CADDY_ADMIN_URL=http://${HOST_IP}:2019 \
-e ASSETS_DIR=/app/assets \
-e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \
-e DASHCADDY_UPDATE_ENABLED=false \
-e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \
${IMAGE}