- 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.
459 lines
16 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|