Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ npm run index # start indexer (separate terminal)
| GET | `/api/v1/tokens/:address` | Token detail |
| GET | `/api/v1/tokens/:address/transfers` | Token transfer history |
| GET | `/health` | Health check |
| GET | `/metrics` | Prometheus scrape endpoint (see [Metrics](#metrics)) |

## Metrics

Both services expose a Prometheus-format scrape endpoint backed by a shared `prom-client` `Registry`, including default Node.js process metrics (CPU, memory, event loop lag, GC):

- **API service** (`:3000`) — `GET /metrics`, registry defined in [`src/metrics.ts`](./src/metrics.ts). Covers HTTP latency/throughput (`http_request_duration_seconds`, `http_requests_total`), error rates, indexer ingestion health, DB/cache/replica status. Gated by [`metricsAuthGuard`](./src/middleware/metricsAuthGuard.ts): if `METRICS_TOKEN` is set, requests must supply it via `Authorization: Bearer <token>` or `X-Metrics-Token`, otherwise they get `401`; if unset, only loopback callers (`127.0.0.1`/`::1`) are allowed and everyone else gets `403`. Set `METRICS_TOKEN` when scraping from outside the host (e.g. a remote Prometheus).
- **Indexer service** (`:3001`) — `GET /metrics`, registry defined in [`indexer/src/metrics.js`](./indexer/src/metrics.js). Covers event ingestion, decode latency, RPC errors, and DB pool utilisation. This endpoint is unauthenticated and intended to be scraped only from inside the trusted service network (e.g. the Docker Compose network); do not expose port `3001` publicly without adding equivalent access control.

## Registering a Contract ABI

Expand Down Expand Up @@ -144,6 +152,7 @@ Because the server caches keys, a typical rotation involves:
| `INDEXER_POLL_INTERVAL_MS` | `5000` | Polling interval |
| `INDEXER_BATCH_SIZE` | `100` | Ledgers per batch |
| `ADMIN_SECRET` | — | Bearer token required by every `/api/admin/*` route (indexer). See [`docs/ADMIN_AUTH.md`](./docs/ADMIN_AUTH.md). |
| `METRICS_TOKEN` | — | Bearer/`X-Metrics-Token` required to scrape the API service's `GET /metrics` from outside the host. Unset restricts it to loopback callers. See [Metrics](#metrics). |
| `MOCK_DATA` | `false` | Gates experimental endpoints that fabricate demo data instead of a real integration. See [Experimental & Mock Data](#experimental--mock-data). `ENABLE_EXPERIMENTAL` is accepted as an alias. |

## Experimental & Mock Data
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { apiKeyAuth } from './middleware/apiKeyAuth';
import { auditLogMiddleware } from './middleware/auditLog';
import { asyncHandler } from './middleware/asyncHandler';
import { rejectUntrustedForwardedHeaders } from './middleware/proxyTrust';
import { metricsAuthGuard } from './middleware/metricsAuthGuard';
import { billingRouter } from './services/stripe-billing';
import { logger } from './logger';
import { validateJwtKeysAtStartup } from './auth/keys';
Expand Down Expand Up @@ -177,6 +178,7 @@ app.use('/api/billing', billingRouter);

app.get(
'/metrics',
metricsAuthGuard,
asyncHandler(async (_req, res) => {
res.set('Content-Type', registry.contentType);
res.end(await registry.metrics());
Expand Down
34 changes: 34 additions & 0 deletions src/middleware/metricsAuthGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from 'express';

/**
* Gates GET /metrics. When METRICS_TOKEN is set, a matching Bearer token or
* X-Metrics-Token header is required (401 otherwise). Without it, only
* loopback callers (127.0.0.1/::1) are allowed (403 for anyone else) so the
* scrape endpoint isn't exposed by default.
*/
export function metricsAuthGuard(req: Request, res: Response, next: NextFunction): void {
const metricsToken = process.env.METRICS_TOKEN?.trim() || null;

if (metricsToken) {
const provided =
(req.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '') ||
(req.headers['x-metrics-token'] as string | undefined) ||
'';
if (provided !== metricsToken) {
res
.status(401)
.set('WWW-Authenticate', 'Bearer realm="metrics"')
.json({ error: 'Unauthorized' });
return;
}
next();
return;
}

const ip = (req.ip ?? '').replace('::ffff:', '');
if (ip !== '127.0.0.1' && ip !== '::1') {
res.status(403).json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' });
return;
}
next();
}
94 changes: 30 additions & 64 deletions tests/metrics-access.test.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,84 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { metricsAuthGuard } from '../src/middleware/metricsAuthGuard';

function buildMetricsApp(metricsToken: string | null) {
function buildMetricsApp() {
const app = express();

app.set('trust proxy', true);

app.get('/metrics', async (req, res) => {
try {
if (metricsToken) {
const provided =
(req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '') ||
(req.headers['x-metrics-token'] as string | undefined) ||
'';
if (provided !== metricsToken) {
res
.status(401)
.set('WWW-Authenticate', 'Bearer realm="metrics"')
.json({ error: 'Unauthorized' });
return;
}
} else {
const ip = (req.ip ?? '').replace('::ffff:', '');
if (ip !== '127.0.0.1' && ip !== '::1') {
res
.status(403)
.json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' });
return;
}
}
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.end('# metrics');
} catch (err) {
res.status(500).json({ error: 'internal' });
}
app.get('/metrics', metricsAuthGuard, (_req, res) => {
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.end('# metrics');
});

return app;
}

describe('metrics — token-protected mode', () => {
afterEach(() => {
delete process.env.METRICS_TOKEN;
});

describe('GET /metrics — token-protected mode', () => {
const token = 'super-secret-metrics-token';

it('returns 401 when no credentials provided', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics');
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp()).get('/metrics');
expect(res.status).toBe(401);
expect(res.headers['www-authenticate']).toMatch(/Bearer/);
});

it('returns 401 for wrong Bearer token', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token');
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp())
.get('/metrics')
.set('Authorization', 'Bearer wrong-token');
expect(res.status).toBe(401);
});

it('returns 401 for wrong X-Metrics-Token header', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics').set('X-Metrics-Token', 'wrong-token');
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp()).get('/metrics').set('X-Metrics-Token', 'wrong-token');
expect(res.status).toBe(401);
});

it('serves metrics with correct Bearer token', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics').set('Authorization', `Bearer ${token}`);
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp()).get('/metrics').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.text).toContain('# metrics');
});

it('serves metrics with correct X-Metrics-Token header', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics').set('X-Metrics-Token', token);
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp()).get('/metrics').set('X-Metrics-Token', token);
expect(res.status).toBe(200);
});

it('returns JSON error body on 401', async () => {
const app = buildMetricsApp(token);
const res = await request(app).get('/metrics');
process.env.METRICS_TOKEN = token;
const res = await request(buildMetricsApp()).get('/metrics');
expect(res.body).toHaveProperty('error');
});
});

