Compare commits

...
16 Commits
Author SHA1 Message Date
Hermes 7485772427 Bump to v1.13.0 - config migration system
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 20:06:46 -07:00
Hermes e5d7da6edd Add config migration system
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
When config.json schema changes between versions, register a migration
function in src/config/migrations.js. On startup, loadSiteConfig() detects
the stored version, runs all migrations forward, and writes the result back.
Users never see the migration — it runs silently and the rest of the app
only ever sees the current schema.

Includes:
- v0 → v1: normalize dns from string to object
- v1 → v2: add dns.provider field (default 'technitium')
- Forward compat: configs from future versions left untouched
- Idempotent: re-running on already-migrated config is a no-op
- Safe: no user data is removed during migration

21 unit tests covering edge cases: null input, forward compat, corrupt
JSON, missing parent dirs, idempotency, full migration chain.
2026-06-10 20:06:09 -07:00
Hermes 28f0fa3c10 Add /api/v1/version to PUBLIC_ROUTES
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 19:55:19 -07:00
Hermes eee32c1eae Fix missing platform-paths import in routes/services.js
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 19:47:52 -07:00
Hermes 37a3282f98 Bump to v1.12.0 - cross-platform standardization
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 19:36:30 -07:00
Hermes 1fbe65f524 Standardize paths, add version endpoint, request timeouts, HOST env var, graceful shutdown
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Cross-platform hardening — removes all hardcoded /app/ paths from route files
and routes them through platform-paths.js so the app works the same way
regardless of Docker layout (single-file mount vs consolidated data dir).

Changes:
- platform-paths.js: add generatedCertsDir, pkiDir, containerUpdatesDir,
  containerFrontendDir, containerAssetsDir, resolveAssetsPath()
- self-updater.js: UPDATE_URL/MIRROR_URL/CHANNEL env var overrides
- routes/ca.js: use platformPaths for cert paths and generated certs dir
- routes/services.js: use platformPaths.pkiRootCert
- routes/themes.js: derive THEMES_DIR from platformPaths.servicesFile
- routes/config/assets.js + backup.js: use resolveAssetsPath() fallback
- routes/services.js + src/app.js: use platformPaths.pkiRootCert
- server.js: HOST env var support, parse PORT as int
- src/app.js: GET /api/v1/version (public, no auth), global request timeout,
  disable x-powered-by, trust proxy
- pylon/dashcaddy-pylon.js: PYLON_HOST env var, graceful shutdown on SIGTERM/SIGINT

A fresh user can now deploy with a custom Docker layout (e.g. /opt/dc/data/
as a single volume mount) and the app finds its files automatically, no env
var configuration required.
2026-06-10 19:36:05 -07:00
Hermes 320f21c113 fix: credential-manager and crypto-utils auto-resolve data directory paths
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The CREDENTIALS_FILE and ENCRYPTION_KEY_FILE env vars defaulted to
__dirname/credentials.json and __dirname/.encryption-key, which works
for the standard install (where individual files are mounted to /app/)
but breaks for deployments using a consolidated data directory at
/app/data/.

Add resolveCredentialsFile() and resolveKeyFile() helpers that:
1. Honor explicit env var if set
2. Check /app/credentials.json and /app/data/credentials.json
3. Check /app/.encryption-key and /app/data/.encryption-key
4. Default to standard path for new installs

This makes DashCaddy deployable with either pattern without requiring
custom env var configuration, which is essential for general-public
reproducibility.
2026-06-10 19:05:07 -07:00
Hermes 5c76c3df97 fix: System Overview widget - expose monitoring/health endpoints publicly + fix data formats
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Add /api/v1/monitoring/stats and /api/v1/health-checks/status to PUBLIC_ROUTES
  so the frontend widget can fetch without auth
- Transform monitoring stats response from nested {cpu:{percent}} to flat
  {cpu: number, memory: number, memoryUsage: number} for the widget
