diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/middleware.js index a6d25c9..eb9c8ba 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/middleware.js @@ -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) { diff --git a/dashcaddy-api/routes/updates.js b/dashcaddy-api/routes/updates.js index 4211f8e..1781d88 100644 --- a/dashcaddy-api/routes/updates.js +++ b/dashcaddy-api/routes/updates.js @@ -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 + // /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({ diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh index 01ffe6f..048e919 100644 --- a/dashcaddy-api/scripts/dashcaddy-update.sh +++ b/dashcaddy-api/scripts/dashcaddy-update.sh @@ -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 diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index 778bd7c..8b7251a 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -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, + // /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)) { diff --git a/scripts/release.sh b/scripts/release.sh index 0ca2163..ed00d39 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -116,9 +116,59 @@ ssh "$RELEASE_HOST" "set -e EOF " -# ── 6. Mirror to backup host ────────────────────────────────────────────── +# ── 6. Mirror to backup host (non-fatal — primary is canonical) ─────────── 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="|,|" 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 ─────────────────────────────────────────────────────────────── 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; } 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 "Done. v$VERSION published from commit $COMMIT." +[[ "$MIRROR_OK" == "true" ]] || echo "(reminder: mirror to get2 failed — investigate $MIRROR_HOST)" diff --git a/status/index.html b/status/index.html index b4f3910..114dced 100644 --- a/status/index.html +++ b/status/index.html @@ -8,7 +8,7 @@ - +