Compare commits

...
4 Commits
Author SHA1 Message Date
Hermes 11cfb8c26a Consolidate response helpers and error logger to single modules
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Two cleanups in one pass for the v1.14.0 'works on any platform' theme:

1. Response helpers — merged src/utils/responses.js and the root-level
   response-helpers.js into a single module at src/utils/responses.js.
   The old module had a richer set (created, noContent, validationError,
   unauthorized, forbidden, notFound, conflict) and is now re-exported
   from the new location. Updated 15 routes to import from
   src/utils/responses and deleted the root response-helpers.js.

2. Error logger — error-handler.js now uses the unified
   src/utils/logging.js#logError (same one src/app.js uses), so all errors
   go to one log file with one rotation policy. Removed the dead
   asyncHandler export (the real one is in src/utils/async-handler.js
   and is used everywhere). Deleted the legacy error-logger.js.

Both are invisible to users — same HTTP response shapes, same log file
path, same error format. Internal-only refactor.
2026-06-10 21:37:55 -07:00
Hermes caa09dcebe Bump to v1.13.1 - fix /health/ready res.status bug
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The readiness probe was using asyncHandler directly, but this codebase's
asyncHandler has signature (logError, fn, context) — first arg is the logger.
Switched to boundAsyncHandler which is what every other route in src/app.js
uses. Verified working on both DNS2 (Docker) and Contabo (systemd).

8 new tests in __tests__/health-endpoints.test.js verify both endpoints.
2026-06-10 21:12:37 -07:00
Hermes 264de9644c Fix /health/ready res.status bug + add comprehensive health endpoint tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The readiness probe was crashing with 'res.status is not a function' because
asyncHandler(async (req, res) => {...}, 'health-ready') was called directly,
but asyncHandler's signature is (logError, fn, context) — first arg is the
logger, not the handler. The fix uses boundAsyncHandler like all other routes
in the file do.

Added 8 unit tests for both /health/live and /health/ready:
- live always 200 (liveness ≠ readiness)
- ready returns 503 when config/services/docker fail
- no 'res.status is not a function' crash when dependencies fail
- all 4 check keys present in response

Also added MONITORING_PUBLIC env var (defaults true) and the new health
endpoints to PUBLIC_ROUTES so k8s probes can hit them without auth.
2026-06-10 20:35:27 -07:00
Hermes e40cb35011 Add MONITORING_PUBLIC env var to gate monitoring endpoints behind auth
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
By default /api/v1/monitoring/stats and /api/v1/health-checks/status are
public (current behavior, dashboard needs them pre-login). Users deploying
DashCaddy on the open internet can now set:

  MONITORING_PUBLIC=false

...or add 'monitoring: { public: false }' to config.json to require auth.
This prevents anonymous disclosure of CPU/memory/disk data.

