feat(updates): seamless release flow — push-notify, VERSION copy, robust mirror
- self-updater: per-instance notify secret (auto-generated), notifyAndApply() triggers an immediate check+apply for the publishing host - routes: POST /api/system/update-notify (X-DashCaddy-Notify-Secret gated, added to public-routes allowlist so TOTP doesn't block machine-to-machine) - dashcaddy-update.sh: include VERSION in backup/deploy/rollback copy lists; belt-and-suspenders write trigger.json commit to VERSION post-deploy. Fixes drift where /app/VERSION stayed at the old commit after self-update. - release.sh: mirror failures are non-fatal+loud; HTTP-verify get2 after rsync; auto-notify co-located instance via /opt/dashcaddy/updates/notify-secret (or honour DASHCADDY_NOTIFY_TARGETS for multi-instance setups).
This commit is contained in:
@@ -299,6 +299,7 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/license/feature/', prefix: true, method: 'GET' },
|
{ path: '/api/license/feature/', prefix: true, method: 'GET' },
|
||||||
{ path: '/api/config', exact: true, method: 'GET' },
|
{ path: '/api/config', exact: true, method: 'GET' },
|
||||||
{ path: '/api/services/status', exact: true, method: 'GET' },
|
{ path: '/api/services/status', exact: true, method: 'GET' },
|
||||||
|
{ path: '/api/system/update-notify', exact: true, method: 'POST' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isPublicRoute(req) {
|
function isPublicRoute(req) {
|
||||||
|
|||||||
@@ -116,6 +116,28 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
|||||||
});
|
});
|
||||||
}, 'system-update-apply'));
|
}, 'system-update-apply'));
|
||||||
|
|
||||||
|
// Notify endpoint — the publishing host POSTs here when a new release is
|
||||||
|
// out so the instance can update within seconds instead of waiting for the
|
||||||
|
// next 30-min poll. Auth is a shared secret in X-DashCaddy-Notify-Secret
|
||||||
|
// (per-instance, generated on first start, lives at
|
||||||
|
// <updatesDir>/notify-secret). This route is in the public-routes allowlist
|
||||||
|
// because TOTP would block machine-to-machine notifies.
|
||||||
|
router.post('/system/update-notify', asyncHandler(async (req, res) => {
|
||||||
|
const presented = req.get('X-DashCaddy-Notify-Secret') || '';
|
||||||
|
const expected = selfUpdater.getNotifySecret() || '';
|
||||||
|
// constant-time compare to avoid timing leaks
|
||||||
|
const presentedBuf = Buffer.from(presented);
|
||||||
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
const ok = presentedBuf.length === expectedBuf.length &&
|
||||||
|
presentedBuf.length > 0 &&
|
||||||
|
require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Invalid notify secret' });
|
||||||
|
}
|
||||||
|
const result = selfUpdater.notifyAndApply('http-notify');
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
}, 'system-update-notify'));
|
||||||
|
|
||||||
// Get update status
|
// Get update status
|
||||||
router.get('/system/update-status', asyncHandler(async (req, res) => {
|
router.get('/system/update-status', asyncHandler(async (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse trigger.json (uses python3 which is available on all supported distros)
|
# Parse trigger.json (uses python3 which is available on all supported distros)
|
||||||
local action version from_version staging_dir api_source_dir
|
local action version from_version staging_dir api_source_dir commit
|
||||||
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||||
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
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'])")
|
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'])")
|
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'])")
|
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 '')")
|
||||||
|
|
||||||
log "=== ${action^^}: v${from_version} -> v${version} ==="
|
log "=== ${action^^}: v${from_version} -> v${version} ==="
|
||||||
log "Staging: ${staging_dir}"
|
log "Staging: ${staging_dir}"
|
||||||
@@ -114,19 +115,20 @@ main() {
|
|||||||
log "Backing up current API files to ${backup_dir}"
|
log "Backing up current API files to ${backup_dir}"
|
||||||
|
|
||||||
# Copy all JS files, package.json, Dockerfile, and tracked subdirs
|
# Copy all JS files, package.json, Dockerfile, and tracked subdirs
|
||||||
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; do
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
[[ -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/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||||
# Save version marker
|
# VERSION (commit hash) was copied from api_source_dir above; preserve as-is
|
||||||
echo "$from_version" > "$backup_dir/VERSION"
|
# so a rollback restores the original commit marker. The version *string* is
|
||||||
|
# already encoded in the backup dir name (${from_version}).
|
||||||
|
|
||||||
cleanup_old_backups
|
cleanup_old_backups
|
||||||
|
|
||||||
# 4. Copy new files from staging to API source
|
# 4. Copy new files from staging to API source
|
||||||
log "Deploying new API files..."
|
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; do
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
if [[ -d "$staging_dir/routes" ]]; then
|
if [[ -d "$staging_dir/routes" ]]; then
|
||||||
@@ -138,6 +140,13 @@ main() {
|
|||||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Belt-and-suspenders: always write the commit from trigger.json to VERSION,
|
||||||
|
# even if the tarball didn't include one. The container's self-updater uses
|
||||||
|
# this to detect the "same version, different commit" case.
|
||||||
|
if [[ -n "$commit" ]]; then
|
||||||
|
echo "$commit" > "$api_source_dir/VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Rebuild container
|
# 5. Rebuild container
|
||||||
log "Rebuilding container..."
|
log "Rebuilding container..."
|
||||||
cd "$api_source_dir"
|
cd "$api_source_dir"
|
||||||
@@ -153,7 +162,7 @@ main() {
|
|||||||
log "ERROR: Docker build failed — rolling back"
|
log "ERROR: Docker build failed — rolling back"
|
||||||
|
|
||||||
# Restore backup
|
# Restore backup
|
||||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
if [[ -d "$backup_dir/routes" ]]; then
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
@@ -178,7 +187,7 @@ main() {
|
|||||||
log "ERROR: Container restart failed — rolling back"
|
log "ERROR: Container restart failed — rolling back"
|
||||||
|
|
||||||
# Restore backup
|
# Restore backup
|
||||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
if [[ -d "$backup_dir/routes" ]]; then
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
@@ -208,7 +217,7 @@ main() {
|
|||||||
log "ERROR: Health check failed after update — rolling back"
|
log "ERROR: Health check failed after update — rolling back"
|
||||||
|
|
||||||
# Restore backup
|
# Restore backup
|
||||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml; do
|
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
|
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
if [[ -d "$backup_dir/routes" ]]; then
|
if [[ -d "$backup_dir/routes" ]]; then
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ class SelfUpdater extends EventEmitter {
|
|||||||
|
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
this._ensureDirs();
|
this._ensureDirs();
|
||||||
|
|
||||||
|
// Notify-secret lives next to instance-id (alongside updates dir on Linux,
|
||||||
|
// <caddyBase>/notify-secret on Windows). Auto-generated on first start.
|
||||||
|
this.notifySecretFile = options.notifySecretFile
|
||||||
|
|| process.env.DASHCADDY_NOTIFY_SECRET_FILE
|
||||||
|
|| path.join(this.config.updatesDir, 'notify-secret');
|
||||||
|
this.notifySecret = this._loadOrCreateNotifySecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
@@ -118,6 +125,26 @@ class SelfUpdater extends EventEmitter {
|
|||||||
return this.status;
|
return this.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNotifySecret() {
|
||||||
|
return this.notifySecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public wrapper for the auto-check+apply loop, used by the notify endpoint
|
||||||
|
// so the publisher can wake an instance up immediately instead of waiting
|
||||||
|
// for the next 30-min poll. Returns immediately; work runs async.
|
||||||
|
notifyAndApply(triggeredBy = 'notify') {
|
||||||
|
if (this.status !== 'idle' && this.status !== 'checking') {
|
||||||
|
return { accepted: false, reason: `busy (status: ${this.status})`, status: this.status };
|
||||||
|
}
|
||||||
|
// Fire-and-forget; the response shouldn't block on the container rebuild.
|
||||||
|
setImmediate(() => {
|
||||||
|
this._autoCheckAndApply().catch(err =>
|
||||||
|
console.error('[SelfUpdater] %s-triggered update error: %s', triggeredBy, err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { accepted: true, triggeredBy };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Check for Updates ──
|
// ── Check for Updates ──
|
||||||
|
|
||||||
async checkForUpdate() {
|
async checkForUpdate() {
|
||||||
@@ -536,6 +563,24 @@ class SelfUpdater extends EventEmitter {
|
|||||||
return digest[0] % 100;
|
return digest[0] % 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_loadOrCreateNotifySecret() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.notifySecretFile)) {
|
||||||
|
const existing = fs.readFileSync(this.notifySecretFile, 'utf8').trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
|
} catch (_) { /* regenerate */ }
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(24).toString('base64url');
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.notifySecretFile), { recursive: true });
|
||||||
|
fs.writeFileSync(this.notifySecretFile, `${secret}\n`, { mode: 0o600 });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SelfUpdater] Failed to persist notify secret:', error.message);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
_loadOrCreateInstanceId() {
|
_loadOrCreateInstanceId() {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.config.instanceIdFile)) {
|
if (fs.existsSync(this.config.instanceIdFile)) {
|
||||||
|
|||||||
+62
-2
@@ -116,9 +116,59 @@ ssh "$RELEASE_HOST" "set -e
|
|||||||
EOF
|
EOF
|
||||||
"
|
"
|
||||||
|
|
||||||
# ── 6. Mirror to backup host ──────────────────────────────────────────────
|
# ── 6. Mirror to backup host (non-fatal — primary is canonical) ───────────
|
||||||
echo "[6/6] Mirroring to $MIRROR_HOST"
|
echo "[6/6] Mirroring to $MIRROR_HOST"
|
||||||
ssh "$RELEASE_HOST" "rsync -aq --delete /var/www/get.dashcaddy.net/release/ $MIRROR_HOST:/var/www/get2.dashcaddy.net/release/"
|
MIRROR_OK=true
|
||||||
|
if ssh "$RELEASE_HOST" "rsync -aq --delete /var/www/get.dashcaddy.net/release/ $MIRROR_HOST:/var/www/get2.dashcaddy.net/release/" 2>&1; then
|
||||||
|
echo " → mirrored"
|
||||||
|
else
|
||||||
|
MIRROR_OK=false
|
||||||
|
echo " ! MIRROR FAILED — get2.dashcaddy.net is stale. Primary continues." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Optional: notify known instances to update immediately ────────────────
|
||||||
|
# Set DASHCADDY_NOTIFY_TARGETS="<url>|<secret>,<url>|<secret>" to push.
|
||||||
|
# If unset, we try the co-located instance at localhost:3001 using the
|
||||||
|
# generated secret at /opt/dashcaddy/updates/notify-secret (silently skipped
|
||||||
|
# if either is missing).
|
||||||
|
if [[ -n "${DASHCADDY_NOTIFY_TARGETS:-}" ]]; then
|
||||||
|
echo "[notify] pushing release to configured targets"
|
||||||
|
IFS=',' read -ra TARGETS <<< "$DASHCADDY_NOTIFY_TARGETS"
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
url="${t%%|*}"
|
||||||
|
secret="${t#*|}"
|
||||||
|
[[ "$url" == "$secret" ]] && { echo " ! malformed target ($t) — need url|secret" >&2; continue; }
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -X POST \
|
||||||
|
-H "X-DashCaddy-Notify-Secret: $secret" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"version\":\"$VERSION\",\"commit\":\"$COMMIT\"}" \
|
||||||
|
"$url" || true)
|
||||||
|
if [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
|
||||||
|
echo " → $url notified (HTTP $code)"
|
||||||
|
else
|
||||||
|
echo " ! $url notify FAILED (HTTP $code)" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Co-located default
|
||||||
|
LOCAL_NOTIFY=$(ssh "$RELEASE_HOST" '
|
||||||
|
if [[ -r /opt/dashcaddy/updates/notify-secret ]] && curl -fsS --max-time 2 http://localhost:3001/api/health >/dev/null 2>&1; then
|
||||||
|
secret=$(cat /opt/dashcaddy/updates/notify-secret)
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" --max-time 5 -X POST \
|
||||||
|
-H "X-DashCaddy-Notify-Secret: $secret" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"version\":\"'"$VERSION"'\",\"commit\":\"'"$COMMIT"'\"}" \
|
||||||
|
http://localhost:3001/api/system/update-notify
|
||||||
|
else
|
||||||
|
echo skip
|
||||||
|
fi
|
||||||
|
' 2>/dev/null || true)
|
||||||
|
case "$LOCAL_NOTIFY" in
|
||||||
|
2*) echo "[notify] co-located instance on $RELEASE_HOST → HTTP $LOCAL_NOTIFY" ;;
|
||||||
|
skip) ;; # no secret or instance not up — silent
|
||||||
|
*) echo "[notify] co-located instance notify failed (HTTP $LOCAL_NOTIFY)" >&2 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Verify ───────────────────────────────────────────────────────────────
|
# ── Verify ───────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
@@ -132,5 +182,15 @@ SHA_HTTP="$(curl -fsSL --max-time 30 "https://get.dashcaddy.net/release/dashcadd
|
|||||||
[[ "$SHA_LOCAL" == "$SHA_HTTP" ]] || { echo "SHA mismatch on served tarball" >&2; exit 1; }
|
[[ "$SHA_LOCAL" == "$SHA_HTTP" ]] || { echo "SHA mismatch on served tarball" >&2; exit 1; }
|
||||||
echo " tarball sha256 → $SHA_HTTP ✓"
|
echo " tarball sha256 → $SHA_HTTP ✓"
|
||||||
|
|
||||||
|
if [[ "$MIRROR_OK" == "true" ]]; then
|
||||||
|
GET2_VER="$(curl -fsSL --max-time 5 https://get2.dashcaddy.net/release/version.json 2>/dev/null | node -p "try{JSON.parse(require('fs').readFileSync('/dev/stdin')).version}catch{'unreachable'}" 2>/dev/null || echo unreachable)"
|
||||||
|
if [[ "$GET2_VER" == "$VERSION" ]]; then
|
||||||
|
echo " get2.dashcaddy.net → $GET2_VER ✓"
|
||||||
|
else
|
||||||
|
echo " ! get2.dashcaddy.net serves '$GET2_VER' (expected $VERSION) — check Caddy/DNS for get2" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Done. v$VERSION published from commit $COMMIT."
|
echo "Done. v$VERSION published from commit $COMMIT."
|
||||||
|
[[ "$MIRROR_OK" == "true" ]] || echo "(reminder: mirror to get2 failed — investigate $MIRROR_HOST)"
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
<meta http-equiv="Pragma" content="no-cache" />
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<meta http-equiv="Expires" content="0" />
|
<meta http-equiv="Expires" content="0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-Nv8xzCSztfdYOL663VgPKWQn6v0lnM0ACWxkxpFfcfY='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-kwm9nLWm/jfIuT8y4i62Xq6mDqe4mRlMZn1tDg+5Zek='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||||
|
|
||||||
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
|
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
|
||||||
|
|||||||
Reference in New Issue
Block a user