Integrate upstream-compatible read-only MeshCentral transport
This commit is contained in:
@@ -322,20 +322,27 @@ export interface UserInfoResponse {
|
||||
export interface MeshesResponse {
|
||||
action: MeshCtrlAction.Meshes;
|
||||
meshes?: Array<{
|
||||
id: string;
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
desc?: string;
|
||||
}>;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export interface NodesResponse {
|
||||
action: MeshCtrlAction.Nodes;
|
||||
nodes?: Array<{
|
||||
id: string;
|
||||
nodes?: Record<string, Array<{
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
meshid: string;
|
||||
online: boolean;
|
||||
meshid?: string;
|
||||
online?: boolean;
|
||||
conn?: number;
|
||||
osdesc?: string;
|
||||
ip?: string[];
|
||||
agent?: unknown;
|
||||
device?: {
|
||||
os?: string;
|
||||
uptime?: number;
|
||||
@@ -343,7 +350,7 @@ export interface NodesResponse {
|
||||
ip?: string[];
|
||||
agentver?: string;
|
||||
};
|
||||
}>;
|
||||
}>>;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
@@ -398,19 +405,23 @@ const responseSchemas: Record<string, z.ZodTypeAny> = {
|
||||
...responseBase,
|
||||
action: z.literal(MeshCtrlAction.Meshes),
|
||||
meshes: z.array(z.object({
|
||||
id: z.string(), name: z.string(), description: z.string().optional(),
|
||||
id: z.string().optional(), _id: z.string().optional(), name: z.string(),
|
||||
description: z.string().optional(), desc: z.string().optional(),
|
||||
}).passthrough()).optional(),
|
||||
}).passthrough(),
|
||||
[MeshCtrlAction.Nodes]: z.object({
|
||||
...responseBase,
|
||||
action: z.literal(MeshCtrlAction.Nodes),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string(), name: z.string(), meshid: z.string(), online: z.boolean(),
|
||||
nodes: z.record(z.array(z.object({
|
||||
id: z.string().optional(), _id: z.string().optional(), name: z.string(),
|
||||
meshid: z.string().optional(), online: z.boolean().optional(),
|
||||
conn: z.number().optional(), osdesc: z.string().optional(),
|
||||
ip: z.array(z.string()).optional(), agent: z.unknown().optional(),
|
||||
device: z.object({
|
||||
os: z.string().optional(), uptime: z.number().optional(), lastseen: z.number().optional(),
|
||||
ip: z.array(z.string()).optional(), agentver: z.string().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough()).optional(),
|
||||
}).passthrough())).optional(),
|
||||
}).passthrough(),
|
||||
[MeshCtrlAction.DeviceInfo]: z.object({
|
||||
...responseBase,
|
||||
@@ -489,7 +500,10 @@ export function parseResponse(data: string, maxSize: number = MAX_RESPONSE_SIZE)
|
||||
|
||||
const result = z.object({
|
||||
action: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
// Unsolicited MeshCentral session/event messages may use different id
|
||||
// shapes. The request-specific schemas validate fields on matched
|
||||
// responses; the generic envelope must not reject bootstrap traffic.
|
||||
id: z.unknown().optional(),
|
||||
err: z.string().optional(),
|
||||
}).passthrough().safeParse(parsed);
|
||||
if (!result.success) {
|
||||
@@ -674,6 +688,13 @@ export class MeshCtrlClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private requestSequence = 0;
|
||||
private connectionReadyAt = 0;
|
||||
private pendingRequests = new Map<string, {
|
||||
action: string;
|
||||
resolve: (response: MeshCtrlResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
// Concurrency and rate limiting
|
||||
private readonly concurrencyLimiter: ConcurrencyLimiter;
|
||||
@@ -741,24 +762,70 @@ export class MeshCtrlClient {
|
||||
|
||||
// Setup WebSocket close handler with diagnostics
|
||||
this.ws.on('close', (code: number, reason: Buffer) => {
|
||||
this.logger.debug('MeshCentral socket closed', { code, reasonBytes: reason.length });
|
||||
const reasonText = reason.toString();
|
||||
this.logger.warn(`WebSocket close event: code=${code}, reason="${reasonText}", url=${wsUrl}`);
|
||||
|
||||
if (this.connected) {
|
||||
this.logger.error('WebSocket closed while still marked as connected');
|
||||
this._cleanup();
|
||||
this.connected = false;
|
||||
for (const [requestId, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('MeshCentral connection closed'));
|
||||
this.pendingRequests.delete(requestId);
|
||||
}
|
||||
this.ws = null;
|
||||
});
|
||||
|
||||
// Setup WebSocket error handler with diagnostics
|
||||
this.ws.on('error', (error: Error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'unknown error';
|
||||
this.logger.error(`WebSocket error event: ${errorMessage}, url=${wsUrl}`);
|
||||
this.logger.debug('MeshCentral socket error state', {
|
||||
connected: this.connected,
|
||||
hasSocket: this.ws !== null,
|
||||
});
|
||||
});
|
||||
const handleMessage = (data: Buffer | string): void => {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(Buffer.isBuffer(data) ? data.toString() : data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof message !== 'object' || message === null) return;
|
||||
const response = message as { action?: unknown; responseid?: unknown };
|
||||
if (typeof response.action !== 'string') return;
|
||||
const responseId = typeof response.responseid === 'string' ? response.responseid : undefined;
|
||||
const pending = responseId ? this.pendingRequests.get(responseId) : undefined;
|
||||
// Legacy `meshes` replies omit responseid. They are safe to correlate
|
||||
// only while this client has exactly one pending meshes request.
|
||||
const legacyMeshes = response.action === MeshCtrlAction.Meshes
|
||||
? [...this.pendingRequests.entries()].filter(([, item]) => item.action === MeshCtrlAction.Meshes)
|
||||
: [];
|
||||
const target = pending ?? (legacyMeshes.length === 1 ? legacyMeshes[0][1] : undefined);
|
||||
const targetId = pending ? responseId : (legacyMeshes.length === 1 ? legacyMeshes[0][0] : undefined);
|
||||
if (!target || !targetId) return;
|
||||
|
||||
try {
|
||||
const parsedResponse = parseResponse(Buffer.isBuffer(data) ? data.toString() : data);
|
||||
const validated = validateResponseForAction(parsedResponse, target.action);
|
||||
clearTimeout(target.timeout);
|
||||
this.pendingRequests.delete(targetId);
|
||||
if (isErrorResponse(validated)) target.reject(new Error('MeshCentral request failed'));
|
||||
else target.resolve(validated);
|
||||
} catch (error) {
|
||||
this.logger.error('MeshCentral dispatcher failed', {
|
||||
action: response.action,
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
});
|
||||
clearTimeout(target.timeout);
|
||||
this.pendingRequests.delete(targetId);
|
||||
target.reject(error instanceof Error ? error : new Error('Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.connected = false;
|
||||
this._cleanup();
|
||||
reject(new Error(`Connection timed out after ${this.timeoutMs}ms`));
|
||||
}, this.timeoutMs);
|
||||
@@ -766,6 +833,10 @@ export class MeshCtrlClient {
|
||||
this.ws!.once('open', () => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = true;
|
||||
// MeshCentral emits serverinfo/userinfo bootstrap traffic immediately
|
||||
// after upgrade. The upstream meshctrl client waits briefly before
|
||||
// its first command; mirror that bounded settling window here.
|
||||
this.connectionReadyAt = Date.now() + 750;
|
||||
|
||||
// MeshCentral authenticates from the x-meshauth header during the
|
||||
// WebSocket upgrade; the first application message is a request,
|
||||
@@ -773,6 +844,12 @@ export class MeshCtrlClient {
|
||||
// Log successful connection
|
||||
this.logger.audit('CONNECTION_ESTABLISHED - SUCCESS');
|
||||
|
||||
// Match meshctrl.js: install the application dispatcher only after
|
||||
// the WebSocket upgrade has completed. Some MeshCentral deployments
|
||||
// close sessions when an application listener is active during the
|
||||
// upgrade/bootstrap transition.
|
||||
this.ws?.on('message', handleMessage);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -886,67 +963,33 @@ export class MeshCtrlClient {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this._cleanup();
|
||||
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
|
||||
}, this.timeoutMs);
|
||||
|
||||
const requestId = request.responseid ?? `openclaw-${Date.now()}-${this.requestSequence++}`;
|
||||
const finalRequest = { ...request, responseid: requestId };
|
||||
const sendDelay = Math.max(0, this.connectionReadyAt - Date.now());
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
|
||||
}, this.timeoutMs);
|
||||
this.pendingRequests.set(requestId, { action: request.action, resolve, reject, timeout });
|
||||
|
||||
// Send request
|
||||
try {
|
||||
if (this.ws) {
|
||||
this.ws.send(JSON.stringify(finalRequest));
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up response listener with bounded size validation
|
||||
const handleResponse = (data: Buffer | string): void => {
|
||||
// Install the pending request before sending. MeshCentral may answer
|
||||
// immediately once its bootstrap phase has settled.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Convert to string if buffer
|
||||
const dataString = Buffer.isBuffer(data) ? data.toString() : data;
|
||||
|
||||
// Validate response size before parsing
|
||||
if (dataString.length > MAX_RESPONSE_SIZE) {
|
||||
if (this.ws && this.connected) {
|
||||
this.logger.debug('MeshCentral request sent', { action: request.action });
|
||||
this.ws.send(JSON.stringify(finalRequest));
|
||||
} else {
|
||||
this.pendingRequests.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
this._cleanup();
|
||||
reject(new Error(`Response size ${dataString.length} bytes exceeds maximum ${MAX_RESPONSE_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = validateResponseForAction(
|
||||
parseResponse(dataString, MAX_RESPONSE_SIZE),
|
||||
request.action,
|
||||
);
|
||||
|
||||
// Match response to request ID
|
||||
if (response.responseid === requestId) {
|
||||
// Remove listener after matching
|
||||
this.ws?.removeListener('message', handleResponse);
|
||||
|
||||
if (isErrorResponse(response)) {
|
||||
reject(new Error('MeshCentral request failed'));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
reject(new Error('Not connected'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
this._cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Unknown error'));
|
||||
reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
|
||||
}
|
||||
};
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.on('message', handleResponse);
|
||||
}
|
||||
}, sendDelay);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user