feat(update): add release policy checks and dashboard version verification

This commit is contained in:
Krystie
2026-05-04 18:05:00 -07:00
parent 0c658a26a8
commit f5fe32b999
3 changed files with 413 additions and 23 deletions

View File

@@ -14,8 +14,8 @@ const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const zlib = require('zlib');
const platformPaths = require('./platform-paths'); const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows; const isWindows = platformPaths.isWindows;
@@ -31,6 +31,10 @@ const DEFAULTS = {
MAX_BACKUPS: 3, MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000, HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000, DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
}; };
class SelfUpdater extends EventEmitter { class SelfUpdater extends EventEmitter {
@@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter {
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR, apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), 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.status = 'idle'; // idle | checking | downloading | applying | waiting
this.checkTimer = null; this.checkTimer = null;
this.lastCheckTime = null; this.lastCheckTime = null;
this.lastCheckResult = null; this.lastCheckResult = null;
this.instanceId = this._loadOrCreateInstanceId();
// Ensure directories exist // Ensure directories exist
this._ensureDirs(); this._ensureDirs();
@@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter {
} }
} }
// ── Version Info ── // ── Version / Identity Info ──
getLocalVersion() { getLocalVersion() {
try { 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() { getStatus() {
return this.status; return this.status;
} }
@@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter {
} }
const local = this.getLocalVersion(); 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.lastCheckTime = Date.now();
this.lastCheckResult = { available, local, remote, sourceUrl }; this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
this.status = 'idle'; this.status = 'idle';
if (available) { if (available) {
@@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter {
} }
const local = this.getLocalVersion(); 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'); const stagingDir = path.join(this.config.updatesDir, 'staging');
try { try {
// 1. Download (try primary, fallback to mirror) // 1. Download (try primary, fallback to mirror)
this.status = 'downloading'; 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 tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`; const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
@@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostApiSrc, stagingDir: hostApiSrc,
apiSourceDir: this.config.apiSourceDir, apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
}; };
await fsp.writeFile( await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'), path.join(this.config.updatesDir, 'trigger.json'),
@@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter {
status: 'pending', status: 'pending',
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: true, apiUpdated: true,
channel: this.config.channel,
instanceId: this.instanceId,
}); });
} else if (isWindows) { } else if (isWindows) {
// Windows: frontend updated, API needs manual restart // Windows: frontend updated, API needs manual restart
@@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter {
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: false, apiUpdated: false,
note: 'API update requires manual container restart on Windows', note: 'API update requires manual container restart on Windows',
channel: this.config.channel,
instanceId: this.instanceId,
}); });
this.status = 'idle'; this.status = 'idle';
} }
@@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter {
toVersion: remoteInfo.version, toVersion: remoteInfo.version,
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: !isWindows && !!apiSrc, apiUpdated: !isWindows && !!apiSrc,
policy,
}; };
} catch (e) { } catch (e) {
this.status = 'idle'; this.status = 'idle';
@@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'failed', status: 'failed',
error: e.message, error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
}); });
throw e; throw e;
} }
@@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostBackupDir, stagingDir: hostBackupDir,
apiSourceDir: this.config.apiSourceDir, apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
}; };
this.status = 'waiting'; this.status = 'waiting';
@@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'pending', status: 'pending',
rollback: true, 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) { _isNewer(local, remote) {
if (!remote || !remote.version) return false; if (!remote || !remote.version) return false;
// Compare semver: split into [major, minor, patch] const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
const lv = (local.version || '0.0.0').split('.').map(Number); if (versionCompare < 0) return true;
const rv = remote.version.split('.').map(Number); if (versionCompare > 0) return false;
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;
}
// Same version — check commit hash // Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true; if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false; 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) { _addToHistory(entry) {
const history = this.getUpdateHistory(); const history = this.getUpdateHistory();
history.unshift(entry); history.unshift(entry);
@@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR, hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR, apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
frontendDir: process.env.DASHCADDY_FRONTEND_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;
module.exports.SelfUpdater = SelfUpdater;

View File

@@ -167,11 +167,103 @@ button:focus-visible {
right: 0; right: 0;
top: 0; top: 0;
padding-top: 10px; padding-top: 10px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.reload-caddy-main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; 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 { .license-status-topbar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -83,6 +83,7 @@
<!-- License status + Reload Caddy Button - top right corner --> <!-- License status + Reload Caddy Button - top right corner -->
<div class="reload-caddy-container"> <div class="reload-caddy-container">
<div class="reload-caddy-main">
<div class="theme-toggle-group"> <div class="theme-toggle-group">
<button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes"> <button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes">
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span> <span id="theme-icon">🎨</span> <span id="theme-label">Dark</span>
@@ -98,6 +99,21 @@
🔄 Reload Caddy 🔄 Reload Caddy
</button> </button>
</div> </div>
<button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
</div>
</div>
<div id="version-info-modal" class="weather-modal">
<div class="weather-modal-content version-info-modal-content">
<h3>DashCaddy Verification Info</h3>
<p class="version-info-subtitle">Current version details and updater verification status.</p>
<div id="version-info-status" class="version-info-status">Loading…</div>
<div id="version-info-grid" class="version-info-grid" style="display:none;"></div>
<div id="version-info-history" class="version-info-history" style="display:none;"></div>
<div class="weather-modal-buttons" style="margin-top: 16px;">
<button id="version-info-close">Close</button>
</div>
</div>
</div> </div>
<!-- Tools row below logo and weather --> <!-- Tools row below logo and weather -->
@@ -538,6 +554,117 @@
} }
var yr = document.getElementById('footer-year'); var yr = document.getElementById('footer-year');
if (yr) yr.textContent = new Date().getFullYear(); if (yr) yr.textContent = new Date().getFullYear();
var versionEl = document.getElementById('dashboard-version');
var versionInfoModal = document.getElementById('version-info-modal');
var versionInfoStatus = document.getElementById('version-info-status');
var versionInfoGrid = document.getElementById('version-info-grid');
var versionInfoHistory = document.getElementById('version-info-history');
var versionInfoClose = document.getElementById('version-info-close');
function formatValue(value) {
if (value == null || value === '') return '—';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function renderInfoRow(label, value) {
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
}
function renderHistory(history) {
if (!Array.isArray(history) || !history.length) {
versionInfoHistory.style.display = 'none';
versionInfoHistory.innerHTML = '';
return;
}
versionInfoHistory.style.display = '';
versionInfoHistory.innerHTML = '<h4>Recent Update History</h4>' + history.slice(0, 5).map(function(entry) {
return '<div class="version-history-entry">'
+ '<div><strong>' + formatValue(entry.version) + '</strong> <span class="version-history-status">' + formatValue(entry.status) + '</span></div>'
+ '<div class="version-history-meta">From ' + formatValue(entry.fromVersion) + ' · ' + formatValue(entry.timestamp) + '</div>'
+ '</div>';
}).join('');
}
function openVersionInfo() {
if (!versionInfoModal) return;
versionInfoModal.classList.add('show');
versionInfoStatus.textContent = 'Loading…';
versionInfoGrid.style.display = 'none';
versionInfoHistory.style.display = 'none';
versionInfoGrid.innerHTML = '';
versionInfoHistory.innerHTML = '';
Promise.all([
fetch('/api/v1/system/version', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Version check failed');
return response.json();
}),
fetch('/api/v1/system/update-status', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update status failed');
return response.json();
}),
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update history failed');
return response.json();
})
]).then(function(results) {
var versionData = results[0] || {};
var statusData = results[1] || {};
var historyData = results[2] || {};
var lastResult = statusData.lastResult || {};
var lastPolicy = lastResult.policy || {};
versionInfoStatus.textContent = 'Verification info loaded.';
versionInfoGrid.style.display = 'grid';
versionInfoGrid.innerHTML = [
renderInfoRow('Version', versionData.version),
renderInfoRow('Commit', versionData.commit),
renderInfoRow('Updater Status', statusData.status),
renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'),
renderInfoRow('Update Available', lastResult.available),
renderInfoRow('Eligible', lastPolicy.eligible),
renderInfoRow('Policy Reason', lastPolicy.reason),
renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)),
renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId)
].join('');
renderHistory(historyData.history);
}).catch(function(error) {
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
});
}
if (versionEl) {
fetch('/api/v1/system/version', { cache: 'no-store' })
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
versionEl.textContent = 'Version ' + data.version;
}
})
.catch(function() {
versionEl.textContent = 'Version unavailable';
});
versionEl.addEventListener('click', openVersionInfo);
}
if (versionInfoClose && versionInfoModal) {
versionInfoClose.addEventListener('click', function() {
versionInfoModal.classList.remove('show');
});
versionInfoModal.addEventListener('click', function(event) {
if (event.target === versionInfoModal) {
versionInfoModal.classList.remove('show');
}
});
}
})(); })();
</script> </script>