Compare commits

...
5 Commits
Author SHA1 Message Date
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
18 changed files with 187 additions and 44 deletions
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
+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;
+2
View File
@@ -305,6 +305,8 @@ 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' },
];
function isPublicRoute(req) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dashcaddy-api",
"version": "1.11.0",
"version": "1.12.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'));
+12 -2
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 {
+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'));
+1 -1
View File
@@ -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;
+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',
+4 -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,
+38 -1
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) => {