Files
multi-simadmin/apps/api/src/application/observability/metrics-service.ts
T
chick 2977f75129 fix: apply security and correctness review findings across both stacks
Legacy panel:
- upstream body reads now carry their own deadline and a 10 MB byte budget;
  a stalled modem can no longer hang /api/status fan-out forever nor OOM
  the proxy (request headers alone had the timeout, bodies had none)
- add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel
  (delete-instance and confirmed-write dialogs) is no longer clickjackable
- only send a JSON content-type when the API console request has a body, so
  payload-less dangerous writes stop failing with 400 and burning the
  one-use confirmation token
- register a form-urlencoded parser (the proxy branch was unreachable) and
  drop the multipart parser that buffered up to 60 MB before rejecting;
  bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body
- remove /api/sms/send from readable paths: GET bypassed the write
  confirmation for a send endpoint
- /api/instances/:id/login maps upstream failures to a stable 502 instead
  of leaking raw error text
- guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request);
  prune dead code (buildClients, cookie expando no-op)

Control plane:
- deleting a notification channel detaches it from rules instead of leaving
  dangling ids that made every referencing rule unreadable and silently
  dropped future notifications; rule reads tolerate unknown ids
- startup sweep resets notification_queue rows stranded in 'sending' by a
  crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on
  the retention timer
- /api/v1/metrics no longer emits operator-assigned node names on the
  session-free scrape; login limiter map is bounded and pruned; Secure
  cookie honors the gateway-declared x-forwarded-proto
- webhook delivery sets redirect: manual (signed payloads are not replayed)
- SMTP envelope sender is validated against CR/LF smuggling
- scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with
  a clear reason instead of burning every retry; contract narrowed to the
  pinned baseline
2026-09-07 01:57:47 +08:00

245 lines
8.3 KiB
TypeScript

