Files
meshcentral-openclaw-bridge/implementation/src/meshctrl-client.ts
T

1061 lines
33 KiB
TypeScript

/**
* MeshCentral protocol client using the official meshctrl.js WebSocket interface.
*
* Implements typed framing forMeshCtrl actions and responses.
* Uses secure TLS validation (no rejectUnauthorized: false).
*
* @see RESEARCH_INTEGRATION.md for protocol analysis
*/
import { Logger, getGlobalLogger } from './logger';
// Token bucket rate limiter and concurrency limiter are defined below.
/** Safe default values for rate limiting and concurrency controls. */
export const RATE_LIMIT_DEFAULTS = {
/** Maximum tokens in bucket (burst capacity) */
burstCapacity: 5,
/** Maximum requests per second */
maxRequestsPerSecond: 10,
/** Maximum time to wait for rate limit (ms) */
rateLimitTimeoutMs: 30_000,
} as const;
export const CONCURRENCY_DEFAULTS = {
/** Maximum concurrent requests */
maxConcurrentRequests: 5,
} as const;
/** Token bucket rate limiter for bounded request concurrency.
* Allows up to `maxTokens` requests per time window, with refill rate of `refillRate` tokens/second.
*/
export class TokenBucket {
private tokens: number;
private lastRefill: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens per second
private readonly timeoutMs: number;
private readonly logger?: Logger;
/**
* Create a TokenBucket with validated safe defaults.
* @param maxTokens - Maximum burst capacity. Must be finite positive integer >= 1 and <= 1000.
* @param refillRate - Tokens added per second. Must be finite positive number >= 0.01 and <= 10000.
* @param timeoutMs - Maximum wait time in ms. Must be finite positive integer >= 100 and <= 300_000.
* @param logger - Optional logger for validation warnings. Uses console.warn if not provided.
*/
constructor(
maxTokens: number,
refillRate: number,
timeoutMs: number,
logger?: Logger,
) {
// Validate maxTokens
if (typeof maxTokens !== 'number' || !isFinite(maxTokens) || !Number.isInteger(maxTokens) || maxTokens < 1) {
throw new Error(
`maxTokens must be a finite positive integer >= 1, got ${maxTokens}`,
);
}
if (maxTokens > 1000) {
const msg = `maxTokens ${maxTokens} exceeds recommended max 1000, using 1000 for safety`;
logger?.warn(msg);
if (!logger) {
// eslint-disable-next-line no-console
console.warn(msg);
}
this.maxTokens = 1000;
} else {
this.maxTokens = maxTokens;
}
// Validate refillRate
if (typeof refillRate !== 'number' || !isFinite(refillRate) || refillRate < 0.01) {
throw new Error(
`refillRate must be a finite positive number >= 0.01, got ${refillRate}`,
);
}
if (refillRate > 10000) {
const msg = `refillRate ${refillRate} exceeds recommended max 10000, using 10000 for safety`;
logger?.warn(msg);
if (!logger) {
// eslint-disable-next-line no-console
console.warn(msg);
}
this.refillRate = 10000;
} else {
this.refillRate = refillRate;
}
// Validate timeoutMs
if (typeof timeoutMs !== 'number' || !isFinite(timeoutMs) || !Number.isInteger(timeoutMs) || timeoutMs < 100) {
throw new Error(
`timeoutMs must be a finite positive integer >= 100ms, got ${timeoutMs}`,
);
}
if (timeoutMs > 300_000) {
const msg = `timeoutMs ${timeoutMs} exceeds recommended max 300000ms, using 300000 for safety`;
logger?.warn(msg);
if (!logger) {
// eslint-disable-next-line no-console
console.warn(msg);
}
this.timeoutMs = 300_000;
} else {
this.timeoutMs = timeoutMs;
}
this.tokens = this.maxTokens;
this.lastRefill = Date.now();
}
/** Try to consume a token. Returns true if successful, false if rate limited. */
acquire(): boolean {
this._refill();
if (this.tokens >= 1) {
this.tokens--;
return true;
}
return false;
}
/** Wait for a token to become available. Resolves when acquired, rejects on timeout. */
async acquireWithWait(): Promise<void> {
const startTime = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
if (this.acquire()) {
return;
}
const elapsed = Date.now() - startTime;
if (elapsed >= this.timeoutMs) {
throw new Error(`Rate limit wait timeout after ${elapsed}ms`);
}
// Calculate how long until next token (in ms)
const tokensNeeded = 1 - this.tokens;
const waitMs = Math.min((tokensNeeded / this.refillRate) * 1000, this.timeoutMs - elapsed);
await new Promise((resolve) => setTimeout(resolve, Math.ceil(waitMs)));
}
}
private _refill(): void {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefill) / 1000;
const tokensToAdd = elapsedSeconds * this.refillRate;
if (tokensToAdd > 0) {
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
}
/** Bounded concurrency controller using a semaphore pattern. */
export class ConcurrencyLimiter {
private pending: number;
private readonly maxPending: number;
private waiting: Array<() => void> = [];
private readonly logger?: Logger;
/**
* Create a concurrency limiter with safe defaults.
* @param maxPending - Maximum concurrent requests. Must be finite positive integer >= 1 and <= 100. Defaults to 5.
* @param logger - Optional logger for validation warnings. Uses console.warn if not provided.
*/
constructor(maxPending: number = CONCURRENCY_DEFAULTS.maxConcurrentRequests, logger?: Logger) {
this.logger = logger;
// Validate maxPending
if (typeof maxPending !== 'number' || !isFinite(maxPending) || !Number.isInteger(maxPending) || maxPending < 1) {
throw new Error(
`maxConcurrentRequests must be a finite positive integer >= 1, got ${maxPending}`,
);
}
if (maxPending > 100) {
const msg = `maxConcurrentRequests ${maxPending} exceeds recommended max 100, using 100 for safety`;
this.logger?.warn(msg);
if (!this.logger) {
// eslint-disable-next-line no-console
console.warn(msg);
}
this.maxPending = 100;
} else {
this.maxPending = maxPending;
}
this.pending = 0;
}
/** Try to enter a critical section. Returns true if allowed, false if at capacity. */
tryEnter(): boolean {
if (this.pending < this.maxPending) {
this.pending++;
return true;
}
return false;
}
/** Wait until we can enter the critical section. Resolves when slot is available. */
async enter(): Promise<void> {
return new Promise<void>((resolve) => {
// Try to acquire immediately
if (this.tryEnter()) {
resolve();
return;
}
// Queue the resolver to be called when a slot opens
this.waiting.push(resolve);
});
}
/** Exit the critical section and wake a waiting caller if any. */
exit(): void {
// Check if there's a waiting caller who can proceed
const resolve = this.waiting.shift();
if (resolve) {
// A caller was waiting - they will proceed and acquire a slot
// pending stays the same (one slot freed, one waiter takes it)
resolve();
} else if (this.pending > 0) {
// No waiters, just decrement pending
this.pending--;
}
}
get pendingCount(): number {
return this.pending;
}
/** Get the maximum concurrent requests allowed. */
getMaxPending(): number {
return this.maxPending;
}
}
import { WebSocket } from 'ws';
import { z } from 'zod';
import { validateCredentials } from './contracts';
// ========== Protocol Constants ==========
export const CONTROL_PATH = '/control.ashx';
export const DEFAULT_TIMEOUT_MS = 30_000;
export const MAX_RESPONSE_SIZE = 1_048_576; // 1 MB
// ========== Action Types ==========
export enum MeshCtrlAction {
ServerVersion = 'serverversion',
UserInfo = 'userinfo',
Meshes = 'meshes',
Nodes = 'nodes',
Events = 'events',
DeviceInfo = 'deviceinfo',
}
// ========== Request Types ==========
export interface MeshCtrlRequest {
action: string;
/** MeshCentral's control protocol correlates requests with responseid. */
responseid?: string;
/** Retained for compatibility with older fixtures; not used for wire correlation. */
id?: number;
[key: string]: unknown;
}
export interface ServerVersionRequest {
action: MeshCtrlAction.ServerVersion;
}
export interface UserinfoRequest {
action: MeshCtrlAction.UserInfo;
}
export interface MesheMeshesRequest {
action: MeshCtrlAction.Meshes;
}
export interface NodesRequest {
action: MeshCtrlAction.Nodes;
meshid: string;
}
export interface DeviceInfoRequest {
action: MeshCtrlAction.DeviceInfo;
id: string;
}
export interface EventsRequest {
action: MeshCtrlAction.Events;
stop?: number;
count?: number;
}
// ========== Response Types ==========
export interface MeshCtrlResponse {
action: string;
responseid?: string;
result?: string;
tags?: Record<string, unknown>;
id?: number;
err?: string;
[key: string]: unknown;
}
export interface ServerVersionResponse {
action: MeshCtrlAction.ServerVersion;
ver?: string;
err?: string;
}
export interface UserInfoResponse {
action: MeshCtrlAction.UserInfo;
user?: {
id: string;
name: string;
domain: string;
admin: number;
};
err?: string;
}
export interface MeshesResponse {
action: MeshCtrlAction.Meshes;
meshes?: Array<{
id?: string;
_id?: string;
name: string;
description?: string;
desc?: string;
}>;
err?: string;
}
export interface NodesResponse {
action: MeshCtrlAction.Nodes;
nodes?: Record<string, Array<{
id?: string;
_id?: string;
name: string;
meshid?: string;
online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
agent?: unknown;
device?: {
os?: string;
uptime?: number;
lastseen?: number;
ip?: string[];
agentver?: string;
};
}>>;
err?: string;
}
export interface DeviceInfoResponse {
action: MeshCtrlAction.DeviceInfo;
device?: {
id: string;
name: string;
meshid: string;
online: boolean;
os?: string;
uptime?: number;
lastseen?: number;
ip?: string[];
agentver?: string;
};
err?: string;
}
export interface EventsResponse {
action: MeshCtrlAction.Events;
events?: Array<{
id: string;
time: number;
type: string;
msg: string;
}>;
err?: string;
}
const responseBase = {
responseid: z.string().optional(),
result: z.string().optional(),
id: z.number().optional(),
err: z.string().optional(),
};
const responseSchemas: Record<string, z.ZodTypeAny> = {
[MeshCtrlAction.ServerVersion]: z.object({
...responseBase,
action: z.literal(MeshCtrlAction.ServerVersion),
ver: z.string().optional(),
}).passthrough(),
[MeshCtrlAction.UserInfo]: z.object({
...responseBase,
action: z.literal(MeshCtrlAction.UserInfo),
user: z.object({
id: z.string(), name: z.string(), domain: z.string(), admin: z.number(),
}).optional(),
}).passthrough(),
[MeshCtrlAction.Meshes]: z.object({
...responseBase,
action: z.literal(MeshCtrlAction.Meshes),
meshes: z.array(z.object({
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.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(),
[MeshCtrlAction.DeviceInfo]: z.object({
...responseBase,
action: z.literal(MeshCtrlAction.DeviceInfo),
device: z.object({
id: z.string(), name: z.string(), meshid: z.string(), online: z.boolean(),
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(),
[MeshCtrlAction.Events]: z.object({
...responseBase,
action: z.literal(MeshCtrlAction.Events),
events: z.array(z.object({
id: z.string(), time: z.number(), type: z.string(), msg: z.string(),
}).passthrough()).optional(),
}).passthrough(),
};
// ========== Authentication ==========
export interface AuthCredentials {
username: string;
password: string;
totpToken?: string;
}
/**
* Encode credentials for x-meshauth header.
* Format: base64(username:password[,totp])
*/
export function encodeAuthHeader(credentials: AuthCredentials): string {
validateCredentials(credentials);
const { username, password, totpToken } = credentials;
// MeshCentral's meshctrl.js wire format is three comma-separated base64
// fields: username, password, and (when present) a base64 TOTP field.
const header = `${Buffer.from(username).toString('base64')},${Buffer.from(password).toString('base64')}`;
return totpToken
? `${header},${Buffer.from(totpToken).toString('base64')}`
: header;
}
/**
* Sanitize credentials for safe logging.
* Never log actual credential values - only whether they exist.
*/
// Re-exported from contracts.ts for convenience
export { sanitizeCredentials } from './contracts';
// ========== Protocol Framing ==========
/**
* Create a MeshCtrl request message.
*/
export function createRequest(action: string, payload?: Record<string, unknown>, id?: number): MeshCtrlRequest {
const request: MeshCtrlRequest = { action, ...(payload ?? {}) };
if (id !== undefined) {
request.id = id;
}
return request;
}
/**
* Parse a MeshCtrl response message with size validation.
*/
export function parseResponse(data: string, maxSize: number = MAX_RESPONSE_SIZE): MeshCtrlResponse {
if (Buffer.byteLength(data, 'utf8') > maxSize) {
throw new Error('MeshCentral response exceeds the configured size limit');
}
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
throw new Error('MeshCentral response is not valid JSON');
}
const result = z.object({
action: z.string().min(1),
// 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) {
throw new Error('MeshCentral response failed protocol validation');
}
return result.data as MeshCtrlResponse;
}
export function validateResponseForAction(response: MeshCtrlResponse, action: string): MeshCtrlResponse {
if (response.action !== action) {
throw new Error('MeshCentral response action did not match the request');
}
const schema = responseSchemas[action];
if (!schema) {
return response;
}
const result = schema.safeParse(response);
if (!result.success) {
throw new Error('MeshCentral response failed schema validation');
}
return result.data as MeshCtrlResponse;
}
/**
* Determine if a response indicates an error.
*/
export function isErrorResponse(response: MeshCtrlResponse): boolean {
return response.err !== undefined && response.err !== '';
}
/**
* Extract error message from a response, if any.
*/
export function getErrorResponseMessage(response: MeshCtrlResponse): string | null {
if (isErrorResponse(response)) {
return response.err ?? 'Unknown error';
}
return null;
}
// ========== Concurrency & Rate Limiting ==========
export interface RateLimitOptions {
/** Maximum requests per second (RPS) */
maxRequestsPerSecond?: number;
/** Burst capacity for requests (max tokens in bucket) */
burstCapacity?: number;
/** Maximum time to wait for rate limit (ms) */
rateLimitTimeoutMs?: number;
}
export interface ConcurrencyOptions {
/** Maximum concurrent requests on a single connection */
maxConcurrentRequests?: number;
}
export interface MeshCtrlClientOptions extends RateLimitOptions, ConcurrencyOptions {
url: string;
timeoutMs?: number;
// TLS configuration
caFile?: string; // Optional CA certificate file path for custom CAs
}
/**
* Validate rate limit options with safe defaults and fail-closed errors.
*/
export function validateRateLimitOptions(options?: RateLimitOptions): {
maxRequestsPerSecond: number;
burstCapacity: number;
rateLimitTimeoutMs: number;
} {
const maxRequestsPerSecond = options?.maxRequestsPerSecond ?? RATE_LIMIT_DEFAULTS.maxRequestsPerSecond;
const burstCapacity = options?.burstCapacity ?? RATE_LIMIT_DEFAULTS.burstCapacity;
const rateLimitTimeoutMs = options?.rateLimitTimeoutMs ?? RATE_LIMIT_DEFAULTS.rateLimitTimeoutMs;
// Validate maxRequestsPerSecond
if (typeof maxRequestsPerSecond !== 'number' || !isFinite(maxRequestsPerSecond)) {
throw new Error(`maxRequestsPerSecond must be a finite number, got ${maxRequestsPerSecond}`);
}
if (maxRequestsPerSecond < 1) {
throw new Error(`maxRequestsPerSecond must be >= 1, got ${maxRequestsPerSecond}`);
}
if (maxRequestsPerSecond > 10000) {
throw new Error(`maxRequestsPerSecond must be <= 10000 for safety, got ${maxRequestsPerSecond}`);
}
// Validate burstCapacity
if (!Number.isInteger(burstCapacity) || burstCapacity < 1) {
throw new Error(`burstCapacity must be a positive integer >= 1, got ${burstCapacity}`);
}
if (burstCapacity > 1000) {
throw new Error(`burstCapacity must be <= 1000 for safety, got ${burstCapacity}`);
}
// Validate rateLimitTimeoutMs
if (!Number.isInteger(rateLimitTimeoutMs) || rateLimitTimeoutMs < 100) {
throw new Error(`rateLimitTimeoutMs must be an integer >= 100ms, got ${rateLimitTimeoutMs}`);
}
if (rateLimitTimeoutMs > 300_000) {
throw new Error(`rateLimitTimeoutMs must be <= 300000ms for safety, got ${rateLimitTimeoutMs}`);
}
return {
maxRequestsPerSecond,
burstCapacity,
rateLimitTimeoutMs,
};
}
/**
* Validate concurrency options with safe defaults and fail-closed errors.
*/
export function validateConcurrencyOptions(options?: ConcurrencyOptions): {
maxConcurrentRequests: number;
} {
const maxConcurrentRequests = options?.maxConcurrentRequests ?? CONCURRENCY_DEFAULTS.maxConcurrentRequests;
// Validate maxConcurrentRequests
if (!Number.isInteger(maxConcurrentRequests) || maxConcurrentRequests < 1) {
throw new Error(`maxConcurrentRequests must be a positive integer >= 1, got ${maxConcurrentRequests}`);
}
if (maxConcurrentRequests > 100) {
throw new Error(`maxConcurrentRequests must be <= 100 for safety, got ${maxConcurrentRequests}`);
}
return {
maxConcurrentRequests,
};
}
/**
* Validate all MeshCtrlClient options with safe defaults and fail-closed errors.
*/
export function validateMeshCtrlClientOptions(options: MeshCtrlClientOptions): {
url: string;
timeoutMs: number;
rateLimitConfig: {
maxRequestsPerSecond: number;
burstCapacity: number;
rateLimitTimeoutMs: number;
};
concurrencyConfig: {
maxConcurrentRequests: number;
};
} {
// Validate URL
if (!options.url || typeof options.url !== 'string' || options.url.trim() === '') {
throw new Error('url is required and must be a non-empty string');
}
// Validate timeoutMs
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000) {
throw new Error(`timeoutMs must be an integer >= 1000ms, got ${timeoutMs}`);
}
if (timeoutMs > 60_000) {
throw new Error(`timeoutMs must be <= 60000ms for safety, got ${timeoutMs}`);
}
const rateLimitConfig = validateRateLimitOptions(options);
const concurrencyConfig = validateConcurrencyOptions(options);
return {
url: options.url.trim(),
timeoutMs,
rateLimitConfig,
concurrencyConfig,
};
}
/**
* MeshCentral protocol client using the control.ashx WebSocket.
*
* Implements secure TLS validation and typed protocol framing.
* Supports bounded request concurrency and rate limiting via token bucket algorithm.
*/
export class MeshCtrlClient {
private readonly url: string;
private readonly timeoutMs: number;
private readonly caFile?: string;
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;
private readonly rateLimiter: TokenBucket | null;
// Logger
private readonly logger: Logger;
constructor(options: MeshCtrlClientOptions) {
const validated = validateMeshCtrlClientOptions(options);
this.url = validated.url;
this.timeoutMs = validated.timeoutMs;
this.caFile = options.caFile;
// Use a dedicated logger for this client instance
this.logger = getGlobalLogger();
// Set the logger name to be more descriptive
this.logger.setName('meshctrl-client');
// Initialize concurrency limiter with validated config
this.concurrencyLimiter = new ConcurrencyLimiter(validated.concurrencyConfig.maxConcurrentRequests, this.logger);
// Initialize rate limiter with validated config (disabled if maxRps is 0)
const { maxRequestsPerSecond, burstCapacity, rateLimitTimeoutMs } = validated.rateLimitConfig;
if (maxRequestsPerSecond > 0 && burstCapacity > 0) {
this.rateLimiter = new TokenBucket(
burstCapacity,
maxRequestsPerSecond,
rateLimitTimeoutMs,
this.logger,
);
} else {
this.rateLimiter = null;
}
}
/**
* Connect to MeshCentral control channel.
* @param credentials - Authentication credentials
* @returns Promise that resolves when connected and authenticated
*/
async connect(credentials: AuthCredentials): Promise<void> {
if (this.connected) {
throw new Error('Already connected');
}
// Log connection attempt (redacted)
this.logger.audit('AUTH_ATTEMPT - PENDING');
// Construct WebSocket URL
const wsUrl = this._buildWebSocketUrl();
// Set authentication header for x-meshauth
const authHeader = encodeAuthHeader(credentials);
// Create WebSocket with explicit secure TLS validation
this.ws = new WebSocket(wsUrl, {
perMessageDeflate: false,
headers: {
'x-meshauth': authHeader,
},
// Explicit TLS validation - never bypass
rejectUnauthorized: true,
});
// 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}`);
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);
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,
// correlated with responseid (not numeric id).
// 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();
});
this.ws!.once('error', (error: Error) => {
clearTimeout(timeout);
this._cleanup();
// Log connection failure
this.logger.audit(`AUTH_ATTEMPT - FAILURE: ${error.message}`);
reject(new Error(`Connection failed: ${error instanceof Error ? error.message : 'unknown'}`));
});
});
}
/**
* Disconnect from MeshCentral control channel.
*/
async disconnect(): Promise<void> {
if (!this.connected) {
return;
}
this._cleanup();
this.connected = false;
// Log disconnection
this.logger.audit('CONNECTION_CLOSED - SUCCESS');
}
/**
* Get current concurrency status.
*/
getConcurrencyStatus(): { pending: number; max: number } {
return {
pending: this.concurrencyLimiter.pendingCount,
max: (this.concurrencyLimiter as unknown as { maxPending: number }).maxPending,
};
}
/**
* Get current rate limiter status.
*/
getRateLimitStatus(): { tokens: number; maxTokens: number } | null {
if (!this.rateLimiter) {
return null;
}
// Access private fields via type assertion for status reporting
const bucket = this.rateLimiter as unknown as { tokens: number; maxTokens: number };
return {
tokens: Math.floor(bucket.tokens),
maxTokens: bucket.maxTokens,
};
}
/**
* Send a MeshCtrl request and wait for response.
* Enforces bounded concurrency and rate limiting.
* @param request - The request to send
* @returns Parsed response with schema validation
*/
async sendRequest(request: MeshCtrlRequest): Promise<MeshCtrlResponse> {
if (!this.connected) {
throw new Error('Not connected');
}
// Enforce rate limiting before concurrency
if (this.rateLimiter) {
await this.rateLimiter.acquireWithWait();
}
// Enforce bounded concurrency
await this.concurrencyLimiter.enter();
try {
return await this._sendWithRetry(request, 3);
} finally {
// Always exit concurrency slot, even on error
this.concurrencyLimiter.exit();
}
}
/**
* Send a request with retry logic for transient failures.
*/
private async _sendWithRetry(request: MeshCtrlRequest, maxRetries: number): Promise<MeshCtrlResponse> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await this._sendRequestInternal(request);
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
if (attempt < maxRetries) {
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
}
}
}
throw new Error(`Request failed after ${maxRetries} attempts: ${lastError?.message}`);
}
/**
* Internal send implementation with timeout and bounded response validation.
*/
private async _sendRequestInternal(request: MeshCtrlRequest): Promise<MeshCtrlResponse> {
if (!this.ws || !this.connected) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
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 });
// Install the pending request before sending. MeshCentral may answer
// immediately once its bootstrap phase has settled.
setTimeout(() => {
try {
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);
reject(new Error('Not connected'));
}
} catch (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
}
}, sendDelay);
});
}
/**
* Build WebSocket URL from HTTP(S) base URL.
*/
private _buildWebSocketUrl(): string {
let url = this.url;
// Convert http:// to ws:// and https:// to wss://
if (url.startsWith('http://')) {
url = 'ws://' + url.substring(7);
} else if (url.startsWith('https://')) {
url = 'wss://' + url.substring(8);
}
// Ensure path ends with /control.ashx
if (!url.endsWith(CONTROL_PATH)) {
if (!url.endsWith('/')) {
url += '/';
}
url += CONTROL_PATH;
}
return url;
}
/**
* Cleanup WebSocket resources.
*/
private _cleanup(): void {
if (!this.ws) {
return;
}
// Capture WebSocket state before cleanup
const readyState = this.ws.readyState;
const wsUrl = (this.ws as unknown as { _url?: string })._url ?? this.url;
// Remove all listeners to prevent duplicate handling
this.ws.removeAllListeners();
// Log cleanup reason based on WebSocket state
if (readyState === WebSocket.CLOSED) {
this.logger.warn(`WebSocket cleanup: connection already closed (url=${wsUrl})`);
} else if (readyState === WebSocket.CLOSING) {
this.logger.warn(`WebSocket cleanup: connection closing (url=${wsUrl})`);
} else if (readyState === WebSocket.OPEN || readyState === WebSocket.CONNECTING) {
this.logger.warn(`WebSocket cleanup: terminating active connection (url=${wsUrl}, readyState=${readyState})`);
try {
this.ws.close(1000, 'Cleanup');
} catch (closeError) {
this.logger.error(`WebSocket cleanup close failed: ${closeError instanceof Error ? closeError.message : 'unknown'}`);
}
} else {
this.logger.warn(`WebSocket cleanup: unknown state ${readyState} (url=${wsUrl})`);
}
this.ws = null;
}
/**
* Get connection status.
*/
isConnected(): boolean {
return this.connected && this.ws !== null;
}
}