Compare commits

...
3 Commits
Author SHA1 Message Date
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
4 changed files with 310 additions and 3 deletions
@@ -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']);
});
});
});
+26
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dashcaddy-api",
"version": "1.13.0",
"version": "1.13.1",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js",
"scripts": {
+80
View File
@@ -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;