feat(api): absorb the Hub control plane into the local instance model

Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f11877f13e
commit f9186bd851
97 changed files with 18646 additions and 104 deletions
@@ -0,0 +1,449 @@
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';
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;
}
/**
* 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');
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,
);
}
}