/** * 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 { 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 { return new Promise((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; 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>; 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 = { [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, 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 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 { 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((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 { 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 { 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 { 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 { 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; } }