describe('metrics — loopback-only mode (no token configured)', () => {
describe('GET /metrics — loopback-only mode (no token configured)', () => {
it('returns 403 for non-loopback IP', async () => {
const app = buildMetricsApp(null);
const res = await request(app).get('/metrics').set('X-Forwarded-For', '203.0.113.5');
const res = await request(buildMetricsApp()).get('/metrics').set('X-Forwarded-For', '203.0.113.5');
// With trust proxy enabled, the spoofed IP triggers 403.
// Without it, supertest connects via loopback and gets 200.
expect([200, 403]).toContain(res.status);
});

it('serves metrics from loopback without any token', async () => {
const app = buildMetricsApp(null);
const res = await request(app).get('/metrics');
const res = await request(buildMetricsApp()).get('/metrics');
expect(res.status).toBe(200);
});

it('returns 403 error body for non-loopback access', async () => {
const app = express();
app.set('trust proxy', true);
app.get('/metrics', async (req, res) => {
try {
const ip = (req.ip ?? '').replace('::ffff:', '');
if (ip !== '127.0.0.1' && ip !== '::1') {
res
.status(403)
.json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' });
return;
}
res.end('# metrics');
} catch (err) {
res.status(500).json({ error: 'internal' });
}
app.get('/metrics', metricsAuthGuard, (_req, res) => {
res.end('# metrics');
});

const res = await request(app).get('/metrics').set('X-Forwarded-For', '203.0.113.5');
Expand Down
Loading