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.
This commit is contained in:
Hermes
2026-06-10 19:36:05 -07:00
parent 320f21c113
commit 1fbe65f524
10 changed files with 108 additions and 35 deletions
+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'));
+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
+1 -1
View File
@@ -46,7 +46,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) => {