279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import {
|
|
renderPrometheusMetrics,
|
|
sendPrometheusMetrics,
|
|
} from '../../dist/server/prometheusMetrics.js';
|
|
import { createPrometheusMetricsRoute } from '../../dist/server/http/routes/prometheusMetricsRoute.js';
|
|
|
|
const observedAt = '2026-08-08T10:00:00.000Z';
|
|
const snapshot = {
|
|
traffic: {
|
|
gatewayBytes: '9007199254740993',
|
|
proxyBytes: '3000',
|
|
gatewayObservedAt: observedAt,
|
|
proxyObservedAt: observedAt,
|
|
},
|
|
devices: [{
|
|
id: 'dev_0011223344556677',
|
|
alias: 'ТВ "Зал"\\основной\nэкран',
|
|
hostname: 'tv.local',
|
|
ip: '192.168.50.7',
|
|
mac: '00:11:22:33:44:55',
|
|
uploadBytes: '9007199254740993',
|
|
downloadBytes: '100',
|
|
trafficObservedAt: observedAt,
|
|
proxyUploadBytes: '0',
|
|
proxyDownloadBytes: '0',
|
|
proxyTrafficObservedAt: null,
|
|
}],
|
|
directTraffic: {
|
|
observedAt,
|
|
uploadBytes: '99',
|
|
downloadBytes: '999',
|
|
series: [{
|
|
deviceId: 'dev_0011223344556677',
|
|
uploadBytes: '77',
|
|
downloadBytes: '888',
|
|
}],
|
|
},
|
|
domainTraffic: {
|
|
observedAt,
|
|
source: {
|
|
error: null,
|
|
mode: 'shadow',
|
|
writer: 'snapshot',
|
|
activeConnections: 4,
|
|
native: {
|
|
state: 'degraded',
|
|
epoch: 'epoch-1',
|
|
sequence: 7,
|
|
observedAt,
|
|
active: 5,
|
|
unattributedUploadBytes: '11',
|
|
unattributedDownloadBytes: '22',
|
|
},
|
|
shadow: {
|
|
activeDifference: 1,
|
|
uploadDifferenceBytes: '-30',
|
|
downloadDifferenceBytes: '40',
|
|
routeMismatches: 2,
|
|
deviceMismatches: 3,
|
|
},
|
|
},
|
|
overflowConnections: '2',
|
|
attributionEvents: {
|
|
unresolved_host: '3',
|
|
unknown_device: '4',
|
|
unsupported_source: '5',
|
|
},
|
|
tracked: [{
|
|
source: 'gateway',
|
|
outbound: 'vpn',
|
|
uploadBytes: '77',
|
|
downloadBytes: '777',
|
|
}],
|
|
routes: [{
|
|
deviceId: 'dev_0011223344556677',
|
|
source: 'gateway',
|
|
outbound: 'vpn',
|
|
uploadBytes: '55',
|
|
downloadBytes: '666',
|
|
}],
|
|
series: [{
|
|
deviceId: 'dev_0011223344556677',
|
|
domain: 'chatgpt.com',
|
|
service: 'OpenAI / ChatGPT',
|
|
source: 'proxy',
|
|
uploadBytes: '12',
|
|
downloadBytes: '345',
|
|
}],
|
|
},
|
|
};
|
|
|
|
test('Prometheus exposition keeps exact counters, stable identity and escaped names', () => {
|
|
const output = renderPrometheusMetrics(snapshot);
|
|
|
|
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
|
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
|
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
|
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
|
assert.doesNotMatch(output, /00:11:22:33:44:55/);
|
|
assert.match(output, /harbor_device_traffic_last_observed_timestamp_seconds\{device_id="dev_0011223344556677",source="gateway"\} 1786183200/);
|
|
assert.match(output, /# TYPE harbor_direct_ipv4_packet_bytes_total counter/);
|
|
assert.match(output, /harbor_direct_ipv4_packet_bytes_total\{direction="download"\} 999/);
|
|
assert.match(output, /# TYPE harbor_device_direct_ipv4_packet_bytes_total counter/);
|
|
assert.match(output, /harbor_device_direct_ipv4_packet_bytes_total\{device_id="dev_0011223344556677",direction="download"\} 888/);
|
|
assert.match(output, /harbor_direct_ipv4_packet_last_observed_timestamp_seconds 1786183200/);
|
|
assert.match(output, /# TYPE harbor_singbox_tracked_bytes_total counter/);
|
|
assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/);
|
|
assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/);
|
|
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/);
|
|
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
|
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
|
|
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
|
|
assert.match(output, /# TYPE harbor_domain_traffic_attribution_events_total counter/);
|
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
|
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
|
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
|
|
assert.match(output, /harbor_traffic_collector_info\{mode="shadow",writer="snapshot"\} 1/);
|
|
assert.match(output, /harbor_traffic_collector_state\{state="degraded"\} 1/);
|
|
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="download"\} 22/);
|
|
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="upload"\} 11/);
|
|
assert.match(output, /harbor_traffic_shadow_active_difference 1/);
|
|
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="download"\} 40/);
|
|
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="upload"\} -30/);
|
|
assert.match(output, /harbor_traffic_shadow_route_mismatches 2/);
|
|
assert.match(output, /harbor_traffic_shadow_device_mismatches 3/);
|
|
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
|
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
|
|
assert.equal(output.endsWith('\n'), true);
|
|
});
|
|
|
|
test('Prometheus response uses the negotiated legacy text contract without mutating the snapshot', () => {
|
|
const before = structuredClone(snapshot);
|
|
const response = {
|
|
writeHead(status, headers) {
|
|
this.status = status;
|
|
this.headers = headers;
|
|
},
|
|
end(body) {
|
|
this.body = body;
|
|
},
|
|
};
|
|
|
|
sendPrometheusMetrics(response, snapshot);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers['content-type'], 'text/plain; version=0.0.4; charset=utf-8');
|
|
assert.match(response.body, /harbor_device_info/);
|
|
assert.deepEqual(snapshot, before);
|
|
});
|
|
|
|
test('legacy combined Gateway snapshots keep existing metrics without collector diagnostics', () => {
|
|
const legacy = structuredClone(snapshot);
|
|
legacy.domainTraffic.source = { error: null, activeConnections: 0 };
|
|
const output = renderPrometheusMetrics(legacy);
|
|
|
|
assert.match(output, /harbor_singbox_tracked_bytes_total/);
|
|
assert.doesNotMatch(output, /harbor_traffic_collector_info/);
|
|
});
|
|
|
|
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
|
|
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
|
|
assert.throws(
|
|
() => renderPrometheusMetrics(invalid),
|
|
/Invalid Prometheus counter/,
|
|
);
|
|
assert.throws(
|
|
() => sendPrometheusMetrics({ writeHead() { throw new Error('headers sent'); } }, invalid),
|
|
/Invalid Prometheus counter/,
|
|
);
|
|
const invalidRoute = structuredClone(snapshot);
|
|
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
|
|
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/);
|
|
const invalidCollector = structuredClone(snapshot);
|
|
invalidCollector.domainTraffic.source.mode = 'future';
|
|
assert.throws(() => renderPrometheusMetrics(invalidCollector), /Invalid traffic collector labels/);
|
|
const invalidShadow = structuredClone(snapshot);
|
|
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
|
|
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
|
|
});
|
|
|
|
function routeResponse() {
|
|
return {
|
|
writeHead(status, headers) {
|
|
this.status = status;
|
|
this.headers = headers;
|
|
},
|
|
end(body) {
|
|
this.body = body;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('Prometheus route reads one current snapshot and preserves the exact text contract', async () => {
|
|
let reads = 0;
|
|
let refreshes = 0;
|
|
const route = createPrometheusMetricsRoute({
|
|
deviceInventory: {
|
|
metricsSnapshot: () => {
|
|
reads += 1;
|
|
return snapshot;
|
|
},
|
|
refresh: () => { refreshes += 1; },
|
|
},
|
|
});
|
|
const res = routeResponse();
|
|
assert.equal(await route.handle({ method: 'GET', url: '/metrics?source=prometheus' }, res), true);
|
|
assert.equal(reads, 1);
|
|
assert.equal(refreshes, 0);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers['content-type'], 'text/plain; version=0.0.4; charset=utf-8');
|
|
assert.equal(res.body, renderPrometheusMetrics(snapshot));
|
|
assert.equal(res.body.endsWith('\n'), true);
|
|
});
|
|
|
|
test('Prometheus route preserves path, method and nullable inventory gating', async () => {
|
|
let reads = 0;
|
|
const route = createPrometheusMetricsRoute({
|
|
deviceInventory: { metricsSnapshot: () => { reads += 1; return snapshot; } },
|
|
});
|
|
for (const url of ['/metrics/', '/api/metrics', '/']) {
|
|
assert.equal(await route.handle({ method: 'GET', url }, routeResponse()), false);
|
|
}
|
|
await assert.rejects(
|
|
route.handle({ method: 'POST', url: '/metrics' }, routeResponse()),
|
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
|
);
|
|
assert.equal(reads, 0);
|
|
|
|
const clientRoute = createPrometheusMetricsRoute({ deviceInventory: null });
|
|
await assert.rejects(
|
|
clientRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
test('Prometheus route propagates snapshot and renderer failures unchanged', async () => {
|
|
const snapshotError = new Error('snapshot failed');
|
|
const snapshotRoute = createPrometheusMetricsRoute({
|
|
deviceInventory: { metricsSnapshot: () => { throw snapshotError; } },
|
|
});
|
|
await assert.rejects(
|
|
snapshotRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
|
(error) => error === snapshotError,
|
|
);
|
|
|
|
const rendererRoute = createPrometheusMetricsRoute({
|
|
deviceInventory: {
|
|
metricsSnapshot: () => ({ traffic: { gatewayBytes: 'broken', proxyBytes: '0' } }),
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
rendererRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
|
/Invalid Prometheus counter/,
|
|
);
|
|
});
|
|
|
|
test('Prometheus route is the sole HTTP owner before API and static fallback', () => {
|
|
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
|
const route = readFileSync(
|
|
new URL('../../src/server/http/routes/prometheusMetricsRoute.ts', import.meta.url),
|
|
'utf8',
|
|
);
|
|
assert.match(index, /createPrometheusMetricsRoute\(\{ deviceInventory \}\)/);
|
|
const delegation = index.indexOf('prometheusMetricsRoute.handle(req, res)');
|
|
const apiDispatch = index.indexOf("requestUrl.pathname.startsWith('/api/')");
|
|
const staticFallback = index.indexOf(': serveStatic(req, res)');
|
|
assert.ok(delegation >= 0 && delegation < apiDispatch && apiDispatch < staticFallback);
|
|
assert.doesNotMatch(index, /['"]\/metrics['"]/);
|
|
assert.doesNotMatch(index, /metricsSnapshot\(\)|sendPrometheusMetrics/);
|
|
assert.match(route, /pathname !== '\/metrics'/);
|
|
assert.match(route, /deviceInventory\.metricsSnapshot\(\)/);
|
|
assert.doesNotMatch(route, /\.refresh\(/);
|
|
});
|