- VERSION: bump from 1.14.4 to 1.14.6 to match package.json (HEAD had stale value) - middleware.js: apply existing totpLimiter (10/15min) to /totp/setup endpoint (was previously unmetered, allowing secret enumeration) - dashcaddy-update.sh: hook post-deploy-patches.sh into the update flow so the container can survive transitions between broken → fixed tarballs - start.sh: add --add-host flags for get.dashcaddy.net and get2.dashcaddy.net so the container can resolve the release server (was failing with ENOTFOUND)
395 lines
15 KiB
Bash
Executable File
395 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# DashCaddy Host-Side Updater
|
|
# Triggered by systemd path unit when the container writes trigger.json.
|
|
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
|
# Writes result.json so the new container knows the outcome.
|
|
#
|
|
# This runs on the HOST, outside the container.
|
|
|
|
set -euo pipefail
|
|
|
|
readonly UPDATES_DIR="/opt/dashcaddy/updates"
|
|
readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
|
|
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
|
|
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
|
|
readonly CONTAINER_NAME="dashcaddy-api"
|
|
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
|
readonly MAX_BACKUPS=3
|
|
readonly HEALTH_TIMEOUT=60
|
|
|
|
# Data directory backup — stored alongside code backups so everything rolls back together
|
|
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
|
readonly DATA_BACKUP_PREFIX="data-backup"
|
|
|
|
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
|
|
|
write_result() {
|
|
local success="$1" version="$2" duration="$3"
|
|
shift 3
|
|
local error="${1:-}"
|
|
|
|
if [[ "$success" == "true" ]]; then
|
|
cat > "$RESULT_FILE" <<EOF
|
|
{
|
|
"success": true,
|
|
"version": "${version}",
|
|
"duration": ${duration},
|
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
}
|
|
EOF
|
|
else
|
|
cat > "$RESULT_FILE" <<EOF
|
|
{
|
|
"success": false,
|
|
"version": "${version}",
|
|
"duration": ${duration},
|
|
"error": "${error}",
|
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
}
|
|
EOF
|
|
fi
|
|
}
|
|
|
|
cleanup_old_backups() {
|
|
local count
|
|
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
|
|
if (( count > MAX_BACKUPS )); then
|
|
log "Cleaning old backups (${count} > ${MAX_BACKUPS})"
|
|
find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \
|
|
| sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \
|
|
| xargs rm -rf
|
|
fi
|
|
}
|
|
|
|
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
|
backup_data_dir() {
|
|
local backup_dir="$1"
|
|
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
|
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
|
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
|
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 "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"
|
|
fi
|
|
}
|
|
|
|
# ── Data restore ──────────────────────────────────────────────────────────────
|
|
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"
|
|
else
|
|
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
|
fi
|
|
}
|
|
|
|
wait_for_health() {
|
|
local port="${1:-3001}"
|
|
local timeout="$HEALTH_TIMEOUT"
|
|
local elapsed=0
|
|
|
|
log "Waiting for health check (timeout: ${timeout}s)..."
|
|
while (( elapsed < timeout )); do
|
|
if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then
|
|
log "Health check passed after ${elapsed}s"
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
elapsed=$(( elapsed + 2 ))
|
|
done
|
|
|
|
log "Health check FAILED after ${timeout}s"
|
|
return 1
|
|
}
|
|
|
|
# ── Shared rollback: restore code + data ────────────────────────────────────
|
|
rollback_restore() {
|
|
local backup_dir="$1"
|
|
log "Rolling back: restoring code files..."
|
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
|
done
|
|
if [[ -d "$backup_dir/routes" ]]; then
|
|
rm -rf "$api_source_dir/routes"
|
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
|
fi
|
|
if [[ -d "$backup_dir/src" ]]; then
|
|
rm -rf "$api_source_dir/src"
|
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
|
fi
|
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
|
rm -rf "$api_source_dir/dns-providers"
|
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
|
fi
|
|
restore_data_dir "$backup_dir"
|
|
}
|
|
|
|
# ── Deployment mode ───────────────────────────────────────────────────────────
|
|
# Reproduce the SAME container the install created so an auto-update keeps every
|
|
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
|
|
# not a minimal subset. Standard installs use docker-compose (compose file in the
|
|
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
|
|
# bare docker run is the last resort. build_image() and restart_container() both
|
|
# honor the detected mode so build and run stay consistent.
|
|
deploy_mode() {
|
|
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
|
|
echo compose
|
|
elif [[ -x /opt/dashcaddy/start.sh ]]; then
|
|
echo startsh
|
|
else
|
|
echo run
|
|
fi
|
|
}
|
|
|
|
# Build the API image using whatever the install is wired for. Returns the build
|
|
# command's exit status so callers can detect failure.
|
|
build_image() {
|
|
cd "$api_source_dir" || return 1
|
|
case "$(deploy_mode)" in
|
|
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
|
|
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
|
|
esac
|
|
}
|
|
|
|
# ── Shared container restart — recreate with the full, install-defined spec ───
|
|
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
|
|
restart_container() {
|
|
cd "$api_source_dir" 2>/dev/null || true
|
|
case "$(deploy_mode)" in
|
|
compose)
|
|
log "Recreating container via docker compose (full compose spec)..."
|
|
docker compose up -d 2>&1 || docker-compose up -d 2>&1
|
|
;;
|
|
startsh)
|
|
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
|
|
bash /opt/dashcaddy/start.sh
|
|
;;
|
|
*)
|
|
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" \
|
|
-p 127.0.0.1:3001:3001 \
|
|
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
|
-e SERVICES_FILE=/app/data/services.json \
|
|
"$IMAGE_TAG"
|
|
;;
|
|
esac
|
|
log "Container recreated"
|
|
}
|
|
|
|
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
|
code_restore() {
|
|
local backup_dir="$1"
|
|
log "Restoring code files..."
|
|
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
|
done
|
|
if [[ -d "$backup_dir/routes" ]]; then
|
|
rm -rf "$api_source_dir/routes"
|
|
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
|
fi
|
|
if [[ -d "$backup_dir/src" ]]; then
|
|
rm -rf "$api_source_dir/src"
|
|
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
|
fi
|
|
if [[ -d "$backup_dir/dns-providers" ]]; then
|
|
rm -rf "$api_source_dir/dns-providers"
|
|
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
local start_time
|
|
start_time=$(date +%s)
|
|
|
|
# 1. Read trigger
|
|
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
|
log "No trigger file found — nothing to do"
|
|
exit 0
|
|
fi
|
|
|
|
# Parse trigger.json (uses python3 which is available on all supported distros)
|
|
local action version from_version staging_dir api_source_dir commit
|
|
local frontend_staging_dir frontend_target_dir
|
|
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
|
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
|
from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])")
|
|
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
|
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
|
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
|
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
|
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
|
# Handle action=rollback (no new version to deploy)
|
|
local to_version="${version}"
|
|
|
|
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
|
log "Staging: ${staging_dir}"
|
|
log "API source: ${api_source_dir}"
|
|
|
|
# Consume the trigger immediately so we don't re-process on failure
|
|
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
|
|
|
# ── Handle rollback ────────────────────────────────────────────────────────
|
|
if [[ "$action" == "rollback" ]]; then
|
|
local backup_dir="${BACKUPS_DIR}/${version}"
|
|
if [[ ! -d "$backup_dir" ]]; then
|
|
log "ERROR: No backup found for version ${version}"
|
|
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
|
rm -f "${TRIGGER_FILE}.processing"
|
|
exit 1
|
|
fi
|
|
|
|
log "Performing rollback to v${version}..."
|
|
rollback_restore "$backup_dir"
|
|
|
|
# Rebuild old code
|
|
log "Rebuilding container..."
|
|
build_image 2>&1 | tail -3 || true
|
|
|
|
restart_container
|
|
wait_for_health || log "WARNING: Health check failed after rollback"
|
|
|
|
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
|
rm -f "${TRIGGER_FILE}.processing"
|
|
log "=== Rollback complete ==="
|
|
exit 0
|
|
fi
|
|
|
|
# ── Handle update ───────────────────────────────────────────────────────────
|
|
if [[ ! -d "$staging_dir" ]]; then
|
|
log "ERROR: Staging directory not found: ${staging_dir}"
|
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
|
rm -f "${TRIGGER_FILE}.processing"
|
|
exit 1
|
|
fi
|
|
|
|
# 2. Backup current API code + data/
|
|
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
|
mkdir -p "$backup_dir"
|
|
log "Backing up current API files to ${backup_dir}"
|
|
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
|
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
|
done
|
|
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
|
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
|
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
|
|
|
|
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
|
backup_data_dir "$backup_dir"
|
|
|
|
cleanup_old_backups
|
|
|
|
# 3. Copy new files from staging to API source
|
|
log "Deploying new API files..."
|
|
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
|
done
|
|
if [[ -d "$staging_dir/routes" ]]; then
|
|
rm -rf "$api_source_dir/routes"
|
|
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
|
fi
|
|
if [[ -d "$staging_dir/src" ]]; then
|
|
rm -rf "$api_source_dir/src"
|
|
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
|
fi
|
|
if [[ -d "$staging_dir/dns-providers" ]]; then
|
|
rm -rf "$api_source_dir/dns-providers"
|
|
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
|
fi
|
|
if [[ -n "$commit" ]]; then
|
|
echo "$commit" > "$api_source_dir/VERSION"
|
|
fi
|
|
|
|
# 3a. Apply post-deploy patches — fix upstream bugs in released tarballs
|
|
# (e.g. v1.14.4 has broken require paths and missing license-keygen module).
|
|
# Runs AFTER staging copy, BEFORE docker build. Idempotent.
|
|
local patch_script="/opt/dashcaddy/scripts/dashcaddy-post-deploy-patches.sh"
|
|
if [[ -x "$patch_script" ]]; then
|
|
log "Applying post-deploy patches..."
|
|
if "$patch_script" "$api_source_dir"; then
|
|
log "Post-deploy patches applied successfully"
|
|
else
|
|
log "WARNING: Post-deploy patches exited non-zero — continuing build anyway"
|
|
fi
|
|
else
|
|
log "NOTE: $patch_script not found or not executable — skipping post-deploy patches"
|
|
fi
|
|
|
|
# 3b. Sync frontend
|
|
if [[ -z "$frontend_staging_dir" ]]; then
|
|
parent_staging=$(dirname "$staging_dir")
|
|
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
|
fi
|
|
if [[ -z "$frontend_target_dir" ]]; then
|
|
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
|
[[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break
|
|
done
|
|
fi
|
|
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
|
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"
|
|
[[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js"
|
|
for sub in dist css vendor js; do
|
|
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
|
mkdir -p "$frontend_target_dir/$sub"
|
|
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
|
fi
|
|
done
|
|
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
|
mkdir -p "$frontend_target_dir/assets"
|
|
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# 4. Rebuild container
|
|
log "Rebuilding container..."
|
|
local build_ok=false
|
|
if build_image; then
|
|
build_ok=true
|
|
fi
|
|
|
|
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
|
|
restart_container
|
|
wait_for_health || true
|
|
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
|
rm -f "${TRIGGER_FILE}.processing"
|
|
exit 1
|
|
fi
|
|
|
|
# 5. Restart container (recreate so new code + env vars take effect)
|
|
restart_container
|
|
|
|
# 6. Health check
|
|
if wait_for_health; then
|
|
local duration=$(( $(date +%s) - start_time ))
|
|
log "=== Update successful: v${to_version} in ${duration}s ==="
|
|
write_result "true" "$to_version" "$duration"
|
|
else
|
|
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
|
|
restart_container
|
|
wait_for_health || log "WARNING: Rollback health check also failed"
|
|
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
|
fi
|
|
|
|
# 7. Cleanup
|
|
rm -f "${TRIGGER_FILE}.processing"
|
|
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
|
|
|
log "=== Update process complete ==="
|
|
}
|
|
|
|
main "$@"
|