Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11cfb8c26a | ||
|
|
caa09dcebe | ||
|
|
264de9644c | ||
|
|
e40cb35011 |
@@ -1,8 +1,18 @@
|
||||
jest.mock('../error-logger', () => ({
|
||||
logError: jest.fn(),
|
||||
// Mock the unified logging module so we can verify logError is called
|
||||
// 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 {
|
||||
AppError,
|
||||
ValidationError,
|
||||
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
|
||||
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', () => {
|
||||
it('returns 400 for ValidationError', () => {
|
||||
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),
|
||||
}));
|
||||
|
||||
jest.mock('../../response-helpers', () => ({
|
||||
jest.mock('../../src/utils/responses', () => ({
|
||||
success: jest.fn((res, data, statusCode = 200) => {
|
||||
return res.status(statusCode).json({ success: true, ...data });
|
||||
}),
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
/**
|
||||
* DashCaddy Error Handler Middleware
|
||||
* 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 { logError } = require('./error-logger');
|
||||
const { LIMITS } = require('./constants');
|
||||
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
|
||||
|
||||
/**
|
||||
* Async route handler wrapper
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
|
||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
|
||||
/**
|
||||
* Global error handling middleware
|
||||
* MUST be registered after all routes in server.js
|
||||
*/
|
||||
function errorMiddleware(err, req, res, next) {
|
||||
// Log all errors with request context
|
||||
logError(req.path, err, {
|
||||
// Log all errors with request context (unified, same file the rest of the app uses)
|
||||
unifiedLogError(
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
req.path,
|
||||
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
|
||||
const isOperational = err.isOperational || err instanceof AppError;
|
||||
@@ -42,7 +46,7 @@ function errorMiddleware(err, req, res, next) {
|
||||
// Build response
|
||||
const response = {
|
||||
success: false,
|
||||
error: isOperational ? err.message : 'Internal server error',
|
||||
error: isOperational ? safeErrorMessage(err) : 'Internal server error',
|
||||
code
|
||||
};
|
||||
|
||||
@@ -81,7 +85,6 @@ function notFoundHandler(req, res, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
asyncHandler,
|
||||
errorMiddleware,
|
||||
notFoundHandler
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -277,9 +277,32 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
// ── 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 = [
|
||||
{ path: '/health', exact: true },
|
||||
{ path: '/health/live', exact: true },
|
||||
{ path: '/health/ready', 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: '/api/v1/tailscale/', prefix: true },
|
||||
{ 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/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
// Monitoring endpoints — only public if MONITORING_PUBLIC is true
|
||||
...(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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.2",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const { DOCKER } = require('../constants');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { NotFoundError } = require('../errors');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Containers route factory
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Credentials routes factory
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { NotFoundError, ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
|
||||
const validatorLib = require('validator');
|
||||
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const { exists } = require('../fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Error logs routes factory
|
||||
|
||||
@@ -7,7 +7,7 @@ const { exists } = require('../fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Monitoring routes factory
|
||||
|
||||
@@ -10,7 +10,7 @@ const { exists } = require('../fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../pagination');
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
|
||||
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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { success } = require('../response-helpers');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
|
||||
@@ -627,6 +627,86 @@ async function createApp() {
|
||||
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
|
||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||
const id = req.params.id;
|
||||
|
||||
@@ -1,22 +1,124 @@
|
||||
/**
|
||||
* 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 = {}) {
|
||||
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 = {}) {
|
||||
return res.json({ success: true, ...data });
|
||||
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
|
||||
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 = {
|
||||
errorResponse,
|
||||
// Success helpers
|
||||
ok,
|
||||
success,
|
||||
successMessage,
|
||||
created,
|
||||
noContent,
|
||||
// Error helpers
|
||||
errorResponse,
|
||||
error,
|
||||
validationError,
|
||||
unauthorized,
|
||||
forbidden,
|
||||
notFound,
|
||||
conflict,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user