From 1fbe65f524acc900f6070114d541902555d448e4 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 10 Jun 2026 19:36:05 -0700 Subject: [PATCH] Standardize paths, add version endpoint, request timeouts, HOST env var, graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashcaddy-api/platform-paths.js | 21 ++++++++++++++ dashcaddy-api/pylon/dashcaddy-pylon.js | 20 +++++++++++-- dashcaddy-api/routes/ca.js | 26 +++++++---------- dashcaddy-api/routes/config/assets.js | 11 ++++---- dashcaddy-api/routes/config/backup.js | 5 ++-- dashcaddy-api/routes/services.js | 2 +- dashcaddy-api/routes/themes.js | 3 +- dashcaddy-api/self-updater.js | 10 +++---- dashcaddy-api/server.js | 6 ++-- dashcaddy-api/src/app.js | 39 +++++++++++++++++++++++++- 10 files changed, 108 insertions(+), 35 deletions(-) diff --git a/dashcaddy-api/platform-paths.js b/dashcaddy-api/platform-paths.js index 9ab658c..a2185fc 100644 --- a/dashcaddy-api/platform-paths.js +++ b/dashcaddy-api/platform-paths.js @@ -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'), diff --git a/dashcaddy-api/pylon/dashcaddy-pylon.js b/dashcaddy-api/pylon/dashcaddy-pylon.js index d8539ae..ce23a3d 100644 --- a/dashcaddy-api/pylon/dashcaddy-pylon.js +++ b/dashcaddy-api/pylon/dashcaddy-pylon.js @@ -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')); diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 4a2fac2..7462308 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -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: [] }); diff --git a/dashcaddy-api/routes/config/assets.js b/dashcaddy-api/routes/config/assets.js index 4d24f66..480b08c 100644 --- a/dashcaddy-api/routes/config/assets.js +++ b/dashcaddy-api/routes/config/assets.js @@ -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}`; diff --git a/dashcaddy-api/routes/config/backup.js b/dashcaddy-api/routes/config/backup.js index 46dbe2c..6d88d42 100644 --- a/dashcaddy-api/routes/config/backup.js +++ b/dashcaddy-api/routes/config/backup.js @@ -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 diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 9a1dbca..71a6e5f 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -46,7 +46,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; diff --git a/dashcaddy-api/routes/themes.js b/dashcaddy-api/routes/themes.js index 393dc04..3c30858 100644 --- a/dashcaddy-api/routes/themes.js +++ b/dashcaddy-api/routes/themes.js @@ -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)) { diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index 2873e38..c308b3f 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -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', diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index f3eb421..97a89d7 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -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, diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index bebd952..1dd46b8 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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) => {