diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index a11e454..a84ae8a 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -14,8 +14,8 @@ const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); +const os = require('os'); const { execSync } = require('child_process'); -const zlib = require('zlib'); const platformPaths = require('./platform-paths'); const isWindows = platformPaths.isWindows; @@ -31,6 +31,10 @@ const DEFAULTS = { MAX_BACKUPS: 3, HEALTH_TIMEOUT: 60000, DOWNLOAD_TIMEOUT: 120000, + CHANNEL: 'stable', + INSTANCE_ID_FILE: platformPaths.isWindows + ? path.join(platformPaths.caddyBase, 'instance-id') + : '/etc/dashcaddy/instance-id', }; class SelfUpdater extends EventEmitter { @@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter { apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), + channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL, + instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE, }; this.status = 'idle'; // idle | checking | downloading | applying | waiting this.checkTimer = null; this.lastCheckTime = null; this.lastCheckResult = null; + this.instanceId = this._loadOrCreateInstanceId(); // Ensure directories exist this._ensureDirs(); @@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter { } } - // ── Version Info ── + // ── Version / Identity Info ── getLocalVersion() { try { @@ -95,6 +102,18 @@ class SelfUpdater extends EventEmitter { } } + getInstanceInfo() { + return { + instanceId: this.instanceId, + channel: this.config.channel, + hostname: os.hostname(), + platform: process.platform, + arch: process.arch, + isWindows, + version: this.getLocalVersion(), + }; + } + getStatus() { return this.status; } @@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter { } const local = this.getLocalVersion(); - const available = this._isNewer(local, remote); + const policy = this._evaluateReleasePolicy(local, remote); + const available = policy.eligible && policy.newer; this.lastCheckTime = Date.now(); - this.lastCheckResult = { available, local, remote, sourceUrl }; + this.lastCheckResult = { + available, + local, + remote, + sourceUrl, + policy, + instance: this.getInstanceInfo(), + }; this.status = 'idle'; if (available) { @@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter { } const local = this.getLocalVersion(); + const policy = this._evaluateReleasePolicy(local, remoteInfo); + if (!policy.eligible) { + throw new Error(`Release not eligible for this instance: ${policy.reason}`); + } + const stagingDir = path.join(this.config.updatesDir, 'staging'); try { // 1. Download (try primary, fallback to mirror) this.status = 'downloading'; - this.emit('update-progress', { step: 'downloading', version: remoteInfo.version }); + this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy }); const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball); const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`; @@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter { stagingDir: hostApiSrc, apiSourceDir: this.config.apiSourceDir, timestamp: new Date().toISOString(), + channel: this.config.channel, + instanceId: this.instanceId, }; await fsp.writeFile( path.join(this.config.updatesDir, 'trigger.json'), @@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter { status: 'pending', frontendUpdated: !!frontendSrc, apiUpdated: true, + channel: this.config.channel, + instanceId: this.instanceId, }); } else if (isWindows) { // Windows: frontend updated, API needs manual restart @@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter { frontendUpdated: !!frontendSrc, apiUpdated: false, note: 'API update requires manual container restart on Windows', + channel: this.config.channel, + instanceId: this.instanceId, }); this.status = 'idle'; } @@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter { toVersion: remoteInfo.version, frontendUpdated: !!frontendSrc, apiUpdated: !isWindows && !!apiSrc, + policy, }; } catch (e) { this.status = 'idle'; @@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter { timestamp: new Date().toISOString(), status: 'failed', error: e.message, + channel: this.config.channel, + instanceId: this.instanceId, }); throw e; } @@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter { stagingDir: hostBackupDir, apiSourceDir: this.config.apiSourceDir, timestamp: new Date().toISOString(), + channel: this.config.channel, + instanceId: this.instanceId, }; this.status = 'waiting'; @@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter { timestamp: new Date().toISOString(), status: 'pending', rollback: true, + channel: this.config.channel, + instanceId: this.instanceId, }); } @@ -362,20 +407,143 @@ class SelfUpdater extends EventEmitter { } } + _evaluateReleasePolicy(local, remote) { + const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable'; + const allowedChannels = Array.isArray(remote?.channels) + ? remote.channels + : Array.isArray(remote?.eligibleChannels) + ? remote.eligibleChannels + : [releaseChannel]; + + if (!allowedChannels.includes(this.config.channel)) { + return { + eligible: false, + newer: this._isNewer(local, remote), + reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`, + releaseChannel, + allowedChannels, + }; + } + + if (remote?.revoked === true) { + return { + eligible: false, + newer: this._isNewer(local, remote), + reason: 'release revoked', + releaseChannel, + allowedChannels, + }; + } + + const minUpdaterVersion = remote?.minUpdaterVersion; + if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) { + return { + eligible: false, + newer: this._isNewer(local, remote), + reason: `requires updater >= ${minUpdaterVersion}`, + releaseChannel, + allowedChannels, + }; + } + + const rollout = this._normalizeRollout(remote?.rollout); + if (rollout < 100) { + const bucket = this._getRolloutBucket(this.instanceId); + if (bucket >= rollout) { + return { + eligible: false, + newer: this._isNewer(local, remote), + reason: `outside rollout (${bucket} >= ${rollout})`, + releaseChannel, + allowedChannels, + rollout, + rolloutBucket: bucket, + }; + } + } + + const targets = remote?.targets; + if (targets && typeof targets === 'object') { + const platformKey = `${process.platform}-${process.arch}`; + const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default; + if (!matchedTarget) { + return { + eligible: false, + newer: this._isNewer(local, remote), + reason: `no target for ${platformKey}`, + releaseChannel, + allowedChannels, + rollout, + }; + } + } + + return { + eligible: true, + newer: this._isNewer(local, remote), + reason: 'eligible', + releaseChannel, + allowedChannels, + rollout, + rolloutBucket: this._getRolloutBucket(this.instanceId), + }; + } + _isNewer(local, remote) { if (!remote || !remote.version) return false; - // Compare semver: split into [major, minor, patch] - const lv = (local.version || '0.0.0').split('.').map(Number); - const rv = remote.version.split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((rv[i] || 0) > (lv[i] || 0)) return true; - if ((rv[i] || 0) < (lv[i] || 0)) return false; - } + const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); + if (versionCompare < 0) return true; + if (versionCompare > 0) return false; // Same version — check commit hash if (remote.commit && local.commit && remote.commit !== local.commit) return true; return false; } + _compareVersions(a, b) { + const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); + const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); + const len = Math.max(av.length, bv.length, 3); + for (let i = 0; i < len; i++) { + const left = av[i] || 0; + const right = bv[i] || 0; + if (left > right) return 1; + if (left < right) return -1; + } + return 0; + } + + _normalizeRollout(value) { + if (value == null) return 100; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 100; + return Math.max(0, Math.min(100, Math.floor(parsed))); + } + + _getRolloutBucket(instanceId) { + const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest(); + return digest[0] % 100; + } + + _loadOrCreateInstanceId() { + try { + if (fs.existsSync(this.config.instanceIdFile)) { + const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim(); + if (existing) return existing; + } + } catch (_) { + // Fall through and regenerate + } + + const instanceId = crypto.randomUUID(); + try { + fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true }); + fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8'); + } catch (error) { + console.warn('[SelfUpdater] Failed to persist instance ID:', error.message); + } + return instanceId; + } + _addToHistory(entry) { const history = this.getUpdateHistory(); history.unshift(entry); @@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({ hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR, apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR, frontendDir: process.env.DASHCADDY_FRONTEND_DIR, + channel: process.env.DASHCADDY_UPDATE_CHANNEL, + instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE, }); module.exports = selfUpdater; +module.exports.SelfUpdater = SelfUpdater; \ No newline at end of file diff --git a/status/css/dashboard.css b/status/css/dashboard.css index 0281dee..0ae51e5 100644 --- a/status/css/dashboard.css +++ b/status/css/dashboard.css @@ -167,11 +167,103 @@ button:focus-visible { right: 0; top: 0; padding-top: 10px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.reload-caddy-main { display: flex; align-items: center; gap: 18px; } +.dashboard-version { + font-size: 0.78rem; + color: var(--muted); + line-height: 1; + padding: 0 2px; + user-select: text; + background: transparent; + border: none; + cursor: pointer; + transition: color 0.15s ease, opacity 0.15s ease; +} + +.dashboard-version:hover { + color: var(--fg); + opacity: 0.9; +} + +.version-info-modal-content { + min-width: 420px; + max-width: 620px; +} + +.version-info-subtitle { + font-size: 0.85rem; + color: var(--muted); + margin: 0 0 16px; +} + +.version-info-status { + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 14px; +} + +.version-info-grid { + display: grid; + gap: 10px; + margin-bottom: 18px; +} + +.version-info-row { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 10px 12px; + border-radius: 8px; + background: var(--card-bg); + border: 1px solid var(--border); +} + +.version-info-label { + font-weight: 600; + color: var(--fg); +} + +.version-info-value { + color: var(--muted); + text-align: right; + word-break: break-word; +} + +.version-info-history h4 { + margin: 0 0 10px; +} + +.version-history-entry { + padding: 10px 12px; + border-radius: 8px; + background: var(--card-bg); + border: 1px solid var(--border); + margin-bottom: 8px; +} + +.version-history-status { + color: var(--muted); + font-size: 0.82rem; + margin-left: 6px; +} + +.version-history-meta { + font-size: 0.78rem; + color: var(--muted); + margin-top: 4px; +} + .license-status-topbar { display: flex; align-items: center; diff --git a/status/index.html b/status/index.html index 2659fcb..82b84ae 100644 --- a/status/index.html +++ b/status/index.html @@ -83,20 +83,36 @@