- Add summary {healthy, unhealthy, total} to health-checks/status response
2026-06-10 18:24:30 -07:00
Hermes 260575c6bd fix: wrap createContainer with user-friendly DC-201 error for missing images
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 17:39:37 -07:00
Hermes e361d9a328 fix: increase pull timeout to 300s, add missing environment:{} to portainer + uptime-kuma templates
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 17:05:28 -07:00
Hermes aa25bcc053 fix: always expose DC-prefixed errors to users in safeErrorMessage
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 17:02:13 -07:00
Hermes bda08b592e fix: idempotent Caddy subpath config, increase Docker pull timeout to 120s, extend health check to 60s
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- helpers.js: treat 'No changes to apply' as success (config already exists = idempotent)
- constants.js: Docker pull timeout 30s → 120s (large images need more time)
- deploy.js: health check 40s → 60s (some apps like filebrowser are slow to start)
2026-06-10 16:43:34 -07:00
Hermes 0e408974a0 fix: harden deploy error handling - guard against undefined errors, safeErrorMessage null check
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- deploy.js: wrap logError/notification in try/catch so they never mask the original deploy error
- deploy.js: use optional chaining for error.message access
- logging.js: safeErrorMessage handles null/undefined error gracefully
2026-06-10 16:39:14 -07:00
Hermes f4b35dcc30 fix: correct apps route mount paths - mount all sub-routers at /apps prefix to match frontend API calls
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-10 16:28:41 -07:00
Hermes 1c0d765182 fix: app route path nesting (deploy/remove/templates), server.js fetchT import, lifetime license expiry, workflows path prefix
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- routes/apps/index.js: mount sub-routers at '/' to avoid double-nesting (was /deploy/deploy, now /deploy)
- server.js: add fetchT import for workflow engine init
- license-manager.js: fix isExpired() for lifetime licenses (null expiresAt → always expired)
- src/app.js: add '/workflows' path prefix to prevent requirePremium gating all routes
- app-templates.js: fix 10 templates missing volumes/healthCheck
- routes/apps/index.js: add e.stack to error logging for better debugging
2026-06-10 16:20:51 -07:00
Hermes 2cd62208ac fix: workflows route mounted without path prefix — blocked all API on free tier; fix 10 app templates missing fields 2026-06-10 15:40:00 -07:00
28 changed files with 616 additions and 79 deletions
+1
View File
@@ -0,0 +1 @@
1.13.0
+1 -1
View File
@@ -1 +1 @@
1.10.0
1.13.0
@@ -0,0 +1,215 @@
/**
* Config migration tests
*
* These tests verify that a config file from any older version of DashCaddy
* gets correctly migrated to the current version. Migration MUST be:
* - Deterministic (same input always produces same output)
* - Idempotent (running migration on already-migrated config is a no-op)
* - Safe (no data loss; only adds fields, never removes user values)
* - Silent (no exceptions thrown for any version from 0 to CURRENT)
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
CURRENT_VERSION,
migrations,
migrate,
loadAndMigrate
} = require('../src/config/migrations');
describe('config/migrations', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('migrate()', () => {
test('null/empty config returns fresh v_current', () => {
const result = migrate(null);
expect(result._version).toBe(CURRENT_VERSION);
});
test('undefined config returns fresh v_current', () => {
const result = migrate(undefined);
expect(result._version).toBe(CURRENT_VERSION);
});
test('v0 (no _version) migrates all the way to current', () => {
const v0 = { tld: '.home', customValue: 'preserved' };
const result = migrate(v0);
expect(result._version).toBe(CURRENT_VERSION);
// User data must be preserved
expect(result.tld).toBe('.home');
expect(result.customValue).toBe('preserved');
});
test('each intermediate version migrates forward to current', () => {
for (let v = 0; v < CURRENT_VERSION; v++) {
const config = { _version: v, tld: '.test' };
const result = migrate(config);
// Final version is always CURRENT_VERSION after running all migrations
expect(result._version).toBe(CURRENT_VERSION);
// User data preserved
expect(result.tld).toBe('.test');
}
});
test('config at current version passes through unchanged', () => {
const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' };
const result = migrate(current);
expect(result).toEqual(current);
});
test('config from FUTURE version is left alone (forward compat)', () => {
const future = { _version: 999, tld: '.home', newField: 'unknown' };
const result = migrate(future);
// We don't touch future configs — let validation catch issues
expect(result._version).toBe(999);
expect(result.newField).toBe('unknown');
});
});
describe('v0 → v1 migration: dns normalization', () => {
test('string dns gets converted to object', () => {
const result = migrations[1]({ dns: '192.168.1.1' });
expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 });
});
test('missing dns gets default object', () => {
const result = migrations[1]({ tld: '.home' });
expect(result.dns).toEqual({ ip: '', port: 5380 });
});
test('object dns passes through unchanged', () => {
const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } });
expect(result.dns.ip).toBe('10.0.0.1');
expect(result.dns.custom).toBe('kept');
});
test('_version is set to 1', () => {
const result = migrations[1]({ tld: '.home' });
expect(result._version).toBe(1);
});
});
describe('v1 → v2 migration: dns.provider field', () => {
test('adds provider: technitium default', () => {
const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 });
expect(result.dns.provider).toBe('technitium');
expect(result.dns.ip).toBe('10.0.0.1');
expect(result.dns.port).toBe(5380);
});
test('respects existing provider if set', () => {
const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 });
expect(result.dns.provider).toBe('cloudflare');
});
test('_version is set to 2', () => {
const result = migrations[2]({ _version: 1 });
expect(result._version).toBe(2);
});
});
describe('loadAndMigrate()', () => {
test('creates fresh config when file does not exist', () => {
const configFile = path.join(tmpDir, 'config.json');
const result = loadAndMigrate(configFile, null);
expect(result._version).toBe(CURRENT_VERSION);
// Should NOT write a file when there was nothing to migrate
expect(fs.existsSync(configFile)).toBe(false);
});
test('migrates old config and writes back to disk', () => {
const configFile = path.join(tmpDir, 'config.json');
// Write an unversioned config (v0)
fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' }));
const result = loadAndMigrate(configFile, null);
// Returned value is migrated
expect(result._version).toBe(CURRENT_VERSION);
expect(result.tld).toBe('.sami');
expect(result.customField).toBe('preserve-me');
// File on disk is updated
const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
expect(written._version).toBe(CURRENT_VERSION);
expect(written.tld).toBe('.sami');
});
test('does not rewrite file when already at current version', () => {
const configFile = path.join(tmpDir, 'config.json');
const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2);
fs.writeFileSync(configFile, original);
// Record mtime before
const mtimeBefore = fs.statSync(configFile).mtimeMs;
// Wait a tick
const start = Date.now();
while (Date.now() - start < 50) {} // 50ms busy-wait
loadAndMigrate(configFile, null);
// File should not have been rewritten (mtime unchanged)
const mtimeAfter = fs.statSync(configFile).mtimeMs;
expect(mtimeAfter).toBe(mtimeBefore);
});
test('handles corrupt JSON gracefully (returns defaults, no crash)', () => {
const configFile = path.join(tmpDir, 'config.json');
fs.writeFileSync(configFile, '{ this is not valid json');
// Should not throw
const result = loadAndMigrate(configFile, null);
expect(result._version).toBe(CURRENT_VERSION);
});
test('creates parent directory if missing', () => {
const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json');
// Pre-create parent dirs (test setup)
fs.mkdirSync(path.dirname(nested), { recursive: true });
fs.writeFileSync(nested, JSON.stringify({ tld: '.home' }));
const result = loadAndMigrate(nested, null);
expect(result._version).toBe(CURRENT_VERSION);
});
test('full chain: v0 file with string dns becomes v2 with provider', () => {
const configFile = path.join(tmpDir, 'config.json');
fs.writeFileSync(configFile, JSON.stringify({
tld: '.sami',
dns: '10.0.0.1'
}));
const result = loadAndMigrate(configFile, null);
expect(result._version).toBe(CURRENT_VERSION);
// After full chain, dns is normalized to object AND has provider
expect(result.dns.ip).toBe('10.0.0.1');
expect(result.dns.port).toBe(5380);
expect(result.dns.provider).toBe('technitium');
});
});
describe('idempotency', () => {
test('running migration twice produces same result', () => {
const v0 = { tld: '.home', customField: 'x' };
const first = migrate(v0);
const second = migrate(first);
expect(second).toEqual(first);
});
test('loadAndMigrate is idempotent across reloads', () => {
const configFile = path.join(tmpDir, 'config.json');
fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' }));
const first = loadAndMigrate(configFile, null);
const second = loadAndMigrate(configFile, null);
expect(second).toEqual(first);
});
});
});
+23 -11
View File
@@ -342,7 +342,8 @@ const APP_TEMPLATES = {
volumes: [
"/var/run/docker.sock:/var/run/docker.sock",
"/opt/portainer/data:/data"
]
],
environment: {}
},
subdomain: "portainer",
defaultPort: 9000,
@@ -393,7 +394,8 @@ const APP_TEMPLATES = {
docker: {
image: "louislam/uptime-kuma:latest",
ports: ["{{PORT}}:3001"],
volumes: ["/opt/uptime-kuma:/app/data"]
volumes: ["/opt/uptime-kuma:/app/data"],
environment: {}
},
subdomain: "uptime",
defaultPort: 3002,
@@ -549,7 +551,7 @@ const APP_TEMPLATES = {
},
subdomain: "dns2",
defaultPort: 953,
healthCheck: null,
healthCheck: "tcp://localhost:53",
subpathSupport: 'strip',
setupInstructions: [
"Configure zone files in /opt/bind9/config/",
@@ -640,14 +642,14 @@ const APP_TEMPLATES = {
],
docker: {
image: "coredns/coredns:latest",
ports: ["53:53", "53:53/udp"],
ports: ["{{PORT}}:53", "53:53", "53:53/udp"],
volumes: ["/opt/coredns/config:/etc/coredns"],
environment: {},
command: ["-conf", "/etc/coredns/Corefile"]
},
subdomain: "dns4",
defaultPort: 53,
healthCheck: null,
healthCheck: "tcp://localhost:53",
subpathSupport: 'strip',
setupInstructions: [
"Create Corefile in /opt/coredns/config/",
@@ -1007,7 +1009,9 @@ const APP_TEMPLATES = {
docker: {
image: "adminer:latest",
ports: ["{{PORT}}:8080"],
volumes: [],
volumes: [
"/opt/adminer:/var/www/html"
],
environment: {
"ADMINER_DEFAULT_SERVER": "postgres"
}
@@ -1099,6 +1103,7 @@ const APP_TEMPLATES = {
popularity: 85,
difficulty: "Easy",
isDashboardWidget: true,
isStaticSite: true,
widgetSelector: ".weather-widget-container",
subdomain: null,
defaultPort: null,
@@ -1126,6 +1131,7 @@ const APP_TEMPLATES = {
popularity: 80,
difficulty: "Easy",
isDashboardWidget: true,
isStaticSite: true,
widgetSelector: ".clock-widget-container",
subdomain: null,
defaultPort: null,
@@ -1908,7 +1914,9 @@ const APP_TEMPLATES = {
docker: {
image: "traefik/whoami:latest",
ports: ["{{PORT}}:80"],
volumes: [],
volumes: [
"/opt/whoami/config:/config"
],
environment: {}
},
subdomain: "whoami",
@@ -2233,7 +2241,9 @@ const APP_TEMPLATES = {
docker: {
image: "excalidraw/excalidraw:latest",
ports: ["{{PORT}}:80"],
volumes: [],
volumes: [
"/opt/excalidraw/data:/var/lib/excalidraw"
],
environment: {}
},
subdomain: "draw",
@@ -2258,7 +2268,9 @@ const APP_TEMPLATES = {
docker: {
image: "corentinth/it-tools:latest",
ports: ["{{PORT}}:80"],
volumes: [],
volumes: [
"/opt/it-tools/config:/config"
],
environment: {}
},
subdomain: "tools",
@@ -2417,7 +2429,7 @@ const APP_TEMPLATES = {
},
subdomain: "mc",
defaultPort: 25565,
healthCheck: null,
healthCheck: "tcp://localhost:25565",
subpathSupport: 'none',
setupInstructions: [
"Server accepts the Minecraft EULA automatically",
@@ -2451,7 +2463,7 @@ const APP_TEMPLATES = {
},
subdomain: "valheim",
defaultPort: 2456,
healthCheck: null,
healthCheck: "tcp://localhost:2456",
subpathSupport: 'none',
setupInstructions: [
"Connect via Steam: Add Server > IP:2456",
+1 -1
View File
@@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR',
// ── Docker ──────────────────────────────────────────────────────
const DOCKER = {
CONTAINER_PREFIX: 'sami-',
TIMEOUT: 30000, // 30s — timeout for docker pull/create operations
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
LOG_CONFIG: {
Type: 'json-file',
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
+20 -1
View File
@@ -10,7 +10,26 @@ const lockfile = require('proper-lockfile');
const fs = require('fs');
const path = require('path');
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json');
// Resolve credentials file path — supports both standard install (/app/credentials.json)
// and custom deployments with consolidated data directory (/app/data/credentials.json)
function resolveCredentialsFile() {
if (process.env.CREDENTIALS_FILE) {
return process.env.CREDENTIALS_FILE;
}
const candidates = [
path.join(__dirname, 'credentials.json'),
path.join(__dirname, 'data', 'credentials.json'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// No existing file — return standard path so first store() creates it there
return candidates[0];
}
const CREDENTIALS_FILE = resolveCredentialsFile();
class CredentialManager {
constructor() {
+20 -2
View File
@@ -15,8 +15,26 @@ const IV_LENGTH = 16; // 128 bits for GCM
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 32;
// Key file location (should be outside of mounted volumes for security)
const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key');
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
function resolveKeyFile() {
if (process.env.ENCRYPTION_KEY_FILE) {
return process.env.ENCRYPTION_KEY_FILE;
}
const candidates = [
path.join(__dirname, '.encryption-key'),
path.join(__dirname, 'data', '.encryption-key'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// No existing file — return standard path so first load creates it there
return candidates[0];
}
const KEY_FILE = resolveKeyFile();
let encryptionKey = null;
+3
View File
@@ -317,6 +317,9 @@ class LicenseManager {
*/
isExpired() {
if (!this.activation) return true;
// Lifetime licenses never expire
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
return Date.now() > new Date(this.activation.expiresAt).getTime();
}
+3
View File
@@ -305,6 +305,9 @@ 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/system/update-notify', exact: true, method: 'POST' },
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
{ path: '/api/v1/version', exact: true, method: 'GET' },
];
function isPublicRoute(req) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dashcaddy-api",
"version": "1.10.0",
"version": "1.13.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js",
"scripts": {
+21
View File
@@ -3,6 +3,7 @@
// All paths can be overridden via environment variables.
const path = require('path');
const fs = require('fs');
const isWindows = process.platform === 'win32';
// Base directories
@@ -34,6 +35,8 @@ const paths = {
caCertDir: path.join(CADDY_SITES, 'ca'),
pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
pkiDir: CADDY_PKI,
// Static site base path
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
@@ -41,6 +44,24 @@ const paths = {
// Docker data path for app volumes
appData: (appName) => path.join(DOCKER_DATA, appName),
// In-container paths (used by self-updater and Docker deployments)
// Override via env vars for custom Docker layouts
containerUpdatesDir: process.env.DASHCADDY_UPDATES_DIR || '/app/updates',
containerFrontendDir: process.env.DASHCADDY_FRONTEND_DIR || '/app/dashboard',
containerAssetsDir: process.env.ASSETS_DIR || '/app/assets',
// Asset path resolution — supports both Docker (single file mount) and
// consolidated data directory layouts
resolveAssetsPath: (envPath) => {
if (envPath) return envPath;
// Standard Docker mount: /app/assets (volume-mounted)
if (fs.existsSync('/app/assets')) return '/app/assets';
// Consolidated data directory: /app/data/assets
if (fs.existsSync(path.join(CADDY_BASE, 'assets'))) return path.join(CADDY_BASE, 'assets');
// Fall back to /app/assets even if it doesn't exist (will create on write)
return '/app/assets';
},
// Log digest directory
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'),
+18 -2
View File
@@ -226,7 +226,23 @@ const server = http.createServer(async (req, res) => {
json(res, 404, { error: 'Not found' });
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`);
const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
const PYLON_HOST = process.env.PYLON_HOST || '0.0.0.0';
server.listen(PYLON_PORT, PYLON_HOST, () => {
console.log(`[Pylon] ${PYLON_NAME} listening on ${PYLON_HOST}:${PYLON_PORT}`);
if (API_KEY) console.log('[Pylon] API key authentication enabled');
});
// Graceful shutdown — drain connections, then exit
const shutdown = (signal) => {
console.log(`[Pylon] ${signal} received, draining...`);
server.close(() => {
console.log('[Pylon] HTTP server closed');
process.exit(0);
});
// Force exit after 5s if connections don't drain
setTimeout(() => process.exit(0), 5000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+17 -6
View File
@@ -197,8 +197,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
}
}
const container = await docker.client.createContainer(containerConfig);
await container.start();
let container;
try {
container = await docker.client.createContainer(containerConfig);
await container.start();
} catch (createErr) {
// If create fails with "no such image", wrap with user-friendly message
const errMsg = createErr?.message || String(createErr);
if (errMsg.includes('No such image') || errMsg.includes('no such image')) {
throw new Error(`[DC-201] Image pull succeeded but container creation failed — image may be corrupted: ${processedTemplate.docker.image}. ${errMsg}`);
}
throw createErr;
}
// Prune dangling images to prevent disk bloat
try {
@@ -306,7 +316,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
} else {
containerId = await deployContainer(appId, config, template);
log.info('deploy', 'Container deployed', { containerId });
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
log.info('deploy', 'Container is healthy', { containerId });
}
@@ -420,10 +430,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
res.json(response);
} catch (error) {
await logError('app-deploy', error, { appId, config });
log.error('deploy', 'Deployment failed', { appId, error: error.message });
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
const msg = error?.message || String(error || 'Unknown error');
log.error('deploy', 'Deployment failed', { appId, error: msg });
const template = ctx.APP_TEMPLATES[appId];
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
errorResponse(res, 500, ctx.safeErrorMessage(error));
}
}, 'apps-deploy'));
+4 -1
View File
@@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
return content.slice(0, endIdx) + injection + content.slice(endIdx);
});
if (!result.success) {
if (!result.success && result.error !== 'No changes to apply') {
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
}
if (result.error === 'No changes to apply') {
log.info('caddy', 'Subpath config already exists, reusing', { subdomain });
}
}
/** Remove a subpath config block from between its markers in the Caddyfile. */
+13 -13
View File
@@ -25,7 +25,6 @@ module.exports = function(ctx) {
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
// Additional context properties needed by routes
APP_TEMPLATES: ctx.APP_TEMPLATES,
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
@@ -40,26 +39,27 @@ module.exports = function(ctx) {
ctx: ctx
};
// Initialize helpers with dependencies (ctx is the Koa context)
const helpers = initHelpers({ ...deps, ctx });
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
const subCtx = Object.assign({}, ctx, { helpers });
try { router.use('/deploy', initDeploy(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
// Mount sub-routers at their prefix paths.
// Sub-modules define routes at '/' (root of their sub-router).
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
try { router.use('/remove', initRemoval(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
try { router.use('/apps', initDeploy(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); }
try { router.use('/apps', initRemoval(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); }
try { router.use('/apps', initTemplates(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
try { router.use('/compose', initCompose(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
try { router.use('/apps', initCompose(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
return router;
};
+10 -16
View File
@@ -12,14 +12,11 @@ module.exports = function(ctx) {
// Get CA certificate information
router.get('/info', ctx.asyncHandler(async (req, res) => {
const certInfoPath = '/app/ca/cert-info.json';
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile;
if (await exists(certInfoPath)) {
certInfoFile = certInfoPath;
} else if (await exists(fallbackCertInfoPath)) {
certInfoFile = fallbackCertInfoPath;
} else {
const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information');
@@ -46,13 +43,11 @@ module.exports = function(ctx) {
// Serve root CA certificate directly (works even without DashCA deployed)
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
const pkiCertPath = '/app/pki/root.crt';
const hostCertPath = platformPaths.pkiRootCert;
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
let certPath;
if (await exists(pkiCertPath)) certPath = pkiCertPath;
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
else if (await exists(hostCertPath)) certPath = hostCertPath;
else {
const { NotFoundError } = require('../errors');
@@ -72,13 +67,12 @@ module.exports = function(ctx) {
}
// Load cert info to get the fingerprint
const certInfoPath = '/app/ca/cert-info.json';
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile;
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
else {
if (await exists(certInfoPath)) {
certInfoFile = certInfoPath;
} else {
const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
}
@@ -100,7 +94,7 @@ module.exports = function(ctx) {
// Look for template in multiple locations (packaged app vs dev)
const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName)
path.join(platformPaths.caddyBase, 'scripts', templateName)
];
let templateContent;
@@ -142,8 +136,8 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
}
const pkiPath = '/app/pki';
const certsDir = '/app/generated-certs';
const pkiPath = platformPaths.pkiDir;
const certsDir = platformPaths.generatedCertsDir;
const domainDir = path.join(certsDir, domain);
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
@@ -246,7 +240,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
// List generated certificates
router.get('/certs', ctx.asyncHandler(async (req, res) => {
const certsDir = '/app/generated-certs';
const certsDir = platformPaths.generatedCertsDir;
if (!await exists(certsDir)) {
return res.json({ success: true, certificates: [] });
+6 -5
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { LIMITS } = require('../../constants');
const { exists } = require('../../fs-helpers');
const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths');
/**
* Config assets routes factory
* @param {Object} deps - Explicit dependencies
@@ -51,7 +52,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const buffer = Buffer.from(base64Data, 'base64');
// Determine assets path (mounted volume)
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
// Ensure directory exists
if (!await exists(assetsPath)) {
@@ -96,7 +97,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
const buffer = Buffer.from(matches[2], 'base64');
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true });
}
@@ -170,7 +171,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Reset all branding to defaults
router.delete('/logo', asyncHandler(async (req, res) => {
const config = await ctx.readConfig();
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
// Delete all custom logo files
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
@@ -234,7 +235,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true });
}
@@ -279,7 +280,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const config = await ctx.readConfig();
// Delete custom favicon files
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
const filesToDelete = ['favicon.ico', 'favicon.png'];
for (const file of filesToDelete) {
const filePath = `${assetsPath}/${file}`;
+3 -2
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { CADDY } = require('../../constants');
const { exists } = require('../../fs-helpers');
const { ValidationError, AuthenticationError } = require('../../errors');
const platformPaths = require('../../platform-paths');
/**
* Config backup routes factory
@@ -115,7 +116,7 @@ module.exports = function(deps) {
// Include custom assets (logo, favicon) as base64
try {
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
const configData = backup.files.config?.data || {};
const assetFiles = [configData.customLogo, configData.customFavicon]
.filter(Boolean)
@@ -346,7 +347,7 @@ module.exports = function(deps) {
// Restore custom assets from base64
if (backup.assets && typeof backup.assets === 'object') {
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
for (const [name, b64] of Object.entries(backup.assets)) {
try {
const safeName = path.basename(name); // prevent path traversal
+8 -1
View File
@@ -322,9 +322,16 @@ module.exports = function({
// ===== HEALTH CHECK (health-checker module) =====
// Get current status for all services
// Returns per-service status plus a summary for the System Overview widget:
// { status: { ... }, summary: { healthy, unhealthy, total } }
router.get('/health-checks/status', asyncHandler(async (req, res) => {
const status = healthChecker.getCurrentStatus();
success(res, { status });
// Build summary for the overview widget
const entries = Object.values(status);
const healthy = entries.filter(s => s.status === 'up' || s.status === 'healthy').length;
const unhealthy = entries.filter(s => s.status === 'down' || s.status === 'unhealthy').length;
const total = entries.length;
success(res, { status, summary: { healthy, unhealthy, total } });
}, 'health-check-status'));
// Get service statistics
+15 -1
View File
@@ -16,8 +16,22 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
// ===== RESOURCE MONITORING ENDPOINTS =====
// Get all container stats (from resource monitor module)
// Returns a flat summary format for the System Overview widget:
// { containerId: { cpu: <percent>, memory: <percent>, memoryUsage: <bytes>, name } }
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
const stats = resourceMonitor.getAllStats();
const raw = resourceMonitor.getAllStats();
// Transform nested { current: { cpu: { percent }, memory: { percent, usage } } }
// into flat { cpu: number, memory: number, memoryUsage: number } for the frontend widget
const stats = {};
for (const [id, data] of Object.entries(raw)) {
const cur = data.current || {};
stats[id] = {
name: data.name,
cpu: typeof cur.cpu === 'object' ? (cur.cpu.percent ?? 0) : (Number(cur.cpu) || 0),
memory: typeof cur.memory === 'object' ? (cur.memory.percent ?? 0) : (Number(cur.memory) || 0),
memoryUsage: typeof cur.memory === 'object' ? (cur.memory.usage ?? 0) : 0,
};
}
success(res, { stats });
}, 'monitoring-stats'));
+2 -1
View File
@@ -11,6 +11,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers');
const platformPaths = require('../platform-paths');
/**
* Services route factory
@@ -46,7 +47,7 @@ module.exports = function({
dns
}) {
const router = express.Router();
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
const PROBE_CONCURRENCY = 6;
let probeHttpsAgent;
+2 -1
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const path = require('path');
const { success } = require('../response-helpers');
const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths');
/**
* Themes routes factory
@@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors');
*/
module.exports = function({ asyncHandler, log }) {
const router = express.Router();
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(platformPaths.servicesFile), 'themes');
// Ensure themes directory exists
if (!fs.existsSync(THEMES_DIR)) {
+5 -5
View File
@@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows;
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',
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.isWindows ? path.join(platformPaths.caddySites, 'status') : '/app/dashboard',
FRONTEND_DIR: platformPaths.containerFrontendDir,
MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable',
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
+7 -2
View File
@@ -25,7 +25,8 @@ process.on('uncaughtException', (error) => {
// Load license
await licenseManager.load();
const PORT = process.env.PORT || 3001;
const PORT = parseInt(process.env.PORT, 10) || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
@@ -43,9 +44,10 @@ process.on('uncaughtException', (error) => {
});
// Start HTTP server
const server = app.listen(PORT, '0.0.0.0', () => {
const server = app.listen(PORT, HOST, () => {
log.info('server', 'DashCaddy API server started', {
port: PORT,
host: HOST,
caddyfile: CADDYFILE_PATH,
caddyAdmin: CADDY_ADMIN_URL,
services: SERVICES_FILE,
@@ -73,9 +75,12 @@ process.on('uncaughtException', (error) => {
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
// Initialize workflow engine if bundled-workflows is available
// NOTE: createApp() already initializes the workflow engine in src/app.js
// This block is kept for backward compat with entry points that don't use createApp()
let workflowEngine = null;
if (bundledWorkflows) {
try {
const { fetchT } = require('./src/utils/http');
const { WorkflowEngine } = bundledWorkflows;
// Create a context with needed services
const workflowCtx = {
+39 -2
View File
@@ -16,6 +16,7 @@ const { asyncHandler } = require('./utils/async-handler');
// Managers and utilities
const StateManager = require('../state-manager');
const platformPaths = require('../platform-paths');
const { LicenseManager } = require('../license-manager');
const credentialManager = require('../credential-manager');
const authManager = require('../auth-manager');
@@ -96,6 +97,19 @@ const { APP } = require('../constants');
async function createApp() {
const app = express();
// Global request timeout (default 5 minutes — covers slow Docker pulls)
// Routes that need longer can override per-request with req.setTimeout()
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
app.use((req, res, next) => {
req.setTimeout(REQUEST_TIMEOUT_MS);
res.setTimeout(REQUEST_TIMEOUT_MS);
next();
});
// Disable x-powered-by header for security (don't advertise framework)
app.disable('x-powered-by');
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
app.set('trust proxy', 1);
// Initialize logging
const log = createLogger(config.LOG_LEVEL);
@@ -111,7 +125,7 @@ async function createApp() {
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
// HTTPS agent for internal CA
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
let httpsAgent;
try {
const caCert = fs.readFileSync(CA_CERT_PATH);
@@ -380,6 +394,29 @@ async function createApp() {
// Build versioned API router
const apiRouter = express.Router();
// Version endpoint — public, no auth required
// Reads version from package.json at startup so the response always matches the running code
let appVersion = '0.0.0';
let appName = 'dashcaddy-api';
try {
const pkg = require('../package.json');
appVersion = pkg.version || appVersion;
appName = pkg.name || appName;
} catch { /* package.json unreadable — keep fallback */ }
apiRouter.get('/version', (req, res) => {
res.json({
success: true,
name: appName,
version: appVersion,
node: process.version,
platform: process.platform,
arch: process.arch,
uptime: process.uptime(),
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
});
});
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
@@ -539,7 +576,7 @@ async function createApp() {
sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker
}));
apiRouter.use(workflowsRoutes({
apiRouter.use('/workflows', workflowsRoutes({
workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
+142
View File
@@ -0,0 +1,142 @@
/**
* Config migration system
*
* When config.json schema changes between versions, register a migration
* function here. On load, the loader detects the stored version, runs all
* migrations from that version forward, and writes the result back.
*
* Migration format:
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
*
* Each migration is responsible for transforming the previous version's
* shape into the next version's shape. They run sequentially, so v1v2v3
* all execute in order.
*
* For first-time users with no config file, the loader creates a fresh
* config with CURRENT_VERSION, so they start at the latest schema.
*/
const fs = require('fs');
const path = require('path');
const platformPaths = require('../../platform-paths');
const CURRENT_VERSION = 2;
/**
* Migrations: keys are the version they PRODUCE.
* Each migration takes a raw config object and returns the next version.
*/
const migrations = {
// v0 (unversioned) → v1: add _version field, normalize dns structure
1: (raw) => {
const migrated = { ...raw };
if (!migrated._version) migrated._version = 1;
// Normalize: older configs may have dns as a string IP, convert to object
if (typeof migrated.dns === 'string') {
migrated.dns = { ip: migrated.dns, port: 5380 };
} else if (!migrated.dns) {
migrated.dns = { ip: '', port: 5380 };
}
return migrated;
},
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
2: (raw) => {
const migrated = { ...raw };
if (migrated.dns && !migrated.dns.provider) {
migrated.dns.provider = 'technitium';
}
migrated._version = 2;
return migrated;
}
};
/**
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
* @param {object} raw - The raw config object (may or may not have _version)
* @returns {object} The migrated config
*/
function migrate(raw) {
if (!raw || typeof raw !== 'object') {
// First-time load: return minimal config at current version
return { _version: CURRENT_VERSION };
}
const fromVersion = raw._version || 0;
if (fromVersion > CURRENT_VERSION) {
// Config from a future version — bail out, don't corrupt it
// The validation step will catch any actual issues
return raw;
}
let current = { ...raw };
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
if (migrations[v]) {
current = migrations[v](current);
} else {
// No migration defined for this version, just bump _version
current._version = v;
}
}
return current;
}
/**
* Load config from disk, run migrations if needed, and write back the
* migrated version. Safe to call on every startup.
* @param {string} configFile - Absolute path to config.json
* @param {object} log - Logger instance
* @returns {object} The migrated config object
*/
function loadAndMigrate(configFile, log) {
let raw = null;
let fileExisted = false;
if (fs.existsSync(configFile)) {
fileExisted = true;
try {
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
} catch (e) {
if (log && log.error) {
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
}
raw = null;
}
}
const fromVersion = raw && raw._version ? raw._version : 0;
const migrated = migrate(raw);
// Only write back to disk if:
// 1. The file already existed (we don't create configs on fresh installs —
// the loader's defaults handle that case), AND
// 2. The version actually changed (no point rewriting identical content)
if (fileExisted && fromVersion < CURRENT_VERSION) {
if (log && log.info) {
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
from: fromVersion,
to: CURRENT_VERSION,
path: configFile
});
}
// Write back the migrated config
try {
// Ensure parent dir exists
const dir = path.dirname(configFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
} catch (e) {
if (log && log.warn) {
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
}
}
}
return migrated;
}
module.exports = {
CURRENT_VERSION,
migrations,
migrate,
loadAndMigrate
};
+11 -3
View File
@@ -1,10 +1,15 @@
/**
* Site configuration loader
* Loads and manages site-wide settings from config.json
*
* Includes automatic migration from older config versions (see migrations.js).
* Users never see the migration it runs silently on startup, writes the
* updated config back, and the rest of the app only ever sees the current
* schema.
*/
const fs = require('fs');
const { validateConfig } = require('../../config-schema');
const { CADDY } = require('../../constants');
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
const siteConfig = {
tld: '.home',
@@ -21,9 +26,11 @@ const siteConfig = {
function loadSiteConfig(CONFIG_FILE, log) {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
// Run migrations first — this handles config.json files from older
// versions of DashCaddy and writes the migrated version back to disk.
const raw = loadAndMigrate(CONFIG_FILE, log);
if (raw && Object.keys(raw).length > 0) {
// Validate config and log any issues
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
if (log && log.warn) {
@@ -76,4 +83,5 @@ module.exports = {
loadSiteConfig,
buildDomain,
buildServiceUrl,
CURRENT_VERSION
};
+5 -1
View File
@@ -94,8 +94,12 @@ async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, addi
* Return a safe error message without leaking internals
*/
function safeErrorMessage(error) {
if (!error) return 'An internal error occurred';
const msg = error.message || String(error);
// Always expose DC-prefixed user-facing errors
if (/\[DC-\d+\]/.test(msg)) return msg;
// Detect port conflict errors
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
@@ -103,7 +107,7 @@ function safeErrorMessage(error) {
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
}
// Only expose short, user-facing messages
// Only expose short, user-facing messages (no paths, stack traces, or internal details)
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
return msg;
}