- Added automatic volume path translation in deployment (deploy.js) - Updated FileBrowser template to use /opt/ instead of hard-coded E:/ - Migrated self-updater.js to use centralized platformPaths module - Updated UI placeholders to use platform-neutral paths (/media/) - All paths now automatically adapt to Windows or Linux at runtime via process.platform detection
532 lines
17 KiB
JavaScript
532 lines
17 KiB
JavaScript
/**
|
|
* DashCaddy Self-Updater
|
|
* Polls for new versions, downloads and stages updates,
|
|
* triggers host-side updater for API container rebuilds.
|
|
*
|
|
* Frontend files are updated directly (zero-downtime).
|
|
* API files require a container rebuild via the host-side systemd service.
|
|
*/
|
|
|
|
const EventEmitter = require('events');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { execSync } = require('child_process');
|
|
const zlib = require('zlib');
|
|
const platformPaths = require('./platform-paths');
|
|
|
|
const DEFAULTS = {
|
|
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
|
UPDATE_URL: 'https://get.dashcaddy.net/release',
|
|
MIRROR_URL: 'https://get2.dashcaddy.net/release',
|
|
UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates',
|
|
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
|
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
|
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
|
FRONTEND_DIR: platformPaths.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
|
|
MAX_BACKUPS: 3,
|
|
HEALTH_TIMEOUT: 60000,
|
|
DOWNLOAD_TIMEOUT: 120000,
|
|
};
|
|
|
|
class SelfUpdater extends EventEmitter {
|
|
constructor(options = {}) {
|
|
super();
|
|
this.config = {
|
|
enabled: options.enabled !== false,
|
|
checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
|
|
updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
|
|
mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
|
|
updatesDir: options.updatesDir || DEFAULTS.UPDATES_DIR,
|
|
// hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
|
|
// Used when writing trigger.json so the host-side script can find staging files.
|
|
hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
|
|
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
|
|
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
|
|
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
|
|
};
|
|
|
|
this.status = 'idle'; // idle | checking | downloading | applying | waiting
|
|
this.checkTimer = null;
|
|
this.lastCheckTime = null;
|
|
this.lastCheckResult = null;
|
|
|
|
// Ensure directories exist
|
|
this._ensureDirs();
|
|
}
|
|
|
|
// ── Lifecycle ──
|
|
|
|
start() {
|
|
if (!this.config.enabled || this.checkTimer) return;
|
|
|
|
console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);
|
|
|
|
// First check after a short delay (let server finish startup)
|
|
setTimeout(() => {
|
|
this._autoCheckAndApply();
|
|
this.checkTimer = setInterval(() => this._autoCheckAndApply(), this.config.checkInterval);
|
|
}, 15000);
|
|
}
|
|
|
|
stop() {
|
|
if (this.checkTimer) {
|
|
clearInterval(this.checkTimer);
|
|
this.checkTimer = null;
|
|
}
|
|
}
|
|
|
|
// ── Version Info ──
|
|
|
|
getLocalVersion() {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
let commit = null;
|
|
try {
|
|
commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
|
|
} catch (_) {}
|
|
return { version: pkg.version, commit };
|
|
} catch (e) {
|
|
return { version: '0.0.0', commit: null };
|
|
}
|
|
}
|
|
|
|
getStatus() {
|
|
return this.status;
|
|
}
|
|
|
|
// ── Check for Updates ──
|
|
|
|
async checkForUpdate() {
|
|
this.status = 'checking';
|
|
try {
|
|
let remote;
|
|
let sourceUrl = this.config.updateUrl;
|
|
try {
|
|
remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
|
|
} catch (primaryErr) {
|
|
console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
|
|
try {
|
|
remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
|
|
sourceUrl = this.config.mirrorUrl;
|
|
} catch (mirrorErr) {
|
|
this.status = 'idle';
|
|
this.lastCheckTime = Date.now();
|
|
this.lastCheckResult = { available: false, error: 'Update servers unreachable' };
|
|
return this.lastCheckResult;
|
|
}
|
|
}
|
|
|
|
const local = this.getLocalVersion();
|
|
const available = this._isNewer(local, remote);
|
|
|
|
this.lastCheckTime = Date.now();
|
|
this.lastCheckResult = { available, local, remote, sourceUrl };
|
|
this.status = 'idle';
|
|
|
|
if (available) {
|
|
this.emit('update-available', remote);
|
|
}
|
|
|
|
return this.lastCheckResult;
|
|
} catch (e) {
|
|
this.status = 'idle';
|
|
this.lastCheckTime = Date.now();
|
|
this.lastCheckResult = { available: false, error: e.message };
|
|
return this.lastCheckResult;
|
|
}
|
|
}
|
|
|
|
// ── Apply Update ──
|
|
|
|
async applyUpdate(remoteInfo) {
|
|
if (this.status !== 'idle' && this.status !== 'checking') {
|
|
throw new Error(`Update already in progress (status: ${this.status})`);
|
|
}
|
|
|
|
const local = this.getLocalVersion();
|
|
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 });
|
|
|
|
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
|
|
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
|
|
const mirrorUrl = `${this.config.mirrorUrl}/${remoteInfo.tarball}`;
|
|
try {
|
|
await this._downloadFile(primaryUrl, tarballPath);
|
|
} catch (dlErr) {
|
|
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
|
|
// Ensure file is fully cleaned up before mirror attempt
|
|
try { fs.unlinkSync(tarballPath); } catch (_) {}
|
|
await this._downloadFile(mirrorUrl, tarballPath);
|
|
}
|
|
|
|
// 2. Verify SHA-256
|
|
const hash = await this._computeSha256(tarballPath);
|
|
if (hash !== remoteInfo.sha256) {
|
|
await fsp.unlink(tarballPath).catch(() => {});
|
|
throw new Error(`SHA-256 mismatch: expected ${remoteInfo.sha256}, got ${hash}`);
|
|
}
|
|
|
|
// 3. Extract
|
|
this.status = 'applying';
|
|
this.emit('update-progress', { step: 'extracting', version: remoteInfo.version });
|
|
|
|
await this._cleanDir(stagingDir);
|
|
await this._extractTarball(tarballPath, stagingDir);
|
|
|
|
// 4. Apply frontend files directly (zero-downtime)
|
|
const frontendSrc = this._findDir(stagingDir, 'status');
|
|
if (frontendSrc) {
|
|
await this._copyDir(frontendSrc, this.config.frontendDir, [
|
|
'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js'
|
|
]);
|
|
this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version });
|
|
}
|
|
|
|
// 5. Trigger API rebuild (Linux only — host-side systemd service)
|
|
const apiSrc = this._findDir(stagingDir, 'dashcaddy-api');
|
|
if (apiSrc && !isWindows) {
|
|
this.status = 'waiting';
|
|
this.emit('update-progress', { step: 'triggering-rebuild', version: remoteInfo.version });
|
|
|
|
// Convert container path to host path for trigger.json
|
|
const hostApiSrc = apiSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
|
|
const trigger = {
|
|
action: 'update',
|
|
version: remoteInfo.version,
|
|
commit: remoteInfo.commit,
|
|
fromVersion: local.version,
|
|
stagingDir: hostApiSrc,
|
|
apiSourceDir: this.config.apiSourceDir,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
await fsp.writeFile(
|
|
path.join(this.config.updatesDir, 'trigger.json'),
|
|
JSON.stringify(trigger, null, 2)
|
|
);
|
|
|
|
// The host-side systemd service will handle the rest.
|
|
// After container restart, checkPostUpdateResult() reads the result.
|
|
this._addToHistory({
|
|
version: remoteInfo.version,
|
|
fromVersion: local.version,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'pending',
|
|
frontendUpdated: !!frontendSrc,
|
|
apiUpdated: true,
|
|
});
|
|
} else if (isWindows) {
|
|
// Windows: frontend updated, API needs manual restart
|
|
this._addToHistory({
|
|
version: remoteInfo.version,
|
|
fromVersion: local.version,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'partial',
|
|
frontendUpdated: !!frontendSrc,
|
|
apiUpdated: false,
|
|
note: 'API update requires manual container restart on Windows',
|
|
});
|
|
this.status = 'idle';
|
|
}
|
|
|
|
// Clean up tarball
|
|
await fsp.unlink(tarballPath).catch(() => {});
|
|
|
|
return {
|
|
success: true,
|
|
fromVersion: local.version,
|
|
toVersion: remoteInfo.version,
|
|
frontendUpdated: !!frontendSrc,
|
|
apiUpdated: !isWindows && !!apiSrc,
|
|
};
|
|
} catch (e) {
|
|
this.status = 'idle';
|
|
this._addToHistory({
|
|
version: remoteInfo.version,
|
|
fromVersion: local.version,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'failed',
|
|
error: e.message,
|
|
});
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ── Post-Update Result ──
|
|
|
|
async checkPostUpdateResult() {
|
|
const resultPath = path.join(this.config.updatesDir, 'result.json');
|
|
try {
|
|
const data = await fsp.readFile(resultPath, 'utf8');
|
|
const result = JSON.parse(data);
|
|
// Delete the result file so we don't process it again
|
|
await fsp.unlink(resultPath).catch(() => {});
|
|
|
|
// Update history
|
|
const history = this.getUpdateHistory();
|
|
const pending = history.find(h => h.status === 'pending');
|
|
if (pending) {
|
|
pending.status = result.success ? 'success' : 'rolled-back';
|
|
pending.duration = result.duration;
|
|
if (result.error) pending.error = result.error;
|
|
this._saveHistory(history);
|
|
}
|
|
|
|
this.status = 'idle';
|
|
return result;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Rollback ──
|
|
|
|
async rollbackToVersion(version) {
|
|
if (isWindows) throw new Error('Auto-rollback not supported on Windows');
|
|
|
|
const backupDir = path.join(this.config.updatesDir, 'backups', version);
|
|
try {
|
|
await fsp.access(backupDir);
|
|
} catch (_) {
|
|
throw new Error(`No backup found for version ${version}`);
|
|
}
|
|
|
|
const local = this.getLocalVersion();
|
|
const hostBackupDir = backupDir.replace(this.config.updatesDir, this.config.hostUpdatesDir);
|
|
const trigger = {
|
|
action: 'rollback',
|
|
version: version,
|
|
fromVersion: local.version,
|
|
stagingDir: hostBackupDir,
|
|
apiSourceDir: this.config.apiSourceDir,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
this.status = 'waiting';
|
|
await fsp.writeFile(
|
|
path.join(this.config.updatesDir, 'trigger.json'),
|
|
JSON.stringify(trigger, null, 2)
|
|
);
|
|
|
|
this._addToHistory({
|
|
version: version,
|
|
fromVersion: local.version,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'pending',
|
|
rollback: true,
|
|
});
|
|
}
|
|
|
|
getAvailableRollbacks() {
|
|
const backupsDir = path.join(this.config.updatesDir, 'backups');
|
|
try {
|
|
return fs.readdirSync(backupsDir)
|
|
.filter(d => fs.statSync(path.join(backupsDir, d)).isDirectory())
|
|
.sort()
|
|
.reverse();
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ── History ──
|
|
|
|
getUpdateHistory() {
|
|
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
|
|
try {
|
|
return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ── Private Methods ──
|
|
|
|
async _autoCheckAndApply() {
|
|
try {
|
|
const result = await this.checkForUpdate();
|
|
if (result.available && result.remote) {
|
|
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
|
|
await this.applyUpdate(result.remote);
|
|
}
|
|
} catch (e) {
|
|
console.error('[SelfUpdater] Auto-update error:', e.message);
|
|
}
|
|
}
|
|
|
|
_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;
|
|
}
|
|
// Same version — check commit hash
|
|
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
|
return false;
|
|
}
|
|
|
|
_addToHistory(entry) {
|
|
const history = this.getUpdateHistory();
|
|
history.unshift(entry);
|
|
// Keep last 50 entries
|
|
if (history.length > 50) history.length = 50;
|
|
this._saveHistory(history);
|
|
}
|
|
|
|
_saveHistory(history) {
|
|
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
|
|
try {
|
|
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
|
|
} catch (e) {
|
|
console.error('[SelfUpdater] Failed to save history:', e.message);
|
|
}
|
|
}
|
|
|
|
async _ensureDirs() {
|
|
for (const dir of [this.config.updatesDir, path.join(this.config.updatesDir, 'staging'), path.join(this.config.updatesDir, 'backups')]) {
|
|
await fsp.mkdir(dir, { recursive: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async _fetchJson(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const mod = url.startsWith('https') ? https : http;
|
|
const req = mod.get(url, { timeout: 15000 }, (res) => {
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
}
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (e) {
|
|
reject(new Error('Invalid JSON from ' + url));
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); });
|
|
});
|
|
}
|
|
|
|
async _downloadFile(url, dest) {
|
|
return new Promise((resolve, reject) => {
|
|
const mod = url.startsWith('https') ? https : http;
|
|
const file = fs.createWriteStream(dest);
|
|
const req = mod.get(url, { timeout: DEFAULTS.DOWNLOAD_TIMEOUT }, (res) => {
|
|
if (res.statusCode !== 200) {
|
|
file.close();
|
|
fs.unlinkSync(dest);
|
|
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
}
|
|
res.pipe(file);
|
|
file.on('finish', () => { file.close(resolve); });
|
|
});
|
|
req.on('error', (e) => {
|
|
file.close();
|
|
fs.unlink(dest, () => {});
|
|
reject(e);
|
|
});
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('Download timeout')); });
|
|
});
|
|
}
|
|
|
|
async _computeSha256(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = fs.createReadStream(filePath);
|
|
stream.on('data', chunk => hash.update(chunk));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async _extractTarball(tarballPath, destDir) {
|
|
await fsp.mkdir(destDir, { recursive: true });
|
|
// Use tar command (available on Linux, and Git Bash on Windows)
|
|
try {
|
|
execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' });
|
|
} catch (e) {
|
|
throw new Error('Failed to extract tarball: ' + e.message);
|
|
}
|
|
}
|
|
|
|
_findDir(baseDir, name) {
|
|
const direct = path.join(baseDir, name);
|
|
if (fs.existsSync(direct)) return direct;
|
|
// Also check one level deeper (e.g., dashcaddy/dashcaddy-api)
|
|
try {
|
|
for (const entry of fs.readdirSync(baseDir)) {
|
|
const sub = path.join(baseDir, entry, name);
|
|
if (fs.existsSync(sub)) return sub;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
async _copyDir(src, dest, items) {
|
|
await fsp.mkdir(dest, { recursive: true });
|
|
for (const item of items) {
|
|
const srcPath = path.join(src, item);
|
|
const destPath = path.join(dest, item);
|
|
try {
|
|
const stat = await fsp.stat(srcPath);
|
|
if (stat.isDirectory()) {
|
|
await this._copyDirRecursive(srcPath, destPath);
|
|
} else {
|
|
await fsp.copyFile(srcPath, destPath);
|
|
}
|
|
} catch (_) {
|
|
// Item may not exist in the update — skip
|
|
}
|
|
}
|
|
}
|
|
|
|
async _copyDirRecursive(src, dest) {
|
|
await fsp.mkdir(dest, { recursive: true });
|
|
const entries = await fsp.readdir(src, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await this._copyDirRecursive(srcPath, destPath);
|
|
} else {
|
|
await fsp.copyFile(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
async _cleanDir(dir) {
|
|
try {
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
} catch (_) {}
|
|
await fsp.mkdir(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
// Singleton
|
|
const selfUpdater = new SelfUpdater({
|
|
enabled: process.env.DASHCADDY_UPDATE_ENABLED !== 'false',
|
|
checkInterval: process.env.DASHCADDY_UPDATE_INTERVAL,
|
|
updateUrl: process.env.DASHCADDY_UPDATE_URL,
|
|
mirrorUrl: process.env.DASHCADDY_MIRROR_URL,
|
|
updatesDir: process.env.DASHCADDY_UPDATES_DIR,
|
|
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
|
|
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
|
|
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
|
|
});
|
|
|
|
module.exports = selfUpdater;
|