[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
@@ -9,6 +9,7 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
const { log } = require('../utils/logging');
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes) {
|
||||
@@ -38,7 +39,7 @@ class BackupManager extends EventEmitter {
|
||||
start() {
|
||||
if (this.running) return;
|
||||
|
||||
console.log('[BackupManager] Starting backup scheduler');
|
||||
log.info('backup', 'Starting backup scheduler');
|
||||
this.running = true;
|
||||
|
||||
// Schedule all configured backups
|
||||
@@ -55,7 +56,7 @@ class BackupManager extends EventEmitter {
|
||||
stop() {
|
||||
if (!this.running) return;
|
||||
|
||||
console.log('[BackupManager] Stopping backup scheduler');
|
||||
log.info('backup', 'Stopping backup scheduler');
|
||||
this.running = false;
|
||||
|
||||
// Clear all scheduled jobs
|
||||
@@ -91,7 +92,7 @@ class BackupManager extends EventEmitter {
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
intervalMs = minutes * 60 * 1000;
|
||||
} else {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
log.warn('backup', 'Invalid schedule', { name, schedule: backup.schedule });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -100,17 +101,17 @@ class BackupManager extends EventEmitter {
|
||||
// Schedule the job
|
||||
const job = setInterval(() => {
|
||||
this.executeBackup(name, backup).catch(error => {
|
||||
console.error(`[BackupManager] Scheduled backup ${name} failed:`, error.message);
|
||||
log.error('backup', error, { name });
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
this.scheduledJobs.set(name, job);
|
||||
console.log(`[BackupManager] Scheduled backup '${name}' every ${backup.schedule}`);
|
||||
log.info('backup', 'Scheduled backup', { name, schedule: backup.schedule });
|
||||
|
||||
// Run immediately if configured
|
||||
if (backup.runImmediately) {
|
||||
this.executeBackup(name, backup).catch(error => {
|
||||
console.error(`[BackupManager] Initial backup ${name} failed:`, error.message);
|
||||
log.error('backup', error, { name, phase: 'initial' });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class BackupManager extends EventEmitter {
|
||||
const startTime = Date.now();
|
||||
const backupId = `${name}-${Date.now()}`;
|
||||
|
||||
console.log(`[BackupManager] Starting backup: ${name}`);
|
||||
log.info('backup', 'Starting backup', { name });
|
||||
|
||||
this.emit('backup-start', { name, backupId, timestamp: new Date().toISOString() });
|
||||
|
||||
@@ -151,7 +152,7 @@ class BackupManager extends EventEmitter {
|
||||
const location = await this.saveToDestination(finalData, dest, backupId);
|
||||
savedLocations.push(location);
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Failed to save to ${dest.type}:`, error.message);
|
||||
log.error('backup', error, { destType: dest.type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +193,7 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
log.info('backup', 'Backup completed', { name, durationMs: duration });
|
||||
|
||||
return historyEntry;
|
||||
} catch (error) {
|
||||
@@ -263,7 +264,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(servicesFile, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up services:', error.message);
|
||||
log.error('backup', error, { source: 'services' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -278,7 +279,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up config:', error.message);
|
||||
log.error('backup', error, { source: 'config' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -291,7 +292,7 @@ class BackupManager extends EventEmitter {
|
||||
const credentialManager = require('../managers/credential-manager');
|
||||
return credentialManager.exportBackup();
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up credentials:', error.message);
|
||||
log.error('backup', error, { source: 'credentials' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -304,7 +305,7 @@ class BackupManager extends EventEmitter {
|
||||
const resourceMonitor = require('../managers/resource-monitor');
|
||||
return resourceMonitor.exportStats();
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up stats:', error.message);
|
||||
log.error('backup', error, { source: 'stats' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -374,7 +375,7 @@ class BackupManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
} catch (volumeError) {
|
||||
console.error(`[BackupManager] Error backing up volume ${volume.Name}:`, volumeError.message);
|
||||
log.error('backup', volumeError, { volume: volume.Name });
|
||||
backupResults.push({
|
||||
name: volume.Name,
|
||||
status: 'failed',
|
||||
@@ -390,7 +391,7 @@ class BackupManager extends EventEmitter {
|
||||
volumes: backupResults
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error backing up volumes:', error.message);
|
||||
log.error('backup', error, { source: 'volumes' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -461,9 +462,9 @@ class BackupManager extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[BackupManager] Volume ${volumeName} restored successfully`);
|
||||
log.info('backup', 'Volume restored', { volume: volumeName });
|
||||
} catch (restoreError) {
|
||||
console.error(`[BackupManager] Error restoring volume ${volBackup.name}:`, restoreError.message);
|
||||
log.error('backup', restoreError, { volume: volBackup.name });
|
||||
restoreResults.push({
|
||||
name: volBackup.name,
|
||||
status: 'failed',
|
||||
@@ -849,7 +850,7 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error('Backup verification failed: checksum mismatch');
|
||||
}
|
||||
|
||||
console.log('[BackupManager] Backup verified successfully');
|
||||
log.info('backup', 'Backup verified successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -860,7 +861,7 @@ class BackupManager extends EventEmitter {
|
||||
* Restore from backup
|
||||
*/
|
||||
async restoreBackup(backupId, options = {}) {
|
||||
console.log(`[BackupManager] Starting restore from backup: ${backupId}`);
|
||||
log.info('backup', 'Starting restore', { backupId });
|
||||
|
||||
this.emit('restore-start', { backupId, timestamp: new Date().toISOString() });
|
||||
|
||||
@@ -922,7 +923,7 @@ class BackupManager extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('[BackupManager] Restore completed successfully');
|
||||
log.info('backup', 'Restore completed successfully');
|
||||
return { success: true, restored };
|
||||
} catch (error) {
|
||||
this.emit('restore-failed', {
|
||||
@@ -940,7 +941,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreServices(services) {
|
||||
const servicesFile = platformPaths.servicesFile;
|
||||
fs.writeFileSync(servicesFile, JSON.stringify(services, null, 2));
|
||||
console.log('[BackupManager] Services restored');
|
||||
log.info('backup', 'Services restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -949,7 +950,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreConfig(config) {
|
||||
const configFile = platformPaths.configFile;
|
||||
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
console.log('[BackupManager] Config restored');
|
||||
log.info('backup', 'Config restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -958,7 +959,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreCredentials(credentials) {
|
||||
const credentialManager = require('../managers/credential-manager');
|
||||
credentialManager.importBackup(credentials);
|
||||
console.log('[BackupManager] Credentials restored');
|
||||
log.info('backup', 'Credentials restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -967,7 +968,7 @@ class BackupManager extends EventEmitter {
|
||||
restoreStats(stats) {
|
||||
const resourceMonitor = require('../managers/resource-monitor');
|
||||
resourceMonitor.importStats(stats);
|
||||
console.log('[BackupManager] Stats restored');
|
||||
log.info('backup', 'Stats restored');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -975,7 +976,7 @@ class BackupManager extends EventEmitter {
|
||||
*/
|
||||
async enforceStorageLimit(name, maxBytes) {
|
||||
const maxStr = formatBytes(maxBytes);
|
||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
||||
log.info('backup', 'Enforcing storage limit', { name, limit: maxStr });
|
||||
|
||||
const backups = this.history
|
||||
.filter(b => b.name === name && b.status === 'success')
|
||||
@@ -994,10 +995,10 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
||||
log.info('backup', 'Current storage usage', { totalSize: formatBytes(totalSize), limit: maxStr });
|
||||
|
||||
if (totalSize <= maxBytes) {
|
||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
||||
log.info('backup', 'Storage limit OK', { totalSize: formatBytes(totalSize), limit: maxStr });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1013,10 +1014,10 @@ class BackupManager extends EventEmitter {
|
||||
const sz = backup.size || 0;
|
||||
totalSize -= sz;
|
||||
freed += sz;
|
||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
||||
log.info('backup', 'Deleted old backup file', { size: formatBytes(sz), path });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
||||
log.error('backup', error, { path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,7 +1025,7 @@ class BackupManager extends EventEmitter {
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
||||
log.info('backup', 'Storage limit enforced', { freed: formatBytes(freed), totalSize: formatBytes(totalSize) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1051,9 +1052,9 @@ class BackupManager extends EventEmitter {
|
||||
// Remove from history
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
|
||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||
log.info('backup', 'Deleted old backup', { backupId: backup.id });
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||
log.error('backup', error, { backupId: backup.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,7 +1110,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(BACKUP_CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error loading config:', error.message);
|
||||
log.error('backup', error, { operation: 'loadConfig' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1125,7 +1126,7 @@ class BackupManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(BACKUP_CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error saving config:', error.message);
|
||||
log.error('backup', error, { operation: 'saveConfig' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,7 +1139,7 @@ class BackupManager extends EventEmitter {
|
||||
return JSON.parse(fs.readFileSync(BACKUP_HISTORY_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error loading history:', error.message);
|
||||
log.error('backup', error, { operation: 'loadHistory' });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1150,7 +1151,7 @@ class BackupManager extends EventEmitter {
|
||||
try {
|
||||
fs.writeFileSync(BACKUP_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[BackupManager] Error saving history:', error.message);
|
||||
log.error('backup', error, { operation: 'saveHistory' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,161 @@
|
||||
* Validates config.json structure to catch typos and invalid values early.
|
||||
*/
|
||||
|
||||
const VALID_TIMEZONES_SAMPLE = [
|
||||
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
|
||||
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland'
|
||||
const VALID_THEMES = ['dark', 'light', 'blue'];
|
||||
const VALID_ROUTING_MODES = ['subdomain', 'subdirectory'];
|
||||
const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
|
||||
const KNOWN_KEYS = [
|
||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string[]} arr
|
||||
* @param {string} val
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInArray(arr, val) {
|
||||
return arr.includes(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTld(ctx, config) {
|
||||
if (config.tld === undefined) return;
|
||||
if (typeof config.tld !== 'string') {
|
||||
ctx.errors.push('tld must be a string');
|
||||
return;
|
||||
}
|
||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||
ctx.errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||
}
|
||||
if (tld.length > 20) {
|
||||
ctx.warnings.push(`tld "${config.tld}" is unusually long`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDns(ctx, config) {
|
||||
if (config.dns === undefined) return;
|
||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||
ctx.errors.push('dns must be an object');
|
||||
return;
|
||||
}
|
||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||
ctx.errors.push('dns.ip must be a string');
|
||||
}
|
||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||
ctx.errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||
}
|
||||
if (config.dns.port !== undefined) {
|
||||
const port = parseInt(config.dns.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||
}
|
||||
}
|
||||
if (config.dns.servers !== undefined) {
|
||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||
ctx.errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
if (config.dns.provider !== undefined) {
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
ctx.errors.push('dns.provider must be a string');
|
||||
} else if (!isInArray(VALID_DNS_PROVIDERS, config.dns.provider)) {
|
||||
ctx.warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${VALID_DNS_PROVIDERS.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDashboardHost(ctx, config) {
|
||||
if (config.dashboardHost === undefined) return;
|
||||
if (typeof config.dashboardHost !== 'string') {
|
||||
ctx.errors.push('dashboardHost must be a string');
|
||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||
ctx.errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTimezone(ctx, config) {
|
||||
if (config.timezone === undefined) return;
|
||||
if (typeof config.timezone !== 'string') {
|
||||
ctx.errors.push('timezone must be a string');
|
||||
} else if (config.timezone) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||
} catch {
|
||||
ctx.errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateTheme(ctx, config) {
|
||||
if (config.theme === undefined) return;
|
||||
if (!isInArray(VALID_THEMES, config.theme)) {
|
||||
ctx.warnings.push(`theme "${config.theme}" is not one of: ${VALID_THEMES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateRoutingMode(ctx, config) {
|
||||
if (config.routingMode === undefined) return;
|
||||
if (!isInArray(VALID_ROUTING_MODES, config.routingMode)) {
|
||||
ctx.errors.push(`routingMode "${config.routingMode}" is not one of: ${VALID_ROUTING_MODES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{errors:string[], warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateDomain(ctx, config) {
|
||||
if (config.domain === undefined) return;
|
||||
if (typeof config.domain !== 'string') {
|
||||
ctx.errors.push('domain must be a string');
|
||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||
ctx.warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{warnings:string[]}} ctx
|
||||
* @param {object} config
|
||||
*/
|
||||
function validateKnownKeys(ctx, config) {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!isInArray(KNOWN_KEYS, key)) {
|
||||
ctx.warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a config object and return errors/warnings.
|
||||
* @param {object} config - The config object to validate
|
||||
@@ -17,123 +166,20 @@ const VALID_TIMEZONES_SAMPLE = [
|
||||
function validateConfig(config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const ctx = { errors, warnings };
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { valid: false, errors: ['Config must be a non-null object'], warnings };
|
||||
}
|
||||
|
||||
// TLD validation
|
||||
if (config.tld !== undefined) {
|
||||
if (typeof config.tld !== 'string') {
|
||||
errors.push('tld must be a string');
|
||||
} else {
|
||||
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
|
||||
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
|
||||
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
|
||||
}
|
||||
if (tld.length > 20) {
|
||||
warnings.push(`tld "${config.tld}" is unusually long`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DNS config validation
|
||||
if (config.dns !== undefined) {
|
||||
if (typeof config.dns !== 'object' || config.dns === null) {
|
||||
errors.push('dns must be an object');
|
||||
} else {
|
||||
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
|
||||
errors.push('dns.ip must be a string');
|
||||
}
|
||||
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
|
||||
errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
|
||||
}
|
||||
if (config.dns.port !== undefined) {
|
||||
const port = parseInt(config.dns.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
|
||||
}
|
||||
}
|
||||
if (config.dns.servers !== undefined) {
|
||||
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
|
||||
errors.push('dns.servers must be an object');
|
||||
}
|
||||
}
|
||||
// DNS provider validation
|
||||
if (config.dns.provider !== undefined) {
|
||||
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
|
||||
if (typeof config.dns.provider !== 'string') {
|
||||
errors.push('dns.provider must be a string');
|
||||
} else if (!validProviders.includes(config.dns.provider)) {
|
||||
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard host validation
|
||||
if (config.dashboardHost !== undefined) {
|
||||
if (typeof config.dashboardHost !== 'string') {
|
||||
errors.push('dashboardHost must be a string');
|
||||
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
|
||||
errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Timezone validation
|
||||
if (config.timezone !== undefined) {
|
||||
if (typeof config.timezone !== 'string') {
|
||||
errors.push('timezone must be a string');
|
||||
} else if (config.timezone) {
|
||||
// Basic format check — full validation would require Intl API
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
|
||||
} catch {
|
||||
errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Theme validation
|
||||
if (config.theme !== undefined) {
|
||||
const validThemes = ['dark', 'light', 'blue'];
|
||||
if (!validThemes.includes(config.theme)) {
|
||||
warnings.push(`theme "${config.theme}" is not one of: ${validThemes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Routing mode validation
|
||||
if (config.routingMode !== undefined) {
|
||||
const validModes = ['subdomain', 'subdirectory'];
|
||||
if (!validModes.includes(config.routingMode)) {
|
||||
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain validation
|
||||
if (config.domain !== undefined) {
|
||||
if (typeof config.domain !== 'string') {
|
||||
errors.push('domain must be a string');
|
||||
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
|
||||
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on unknown top-level keys
|
||||
const knownKeys = [
|
||||
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
|
||||
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
|
||||
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!knownKeys.includes(key)) {
|
||||
warnings.push(`Unknown config key "${key}" — possible typo?`);
|
||||
}
|
||||
}
|
||||
validateTld(ctx, config);
|
||||
validateDns(ctx, config);
|
||||
validateDashboardHost(ctx, config);
|
||||
validateTimezone(ctx, config);
|
||||
validateTheme(ctx, config);
|
||||
validateRoutingMode(ctx, config);
|
||||
validateDomain(ctx, config);
|
||||
validateKnownKeys(ctx, config);
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* DC-086: Structured error code system for consistent API error responses.
|
||||
*
|
||||
* Format: DC-[MODULE]-[NUMBER]
|
||||
* Modules: AUTH, CONTAINER, SERVICE, DNS, CADDY, CA, BACKUP, CONFIG,
|
||||
* BILL, HEALTH, NETWORK, SYSTEM, GENERAL
|
||||
*
|
||||
* Usage in routes:
|
||||
* const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
* errorResponse(res, 400, ErrorCodes.CONTAINER.INVALID_ID, 'Container ID has invalid characters');
|
||||
*
|
||||
* Clients can use the machine-readable code for i18n and error-specific handling
|
||||
* while the human message provides immediate context.
|
||||
*/
|
||||
|
||||
const ErrorCodes = {
|
||||
// ── General ──
|
||||
GENERAL: {
|
||||
INVALID_INPUT: 'DC-GEN-001',
|
||||
NOT_FOUND: 'DC-GEN-002',
|
||||
RATE_LIMITED: 'DC-GEN-003',
|
||||
INTERNAL: 'DC-GEN-004',
|
||||
UNAUTHORIZED: 'DC-GEN-005',
|
||||
FORBIDDEN: 'DC-GEN-006',
|
||||
CONFLICT: 'DC-GEN-007',
|
||||
TIMEOUT: 'DC-GEN-008',
|
||||
},
|
||||
|
||||
// ── Authentication ──
|
||||
AUTH: {
|
||||
NO_SESSION: 'DC-AUTH-001',
|
||||
INVALID_TOKEN: 'DC-AUTH-002',
|
||||
SESSION_EXPIRED: 'DC-AUTH-003',
|
||||
TOTP_REQUIRED: 'DC-AUTH-004',
|
||||
TOTP_INVALID: 'DC-AUTH-005',
|
||||
PROVIDER_DISABLED: 'DC-AUTH-006',
|
||||
INVITE_EXPIRED: 'DC-AUTH-007',
|
||||
INVITE_INVALID: 'DC-AUTH-008',
|
||||
KEY_REVOKED: 'DC-AUTH-009',
|
||||
LAST_ADMIN: 'DC-AUTH-010',
|
||||
},
|
||||
|
||||
// ── Containers ──
|
||||
CONTAINER: {
|
||||
NOT_FOUND: 'DC-CONT-001',
|
||||
INVALID_ID: 'DC-CONT-002',
|
||||
INVALID_NAME: 'DC-CONT-003',
|
||||
INVALID_IMAGE: 'DC-CONT-004',
|
||||
ALREADY_RUNNING: 'DC-CONT-005',
|
||||
ALREADY_STOPPED: 'DC-CONT-006',
|
||||
START_FAILED: 'DC-CONT-007',
|
||||
STOP_FAILED: 'DC-CONT-008',
|
||||
DELETE_FAILED: 'DC-CONT-009',
|
||||
INVALID_RESOURCES: 'DC-CONT-010',
|
||||
DOCKER_UNREACHABLE: 'DC-CONT-011',
|
||||
},
|
||||
|
||||
// ── Services ──
|
||||
SERVICE: {
|
||||
NOT_FOUND: 'DC-SVC-001',
|
||||
INVALID_ID: 'DC-SVC-002',
|
||||
INVALID_SUBDOMAIN: 'DC-SVC-003',
|
||||
INVALID_PORT: 'DC-SVC-004',
|
||||
DUPLICATE_ID: 'DC-SVC-005',
|
||||
INVALID_URL: 'DC-SVC-006',
|
||||
INVALID_PROTOCOL: 'DC-SVC-007',
|
||||
PORT_IN_USE: 'DC-SVC-008',
|
||||
DEPENDENCY_CYCLE: 'DC-SVC-009',
|
||||
},
|
||||
|
||||
// ── DNS ──
|
||||
DNS: {
|
||||
INVALID_RECORD: 'DC-DNS-001',
|
||||
INVALID_ZONE: 'DC-DNS-002',
|
||||
PROVIDER_ERROR: 'DC-DNS-003',
|
||||
PROPAGATION_TIMEOUT: 'DC-DNS-004',
|
||||
INVALID_CREDENTIALS: 'DC-DNS-005',
|
||||
},
|
||||
|
||||
// ── Caddy / Reverse Proxy ──
|
||||
CADDY: {
|
||||
ADMIN_UNREACHABLE: 'DC-CAD-001',
|
||||
CONFIG_INVALID: 'DC-CAD-002',
|
||||
RELOAD_FAILED: 'DC-CAD-003',
|
||||
SITE_EXISTS: 'DC-CAD-004',
|
||||
SITE_NOT_FOUND: 'DC-CAD-005',
|
||||
},
|
||||
|
||||
// ── Certificate Authority ──
|
||||
CA: {
|
||||
NOT_INITIALIZED: 'DC-CA-001',
|
||||
INVALID_DOMAIN: 'DC-CA-002',
|
||||
CERT_NOT_FOUND: 'DC-CA-003',
|
||||
GENERATION_FAILED: 'DC-CA-004',
|
||||
INVALID_FORMAT: 'DC-CA-005',
|
||||
},
|
||||
|
||||
// ── Backup ──
|
||||
BACKUP: {
|
||||
NO_SCHEDULE: 'DC-BAK-001',
|
||||
BACKUP_FAILED: 'DC-BAK-002',
|
||||
RESTORE_FAILED: 'DC-BAK-003',
|
||||
INVALID_CONFIG: 'DC-BAK-004',
|
||||
},
|
||||
|
||||
// ── Billing / License ──
|
||||
BILL: {
|
||||
CHECKOUT_FAILED: 'DC-BILL-001',
|
||||
LICENSE_INVALID: 'DC-BILL-002',
|
||||
LICENSE_EXPIRED: 'DC-BILL-003',
|
||||
LICENSE_NOT_FOUND: 'DC-BILL-004',
|
||||
FEATURE_LOCKED: 'DC-BILL-005',
|
||||
WEBHOOK_INVALID: 'DC-BILL-006',
|
||||
},
|
||||
|
||||
// ── Health Monitoring ──
|
||||
HEALTH: {
|
||||
CHECK_FAILED: 'DC-HLT-001',
|
||||
INCIDENT_NOT_FOUND: 'DC-HLT-002',
|
||||
INVALID_SEVERITY: 'DC-HLT-003',
|
||||
},
|
||||
|
||||
// ── Network ──
|
||||
NETWORK: {
|
||||
INVALID_IP: 'DC-NET-001',
|
||||
INVALID_CIDR: 'DC-NET-002',
|
||||
INVALID_HOSTNAME: 'DC-NET-003',
|
||||
GATEWAY_TIMEOUT: 'DC-NET-004',
|
||||
},
|
||||
|
||||
// ── System / Config ──
|
||||
SYSTEM: {
|
||||
CONFIG_INVALID: 'DC-SYS-001',
|
||||
CONFIG_SAVE_FAILED: 'DC-SYS-002',
|
||||
STARTUP_FAILED: 'DC-SYS-003',
|
||||
DATA_DIR_UNSAFE: 'DC-SYS-004',
|
||||
DISK_FULL: 'DC-SYS-005',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { ErrorCodes };
|
||||
@@ -34,7 +34,7 @@ function errorMiddleware(err, req, res, next) {
|
||||
userId: req.user?.id,
|
||||
body: req.body
|
||||
}
|
||||
).catch(e => console.error('Failed to write to error log:', e.message));
|
||||
).catch(e => process.stderr.write(`[error-handler] Failed to write to error log: ${e.message}\n`));
|
||||
|
||||
// Determine if this is an operational error (AppError) or programming error
|
||||
const isOperational = err.isOperational || err instanceof AppError;
|
||||
@@ -65,11 +65,7 @@ function errorMiddleware(err, req, res, next) {
|
||||
|
||||
// For non-operational errors, log as fatal
|
||||
if (!isOperational) {
|
||||
console.error('FATAL: Non-operational error detected', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path
|
||||
});
|
||||
process.stderr.write(`[FATAL] Non-operational error detected: ${JSON.stringify({ error: err.message, stack: err.stack, path: req.path })}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* DC-071: Error tracking integration framework
|
||||
*
|
||||
* Provides an opt-in error tracking interface that can forward uncaught
|
||||
* errors to external services (Sentry, Bugsnag, etc.) when configured.
|
||||
*
|
||||
* In production, set ERROR_TRACKING_DSN environment variable to enable.
|
||||
* Without a DSN, errors are logged normally but not forwarded.
|
||||
*
|
||||
* Usage:
|
||||
* const { errorTracker } = require('./utilities/error-tracker');
|
||||
* errorTracker.init({ dsn: process.env.ERROR_TRACKING_DSN, release: '1.15.0' });
|
||||
* errorTracker.capture(error, { extra: { route: req.path } });
|
||||
*/
|
||||
|
||||
const os = require('os');
|
||||
|
||||
class ErrorTracker {
|
||||
constructor() {
|
||||
this.dsn = null;
|
||||
this.release = null;
|
||||
this.enabled = false;
|
||||
this.pendingFlush = Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the error tracker.
|
||||
* If no DSN is provided, tracking is disabled (errors still log normally).
|
||||
*/
|
||||
init({ dsn, release, environment } = {}) {
|
||||
this.dsn = dsn || process.env.ERROR_TRACKING_DSN;
|
||||
this.release = release || process.env.npm_package_version || 'unknown';
|
||||
this.environment = environment || process.env.NODE_ENV || 'production';
|
||||
this.enabled = !!this.dsn;
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an error and forward to the tracking service.
|
||||
* Non-blocking — swallows network errors silently.
|
||||
*/
|
||||
capture(error, context = {}) {
|
||||
if (!this.enabled || !error) return;
|
||||
|
||||
const payload = {
|
||||
event_id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: 'node',
|
||||
level: 'error',
|
||||
release: this.release,
|
||||
environment: this.environment,
|
||||
message: error.message || String(error),
|
||||
stacktrace: error.stack || '',
|
||||
exception: {
|
||||
type: error.constructor.name,
|
||||
value: error.message,
|
||||
},
|
||||
tags: {
|
||||
hostname: os.hostname(),
|
||||
node_version: process.version,
|
||||
...context.tags,
|
||||
},
|
||||
extra: {
|
||||
pid: process.pid,
|
||||
memory: process.memoryUsage().rss,
|
||||
uptime: process.uptime(),
|
||||
...context.extra,
|
||||
},
|
||||
request: context.request || undefined,
|
||||
user: context.user || undefined,
|
||||
};
|
||||
|
||||
// Fire-and-forget — don't block the event loop
|
||||
this.pendingFlush = this._send(payload).catch(() => {
|
||||
// Silent failure — tracking errors should never crash the app
|
||||
});
|
||||
|
||||
return payload.event_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a message (not an error) at the specified level.
|
||||
*/
|
||||
captureMessage(message, level = 'info', context = {}) {
|
||||
if (!this.enabled) return;
|
||||
return this.capture(
|
||||
Object.assign(new Error(message), { stack: '' }),
|
||||
{ ...context, tags: { ...context.tags, level } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the payload to the tracking service DSN.
|
||||
* Currently implements the Sentry envelope format.
|
||||
*/
|
||||
async _send(payload) {
|
||||
if (!this.dsn) return;
|
||||
|
||||
const url = new URL(this.dsn);
|
||||
const projectId = url.pathname.replace(/^\//, '');
|
||||
const apiKey = url.username;
|
||||
const ingestUrl = `${url.protocol}//${url.host}/api/${projectId}/store/`;
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(ingestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sentry-Auth': `Sentry sentry_key=${apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Non-OK response — silently ignore
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending events to flush.
|
||||
*/
|
||||
async flush(timeoutMs = 2000) {
|
||||
await Promise.race([
|
||||
this.pendingFlush,
|
||||
new Promise(resolve => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express error-handling middleware that captures errors before
|
||||
* forwarding to the next error handler.
|
||||
*/
|
||||
middleware() {
|
||||
return (err, req, res, next) => {
|
||||
this.capture(err, {
|
||||
request: {
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
},
|
||||
extra: {
|
||||
requestId: req.id,
|
||||
path: req.path,
|
||||
},
|
||||
});
|
||||
next(err);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ErrorTracker();
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* DC-077: Internationalization (i18n) framework for DashCaddy
|
||||
*
|
||||
* Lightweight translation system for the dashboard frontend and API responses.
|
||||
* Supports multiple languages via JSON translation files loaded on demand.
|
||||
*
|
||||
* Languages are stored in /assets/i18n/{lang}.json
|
||||
* Default language is 'en' (English).
|
||||
*
|
||||
* Usage in frontend JS:
|
||||
* const { t, setLanguage, getLanguage } = window.DCI18n;
|
||||
* document.querySelector('.title').textContent = t('dashboard.title');
|
||||
*
|
||||
* Usage in API responses:
|
||||
* const i18n = require('./i18n');
|
||||
* const msg = i18n.t('error.container_not_found', req.lang || 'en');
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Built-in translations (loaded synchronously at startup)
|
||||
const TRANSLATIONS = {
|
||||
en: {
|
||||
'dashboard.title': 'Dashboard',
|
||||
'dashboard.services': 'Services',
|
||||
'dashboard.containers': 'Containers',
|
||||
'dashboard.health': 'Health',
|
||||
'dashboard.settings': 'Settings',
|
||||
'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Monitoring',
|
||||
'dashboard.security': 'Security',
|
||||
|
||||
'service.status.healthy': 'Healthy',
|
||||
'service.status.degraded': 'Degraded',
|
||||
'service.status.down': 'Down',
|
||||
'service.status.unknown': 'Unknown',
|
||||
'service.status.pending': 'Pending',
|
||||
|
||||
'action.start': 'Start',
|
||||
'action.stop': 'Stop',
|
||||
'action.restart': 'Restart',
|
||||
'action.delete': 'Delete',
|
||||
'action.update': 'Update',
|
||||
'action.deploy': 'Deploy',
|
||||
'action.save': 'Save',
|
||||
'action.cancel': 'Cancel',
|
||||
'action.confirm': 'Confirm',
|
||||
|
||||
'error.not_found': 'Resource not found',
|
||||
'error.unauthorized': 'Unauthorized',
|
||||
'error.forbidden': 'Forbidden',
|
||||
'error.rate_limited': 'Too many requests',
|
||||
'error.internal': 'Internal server error',
|
||||
'error.container_not_found': 'Container not found',
|
||||
'error.service_not_found': 'Service not found',
|
||||
'error.invalid_input': 'Invalid input',
|
||||
'error.docker_unreachable': 'Docker daemon is not reachable',
|
||||
'error.disk_full': 'Disk space is critically low',
|
||||
},
|
||||
|
||||
es: {
|
||||
'dashboard.title': 'Panel de control',
|
||||
'dashboard.services': 'Servicios',
|
||||
'dashboard.containers': 'Contenedores',
|
||||
'dashboard.health': 'Salud',
|
||||
'dashboard.settings': 'Configuración',
|
||||
'dashboard.backups': 'Copias de seguridad',
|
||||
'dashboard.monitoring': 'Monitoreo',
|
||||
'dashboard.security': 'Seguridad',
|
||||
|
||||
'service.status.healthy': 'Saludable',
|
||||
'service.status.degraded': 'Degradado',
|
||||
'service.status.down': 'Caído',
|
||||
'service.status.unknown': 'Desconocido',
|
||||
'service.status.pending': 'Pendiente',
|
||||
|
||||
'action.start': 'Iniciar',
|
||||
'action.stop': 'Detener',
|
||||
'action.restart': 'Reiniciar',
|
||||
'action.delete': 'Eliminar',
|
||||
'action.update': 'Actualizar',
|
||||
'action.deploy': 'Desplegar',
|
||||
'action.save': 'Guardar',
|
||||
'action.cancel': 'Cancelar',
|
||||
'action.confirm': 'Confirmar',
|
||||
|
||||
'error.not_found': 'Recurso no encontrado',
|
||||
'error.unauthorized': 'No autorizado',
|
||||
'error.forbidden': 'Prohibido',
|
||||
'error.rate_limited': 'Demasiadas solicitudes',
|
||||
'error.internal': 'Error interno del servidor',
|
||||
'error.container_not_found': 'Contenedor no encontrado',
|
||||
'error.service_not_found': 'Servicio no encontrado',
|
||||
'error.invalid_input': 'Entrada inválida',
|
||||
'error.docker_unreachable': 'El demonio de Docker no es accesible',
|
||||
'error.disk_full': 'Espacio en disco críticamente bajo',
|
||||
},
|
||||
|
||||
fr: {
|
||||
'dashboard.title': 'Tableau de bord',
|
||||
'dashboard.services': 'Services',
|
||||
'dashboard.containers': 'Conteneurs',
|
||||
'dashboard.health': 'Santé',
|
||||
'dashboard.settings': 'Paramètres',
|
||||
'dashboard.backups': 'Sauvegardes',
|
||||
'dashboard.monitoring': 'Surveillance',
|
||||
'dashboard.security': 'Sécurité',
|
||||
|
||||
'service.status.healthy': 'Sain',
|
||||
'service.status.degraded': 'Dégradé',
|
||||
'service.status.down': 'Hors ligne',
|
||||
'service.status.unknown': 'Inconnu',
|
||||
'service.status.pending': 'En attente',
|
||||
|
||||
'action.start': 'Démarrer',
|
||||
'action.stop': 'Arrêter',
|
||||
'action.restart': 'Redémarrer',
|
||||
'action.delete': 'Supprimer',
|
||||
'action.update': 'Mettre à jour',
|
||||
'action.deploy': 'Déployer',
|
||||
'action.save': 'Enregistrer',
|
||||
'action.cancel': 'Annuler',
|
||||
'action.confirm': 'Confirmer',
|
||||
|
||||
'error.not_found': 'Ressource introuvable',
|
||||
'error.unauthorized': 'Non autorisé',
|
||||
'error.forbidden': 'Interdit',
|
||||
'error.rate_limited': 'Trop de requêtes',
|
||||
'error.internal': 'Erreur interne du serveur',
|
||||
'error.container_not_found': 'Conteneur introuvable',
|
||||
'error.service_not_found': 'Service introuvable',
|
||||
'error.invalid_input': 'Entrée invalide',
|
||||
'error.docker_unreachable': 'Le démon Docker est injoignable',
|
||||
'error.disk_full': 'Espace disque critique',
|
||||
},
|
||||
|
||||
de: {
|
||||
'dashboard.title': 'Dashboard',
|
||||
'dashboard.services': 'Dienste',
|
||||
'dashboard.containers': 'Container',
|
||||
'dashboard.health': 'Zustand',
|
||||
'dashboard.settings': 'Einstellungen',
|
||||
'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Überwachung',
|
||||
'dashboard.security': 'Sicherheit',
|
||||
|
||||
'service.status.healthy': 'Gesund',
|
||||
'service.status.degraded': 'Beeinträchtigt',
|
||||
'service.status.down': 'Ausgefallen',
|
||||
'service.status.unknown': 'Unbekannt',
|
||||
'service.status.pending': 'Ausstehend',
|
||||
|
||||
'action.start': 'Starten',
|
||||
'action.stop': 'Stopp',
|
||||
'action.restart': 'Neustart',
|
||||
'action.delete': 'Löschen',
|
||||
'action.update': 'Aktualisieren',
|
||||
'action.deploy': 'Bereitstellen',
|
||||
'action.save': 'Speichern',
|
||||
'action.cancel': 'Abbrechen',
|
||||
'action.confirm': 'Bestätigen',
|
||||
|
||||
'error.not_found': 'Ressource nicht gefunden',
|
||||
'error.unauthorized': 'Nicht autorisiert',
|
||||
'error.forbidden': 'Verboten',
|
||||
'error.rate_limited': 'Zu viele Anfragen',
|
||||
'error.internal': 'Interner Serverfehler',
|
||||
'error.container_not_found': 'Container nicht gefunden',
|
||||
'error.service_not_found': 'Dienst nicht gefunden',
|
||||
'error.invalid_input': 'Ungültige Eingabe',
|
||||
'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
|
||||
'error.disk_full': 'Speicherplatz kritisch niedrig',
|
||||
},
|
||||
|
||||
ar: {
|
||||
'dashboard.title': 'لوحة التحكم',
|
||||
'dashboard.services': 'الخدمات',
|
||||
'dashboard.containers': 'الحاويات',
|
||||
'dashboard.health': 'الصحة',
|
||||
'dashboard.settings': 'الإعدادات',
|
||||
'dashboard.backups': 'النسخ الاحتياطية',
|
||||
'dashboard.monitoring': 'المراقبة',
|
||||
'dashboard.security': 'الأمان',
|
||||
|
||||
'service.status.healthy': 'سليم',
|
||||
'service.status.degraded': 'متدهور',
|
||||
'service.status.down': 'متوقف',
|
||||
'service.status.unknown': 'غير معروف',
|
||||
'service.status.pending': 'قيد الانتظار',
|
||||
|
||||
'action.start': 'تشغيل',
|
||||
'action.stop': 'إيقاف',
|
||||
'action.restart': 'إعادة تشغيل',
|
||||
'action.delete': 'حذف',
|
||||
'action.update': 'تحديث',
|
||||
'action.deploy': 'نشر',
|
||||
'action.save': 'حفظ',
|
||||
'action.cancel': 'إلغاء',
|
||||
'action.confirm': 'تأكيد',
|
||||
|
||||
'error.not_found': 'المورد غير موجود',
|
||||
'error.unauthorized': 'غير مصرح',
|
||||
'error.forbidden': 'محظور',
|
||||
'error.rate_limited': 'طلبات كثيرة جداً',
|
||||
'error.internal': 'خطأ داخلي في الخادم',
|
||||
'error.container_not_found': 'الحاوية غير موجودة',
|
||||
'error.service_not_found': 'الخدمة غير موجودة',
|
||||
'error.invalid_input': 'إدخال غير صالح',
|
||||
'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
|
||||
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج',
|
||||
},
|
||||
};
|
||||
|
||||
const SUPPORTED_LANGUAGES = Object.keys(TRANSLATIONS);
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/**
|
||||
* Translate a key to the specified language.
|
||||
* Falls back to English, then to the key itself if not found.
|
||||
*/
|
||||
function t(key, lang = DEFAULT_LANGUAGE) {
|
||||
const dict = TRANSLATIONS[lang] || TRANSLATIONS[DEFAULT_LANGUAGE];
|
||||
return dict[key] || TRANSLATIONS[DEFAULT_LANGUAGE][key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of supported languages
|
||||
*/
|
||||
function getSupportedLanguages() {
|
||||
return SUPPORTED_LANGUAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language is supported
|
||||
*/
|
||||
function isSupported(lang) {
|
||||
return SUPPORTED_LANGUAGES.includes(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language from Accept-Language header
|
||||
*/
|
||||
function detectLanguage(acceptLanguage) {
|
||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||
const langs = acceptLanguage.split(',').map(l => {
|
||||
const [code, q] = l.trim().split(';q=');
|
||||
return { code: code.split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
|
||||
}).sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const { code } of langs) {
|
||||
if (isSupported(code)) return code;
|
||||
}
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
t,
|
||||
getSupportedLanguages,
|
||||
isSupported,
|
||||
detectLanguage,
|
||||
DEFAULT_LANGUAGE,
|
||||
TRANSLATIONS,
|
||||
};
|
||||
@@ -113,6 +113,41 @@ module.exports = function configureMiddleware(app, {
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Tailscale authentication helpers ──
|
||||
|
||||
const PROBE_PATHS_TAILSCALE = new Set([
|
||||
'/health', '/health/live', '/health/ready', '/healthz', '/readyz',
|
||||
]);
|
||||
|
||||
function isTailScaleProbePath(reqPath) {
|
||||
return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/');
|
||||
}
|
||||
|
||||
function extractTailscaleIPs(req) {
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip =>
|
||||
isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP };
|
||||
}
|
||||
|
||||
async function isIPInTailnet(clientTailscaleIP) {
|
||||
const status = await getTailscaleStatus();
|
||||
if (!status) return true; // no status = can't verify = allow
|
||||
|
||||
const knownIPs = new Set();
|
||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
for (const peer of Object.values(status.Peer || {})) {
|
||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
}
|
||||
return knownIPs.has(clientTailscaleIP);
|
||||
}
|
||||
|
||||
// ── Tailscale authentication middleware (optional) ──
|
||||
const tailscaleAuthMiddleware = async (req, res, next) => {
|
||||
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
|
||||
@@ -121,25 +156,11 @@ module.exports = function configureMiddleware(app, {
|
||||
|
||||
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
|
||||
// don't carry a Tailscale identity header.
|
||||
if (req.path === '/health'
|
||||
|| req.path === '/health/live'
|
||||
|| req.path === '/health/ready'
|
||||
|| req.path === '/healthz'
|
||||
|| req.path === '/readyz'
|
||||
|| req.path.startsWith('/probe/')) {
|
||||
if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/v1/tailscale/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req);
|
||||
|
||||
if (!fromTailscale) {
|
||||
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
|
||||
@@ -148,27 +169,14 @@ module.exports = function configureMiddleware(app, {
|
||||
});
|
||||
}
|
||||
|
||||
if (tailscaleConfig.allowedTailnet) {
|
||||
if (tailscaleConfig.allowedTailnet && clientTailscaleIP) {
|
||||
try {
|
||||
const status = await getTailscaleStatus();
|
||||
if (status) {
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
|
||||
if (clientTailscaleIP) {
|
||||
const knownIPs = new Set();
|
||||
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
for (const peer of Object.values(status.Peer || {})) {
|
||||
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
|
||||
}
|
||||
if (!knownIPs.has(clientTailscaleIP)) {
|
||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||
requiresTailscale: true,
|
||||
clientIP
|
||||
});
|
||||
}
|
||||
}
|
||||
const inTailnet = await isIPInTailnet(clientTailscaleIP);
|
||||
if (!inTailnet) {
|
||||
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
|
||||
requiresTailscale: true,
|
||||
clientIP
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
|
||||
@@ -429,6 +437,12 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
// DC-075: System health endpoint for external uptime monitoring (UptimeRobot, BetterStack)
|
||||
{ path: '/api/v1/system/health', exact: true, method: 'GET' },
|
||||
// DC-097: Prometheus metrics endpoint (scraped by Prometheus, no auth)
|
||||
{ path: '/api/v1/metrics/prometheus', exact: true, method: 'GET' },
|
||||
// DC-077: i18n endpoints (language list + translations, public)
|
||||
{ path: '/api/v1/i18n/', prefix: true, method: 'GET' },
|
||||
// System Overview widget on the dashboard — needs the flattened CPU/mem
|
||||
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
@@ -561,6 +575,18 @@ module.exports = function configureMiddleware(app, {
|
||||
});
|
||||
|
||||
app.use(generalLimiter);
|
||||
|
||||
// ── DC-073: Debug request logger (gated behind LOG_LEVEL=debug) ──
|
||||
if (process.env.LOG_LEVEL === 'debug') {
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
process.stderr.write(`[req] ${req.method} ${req.path} ${res.statusCode} ${duration}ms\n`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/v1/dns/credentials', strictLimiter);
|
||||
app.use('/api/v1/apps/deploy', strictLimiter);
|
||||
app.use('/api/v1/backup/restore', strictLimiter);
|
||||
|
||||
@@ -74,11 +74,22 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI
|
||||
}
|
||||
|
||||
// 3. Check if port is available
|
||||
// CRITICAL: listen() and close() are async. If we fire-and-forget both
|
||||
// (the old code), the kernel hasn't released the port by the time
|
||||
// app.listen(PORT) runs in server.js → EADDRINUSE → crash loop.
|
||||
// Await both via Promises so the port is truly free before we return.
|
||||
const net = require('net');
|
||||
const portCheckServer = net.createServer();
|
||||
try {
|
||||
portCheckServer.listen(PORT, '0.0.0.0');
|
||||
portCheckServer.close();
|
||||
await new Promise((resolve, reject) => {
|
||||
portCheckServer.once('error', reject);
|
||||
portCheckServer.listen(PORT, '0.0.0.0', () => {
|
||||
portCheckServer.close(() => {
|
||||
portCheckServer.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
log.info('startup', `Port ${PORT} is available`);
|
||||
} catch (error) {
|
||||
errors.push(`Port ${PORT} is already in use or cannot be bound`);
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
*
|
||||
* Priority:
|
||||
* 1. internet → https://www.google.com
|
||||
* 2. isExternal + externalUrl → use as-is
|
||||
* 3. service.url → prepend https:// if no protocol
|
||||
* 4. dnsServers config → http://{ip}:{port}
|
||||
* 5. fallback → buildServiceUrl(id)
|
||||
* 2. healthCheckUrl → use as-is (bypass SSO/Caddy for direct container health checks)
|
||||
* 3. isExternal + externalUrl → use as-is
|
||||
* 4. service.url → prepend https:// if no protocol
|
||||
* 5. dnsServers config → http://{ip}:{port}
|
||||
* 6. fallback → buildServiceUrl(id)
|
||||
*
|
||||
* @param {string} id - service identifier
|
||||
* @param {Object|null} service - service object from services.json (may be null for top-card services)
|
||||
@@ -22,6 +23,7 @@
|
||||
*/
|
||||
function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) {
|
||||
if (id === 'internet') return 'https://www.google.com';
|
||||
if (service?.healthCheckUrl) return service.healthCheckUrl;
|
||||
if (service?.isExternal && service.externalUrl) return service.externalUrl;
|
||||
if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`;
|
||||
const dnsServer = siteConfig?.dnsServers?.[id];
|
||||
|
||||
Reference in New Issue
Block a user