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:
Sami
2026-05-16 23:46:53 -07:00
parent edac587c5c
commit c66fe498b6
6 changed files with 148 additions and 11 deletions
+1
View File
@@ -299,6 +299,7 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/license/feature/', prefix: true, method: 'GET' },
{ path: '/api/config', exact: true, method: 'GET' },
{ path: '/api/services/status', exact: true, method: 'GET' },
{ path: '/api/system/update-notify', exact: true, method: 'POST' },
];
function isPublicRoute(req) {
+22
View File
@@ -116,6 +116,28 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
});
}, '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
router.get('/system/update-status', asyncHandler(async (req, res) => {
res.json({
+17 -8
View File
@@ -86,12 +86,13 @@ main() {
fi
# 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'])")
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 '')")
log "=== ${action^^}: v${from_version} -> v${version} ==="
log "Staging: ${staging_dir}"
@@ -114,19 +115,20 @@ main() {
log "Backing up current API files to ${backup_dir}"
# 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
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/"
# Save version marker
echo "$from_version" > "$backup_dir/VERSION"
# VERSION (commit hash) was copied from api_source_dir above; preserve as-is
# so a rollback restores the original commit marker. The version *string* is
# already encoded in the backup dir name (${from_version}).
cleanup_old_backups
# 4. 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; 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
done
if [[ -d "$staging_dir/routes" ]]; then
@@ -138,6 +140,13 @@ main() {
cp -rf "$staging_dir/src" "$api_source_dir/src"
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
log "Rebuilding container..."
cd "$api_source_dir"
@@ -153,7 +162,7 @@ main() {
log "ERROR: Docker build failed — rolling back"
# 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
done
if [[ -d "$backup_dir/routes" ]]; then
@@ -178,7 +187,7 @@ main() {
log "ERROR: Container restart failed — rolling back"
# 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
done
if [[ -d "$backup_dir/routes" ]]; then
@@ -208,7 +217,7 @@ main() {
log "ERROR: Health check failed after update — rolling back"
# 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
done
if [[ -d "$backup_dir/routes" ]]; then
+45
View File
@@ -64,6 +64,13 @@ class SelfUpdater extends EventEmitter {
// Ensure directories exist
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 ──
@@ -118,6 +125,26 @@ class SelfUpdater extends EventEmitter {
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 ──
async checkForUpdate() {
@@ -536,6 +563,24 @@ class SelfUpdater extends EventEmitter {
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() {
try {
if (fs.existsSync(this.config.instanceIdFile)) {