Files
multi-simadmin/apps/api/src/application/instances/device-action-service.ts
T
chick 961eb928c4 feat(api): add device identity guard and Prometheus metrics
- Persist device identity observations and expose fleet identity summaries.

- Block device control actions when identity verification fails.

- Add metrics collection and a Prometheus scrape endpoint.
2026-09-07 00:44:51 +08:00

459 lines
16 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { InstanceService } from './instance-service.js';
import {
DEVICE_ACTIONS,
findDeviceAction,
type DeviceActionDefinition,
type DeviceActionField,
type DeviceActionRisk,
} from './device-action-catalog.js';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
import type {
InstanceSessionStore,
UpstreamResponse,
UpstreamSessionClientOptions,
} from '../connections/upstream-session-client.js';
export type DeviceActionErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_FAILED'
| 'SESSION_INVALID'
| 'UPSTREAM_FAILED'
| 'NOT_DISPATCHED'
| 'IDENTITY_UNCONFIRMED';
export class DeviceActionError extends Error {
constructor(
readonly code: DeviceActionErrorCode,
readonly fieldId?: string,
) {
super(code);
this.name = 'DeviceActionError';
}
}
export interface DeviceActionDescriptor {
readonly id: string;
readonly module: DeviceActionDefinition['module'];
readonly title: string;
readonly description: string;
readonly risk: DeviceActionRisk;
readonly method: DeviceActionDefinition['method'];
readonly fields: readonly DeviceActionField[];
}
export interface DeviceActionResult {
readonly actionId: string;
readonly title: string;
readonly status: number;
readonly ok: boolean;
readonly data: unknown;
readonly message: string | null;
readonly durationMs: number;
}
export interface DeviceActionContext {
readonly actor: string;
readonly requestId: string;
/** R2 and R3 actions only dispatch when the caller repeats an explicit confirmation. */
readonly confirm?: boolean;
}
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
const SECRET_KEY =
/(password|passwd|secret|token|cookie|authorization|apikey|api_key|privatekey|private_key|session|confirmation_code)/iu;
const MAX_RESPONSE_BYTES = 32_768;
const MAX_DEPTH = 5;
const MAX_ARRAY_ENTRIES = 64;
const MAX_STRING_LENGTH = 512;
const DEFAULT_TIMEOUT_MS = 15_000;
const TIMEOUT_SENTINEL: UpstreamResponse = { status: 0, headers: {}, body: '' };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function sanitize(value: unknown, depth = 0): unknown {
if (value === null) return null;
switch (typeof value) {
case 'string':
return value.length > MAX_STRING_LENGTH
? `${value.slice(0, MAX_STRING_LENGTH)}...`
: CONTROL_CHARACTERS.test(value)
? value.replace(CONTROL_CHARACTERS, '')
: value;
case 'number':
return Number.isFinite(value) ? value : null;
case 'boolean':
return value;
default:
break;
}
if (depth >= MAX_DEPTH) return null;
if (Array.isArray(value))
return value.slice(0, MAX_ARRAY_ENTRIES).map((item) => sanitize(item, depth + 1));
if (isRecord(value)) {
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (SECRET_KEY.test(key)) continue;
output[key] = sanitize(entry, depth + 1);
}
return output;
}
return null;
}
async function withTimeout(
operation: Promise<UpstreamResponse>,
timeoutMs: number,
): Promise<UpstreamResponse> {
let timer: ReturnType<typeof setTimeout> | undefined;
const guard = new Promise<UpstreamResponse>((resolve) => {
timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs);
});
try {
return await Promise.race([operation, guard]);
} finally {
if (timer) clearTimeout(timer);
}
}
function validateBoolean(field: DeviceActionField, raw: unknown): boolean {
if (typeof raw !== 'boolean') throw new DeviceActionError('VALIDATION_FAILED', field.id);
return raw;
}
function validateNumber(field: DeviceActionField, raw: unknown): number {
if (typeof raw !== 'number' || !Number.isSafeInteger(raw))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
if (field.min !== undefined && raw < field.min)
throw new DeviceActionError('VALIDATION_FAILED', field.id);
if (field.max !== undefined && raw > field.max)
throw new DeviceActionError('VALIDATION_FAILED', field.id);
return raw;
}
function validateText(field: DeviceActionField, raw: unknown): string {
if (typeof raw !== 'string') throw new DeviceActionError('VALIDATION_FAILED', field.id);
const value = raw.trim();
if (value.length === 0) {
if (field.required) throw new DeviceActionError('VALIDATION_FAILED', field.id);
return '';
}
if (value.length > (field.maxLength ?? 128))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
if (CONTROL_CHARACTERS.test(value)) throw new DeviceActionError('VALIDATION_FAILED', field.id);
if (field.pattern !== undefined && !new RegExp(field.pattern, 'u').test(value))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
return value;
}
function validateChoice(field: DeviceActionField, raw: unknown): string {
const value = validateText(field, raw);
if (!value) return '';
if (!(field.choices ?? []).some((choice) => choice.value === value))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
return value;
}
function validateList(field: DeviceActionField, raw: unknown): string[] {
if (!Array.isArray(raw) || raw.length > 128)
throw new DeviceActionError('VALIDATION_FAILED', field.id);
const output: string[] = [];
for (const item of raw) {
if (typeof item !== 'string' || item.length > 64 || CONTROL_CHARACTERS.test(item))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
const value = item.trim();
if (value) output.push(value);
}
if (field.required && output.length === 0)
throw new DeviceActionError('VALIDATION_FAILED', field.id);
return output;
}
/**
* The audit reader only accepts fully redacted summaries, so a submitted value is never stored:
* the record says which fields were supplied and leaves the content out.
*/
function summarize(
action: DeviceActionDefinition,
values: Readonly<Record<string, unknown>>,
): readonly { fieldId: string; displayValue: string; redacted: true }[] {
return (action.fields ?? [])
.filter((field) => values[field.id] !== undefined)
.map((field) => ({ fieldId: field.id, displayValue: '[REDACTED]', redacted: true as const }));
}
export interface DeviceActionServiceOptions {
readonly instances: InstanceService;
readonly sessions: InstanceSessionStore;
readonly request: UpstreamSessionClientOptions['request'];
readonly db: SqliteDatabase;
readonly ensureSession?: (instanceId: string, origin: string, force?: boolean) => Promise<void>;
readonly now?: () => Date;
readonly id?: () => string;
/**
* The identity guard. A node whose reported hardware stopped matching its record, or whose
* identity is claimed by another record, is not safe to control: the command would land on a
* device the operator is not looking at. Unbinding stays available because it is the way out.
*/
readonly identities?: { isBlocked(instanceId: string): boolean };
}
/**
* Runs an allowlisted device mutation on behalf of the console. Every dispatch is validated
* against the catalog, audited before the response leaves the process, and re-authorized once
* when the device session has expired.
*/
export class DeviceActionService {
private readonly now: () => Date;
private readonly id: () => string;
constructor(private readonly options: DeviceActionServiceOptions) {
this.now = options.now ?? (() => new Date());
this.id = options.id ?? randomUUID;
}
list(): readonly DeviceActionDescriptor[] {
return DEVICE_ACTIONS.map((action) =>
Object.freeze({
id: action.id,
module: action.module,
title: action.title,
description: action.description,
risk: action.risk,
method: action.method,
fields: Object.freeze(
[...(action.fields ?? [])].map((field) => Object.freeze({ ...field })),
),
}),
);
}
async execute(
instanceId: string,
actionId: string,
params: Readonly<Record<string, unknown>>,
context: DeviceActionContext,
): Promise<DeviceActionResult> {
const action = findDeviceAction(actionId);
if (!action) throw new DeviceActionError('NOT_FOUND');
if (action.risk !== 'R1' && context.confirm !== true)
throw new DeviceActionError('VALIDATION_FAILED', 'confirm');
const instance = await this.options.instances.get(instanceId);
if (!instance) throw new DeviceActionError('NOT_FOUND');
const session = this.options.sessions.sessionFor(instanceId);
if (session && session.origin !== instance.origin)
throw new DeviceActionError('SESSION_INVALID');
if (action.id !== 'hub.unbind' && this.options.identities?.isBlocked(instanceId))
throw new DeviceActionError('IDENTITY_UNCONFIRMED');
const values = this.#validate(action, params);
const { path, query, body } = this.#assemble(action, values);
if (!session && this.options.ensureSession) {
try {
await this.options.ensureSession(instanceId, instance.origin);
} catch {
// Passwordless devices accept anonymous mutations; otherwise the probe reports below.
}
}
const timeoutMs = action.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const target = `${instance.origin}/api${path}${query}`;
const send = async (cookie: string | undefined): Promise<UpstreamResponse> => {
const headers: Record<string, string> = {};
if (body === undefined) headers.accept = 'application/json';
else {
headers.accept = 'application/json';
headers['content-type'] = 'application/json';
}
if (cookie) headers.cookie = cookie;
return withTimeout(
this.options.request({
url: target,
method: action.method,
headers,
deviceAction: { body },
}),
timeoutMs,
);
};
const startedAt = this.now().getTime();
let token = this.options.sessions.sessionFor(instanceId)?.cookie;
let response: UpstreamResponse;
try {
response = await send(token);
if ((response.status === 401 || response.status === 403) && this.options.ensureSession) {
try {
await this.options.ensureSession(instanceId, instance.origin, true);
} catch {
// Keep the device answer; the result below reports the failure.
}
const refreshed = this.options.sessions.sessionFor(instanceId)?.cookie;
if (refreshed && refreshed !== token) {
token = refreshed;
response = await send(token);
}
}
} catch {
// The pinned transport refused or could not reach the device: nothing left this process.
const durationMs = Math.max(0, this.now().getTime() - startedAt);
this.#audit(action, instanceId, context, values, 0, false, durationMs);
throw new DeviceActionError('UPSTREAM_FAILED');
}
const durationMs = Math.max(0, this.now().getTime() - startedAt);
const parsed = this.#interpret(response);
this.#audit(action, instanceId, context, values, response.status, parsed.ok, durationMs);
return {
actionId: action.id,
title: action.title,
status: response.status,
ok: parsed.ok,
data: parsed.data,
message: parsed.message,
durationMs,
};
}
#validate(
action: DeviceActionDefinition,
params: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const field of action.fields ?? []) {
const raw = params[field.id];
if (raw === undefined || raw === null) {
if (field.required) throw new DeviceActionError('VALIDATION_FAILED', field.id);
continue;
}
switch (field.kind) {
case 'boolean':
values[field.id] = validateBoolean(field, raw);
break;
case 'number':
values[field.id] = validateNumber(field, raw);
break;
case 'string':
case 'secret': {
const text = validateText(field, raw);
if (text) values[field.id] = text;
break;
}
case 'choice': {
const choice = validateChoice(field, raw);
if (choice) values[field.id] = choice;
break;
}
case 'string-list': {
const list = validateList(field, raw);
if (list.length > 0 || field.required) values[field.id] = list;
break;
}
default:
throw new DeviceActionError('VALIDATION_FAILED', field.id);
}
}
const declared = new Set((action.fields ?? []).map((field) => field.id));
for (const key of Object.keys(params))
if (!declared.has(key)) throw new DeviceActionError('VALIDATION_FAILED', key);
return values;
}
#assemble(
action: DeviceActionDefinition,
values: Record<string, unknown>,
): { path: string; query: string; body: Readonly<Record<string, unknown>> | undefined } {
let path = action.path;
const queryParts: string[] = [];
const body: Record<string, unknown> = { ...(action.fixed ?? {}) };
for (const field of action.fields ?? []) {
const value = values[field.id];
if (value === undefined) continue;
const place = field.in ?? 'body';
if (place === 'path') {
if (!/^[A-Za-z0-9_.\-]{1,128}$/u.test(String(value)))
throw new DeviceActionError('VALIDATION_FAILED', field.id);
const token = `{${field.id}}`;
if (!path.includes(token)) throw new DeviceActionError('VALIDATION_FAILED', field.id);
path = path.replace(token, String(value));
} else if (place === 'query') {
const rendered = Array.isArray(value)
? value.map((item) => encodeURIComponent(item)).join(',')
: encodeURIComponent(String(value));
queryParts.push(`${field.id}=${rendered}`);
} else body[field.id] = value;
}
if (path.includes('{')) throw new DeviceActionError('VALIDATION_FAILED');
const query = queryParts.length ? `?${queryParts.join('&')}` : '';
const empty = action.emptyJsonObject === true;
return {
path,
query,
body: Object.keys(body).length || empty ? body : undefined,
};
}
#interpret(response: UpstreamResponse): {
ok: boolean;
data: unknown;
message: string | null;
} {
if (response.status === 0) return { ok: false, data: null, message: '设备未在限定时间内响应' };
if (Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES)
return { ok: false, data: null, message: '设备响应超出长度限制' };
let root: unknown = null;
if (response.body.trim().length > 0) {
try {
root = JSON.parse(response.body);
} catch {
root = null;
}
}
const record = isRecord(root) ? root : undefined;
const status = typeof record?.status === 'string' ? record.status : undefined;
const ok =
response.status >= 200 &&
response.status < 300 &&
(status === undefined || status === 'success' || status === 'ok');
const rawMessage =
(typeof record?.message === 'string' && record.message) ||
(typeof record?.msg === 'string' && record.msg) ||
null;
const message = rawMessage ? rawMessage.slice(0, MAX_STRING_LENGTH) : null;
return { ok, data: sanitize(record?.data ?? record ?? null), message };
}
#audit(
action: DeviceActionDefinition,
instanceId: string,
context: DeviceActionContext,
values: Record<string, unknown>,
status: number,
ok: boolean,
durationMs: number,
): void {
const created_at = this.now().toISOString();
this.options.db
.prepare(
`INSERT INTO audit_events
(id,instance_id,job_id,actor,operation_id,risk_level,request_id,parameters_summary_json,body_digest,result_code,duration_ms,created_at)
VALUES (?,?,NULL,?,?,?,?,?,?,?,?,?)`,
)
.run(
this.id(),
instanceId,
context.actor,
action.id,
action.risk,
context.requestId,
JSON.stringify(summarize(action, values)),
null,
ok ? 'succeeded' : 'failed',
durationMs,
created_at,
);
}
}