Merge krystie-improvements into main

Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
Hermes
2026-06-25 16:43:10 -07:00
171 changed files with 11759 additions and 1006 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
/**
* Docker Maintenance Module
* Scheduled cleanup to prevent Docker disk bloat:
* - Prunes dangling images
* - Prunes stopped non-managed containers
* - Clears build cache
* - Monitors disk usage and warns when thresholds exceeded
*/
const Docker = require('dockerode');
const EventEmitter = require('events');
const { DOCKER } = require('../utilities/constants');
const docker = new Docker();
class DockerMaintenance extends EventEmitter {
constructor() {
super();
this.interval = null;
this.running = false;
this.lastRun = null;
this.lastResult = null;
}
start() {
if (this.running) return;
this.running = true;
// Run first maintenance 5 minutes after startup (let everything settle)
setTimeout(() => {
if (!this.running) return;
this.runMaintenance().catch(() => {});
}, 5 * 60 * 1000);
// Then run on the configured interval (default 24h)
this.interval = setInterval(() => {
this.runMaintenance().catch(() => {});
}, DOCKER.MAINTENANCE.INTERVAL);
}
stop() {
if (!this.running) return;
this.running = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
async runMaintenance() {
const startTime = Date.now();
const result = {
timestamp: new Date().toISOString(),
pruned: { images: 0, containers: 0, buildCache: 0 },
spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 },
diskUsage: null,
warnings: [],
containersWithoutLogLimits: []
};
try {
// 1. Prune dangling images
try {
const imgResult = await docker.pruneImages({ filters: { dangling: { true: true } } });
result.pruned.images = (imgResult.ImagesDeleted || []).length;
result.spaceReclaimed.images = imgResult.SpaceReclaimed || 0;
} catch (e) {
result.warnings.push(`Image prune failed: ${e.message}`);
}
// 2. Prune stopped containers (only non-managed ones)
try {
const stopped = await docker.listContainers({
all: true,
filters: { status: ['exited', 'dead'] }
});
for (const c of stopped) {
// Skip DashCaddy-managed containers — user may want to restart them
if (c.Labels?.['sami.managed'] === 'true') continue;
// Skip containers stopped less than 24h ago
const stoppedAge = Date.now() / 1000 - c.Created;
if (stoppedAge < 86400) continue;
try {
const container = docker.getContainer(c.Id);
await container.remove({ force: true });
result.pruned.containers++;
} catch (e) {
// Container may have been removed between list and remove
}
}
} catch (e) {
result.warnings.push(`Container prune failed: ${e.message}`);
}
// 3. Prune build cache
try {
const cacheResult = await docker.pruneBuilder();
result.spaceReclaimed.buildCache = cacheResult.SpaceReclaimed || 0;
result.pruned.buildCache = (cacheResult.CachesDeleted || []).length;
} catch (e) {
// Build cache prune may not be available on all Docker versions
result.warnings.push(`Build cache prune failed: ${e.message}`);
}
// 4. Get disk usage
try {
const df = await docker.df();
result.diskUsage = {
images: {
count: (df.Images || []).length,
sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0)
},
containers: {
count: (df.Containers || []).length,
sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0)
},
volumes: {
count: (df.Volumes?.Volumes || []).length,
sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0)
},
buildCache: {
count: (df.BuildCache || []).length,
sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0)
}
};
result.diskUsage.totalBytes =
result.diskUsage.images.sizeBytes +
result.diskUsage.containers.sizeBytes +
result.diskUsage.volumes.sizeBytes +
result.diskUsage.buildCache.sizeBytes;
result.diskUsage.totalGB = +(result.diskUsage.totalBytes / (1024 ** 3)).toFixed(2);
if (result.diskUsage.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) {
result.warnings.push(`Docker disk usage is ${result.diskUsage.totalGB}GB (threshold: ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB)`);
}
} catch (e) {
result.warnings.push(`Disk usage check failed: ${e.message}`);
}
// 5. Check for containers without log rotation
try {
const running = await docker.listContainers({ all: false });
for (const c of running) {
if (c.Labels?.['sami.managed'] !== 'true') continue;
try {
const container = docker.getContainer(c.Id);
const info = await container.inspect();
const logConfig = info.HostConfig?.LogConfig;
if (!logConfig?.Config?.['max-size']) {
result.containersWithoutLogLimits.push({
name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12),
id: c.Id.slice(0, 12)
});
}
} catch (e) {
// Container may have stopped between list and inspect
}
}
if (result.containersWithoutLogLimits.length > 0) {
result.warnings.push(
`${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`
);
}
} catch (e) {
result.warnings.push(`Log config check failed: ${e.message}`);
}
result.spaceReclaimed.total =
result.spaceReclaimed.images +
result.spaceReclaimed.containers +
result.spaceReclaimed.buildCache;
result.duration = Date.now() - startTime;
this.lastRun = new Date().toISOString();
this.lastResult = result;
this.emit('maintenance-complete', result);
return result;
} catch (error) {
result.error = error.message;
result.duration = Date.now() - startTime;
this.lastResult = result;
this.emit('maintenance-failed', result);
throw error;
}
}
/** Get Docker disk usage snapshot (callable on demand) */
async getDiskUsage() {
try {
const df = await docker.df();
const images = { count: (df.Images || []).length, sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) };
const containers = { count: (df.Containers || []).length, sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) };
const volumes = { count: (df.Volumes?.Volumes || []).length, sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) };
const buildCache = { count: (df.BuildCache || []).length, sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) };
const totalBytes = images.sizeBytes + containers.sizeBytes + volumes.sizeBytes + buildCache.sizeBytes;
return { images, containers, volumes, buildCache, totalBytes, totalGB: +(totalBytes / (1024 ** 3)).toFixed(2) };
} catch (e) {
return null;
}
}
getStatus() {
return {
running: this.running,
lastRun: this.lastRun,
lastResult: this.lastResult
};
}
}
module.exports = new DockerMaintenance();
+779
View File
@@ -0,0 +1,779 @@
/**
* 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 os = require('os');
const { execSync } = require('child_process');
const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows;
const DEFAULTS = {
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
UPDATES_DIR: platformPaths.containerUpdatesDir,
// 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.containerFrontendDir,
MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000,
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
};
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,
// hostFrontendDir is the path on the HOST where Caddy serves the dashboard
// from. The in-container `frontendDir` is often a path that isn't mounted
// (e.g. /app/dashboard with no bind mount), so writing there is silently
// useless. When this is set, we pass it to the host-side updater script
// and skip the in-container copy entirely.
hostFrontendDir: options.hostFrontendDir
|| process.env.DASHCADDY_HOST_FRONTEND_DIR
|| (platformPaths.isWindows ? null : '/var/www/dashcaddy-status'),
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();
// 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 ──
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 / Identity 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 { /* ignore */ }
return { version: pkg.version, commit };
} catch (e) {
return { version: '0.0.0', commit: null };
}
}
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;
}
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() {
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 policy = this._evaluateReleasePolicy(local, remote);
const available = policy.eligible && policy.newer;
this.lastCheckTime = Date.now();
this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
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 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, policy });
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 { /* ignore */ }
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. Locate frontend source. The actual deploy is done either here (if no
// hostFrontendDir is configured, e.g. Windows or unusual setups) or by
// the host-side updater script via trigger.json (preferred path on Linux,
// where Caddy serves from /var/www/... outside the container's filesystem).
const frontendSrc = this._findDir(stagingDir, 'status');
let hostFrontendStagingPath = null;
if (frontendSrc && this.config.hostFrontendDir) {
// Defer the copy to the host-side script. Just compute the host path
// for staging so it can find the files.
hostFrontendStagingPath = frontendSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
} else if (frontendSrc) {
// No host path configured — copy in-container (legacy / Windows path).
await this._copyDir(frontendSrc, this.config.frontendDir, [
'dist', 'css', 'assets', 'vendor', 'js', '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,
frontendStagingDir: hostFrontendStagingPath,
frontendTargetDir: this.config.hostFrontendDir || null,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
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,
channel: this.config.channel,
instanceId: this.instanceId,
});
} 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',
channel: this.config.channel,
instanceId: this.instanceId,
});
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,
policy,
};
} catch (e) {
this.status = 'idle';
this._addToHistory({
version: remoteInfo.version,
fromVersion: local.version,
timestamp: new Date().toISOString(),
status: 'failed',
error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
});
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 the matching history entry, preferring the newest pending item
// for the same target version. Fall back to the newest pending item if
// older result files lack enough metadata to match more precisely.
const history = this.getUpdateHistory();
const pendingIndex = history.findIndex(
h => h.status === 'pending' && (!result.version || h.version === result.version)
);
const fallbackIndex = pendingIndex === -1
? history.findIndex(h => h.status === 'pending')
: -1;
const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;
if (historyIndex !== -1) {
const pending = history[historyIndex];
pending.status = result.success ? 'success' : 'rolled-back';
pending.duration = result.duration;
if (result.error) pending.error = result.error;
if (result.version) pending.version = result.version;
if (result.timestamp) pending.completedAt = result.timestamp;
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(),
channel: this.config.channel,
instanceId: this.instanceId,
};
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,
channel: this.config.channel,
instanceId: this.instanceId,
});
}
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);
}
}
_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;
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;
}
_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)) {
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);
// 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 { /* ignore */ }
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 { /* ignore */ }
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,
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
});
module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;