Compare commits

...
9 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
22 changed files with 558 additions and 48 deletions
+1 -1
View File
@@ -1 +1 @@
1.11.0 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);
});
});
});
+20 -1
View File
@@ -10,7 +10,26 @@ const lockfile = require('proper-lockfile');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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 { class CredentialManager {
constructor() { constructor() {
+20 -2
View File
@@ -15,8 +15,26 @@ const IV_LENGTH = 16; // 128 bits for GCM
const AUTH_TAG_LENGTH = 16; const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 32; const SALT_LENGTH = 32;
// Key file location (should be outside of mounted volumes for security) // Resolve encryption key file path — supports both standard install (/app/.encryption-key)
const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.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; let encryptionKey = null;
+3
View File
@@ -305,6 +305,9 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/config', exact: true, method: 'GET' }, { path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', 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/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) { function isPublicRoute(req) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.11.0", "version": "1.13.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+21
View File
@@ -3,6 +3,7 @@
// All paths can be overridden via environment variables. // All paths can be overridden via environment variables.
const path = require('path'); const path = require('path');
const fs = require('fs');
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
// Base directories // Base directories
@@ -34,6 +35,8 @@ const paths = {
caCertDir: path.join(CADDY_SITES, 'ca'), caCertDir: path.join(CADDY_SITES, 'ca'),
pkiRootCert: path.join(CADDY_PKI, 'root.crt'), pkiRootCert: path.join(CADDY_PKI, 'root.crt'),
pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'), pkiIntermediateCert: path.join(CADDY_PKI, 'intermediate.crt'),
generatedCertsDir: path.join(CADDY_SITES, 'generated-certs'),
pkiDir: CADDY_PKI,
// Static site base path // Static site base path
sitePath: (subdomain) => path.join(CADDY_SITES, subdomain), sitePath: (subdomain) => path.join(CADDY_SITES, subdomain),
@@ -41,6 +44,24 @@ const paths = {
// Docker data path for app volumes // Docker data path for app volumes
appData: (appName) => path.join(DOCKER_DATA, appName), 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 // Log digest directory
digestDir: process.env.DIGEST_DIR || path.join(CADDY_BASE, 'digests'), 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' }); json(res, 404, { error: 'Not found' });
}); });
server.listen(PORT, '0.0.0.0', () => { const PYLON_PORT = parseInt(process.env.PYLON_PORT, 10) || 7842;
console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`); 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'); 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'));
+11 -1
View File
@@ -197,8 +197,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
} }
} }
const container = await docker.client.createContainer(containerConfig); let container;
try {
container = await docker.client.createContainer(containerConfig);
await container.start(); 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 // Prune dangling images to prevent disk bloat
try { try {
+10 -16
View File
@@ -12,14 +12,11 @@ module.exports = function(ctx) {
// Get CA certificate information // Get CA certificate information
router.get('/info', ctx.asyncHandler(async (req, res) => { router.get('/info', ctx.asyncHandler(async (req, res) => {
const certInfoPath = '/app/ca/cert-info.json'; const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile; let certInfoFile;
if (await exists(certInfoPath)) { if (await exists(certInfoPath)) {
certInfoFile = certInfoPath; certInfoFile = certInfoPath;
} else if (await exists(fallbackCertInfoPath)) {
certInfoFile = fallbackCertInfoPath;
} else { } else {
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information'); throw new NotFoundError('CA certificate information');
@@ -46,13 +43,11 @@ module.exports = function(ctx) {
// Serve root CA certificate directly (works even without DashCA deployed) // Serve root CA certificate directly (works even without DashCA deployed)
router.get('/root.crt', ctx.asyncHandler(async (req, res) => { router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
const pkiCertPath = '/app/pki/root.crt';
const hostCertPath = platformPaths.pkiRootCert; const hostCertPath = platformPaths.pkiRootCert;
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt'); const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
let certPath; let certPath;
if (await exists(pkiCertPath)) certPath = pkiCertPath; if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
else if (await exists(hostCertPath)) certPath = hostCertPath; else if (await exists(hostCertPath)) certPath = hostCertPath;
else { else {
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
@@ -72,13 +67,12 @@ module.exports = function(ctx) {
} }
// Load cert info to get the fingerprint // Load cert info to get the fingerprint
const certInfoPath = '/app/ca/cert-info.json'; const certInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile; let certInfoFile;
if (await exists(certInfoPath)) certInfoFile = certInfoPath; if (await exists(certInfoPath)) {
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2; certInfoFile = certInfoPath;
else { } else {
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.'); 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) // Look for template in multiple locations (packaged app vs dev)
const templatePaths = [ const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName), path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName) path.join(platformPaths.caddyBase, 'scripts', templateName)
]; ];
let templateContent; 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})`); return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
} }
const pkiPath = '/app/pki'; const pkiPath = platformPaths.pkiDir;
const certsDir = '/app/generated-certs'; const certsDir = platformPaths.generatedCertsDir;
const domainDir = path.join(certsDir, domain); const domainDir = path.join(certsDir, domain);
const intermediateCert = path.join(pkiPath, 'intermediate.crt'); const intermediateCert = path.join(pkiPath, 'intermediate.crt');
@@ -246,7 +240,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
// List generated certificates // List generated certificates
router.get('/certs', ctx.asyncHandler(async (req, res) => { router.get('/certs', ctx.asyncHandler(async (req, res) => {
const certsDir = '/app/generated-certs'; const certsDir = platformPaths.generatedCertsDir;
if (!await exists(certsDir)) { if (!await exists(certsDir)) {
return res.json({ success: true, certificates: [] }); return res.json({ success: true, certificates: [] });
+6 -5
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { LIMITS } = require('../../constants'); const { LIMITS } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths');
/** /**
* Config assets routes factory * Config assets routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -51,7 +52,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');
// Determine assets path (mounted volume) // Determine assets path (mounted volume)
const assetsPath = process.env.ASSETS_PATH || '/app/assets'; const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
// Ensure directory exists // Ensure directory exists
if (!await exists(assetsPath)) { if (!await exists(assetsPath)) {
@@ -96,7 +97,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1]; const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
const buffer = Buffer.from(matches[2], 'base64'); 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)) { if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true }); await fsp.mkdir(assetsPath, { recursive: true });
} }
@@ -170,7 +171,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Reset all branding to defaults // Reset all branding to defaults
router.delete('/logo', asyncHandler(async (req, res) => { router.delete('/logo', asyncHandler(async (req, res) => {
const config = await ctx.readConfig(); 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 // Delete all custom logo files
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean); 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 base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64'); 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)) { if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true }); await fsp.mkdir(assetsPath, { recursive: true });
} }
@@ -279,7 +280,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const config = await ctx.readConfig(); const config = await ctx.readConfig();
// Delete custom favicon files // 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']; const filesToDelete = ['favicon.ico', 'favicon.png'];
for (const file of filesToDelete) { for (const file of filesToDelete) {
const filePath = `${assetsPath}/${file}`; const filePath = `${assetsPath}/${file}`;
+3 -2
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { CADDY } = require('../../constants'); const { CADDY } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError, AuthenticationError } = require('../../errors'); const { ValidationError, AuthenticationError } = require('../../errors');
const platformPaths = require('../../platform-paths');
/** /**
* Config backup routes factory * Config backup routes factory
@@ -115,7 +116,7 @@ module.exports = function(deps) {
// Include custom assets (logo, favicon) as base64 // Include custom assets (logo, favicon) as base64
try { try {
const assetsDir = process.env.ASSETS_DIR || '/app/assets'; const assetsDir = platformPaths.resolveAssetsPath(process.env.ASSETS_DIR);
const configData = backup.files.config?.data || {}; const configData = backup.files.config?.data || {};
const assetFiles = [configData.customLogo, configData.customFavicon] const assetFiles = [configData.customLogo, configData.customFavicon]
.filter(Boolean) .filter(Boolean)
@@ -346,7 +347,7 @@ module.exports = function(deps) {
// Restore custom assets from base64 // Restore custom assets from base64
if (backup.assets && typeof backup.assets === 'object') { 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)) { for (const [name, b64] of Object.entries(backup.assets)) {
try { try {
const safeName = path.basename(name); // prevent path traversal const safeName = path.basename(name); // prevent path traversal
+8 -1
View File
@@ -322,9 +322,16 @@ module.exports = function({
// ===== HEALTH CHECK (health-checker module) ===== // ===== HEALTH CHECK (health-checker module) =====
// Get current status for all services // 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) => { router.get('/health-checks/status', asyncHandler(async (req, res) => {
const status = healthChecker.getCurrentStatus(); 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')); }, 'health-check-status'));
// Get service statistics // Get service statistics
+15 -1
View File
@@ -16,8 +16,22 @@ module.exports = function({ resourceMonitor, docker, asyncHandler, log, notifica
// ===== RESOURCE MONITORING ENDPOINTS ===== // ===== RESOURCE MONITORING ENDPOINTS =====
// Get all container stats (from resource monitor module) // 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) => { 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 }); success(res, { stats });
}, 'monitoring-stats')); }, 'monitoring-stats'));
+2 -1
View File
@@ -11,6 +11,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors'); const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../response-helpers');
const platformPaths = require('../platform-paths');
/** /**
* Services route factory * Services route factory
@@ -46,7 +47,7 @@ module.exports = function({
dns dns
}) { }) {
const router = express.Router(); 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; const PROBE_CONCURRENCY = 6;
let probeHttpsAgent; let probeHttpsAgent;
+2 -1
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { success } = require('../response-helpers'); const { success } = require('../response-helpers');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths');
/** /**
* Themes routes factory * Themes routes factory
@@ -13,7 +14,7 @@ const { ValidationError, NotFoundError } = require('../errors');
*/ */
module.exports = function({ asyncHandler, log }) { module.exports = function({ asyncHandler, log }) {
const router = express.Router(); 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 // Ensure themes directory exists
if (!fs.existsSync(THEMES_DIR)) { if (!fs.existsSync(THEMES_DIR)) {
+5 -5
View File
@@ -21,17 +21,17 @@ const isWindows = platformPaths.isWindows;
const DEFAULTS = { const DEFAULTS = {
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
UPDATE_URL: 'https://get.dashcaddy.net/release', UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
MIRROR_URL: 'https://get2.dashcaddy.net/release', MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
UPDATES_DIR: platformPaths.isWindows ? path.join(platformPaths.caddyBase, 'updates') : '/app/updates', UPDATES_DIR: platformPaths.containerUpdatesDir,
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater // 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'), API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard // 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, MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000, HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000, DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable', CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id') ? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id', : '/etc/dashcaddy/instance-id',
+4 -2
View File
@@ -25,7 +25,8 @@ process.on('uncaughtException', (error) => {
// Load license // Load license
await licenseManager.load(); 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 CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl; const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
@@ -43,9 +44,10 @@ process.on('uncaughtException', (error) => {
}); });
// Start HTTP server // 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', { log.info('server', 'DashCaddy API server started', {
port: PORT, port: PORT,
host: HOST,
caddyfile: CADDYFILE_PATH, caddyfile: CADDYFILE_PATH,
caddyAdmin: CADDY_ADMIN_URL, caddyAdmin: CADDY_ADMIN_URL,
services: SERVICES_FILE, services: SERVICES_FILE,
+38 -1
View File
@@ -16,6 +16,7 @@ const { asyncHandler } = require('./utils/async-handler');
// Managers and utilities // Managers and utilities
const StateManager = require('../state-manager'); const StateManager = require('../state-manager');
const platformPaths = require('../platform-paths');
const { LicenseManager } = require('../license-manager'); const { LicenseManager } = require('../license-manager');
const credentialManager = require('../credential-manager'); const credentialManager = require('../credential-manager');
const authManager = require('../auth-manager'); const authManager = require('../auth-manager');
@@ -96,6 +97,19 @@ const { APP } = require('../constants');
async function createApp() { async function createApp() {
const app = express(); 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 // Initialize logging
const log = createLogger(config.LOG_LEVEL); const log = createLogger(config.LOG_LEVEL);
@@ -111,7 +125,7 @@ async function createApp() {
licenseManager.loadSecret(config.LICENSE_SECRET_FILE); licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
// HTTPS agent for internal CA // 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; let httpsAgent;
try { try {
const caCert = fs.readFileSync(CA_CERT_PATH); const caCert = fs.readFileSync(CA_CERT_PATH);
@@ -380,6 +394,29 @@ async function createApp() {
// Build versioned API router // Build versioned API router
const apiRouter = express.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 // Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) { if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => { ctx.resourceMonitor.on('alert', (alertData) => {
+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 v1→v2→v3
* 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 * Site configuration loader
* Loads and manages site-wide settings from config.json * 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 { validateConfig } = require('../../config-schema');
const { CADDY } = require('../../constants'); const { CADDY } = require('../../constants');
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
const siteConfig = { const siteConfig = {
tld: '.home', tld: '.home',
@@ -21,9 +26,11 @@ const siteConfig = {
function loadSiteConfig(CONFIG_FILE, log) { function loadSiteConfig(CONFIG_FILE, log) {
try { try {
if (fs.existsSync(CONFIG_FILE)) { // Run migrations first — this handles config.json files from older
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); // 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 // Validate config and log any issues
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
if (log && log.warn) { if (log && log.warn) {
@@ -76,4 +83,5 @@ module.exports = {
loadSiteConfig, loadSiteConfig,
buildDomain, buildDomain,
buildServiceUrl, buildServiceUrl,
CURRENT_VERSION
}; };