The check uses env var first, then config.json, then defaults to true
(preserves current behavior for existing users).
2026-06-10 20:13:53 -07:00
25 changed files with 476 additions and 320 deletions
+13 -20
View File
@@ -1,8 +1,18 @@
jest.mock('../error-logger', () => ({ // Mock the unified logging module so we can verify logError is called
logError: jest.fn(), // without writing to the actual error.log file
jest.mock('../src/utils/logging', () => ({
logError: jest.fn().mockResolvedValue(),
safeErrorMessage: jest.fn((err) => {
if (!err) return 'An internal error occurred';
return err.message || String(err);
}),
createLogger: jest.fn(() => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
})),
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
})); }));
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler'); const { errorMiddleware, notFoundHandler } = require('../error-handler');
const { const {
AppError, AppError,
ValidationError, ValidationError,
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
next = jest.fn(); next = jest.fn();
}); });
describe('asyncHandler', () => {
it('calls the wrapped function', async () => {
const fn = jest.fn().mockResolvedValue();
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(fn).toHaveBeenCalledWith(req, res, next);
});
it('calls next(err) on rejected promise', async () => {
const error = new Error('async fail');
const fn = jest.fn().mockRejectedValue(error);
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(next).toHaveBeenCalledWith(error);
});
});
describe('errorMiddleware', () => { describe('errorMiddleware', () => {
it('returns 400 for ValidationError', () => { it('returns 400 for ValidationError', () => {
const err = new ValidationError('bad input', 'email'); const err = new ValidationError('bad input', 'email');
@@ -0,0 +1,201 @@
/**
* Health endpoint tests
*
* Verifies:
* - /health/live always returns 200
* - /health/ready returns 200 with valid structure when all deps OK
* - /health/ready returns 503 when a critical dep is down
* - /health/ready does NOT crash with "res.status is not a function"
*/
const express = require('express');
const request = require('supertest');
// Mock dockerode BEFORE anything else
jest.mock('dockerode', () => {
return jest.fn().mockImplementation(() => ({
ping: jest.fn().mockImplementation(() => {
if (process.env.MOCK_DOCKER_DOWN === '1') {
return Promise.reject(new Error('docker unreachable'));
}
return Promise.resolve('OK');
})
}));
});
// Build a minimal Express app with the same health handlers as src/app.js
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
const app = express();
const config = {
CONFIG_FILE: '/tmp/dc-test-config.json',
SERVICES_FILE: '/tmp/dc-test-services.json',
CADDY_ADMIN_URL: 'http://localhost:2019'
};
// Mock fs
const fs = require('fs');
const realExistsSync = fs.existsSync;
const realReadFileSync = fs.readFileSync;
fs.existsSync = (p) => {
if (p === config.CONFIG_FILE) return configOk;
if (p === config.SERVICES_FILE) return servicesOk;
return realExistsSync(p);
};
fs.readFileSync = (p, ...args) => {
if (p === config.CONFIG_FILE) {
if (!configOk) throw new Error('config not found');
return '{}';
}
if (p === config.SERVICES_FILE) {
if (!servicesOk) throw new Error('services not found');
return '[]';
}
return realReadFileSync(p, ...args);
};
// /health/live (matches src/app.js exactly)
app.get('/health/live', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
const { asyncHandler } = require('../src/utils/async-handler');
const logError = async () => {}; // noop logger
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
try {
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
try {
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
res.status(allOk ? 200 : 503).json(body);
}));
return app;
}
describe('Health Endpoints', () => {
beforeEach(() => {
delete process.env.MOCK_DOCKER_DOWN;
});
describe('GET /health/live', () => {
it('always returns 200 with status: alive', async () => {
const app = buildApp();
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
expect(res.body.status).toBe('alive');
expect(typeof res.body.uptime).toBe('number');
});
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
});
});
describe('GET /health/ready', () => {
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
// config + services + docker should all be OK
expect(res.body.checks.configFile.ok).toBe(true);
expect(res.body.checks.servicesFile.ok).toBe(true);
expect(res.body.checks.docker.ok).toBe(true);
// caddy is tested in sandbox — may be 403 or 200
expect(res.body).toHaveProperty('checks');
expect(res.body).toHaveProperty('status');
});
it('returns 503 when config file is missing', async () => {
const app = buildApp({ configOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.status).toBe('not-ready');
expect(res.body.checks.configFile.ok).toBe(false);
});
it('returns 503 when services file is missing', async () => {
const app = buildApp({ servicesOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.servicesFile.ok).toBe(false);
});
it('returns 503 when Docker is unreachable', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.docker.ok).toBe(false);
});
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
const bodyStr = JSON.stringify(res.body);
expect(bodyStr).not.toMatch(/res\.status is not a function/);
// Should always be a valid response object
expect(res.body).toHaveProperty('checks');
});
it('responds with all 4 expected check keys', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
});
});
});
@@ -34,7 +34,7 @@ jest.mock('../../pagination', () => ({
parsePaginationParams: jest.fn(() => null), parsePaginationParams: jest.fn(() => null),
})); }));
jest.mock('../../response-helpers', () => ({ jest.mock('../../src/utils/responses', () => ({
success: jest.fn((res, data, statusCode = 200) => { success: jest.fn((res, data, statusCode = 200) => {
return res.status(statusCode).json({ success: true, ...data }); return res.status(statusCode).json({ success: true, ...data });
}), }),
+30 -27
View File
@@ -1,66 +1,70 @@
/** /**
* DashCaddy Error Handler Middleware * DashCaddy Error Handler Middleware
* Centralizes error handling logic to eliminate duplicate catch blocks * Centralizes error handling logic to eliminate duplicate catch blocks
*
* Logging: this middleware uses the unified logError from src/utils/logging.js
* (same one src/app.js uses), so all errors go to one log file. The legacy
* ./error-logger.js and its ./error.log file have been retired.
*/ */
const path = require('path');
const { AppError } = require('./errors'); const { AppError } = require('./errors');
const { logError } = require('./error-logger'); const { LIMITS } = require('./constants');
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
/** const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
* Async route handler wrapper const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
* Automatically catches errors and passes to error middleware
* Usage: app.get('/route', asyncHandler(async (req, res) => { ... }))
*/
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
/** /**
* Global error handling middleware * Global error handling middleware
* MUST be registered after all routes in server.js * MUST be registered after all routes in server.js
*/ */
function errorMiddleware(err, req, res, next) { function errorMiddleware(err, req, res, next) {
// Log all errors with request context // Log all errors with request context (unified, same file the rest of the app uses)
logError(req.path, err, { unifiedLogError(
method: req.method, ERROR_LOG_FILE,
ip: req.ip, MAX_ERROR_LOG_SIZE,
userId: req.user?.id, req.path,
body: req.body err,
}); {
method: req.method,
ip: req.ip,
userId: req.user?.id,
body: req.body
}
).catch(e => console.error('Failed to write to error log:', e.message));
// Determine if this is an operational error (AppError) or programming error // Determine if this is an operational error (AppError) or programming error
const isOperational = err.isOperational || err instanceof AppError; const isOperational = err.isOperational || err instanceof AppError;
// Status code // Status code
const statusCode = err.statusCode || 500; const statusCode = err.statusCode || 500;
// Error code (DC-XXX format) // Error code (DC-XXX format)
const code = err.code || `DC-${statusCode}`; const code = err.code || `DC-${statusCode}`;
// Build response // Build response
const response = { const response = {
success: false, success: false,
error: isOperational ? err.message : 'Internal server error', error: isOperational ? safeErrorMessage(err) : 'Internal server error',
code code
}; };
// Add optional fields if present // Add optional fields if present
if (err.requiresTotp) response.requiresTotp = true; if (err.requiresTotp) response.requiresTotp = true;
if (err.retryAfter) response.retryAfter = err.retryAfter; if (err.retryAfter) response.retryAfter = err.retryAfter;
if (err.field) response.field = err.field; if (err.field) response.field = err.field;
if (err.resource) response.resource = err.resource; if (err.resource) response.resource = err.resource;
if (err.details && Object.keys(err.details).length > 0) response.details = err.details; if (err.details && Object.keys(err.details).length > 0) response.details = err.details;
// Development mode: include stack trace // Development mode: include stack trace
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
response.stack = err.stack; response.stack = err.stack;
} }
// Send response // Send response
res.status(statusCode).json(response); res.status(statusCode).json(response);
// For non-operational errors, log as fatal // For non-operational errors, log as fatal
if (!isOperational) { if (!isOperational) {
console.error('FATAL: Non-operational error detected', { console.error('FATAL: Non-operational error detected', {
@@ -81,7 +85,6 @@ function notFoundHandler(req, res, next) {
} }
module.exports = { module.exports = {
asyncHandler,
errorMiddleware, errorMiddleware,
notFoundHandler notFoundHandler
}; };
-135
View File
@@ -1,135 +0,0 @@
// Error Logger Utility
// Centralized error logging with rotation and request context tracking
const fsp = require('fs').promises;
const path = require('path');
const { LIMITS } = require('./constants');
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
/**
* Check if file exists
*/
async function exists(filepath) {
try {
await fsp.access(filepath);
return true;
} catch {
return false;
}
}
/**
* Log error with context and rotation
* @param {string} context - Where the error occurred
* @param {Error|string} error - The error to log
* @param {Object} additionalInfo - Additional context (req, etc.)
*/
async function logError(context, error, additionalInfo = {}) {
const timestamp = new Date().toISOString();
// Extract request context if a request object is provided
const requestContext = extractRequestContext(additionalInfo.req);
if (additionalInfo.req) {
delete additionalInfo.req; // Remove req to avoid circular refs
}
const logEntry = {
timestamp,
context,
...requestContext,
error: {
message: error.message || error,
stack: error.stack,
code: error.code
},
...additionalInfo
};
// Format log line with request context
const contextInfo = Object.keys(requestContext).length > 0
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
: '';
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
try {
// Rotate log if it exceeds max size
await rotateLogIfNeeded();
await fsp.appendFile(ERROR_LOG_FILE, logLine);
} catch (e) {
console.error('Failed to write to error log', e.message);
}
}
/**
* Extract request context from Express request object
*/
function extractRequestContext(req) {
if (!req) return {};
const clientIP = req.ip || req.socket?.remoteAddress || '';
return {
requestId: req.id,
ip: clientIP,
userAgent: req.get('user-agent'),
method: req.method,
path: req.path
};
}
/**
* Rotate log file if it exceeds max size
*/
async function rotateLogIfNeeded() {
try {
const stats = await fsp.stat(ERROR_LOG_FILE);
if (stats.size > MAX_ERROR_LOG_SIZE) {
const rotated = ERROR_LOG_FILE + '.1';
if (await exists(rotated)) {
await fsp.unlink(rotated);
}
await fsp.rename(ERROR_LOG_FILE, rotated);
}
} catch (_) {
// File may not exist yet, that's fine
}
}
/**
* Return a safe error message to the client without leaking internals
*/
function safeErrorMessage(error) {
const msg = error.message || String(error);
// Detect port conflict errors from Docker
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
const port = portMatch ? portMatch[1] : 'requested';
return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`;
}
// Detect container not found errors
if (msg.includes('No such container')) {
return 'Container not found';
}
// Detect network errors
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) {
return 'Service unavailable';
}
// Generic safe message for unknown errors
if (process.env.NODE_ENV === 'production') {
return 'An error occurred. Please try again or contact support.';
}
// In development, show the actual error
return msg;
}
module.exports = {
logError,
safeErrorMessage
};
+28 -2
View File
@@ -277,9 +277,32 @@ module.exports = function configureMiddleware(app, {
} }
// ── Public routes (bypass TOTP and JWT auth) ── // ── Public routes (bypass TOTP and JWT auth) ──
// Routes here are accessible without authentication. By default the
// monitoring/health-check endpoints are public so the dashboard can
// render widgets before the user logs in. Set MONITORING_PUBLIC=false
// (env var) or `monitoring: { public: false }` (config.json) to require
// auth for these — useful for internet-exposed deployments where
// CPU/memory/disk data is sensitive.
const MONITORING_PUBLIC = (() => {
if (process.env.MONITORING_PUBLIC === 'false') return false;
if (process.env.MONITORING_PUBLIC === 'true') return true;
// Default: check config.json if loaded
try {
const cfg = require('./src/config/site').siteConfig;
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
return cfg.monitoring.public;
}
} catch { /* config not loaded yet, use default */ }
return true; // default: public (current behavior, dashboard needs it)
})();
const PUBLIC_ROUTES = [ const PUBLIC_ROUTES = [
{ path: '/health', exact: true }, { path: '/health', exact: true },
{ path: '/health/live', exact: true },
{ path: '/health/ready', exact: true },
{ path: '/api/v1/health', exact: true }, { path: '/api/v1/health', exact: true },
{ path: '/api/v1/health/live', exact: true },
{ path: '/api/v1/health/ready', exact: true },
{ path: '/probe/', prefix: true }, { path: '/probe/', prefix: true },
{ path: '/api/v1/tailscale/', prefix: true }, { path: '/api/v1/tailscale/', prefix: true },
{ path: '/api/v1/totp/config', exact: true, method: 'GET' }, { path: '/api/v1/totp/config', exact: true, method: 'GET' },
@@ -305,8 +328,11 @@ 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' }, // Monitoring endpoints — only public if MONITORING_PUBLIC is true
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' }, ...(MONITORING_PUBLIC ? [
{ 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' }, { path: '/api/v1/version', exact: true, method: 'GET' },
]; ];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.13.0", "version": "1.13.2",
"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": {
-114
View File
@@ -1,114 +0,0 @@
// Response Helpers
// Standardize API response format across all routes
const { HTTP_STATUS } = require('./constants');
/**
* Success response with data
*/
function success(res, data, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({
success: true,
...data
});
}
/**
* Success response with message
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({
success: true,
message
});
}
/**
* Created response (201)
*/
function created(res, data) {
return res.status(HTTP_STATUS.CREATED).json({
success: true,
...data
});
}
/**
* No content response (204)
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
/**
* Error response
*/
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.status(statusCode).json({
success: false,
error: message
});
}
/**
* Validation error response (400)
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({
success: false,
error: message
});
}
/**
* Unauthorized response (401)
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
success: false,
error: message
});
}
/**
* Forbidden response (403)
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({
success: false,
error: message
});
}
/**
* Not found response (404)
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({
success: false,
error: message
});
}
/**
* Conflict response (409)
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({
success: false,
error: message
});
}
module.exports = {
success,
successMessage,
created,
noContent,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict
};
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { DOCKER } = require('../constants'); const { DOCKER } = require('../constants');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Containers route factory * Containers route factory
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
/** /**
* Credentials routes factory * Credentials routes factory
+1 -1
View File
@@ -15,7 +15,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { NotFoundError, ValidationError } = require('../errors'); const { NotFoundError, ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const validatorLib = require('validator'); const validatorLib = require('validator');
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@ const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Error logs routes factory * Error logs routes factory
+1 -1
View File
@@ -7,7 +7,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Monitoring routes factory * Monitoring routes factory
+1 -1
View File
@@ -10,7 +10,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); 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('../src/utils/responses');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
/** /**
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse, notFound } = require('../response-helpers'); const { success, error: errorResponse, notFound } = require('../src/utils/responses');
/** /**
* SSL Monitor route factory * SSL Monitor route factory
+1 -1
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
+80
View File
@@ -627,6 +627,86 @@ async function createApp() {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });
// Liveness probe — "is the process alive?"
// Always returns 200 unless the Node.js event loop is completely blocked.
// Used by k8s/Docker to decide whether to RESTART the container.
// DO NOT add dependency checks here — those belong in /health/ready.
app.get('/health/live', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
// Readiness probe — "is the app ready to serve traffic?"
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
// Returns 200 with details if all OK, 503 with failed components otherwise.
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
// Check 1: Config file readable
try {
const fs = require('fs');
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
// Check 2: Services file readable
try {
const fs = require('fs');
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
// Check 3: Docker daemon reachable
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
// Check 4: Caddy admin API reachable
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, {
signal: controller.signal
});
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
res.status(allOk ? 200 : 503).json(body);
}));
// Lightweight probe endpoint // Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => { app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
const id = req.params.id; const id = req.params.id;
+107 -5
View File
@@ -1,22 +1,124 @@
/** /**
* Response helpers - Standard API response formats * Response helpers - Standard API response formats
*
* Single source of truth for HTTP response shapes across DashCaddy.
* Standard envelope: { success: true, ...data } or { success: false, error: "..." }.
*
* All routes should import from this module — do not call res.json/res.status
* directly with the response shape, use these helpers instead.
*/ */
const { HTTP_STATUS } = require('../../constants');
// ── Success helpers ────────────────────────────────────────────
/** /**
* Standard error response * Standard success response. Use this in route handlers.
* Wraps the data object with a `success: true` envelope.
* @param {object} res Express response
* @param {object} [data={}] fields to include in the response body
* @param {number} [statusCode=200] HTTP status code
*/
function ok(res, data = {}, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, ...data });
}
/**
* Alias for `ok` — prefer `ok` in new code, but kept for code that imports as `success`.
*/
function success(res, data, statusCode) {
return ok(res, data, statusCode);
}
/**
* Success response with a human-readable message field.
* Use when there's no data to return, just confirmation.
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, message });
}
/**
* 201 Created response.
*/
function created(res, data = {}) {
return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data });
}
/**
* 204 No Content response.
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
// ── Error helpers ──────────────────────────────────────────────
/**
* Standard error response. Use this in route handlers.
* @param {object} res Express response
* @param {number} statusCode HTTP status code
* @param {string} message Human-readable error message
* @param {object} [extras={}] additional fields to merge into the response
*/ */
function errorResponse(res, statusCode, message, extras = {}) { function errorResponse(res, statusCode, message, extras = {}) {
return res.status(statusCode).json({ success: false, error: message, ...extras }); return res.status(statusCode).json({ success: false, error: message, ...extras });
} }
/** /**
* Standard success response * Alias for `errorResponse` — kept for code that imports as `error`.
*/ */
function ok(res, data = {}) { function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.json({ success: true, ...data }); return res.status(statusCode).json({ success: false, error: message });
}
/**
* 400 Bad Request — invalid input from the user.
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message });
}
/**
* 401 Unauthorized — no valid credentials.
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message });
}
/**
* 403 Forbidden — credentials valid but permission denied.
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message });
}
/**
* 404 Not Found — resource doesn't exist.
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message });
}
/**
* 409 Conflict — request conflicts with current state (e.g. duplicate).
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message });
} }
module.exports = { module.exports = {
errorResponse, // Success helpers
ok, ok,
success,
successMessage,
created,
noContent,
// Error helpers
errorResponse,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict,
}; };