import type Database from 'better-sqlite3';
import type { ConnectionSummary } from '../system/connection-log-service.js';
import type { DeviceIdentity } from '../identity/device-identity-service.js';
/**
* Prometheus exposition for the fused control plane.
*
* The scrape must never reach a device: every value here comes from rows the console already
* writes (instances, probes, queues, jobs, identity records). A monitoring system polling every
* 15 seconds should not be able to wake a modem.
*/
const PREFIX = 'multi_simadmin';
export interface MetricsSource {
readonly connections: { summarize(): readonly ConnectionSummary[] };
readonly identities: { list(): readonly DeviceIdentity[] };
}
export interface MetricsOptions {
readonly db: Database.Database;
readonly version: string;
readonly sources: MetricsSource;
readonly now?: () => Date;
}
interface CountRow {
readonly status: string;
readonly count: number;
}
/** Label values are operator- or device-controlled, so they are escaped the Prometheus way. */
function label(value: string): string {
return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"').replace(/\n/gu, '\\n');
}
const number = (value: unknown): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
function sample(name: string, help: string, type: 'gauge' | 'counter', lines: readonly string[]) {
return [`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`, ...lines];
}
export class MetricsService {
readonly #db: Database.Database;
readonly #version: string;
readonly #sources: MetricsSource;
readonly #clock: () => Date;
constructor(options: MetricsOptions) {
this.#db = options.db;
this.#version = options.version;
this.#sources = options.sources;
this.#clock = options.now ?? (() => new Date());
}
render(): string {
const nodes = this.#db
.prepare('SELECT id,name,enabled FROM instances ORDER BY name, id')
.all() as Array<{
id: string;
name: string;
enabled: number;
}>;
const ids = new Set(nodes.map((node) => node.id));
const lines: string[] = [];
lines.push(
...sample(`${PREFIX}_build_info`, 'Build identity of the control plane.', 'gauge', [
`${PREFIX}_build_info{version="${label(this.#version)}"} 1`,
]),
...sample(`${PREFIX}_scrape_timestamp_seconds`, 'Wall-clock time of this scrape.', 'gauge', [
`${PREFIX}_scrape_timestamp_seconds ${Math.floor(this.#clock().getTime() / 1000)}`,
]),
);
// Operator-assigned names can leak hostnames/locations to unauthenticated
// scrapers; the series intentionally stays opaque to node ids.
const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
lines.push(
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
`${PREFIX}_nodes_total ${nodes.length}`,
]),
...sample(
`${PREFIX}_node_enabled`,
'Whether the node is enabled (1) or paused (0).',
'gauge',
nodes.map(
(node) => `${PREFIX}_node_enabled{node="${label(node.id)}"} ${node.enabled ? 1 : 0}`,
),
),
);
const probes = this.#sources.connections.summarize();
lines.push(
...sample(
`${PREFIX}_node_probe_total`,
'Health probes recorded in the retained window, by outcome.',
'counter',
probes.flatMap((probe) => [
`${PREFIX}_node_probe_total{node="${label(probe.instanceId)}",outcome="success"} ${probe.success}`,
`${PREFIX}_node_probe_total{node="${label(probe.instanceId)}",outcome="failed"} ${probe.failed}`,
]),
),
...sample(
`${PREFIX}_node_availability_ratio`,
'Share of successful probes in the retained window.',
'gauge',
probes.map(
(probe) =>
`${PREFIX}_node_availability_ratio{node="${label(probe.instanceId)}"} ${(probe.availabilityPercent / 100).toFixed(3)}`,
),
),
...sample(
`${PREFIX}_node_probe_duration_ms`,
'Average probe latency in the retained window.',
'gauge',
probes.map(
(probe) =>
`${PREFIX}_node_probe_duration_ms{node="${label(probe.instanceId)}"} ${number(probe.averageDurationMs)}`,
),
),
);
const identities = this.#sources.identities.list();
const pending = identities.filter((identity) => identity.status === 'pending');
lines.push(
...sample(
`${PREFIX}_node_identity_tracked`,
'Whether the node has ever reported its hardware identity.',
'gauge',
identities.map(
(identity) => `${PREFIX}_node_identity_tracked{node="${label(identity.instanceId)}"} 1`,
),
),
...sample(
`${PREFIX}_node_identity_pending`,
'1 while the identity guard holds the node and control actions are refused.',
'gauge',
identities.map(
(identity) =>
`${PREFIX}_node_identity_pending{node="${label(identity.instanceId)}"} ${identity.status === 'pending' ? 1 : 0}`,
),
),
...sample(
`${PREFIX}_identity_pending_total`,
'Nodes waiting for an operator to confirm the device behind them.',
'gauge',
[`${PREFIX}_identity_pending_total ${pending.length}`],
),
);
lines.push(
...this.#statusMetric(
`${PREFIX}_sms_outbox`,
'Queued cross-node SMS sends, by state.',
'SELECT status, COUNT(*) AS count FROM sms_outbox GROUP BY status',
['queued', 'sending', 'sent', 'failed', 'cancelled'],
),
...this.#statusMetric(
`${PREFIX}_notification_queue`,
'Central notification queue, by state.',
'SELECT status, COUNT(*) AS count FROM notification_queue GROUP BY status',
['pending', 'sending', 'succeeded', 'failed', 'cancelled'],
),
...this.#statusMetric(
`${PREFIX}_jobs`,
'Fleet operations, by state.',
'SELECT status, COUNT(*) AS count FROM jobs GROUP BY status',
[
'queued',
'running',
'cancelling',
'succeeded',
'partially-succeeded',
'failed',
'cancelled',
'unknown-result',
],
),
...this.#statusMetric(
`${PREFIX}_scheduled_tasks`,
'Automation tasks, by state.',
`SELECT CASE WHEN enabled=1 THEN 'armed' ELSE 'paused' END AS status,
COUNT(*) AS count FROM scheduled_tasks GROUP BY enabled`,
['armed', 'paused'],
),
);
const groups = this.#count('SELECT COUNT(*) AS count FROM device_groups');
const tags = this.#count('SELECT COUNT(*) AS count FROM tag_registry');
const messages = this.#count('SELECT COUNT(*) AS count FROM sms_messages');
const probed = new Set(probes.map((probe) => probe.instanceId));
const unprobed = [...ids].filter((id) => !probed.has(id)).length;
lines.push(
...sample(`${PREFIX}_device_groups`, 'Node groups.', 'gauge', [
`${PREFIX}_device_groups ${groups}`,
]),
...sample(`${PREFIX}_device_tags`, 'Labels in the tag registry.', 'gauge', [
`${PREFIX}_device_tags ${tags}`,
]),
...sample(
`${PREFIX}_sms_messages`,
'SMS rows synchronised into the central message centre.',
'gauge',
[`${PREFIX}_sms_messages ${messages}`],
),
...sample(
`${PREFIX}_node_unprobed`,
'Registered nodes without a health probe in the retained window.',
'gauge',
[`${PREFIX}_node_unprobed ${unprobed}`],
),
);
return `${lines.join('\n')}\n`;
}
#count(sql: string): number {
const row = this.#db.prepare(sql).get() as { count?: number } | undefined;
return number(row?.count ?? 0);
}
/** Every state is emitted, including the empty ones, so a query never sees a series vanish. */
#statusMetric(
name: string,
help: string,
sql: string,
states: readonly string[],
): readonly string[] {
const rows = this.#db.prepare(sql).all() as readonly CountRow[];
const counts = new Map(rows.map((row) => [String(row.status), number(row.count)]));
return sample(
name,
help,
'gauge',
states.map((state) => `${name}{status="${label(state)}"} ${counts.get(state) ?? 0}`),
);
}
}