mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Protect Prometheus metrics in production (#576)
This commit is contained in:
parent
14cb4b9339
commit
a439791abb
7 changed files with 230 additions and 11 deletions
|
|
@ -75,6 +75,11 @@ VERITAS_ADMIN_KEY=
|
|||
# Days after which telemetry is compressed
|
||||
# TELEMETRY_COMPRESS_DAYS=7
|
||||
|
||||
# Prometheus /metrics exposure
|
||||
# In production, /metrics requires normal auth with telemetry:read unless one of these is set.
|
||||
# PROMETHEUS_METRICS_TOKEN=
|
||||
# PROMETHEUS_METRICS_PUBLIC=false
|
||||
|
||||
# ── External Services ────────────────────────────────────────
|
||||
# Clawdbot gateway URL (default: http://127.0.0.1:18789)
|
||||
# CLAWDBOT_GATEWAY=http://127.0.0.1:18789
|
||||
|
|
|
|||
|
|
@ -532,6 +532,14 @@ All variables are set in `server/.env` (or passed as environment variables in Do
|
|||
| `CSP_REPORT_ONLY` | `false` | Use Content-Security-Policy-Report-Only instead of enforcing |
|
||||
| `CSP_REPORT_URI` | — | URL to receive CSP violation reports |
|
||||
|
||||
### Prometheus metrics
|
||||
|
||||
`GET /metrics` remains unauthenticated for local development. In production, use one of these explicit modes:
|
||||
|
||||
- `PROMETHEUS_METRICS_TOKEN=<secret>` and configure Prometheus to send `Authorization: Bearer <secret>`.
|
||||
- A normal Veritas API key whose role or permissions include `telemetry:read`.
|
||||
- `PROMETHEUS_METRICS_PUBLIC=true` only on a trusted private network where unauthenticated operational metrics are intentional.
|
||||
|
||||
### Data & Storage
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
|
|
|||
|
|
@ -578,6 +578,21 @@ All variables live in `server/.env` (copy from `server/.env.example`).
|
|||
| `TRUST_PROXY` | — | Express proxy trust. Use `1` for single-hop (nginx/Caddy). `true` is blocked |
|
||||
| `RATE_LIMIT_MAX` | `300` | Max API requests/minute/IP (localhost exempt) |
|
||||
|
||||
### Prometheus metrics
|
||||
|
||||
`GET /metrics` is public only in local development. In production, scrape it with one of these explicit configurations:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: veritas-kanban
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ['veritas.example.com']
|
||||
bearer_token: '<PROMETHEUS_METRICS_TOKEN>'
|
||||
```
|
||||
|
||||
Set `PROMETHEUS_METRICS_TOKEN` on the Veritas server to the same secret, or use a normal Veritas API key with `telemetry:read` permission in the `Authorization: Bearer <key>` header. Only set `PROMETHEUS_METRICS_PUBLIC=true` for trusted private networks where unauthenticated metrics are intentional.
|
||||
|
||||
### Data & Storage
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
|
|
|||
131
server/src/__tests__/routes/prometheus.test.ts
Normal file
131
server/src/__tests__/routes/prometheus.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
async function buildApp(env: Record<string, string | undefined>) {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.PROMETHEUS_METRICS_PUBLIC;
|
||||
delete process.env.PROMETHEUS_METRICS_TOKEN;
|
||||
delete process.env.VERITAS_ADMIN_KEY;
|
||||
delete process.env.VERITAS_API_KEYS;
|
||||
delete process.env.VERITAS_AUTH_ENABLED;
|
||||
delete process.env.VERITAS_AUTH_LOCALHOST_BYPASS;
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
vi.doMock('../../config/security.js', () => ({
|
||||
getSecurityConfig: vi.fn(() => ({
|
||||
authEnabled: false,
|
||||
passwordHash: null,
|
||||
})),
|
||||
getJwtSecret: vi.fn(() => 'test-jwt-secret'),
|
||||
getValidJwtSecrets: vi.fn(() => ['test-jwt-secret']),
|
||||
}));
|
||||
|
||||
vi.doMock('../../services/metrics/prometheus.js', () => ({
|
||||
getPrometheusCollector: () => ({
|
||||
scrape: () => '# HELP veritas_test_metric Test metric\nveritas_test_metric 1\n',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { prometheusMetricsRouter } = await import('../../routes/prometheus.js');
|
||||
const app = express();
|
||||
app.use(prometheusMetricsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Prometheus metrics route', () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('allows unauthenticated local development scrapes', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'development',
|
||||
VERITAS_ADMIN_KEY: 'dev-admin-key',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.text).toContain('veritas_test_metric 1');
|
||||
});
|
||||
|
||||
it('requires authentication in production by default', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'production',
|
||||
VERITAS_ADMIN_KEY: 'production-admin-key',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('allows production scrapes with an authenticated telemetry reader', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'production',
|
||||
VERITAS_ADMIN_KEY: 'production-admin-key',
|
||||
VERITAS_API_KEYS: 'prometheus:metrics-reader:read-only',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics').set('Authorization', 'Bearer metrics-reader');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('veritas_test_metric 1');
|
||||
});
|
||||
|
||||
it('rejects production API keys without telemetry read permission', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'production',
|
||||
VERITAS_ADMIN_KEY: 'production-admin-key',
|
||||
VERITAS_API_KEYS: 'agent:agent-key:agent',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics').set('Authorization', 'Bearer agent-key');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('allows production scrapes with the dedicated Prometheus bearer token', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'production',
|
||||
VERITAS_ADMIN_KEY: 'production-admin-key',
|
||||
PROMETHEUS_METRICS_TOKEN: 'prometheus-secret',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics').set('Authorization', 'Bearer prometheus-secret');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('veritas_test_metric 1');
|
||||
});
|
||||
|
||||
it('allows explicit public production scrapes only when opted in', async () => {
|
||||
const app = await buildApp({
|
||||
NODE_ENV: 'production',
|
||||
VERITAS_ADMIN_KEY: 'production-admin-key',
|
||||
PROMETHEUS_METRICS_PUBLIC: 'true',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/metrics');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('veritas_test_metric 1');
|
||||
});
|
||||
});
|
||||
|
|
@ -117,6 +117,12 @@ export const envSchema = z.object({
|
|||
/** Days after which telemetry is compressed */
|
||||
TELEMETRY_COMPRESS_DAYS: positiveIntString,
|
||||
|
||||
/** Allow unauthenticated Prometheus scraping in production */
|
||||
PROMETHEUS_METRICS_PUBLIC: booleanString.default(false),
|
||||
|
||||
/** Dedicated bearer token for Prometheus scraping */
|
||||
PROMETHEUS_METRICS_TOKEN: z.string().optional(),
|
||||
|
||||
// ── External Services ───────────────────────────────────────────────
|
||||
/** Clawdbot gateway URL */
|
||||
CLAWDBOT_GATEWAY: z.string().url().optional().default('http://127.0.0.1:18789'),
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ import { webhookN8nRouter } from './routes/webhook-n8n.js';
|
|||
import { cspNonceMiddleware, injectCspNonceAttributes } from './middleware/csp-nonce.js';
|
||||
import { apiDocsCspOverride, buildCspDirectives } from './config/csp.js';
|
||||
import { healthRouter, apiHealthRouter, setHealthWss } from './routes/health.js';
|
||||
import { getPrometheusCollector } from './services/metrics/prometheus.js';
|
||||
import { metricsCollector } from './middleware/metrics-collector.js';
|
||||
import { prometheusMetricsRouter } from './routes/prometheus.js';
|
||||
import { getStorageTypeFromEnv, initStorage, shutdownStorage } from './storage/index.js';
|
||||
import {
|
||||
canReceiveWebSocketEvent,
|
||||
|
|
@ -349,16 +349,10 @@ app.use('/health', healthRouter);
|
|||
// Canonical VK API health signal (unauthenticated; used by dev tooling/watchdogs)
|
||||
app.use('/api/health', apiHealthRouter);
|
||||
|
||||
// ============================================
|
||||
// Prometheus Metrics (unauthenticated, for scraping)
|
||||
// ============================================
|
||||
// Returns metrics in Prometheus exposition text format.
|
||||
// Placed before authentication so Prometheus can scrape without credentials.
|
||||
app.get('/metrics', (_req, res) => {
|
||||
const collector = getPrometheusCollector();
|
||||
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||
res.send(collector.scrape());
|
||||
});
|
||||
// Prometheus exposition endpoint. Public in local development; production
|
||||
// requires normal auth with telemetry:read, PROMETHEUS_METRICS_TOKEN, or an
|
||||
// explicit PROMETHEUS_METRICS_PUBLIC=true opt-in.
|
||||
app.use(prometheusMetricsRouter);
|
||||
|
||||
// Metrics collection middleware — records per-request HTTP metrics.
|
||||
// Placed after health/metrics endpoints so those aren't self-instrumented
|
||||
|
|
|
|||
60
server/src/routes/prometheus.ts
Normal file
60
server/src/routes/prometheus.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import crypto from 'crypto';
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import {
|
||||
authenticate,
|
||||
authorizePermission,
|
||||
type AuthenticatedRequest,
|
||||
} from '../middleware/auth.js';
|
||||
import { getPrometheusCollector } from '../services/metrics/prometheus.js';
|
||||
|
||||
export const prometheusMetricsRouter = Router();
|
||||
|
||||
function envFlagEnabled(name: string): boolean {
|
||||
return process.env[name]?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function publicMetricsAllowed(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || envFlagEnabled('PROMETHEUS_METRICS_PUBLIC');
|
||||
}
|
||||
|
||||
function constantTimeEquals(actual: string, expected: string): boolean {
|
||||
const actualBuffer = Buffer.from(actual);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
|
||||
return (
|
||||
actualBuffer.length === expectedBuffer.length &&
|
||||
crypto.timingSafeEqual(actualBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
function hasMetricsBearerToken(req: Request): boolean {
|
||||
const expectedToken = process.env.PROMETHEUS_METRICS_TOKEN?.trim();
|
||||
if (!expectedToken) return false;
|
||||
|
||||
const authorization = req.headers.authorization;
|
||||
if (!authorization?.startsWith('Bearer ')) return false;
|
||||
|
||||
return constantTimeEquals(authorization.slice(7).trim(), expectedToken);
|
||||
}
|
||||
|
||||
function protectPrometheusMetrics(req: Request, res: Response, next: NextFunction): void {
|
||||
if (publicMetricsAllowed() || hasMetricsBearerToken(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
authenticate(req as AuthenticatedRequest, res, (authError?: unknown) => {
|
||||
if (authError) {
|
||||
next(authError);
|
||||
return;
|
||||
}
|
||||
|
||||
authorizePermission('telemetry:read')(req as AuthenticatedRequest, res, next);
|
||||
});
|
||||
}
|
||||
|
||||
prometheusMetricsRouter.get('/metrics', protectPrometheusMetrics, (_req, res) => {
|
||||
const collector = getPrometheusCollector();
|
||||
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||
res.send(collector.scrape());
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue