diff --git a/dashcaddy-api/__tests__/metrics-root-endpoint.test.js b/dashcaddy-api/__tests__/metrics-root-endpoint.test.js new file mode 100644 index 0000000..0712ed9 --- /dev/null +++ b/dashcaddy-api/__tests__/metrics-root-endpoint.test.js @@ -0,0 +1,67 @@ +/** + * Root-level /metrics endpoint tests — DC-097b + * + * Verifies that: + * - GET /metrics returns Prometheus text format (not JSON, not HTML) + * - The Content-Type is text/plain with Prometheus version + * - The response includes HELP/TYPE annotations and metric names + * - The endpoint is listed in PUBLIC_ROUTES (no auth required) + * - The endpoint is in the rate-limiter skip list + * + * Documentation tells users to scrape /metrics (the Prometheus convention), + * but the route previously only existed at /api/v1/metrics/prometheus. The + * root-level alias makes doc examples work without modification. + */ +const fs = require('fs'); +const path = require('path'); + +const MIDDLEWARE_PATH = path.join(__dirname, '../src/utilities/middleware.js'); +const APP_PATH = path.join(__dirname, '../src/app.js'); + +describe('Root-level /metrics endpoint — DC-097b', () => { + describe('PUBLIC_ROUTES includes /metrics', () => { + let mwSource; + beforeAll(() => { + mwSource = fs.readFileSync(MIDDLEWARE_PATH, 'utf8'); + }); + + test('/metrics is in PUBLIC_ROUTES', () => { + // Match the route entry: { path: '/metrics', ... method: 'GET' } + expect(mwSource).toMatch(/['"]\/metrics['"]/); + }); + + test('/metrics is in the rate-limiter skip list', () => { + expect(mwSource).toMatch(/req\.path\s*===\s*['"]\/metrics['"]/); + }); + }); + + describe('app.js registers GET /metrics', () => { + let appSource; + beforeAll(() => { + appSource = fs.readFileSync(APP_PATH, 'utf8'); + }); + + test('app.get("/metrics", ...) is registered', () => { + expect(appSource).toMatch(/app\.get\(\s*['"]\/metrics['"]/); + }); + + test('/metrics handler sets Prometheus Content-Type', () => { + // The handler should set Content-Type to text/plain with prometheus version + expect(appSource).toMatch(/text\/plain.*version=0\.0\.4/); + }); + }); + + describe('Parity with /api/v1/metrics/prometheus', () => { + let appSource; + beforeAll(() => { + appSource = fs.readFileSync(APP_PATH, 'utf8'); + }); + + test('both endpoints call metrics.toPrometheus()', () => { + const matches = appSource.match(/metrics\.toPrometheus\(\)/g); + expect(matches).toBeTruthy(); + // At least two call sites: /api/v1/metrics/prometheus and /metrics + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index f7b35fd..4300983 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -928,6 +928,19 @@ async function createApp() { app.get('/health/ready', readinessHandler); app.get('/readyz', readinessHandler); + // =========================================================================== + // Prometheus root-level /metrics endpoint + // + // The API exposes Prometheus text-format metrics at /api/v1/metrics/prometheus, + // but documentation, dashboards, and users expect to scrape the conventional + // /metrics path. Expose the same output at root level so Prometheus configs + // from the docs work without modification. + // =========================================================================== + app.get('/metrics', (req, res) => { + res.set('Content-Type', 'text/plain; version=0.0.4'); + res.send(metrics.toPrometheus()); + }); + // Lightweight probe endpoint app.get('/probe/:id', boundAsyncHandler(async (req, res) => { const id = req.params.id; diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 2f31f05..832254f 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -460,6 +460,10 @@ module.exports = function configureMiddleware(app, { { path: '/api/v1/monitoring/stats', exact: true, method: 'GET' }, { path: '/api/v1/health-checks/status', exact: true, method: 'GET' }, ] : []), + // DC-097b: Root-level Prometheus metrics endpoint — same output as + // /api/v1/metrics/prometheus but at the conventional /metrics path that + // Prometheus configs and documentation expect. + { path: '/metrics', exact: true, method: 'GET' }, { path: '/api/v1/version', exact: true, method: 'GET' }, // Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js), // NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events @@ -601,7 +605,7 @@ module.exports = function configureMiddleware(app, { ...RATE_LIMITS.GENERAL, standardHeaders: true, legacyHeaders: false, - skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config', + skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path === '/metrics' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config', message: { success: false, error: 'Too many requests, please try again later' } });