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 { 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 = {}; 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, timeoutMs: number, ): Promise { let timer: ReturnType | undefined; const guard = new Promise((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>, ): 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; 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>, context: DeviceActionContext, ): Promise { 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 => { const headers: Record = {}; 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 { const values: Record = {}; 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, ): { path: string; query: string; body: Readonly> | undefined } { let path = action.path; const queryParts: string[] = []; const body: Record = { ...(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, 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, ); } }