Integrate upstream-compatible read-only MeshCentral transport
This commit is contained in:
@@ -23,6 +23,7 @@ implementation/
|
||||
│ ├── contracts.ts # Typed schemas and interfaces
|
||||
│ ├── adapter.ts # Adapter factory and base interface
|
||||
│ ├── mock-adapter.ts # Mock implementation for development
|
||||
│ ├── upstream-transport.ts # Proven read-only MeshCentral transport
|
||||
│ └── __tests__/ # Test suite
|
||||
├── package.json # Node.js dependencies
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
@@ -115,7 +116,7 @@ main().catch(console.error);
|
||||
| `timeoutMs` | number | `10_000` | Request timeout in milliseconds |
|
||||
| `maxOutputLength` | number | `65_536` | Maximum output bytes for diagnostics (truncates if exceeded) |
|
||||
|
||||
> **Note:** The real adapter uses MeshCentral's `/control.ashx` WebSocket protocol for read-only inventory/status calls. Diagnostics still return mock data; no remote commands or state-changing operations are implemented.
|
||||
> **Note:** The real adapter uses the upstream-compatible MeshCentral `/control.ashx` WebSocket transport for read-only inventory/status calls. MeshCentral bootstrap messages and native grouped response formats are handled explicitly. Diagnostics still return mock data; no remote commands or state-changing operations are implemented.
|
||||
|
||||
## Supported Diagnostic Checks
|
||||
|
||||
@@ -156,7 +157,7 @@ All errors include a code from the `ErrorCodes` enum:
|
||||
|
||||
> The real adapter is implemented locally and may be used only for the documented read-only compatibility test. It must remain limited to the approved MeshCentral account and PVE3 target.
|
||||
|
||||
- **MeshCentral control-channel integration** - Implemented locally via `/control.ashx`
|
||||
- **MeshCentral control-channel integration** - Verified live via `/control.ashx`
|
||||
- **Device/group inventory and status** - Implemented locally
|
||||
- **Response size and schema validation** - Implemented and unit-tested
|
||||
- **Real-time status updates** - Not implemented
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
import { createMeshCentralAdapter } from '../adapter';
|
||||
import { BridgeError } from '../contracts';
|
||||
|
||||
describe('createMeshCentralAdapter', () => {
|
||||
it('should return mock adapter by default', () => {
|
||||
@@ -16,11 +15,9 @@ describe('createMeshCentralAdapter', () => {
|
||||
expect(adapter.name).toBe('mock');
|
||||
});
|
||||
|
||||
it('should throw for real adapter without credentials', async () => {
|
||||
// Real adapter requires credentials
|
||||
expect(() =>
|
||||
createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' }),
|
||||
).toThrow(BridgeError);
|
||||
it('should allow real adapter construction before protected credentials are injected', () => {
|
||||
const adapter = createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' });
|
||||
expect(adapter.name).toBe('meshcentral-real');
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
|
||||
@@ -2,11 +2,12 @@ describe('live read-only test safety contract', () => {
|
||||
it('does not expose a general-purpose command or diagnostic action', async () => {
|
||||
const source = await import('node:fs').then(({ readFileSync }) =>
|
||||
readFileSync(require.resolve('../live-readonly-test'), 'utf8'));
|
||||
expect(source).toContain("MeshCtrlAction.ServerVersion");
|
||||
expect(source).toContain("MeshCtrlAction.Meshes");
|
||||
expect(source).toContain("MeshCtrlAction.Nodes");
|
||||
expect(source).toContain("MeshCtrlAction.DeviceInfo");
|
||||
expect(source).toContain("nodeid: approvedDevice.id");
|
||||
expect(source).not.toContain("MeshCtrlAction.ServerVersion");
|
||||
expect(source).not.toContain("MeshCtrlAction.DeviceInfo");
|
||||
expect(source).toContain("meshid: groupId");
|
||||
expect(source).toContain("UpstreamMeshTransport");
|
||||
expect(source).not.toContain('runcommand');
|
||||
expect(source).not.toContain('diagnostic');
|
||||
expect(source).not.toContain('process.argv');
|
||||
|
||||
@@ -19,9 +19,7 @@ import {
|
||||
validateCredentials,
|
||||
} from './contracts';
|
||||
import { createMockAdapter } from './mock-adapter';
|
||||
import {
|
||||
MeshCtrlClient,
|
||||
} from './meshctrl-client';
|
||||
import { UpstreamMeshTransport } from './upstream-transport';
|
||||
import {
|
||||
createAuditEntry,
|
||||
AuditAction,
|
||||
@@ -40,7 +38,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
readonly version = '0.1.0';
|
||||
|
||||
private config: BridgeConfig;
|
||||
private client: MeshCtrlClient | null = null;
|
||||
private client: UpstreamMeshTransport | null = null;
|
||||
private credentials: { username: string; password: string; totpToken?: string } | null = null;
|
||||
private connected = false;
|
||||
private logger: Logger;
|
||||
@@ -188,12 +186,9 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.adapterType === 'real' && (!hasUsername || !hasPassword)) {
|
||||
throw new BridgeError(
|
||||
ErrorCodes.ConfigurationInvalid,
|
||||
'Real adapter requires authentication credentials',
|
||||
);
|
||||
}
|
||||
// Real-adapter credentials may be supplied later through setCredentials().
|
||||
// Keep the constructor usable for protected runtime secret injection;
|
||||
// _ensureConnected() remains the enforcement point before any connection.
|
||||
|
||||
// Validate credential formats if present
|
||||
if (hasUsername && hasPassword) {
|
||||
@@ -297,10 +292,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
this.client = new MeshCtrlClient({
|
||||
url: this.config.meshCentralUrl,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
});
|
||||
this.client = new UpstreamMeshTransport(this.config.meshCentralUrl);
|
||||
|
||||
try {
|
||||
await this.client.connect(this.credentials);
|
||||
@@ -402,34 +394,6 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
groupId: request?.groupId,
|
||||
});
|
||||
|
||||
// Get server version first to verify connection
|
||||
try {
|
||||
const versionResponse = await this.client!.sendRequest({
|
||||
action: 'serverversion',
|
||||
});
|
||||
|
||||
if (versionResponse.err) {
|
||||
this._auditLog(AuditAction.MESHCENTRAL_REQUEST, AuditStatus.FAILURE, {
|
||||
actor: this._getActorName(),
|
||||
resource: 'serverversion',
|
||||
error: versionResponse.err,
|
||||
});
|
||||
|
||||
throw new BridgeError(
|
||||
ErrorCodes.MeshCentralError,
|
||||
'Failed to establish connection with MeshCentral server',
|
||||
);
|
||||
} else {
|
||||
this._auditLog(AuditAction.MESHCENTRAL_RESPONSE, AuditStatus.SUCCESS, {
|
||||
actor: this._getActorName(),
|
||||
resource: 'serverversion',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await this._disconnect();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get meshes (device groups)
|
||||
const meshesResponse = await this.client!.sendRequest({
|
||||
action: 'meshes',
|
||||
@@ -442,7 +406,10 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string }>;
|
||||
const meshes = ((meshesResponse.meshes ?? []) as Array<{ id?: string; _id?: string; name: string; description?: string; desc?: string }>).map((mesh) => ({
|
||||
...mesh,
|
||||
id: mesh.id ?? mesh._id ?? '',
|
||||
})).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string }>;
|
||||
|
||||
// If group filter provided, find matching mesh
|
||||
if (request?.groupId) {
|
||||
@@ -489,7 +456,29 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
const nodes = (nodesResponse.nodes ?? []) as Array<{ id: string; name: string; online: boolean; meshid?: string; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }>;
|
||||
const groupedNodes = (nodesResponse.nodes ?? {}) as Record<string, Array<{
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
meshid?: string;
|
||||
online?: boolean;
|
||||
conn?: number;
|
||||
osdesc?: string;
|
||||
ip?: string[];
|
||||
device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string };
|
||||
}>>;
|
||||
const nodes = Object.entries(groupedNodes).flatMap(([groupId, groupNodes]) =>
|
||||
groupNodes.map((node) => ({
|
||||
id: node.id ?? node._id ?? '',
|
||||
name: node.name,
|
||||
meshid: node.meshid ?? groupId,
|
||||
online: node.online ?? node.conn === 1,
|
||||
device: node.device ?? {
|
||||
os: node.osdesc,
|
||||
ip: node.ip,
|
||||
},
|
||||
})),
|
||||
).filter((node) => node.id !== '') as Array<{ id: string; name: string; online: boolean; meshid?: string; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }>;
|
||||
|
||||
if (!this._isGroupAllowed(mesh.id)) {
|
||||
return [];
|
||||
@@ -563,7 +552,17 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string; description?: string }>;
|
||||
const meshes = ((meshesResponse.meshes ?? []) as Array<{
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
desc?: string;
|
||||
}>).map((mesh) => ({
|
||||
...mesh,
|
||||
id: mesh.id ?? mesh._id ?? '',
|
||||
description: mesh.description ?? mesh.desc,
|
||||
})).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string; description?: string }>;
|
||||
|
||||
// Filter by group allowlist if configured
|
||||
const allowedMeshes = this._filterByAllowlist(
|
||||
@@ -660,11 +659,11 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
|
||||
try {
|
||||
await this._ensureConnected();
|
||||
|
||||
const versionResponse = await this.client!.sendRequest({
|
||||
action: 'serverversion',
|
||||
});
|
||||
|
||||
const healthy = !versionResponse.err;
|
||||
// `meshes` is the least-privileged authenticated inventory operation
|
||||
// and is available without the server-upgrade site permission required
|
||||
// by MeshCentral's `serverversion` command.
|
||||
const meshesResponse = await this.client!.sendRequest({ action: 'meshes' });
|
||||
const healthy = Array.isArray(meshesResponse.meshes);
|
||||
this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, {
|
||||
actor: this._getActorName(),
|
||||
resource: 'health-check',
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
BridgeConfig,
|
||||
} from './contracts';
|
||||
export { createMockAdapter } from './mock-adapter';
|
||||
export { UpstreamMeshTransport } from './upstream-transport';
|
||||
|
||||
// Protocol client exports
|
||||
export {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
*
|
||||
* This file intentionally has no CLI credential or target arguments. The
|
||||
* password is read at runtime from the protected credential file and is never
|
||||
* printed. Only serverversion, meshes, and nodes requests are sent.
|
||||
* printed. Only meshes and nodes requests are sent. MeshCentral's
|
||||
* serverversion command requires server-upgrade capability and is not needed
|
||||
* to prove the read-only inventory path.
|
||||
*/
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { MeshCtrlAction, MeshCtrlClient } from './meshctrl-client';
|
||||
import { MeshCtrlAction } from './meshctrl-client';
|
||||
import { UpstreamMeshTransport } from './upstream-transport';
|
||||
|
||||
/* eslint-disable no-console -- CLI probe intentionally emits one safe result or generic failure. */
|
||||
|
||||
@@ -31,57 +34,48 @@ function readProtectedPassword(filePath = DEFAULT_CREDENTIAL_FILE): string {
|
||||
|
||||
export async function runReadOnlyCompatibilityTest(): Promise<void> {
|
||||
const password = readProtectedPassword();
|
||||
const client = new MeshCtrlClient({ url: MESH_URL, timeoutMs: 30_000 });
|
||||
const client = new UpstreamMeshTransport(MESH_URL);
|
||||
let stage = 'connect';
|
||||
|
||||
try {
|
||||
await client.connect({ username: USERNAME, password });
|
||||
stage = 'serverversion';
|
||||
const version = await client.sendRequest({ action: MeshCtrlAction.ServerVersion });
|
||||
const credentials = { username: USERNAME, password };
|
||||
stage = 'meshes';
|
||||
const meshesResponse = await client.sendRequest({ action: MeshCtrlAction.Meshes });
|
||||
const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id: string; name: string }> : [];
|
||||
const meshesResponse = await client.request(credentials, { action: MeshCtrlAction.Meshes });
|
||||
const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id?: string; _id?: string; name: string }> : [];
|
||||
const approvedGroup = meshes.find((mesh) => mesh.name === APPROVED_GROUP_NAME);
|
||||
|
||||
if (!approvedGroup) {
|
||||
throw new Error('Approved MeshCentral group was not found');
|
||||
}
|
||||
|
||||
const groupId = approvedGroup._id ?? approvedGroup.id;
|
||||
if (!groupId) throw new Error('Approved MeshCentral group had no identifier');
|
||||
|
||||
stage = 'nodes';
|
||||
const nodesResponse = await client.sendRequest({
|
||||
const nodesResponse = await client.request(credentials, {
|
||||
action: MeshCtrlAction.Nodes,
|
||||
meshid: approvedGroup.id,
|
||||
meshid: groupId,
|
||||
});
|
||||
const nodes = Array.isArray(nodesResponse.nodes) ? nodesResponse.nodes as Array<{ id: string; name: string; meshid: string; online: boolean }> : [];
|
||||
const approvedDevice = nodes.find((node) => node.name === APPROVED_DEVICE_NAME);
|
||||
const groupedNodes = nodesResponse.nodes ?? {};
|
||||
const nodes = Object.values(groupedNodes).flat() as Array<{ id?: string; _id?: string; name: string; online?: boolean }>;
|
||||
const approvedDevice = nodes.find((node) => node.name.toLowerCase() === APPROVED_DEVICE_NAME.toLowerCase());
|
||||
|
||||
if (!approvedDevice) {
|
||||
throw new Error('Approved PVE3 device was not found in the Servers group');
|
||||
}
|
||||
|
||||
stage = 'deviceinfo';
|
||||
const deviceInfo = await client.sendRequest({
|
||||
action: MeshCtrlAction.DeviceInfo,
|
||||
nodeid: approvedDevice.id,
|
||||
});
|
||||
if (deviceInfo.err || !deviceInfo.device) {
|
||||
throw new Error('Approved PVE3 metadata could not be retrieved');
|
||||
}
|
||||
|
||||
// Deliberately emit only non-sensitive compatibility facts.
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
serverVersionAction: version.action,
|
||||
serverVersionResult: typeof version.result === 'string' ? version.result : undefined,
|
||||
group: APPROVED_GROUP_NAME,
|
||||
device: { name: approvedDevice.name, online: approvedDevice.online },
|
||||
device: { name: approvedDevice.name, online: approvedDevice.online ?? false },
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection';
|
||||
throw new Error(`live test failed at ${stage} (${category})`);
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { AuthCredentials, MeshCtrlResponse, CONTROL_PATH, encodeAuthHeader, MAX_RESPONSE_SIZE, parseResponse } from './meshctrl-client';
|
||||
|
||||
/** Minimal upstream-compatible, read-only MeshCentral control transport. */
|
||||
export class UpstreamMeshTransport {
|
||||
private ws: WebSocket | null = null;
|
||||
private connectionPromise: Promise<WebSocket> | null = null;
|
||||
private credentials: AuthCredentials | null = null;
|
||||
private readonly url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
const normalized = url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
this.url = normalized.endsWith(CONTROL_PATH) ? normalized : `${normalized.replace(/\/$/, '')}${CONTROL_PATH}`;
|
||||
}
|
||||
|
||||
async connect(credentials: AuthCredentials): Promise<void> {
|
||||
this.credentials = credentials;
|
||||
await this.open(credentials);
|
||||
}
|
||||
|
||||
async sendRequest(request: Record<string, unknown>): Promise<MeshCtrlResponse> {
|
||||
if (!this.credentials) throw new Error('Not connected');
|
||||
const ws = await this.open(this.credentials);
|
||||
const responseId = typeof request.responseid === 'string' ? request.responseid : `openclaw-${Date.now()}`;
|
||||
const message = { ...request, responseid: responseId };
|
||||
|
||||
return new Promise<MeshCtrlResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.removeListener('message', onMessage);
|
||||
reject(new Error('MeshCentral request timed out'));
|
||||
}, 30_000);
|
||||
|
||||
const finish = (fn: () => void): void => {
|
||||
clearTimeout(timeout);
|
||||
ws.removeListener('message', onMessage);
|
||||
fn();
|
||||
};
|
||||
|
||||
const onMessage = (raw: Buffer | string): void => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString() : raw;
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE) return finish(() => reject(new Error('MeshCentral response exceeds size limit')));
|
||||
let parsed: MeshCtrlResponse;
|
||||
try {
|
||||
parsed = parseResponse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bootstrap and event messages are unsolicited. MeshCentral's
|
||||
// legacy meshes response also omits responseid, so correlate it by
|
||||
// action while this transport has one active request.
|
||||
if (parsed.responseid !== responseId && !(request.action === 'meshes' && parsed.action === 'meshes')) return;
|
||||
finish(() => resolve(parsed));
|
||||
};
|
||||
|
||||
ws.on('message', onMessage);
|
||||
ws.send(JSON.stringify(message));
|
||||
});
|
||||
}
|
||||
|
||||
async request(credentials: AuthCredentials, request: Record<string, unknown>): Promise<MeshCtrlResponse> {
|
||||
await this.connect(credentials);
|
||||
return this.sendRequest(request);
|
||||
}
|
||||
|
||||
private async open(credentials: AuthCredentials): Promise<WebSocket> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return this.ws;
|
||||
if (this.connectionPromise) return this.connectionPromise;
|
||||
|
||||
this.connectionPromise = new Promise<WebSocket>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url, {
|
||||
rejectUnauthorized: true,
|
||||
perMessageDeflate: false,
|
||||
headers: { 'x-meshauth': encodeAuthHeader(credentials) },
|
||||
});
|
||||
this.ws = ws;
|
||||
const timeout = setTimeout(() => {
|
||||
ws.terminate();
|
||||
reject(new Error('MeshCentral connection timed out'));
|
||||
}, 30_000);
|
||||
ws.once('open', async () => {
|
||||
clearTimeout(timeout);
|
||||
// Match the upstream client's startup behavior before the first command.
|
||||
await new Promise((settled) => setTimeout(settled, 750));
|
||||
resolve(ws);
|
||||
});
|
||||
ws.once('error', (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
ws.once('close', () => {
|
||||
if (this.ws === ws) this.ws = null;
|
||||
});
|
||||
}).finally(() => {
|
||||
this.connectionPromise = null;
|
||||
});
|
||||
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const ws = this.ws;
|
||||
this.ws = null;
|
||||
this.connectionPromise = null;
|
||||
this.credentials = null;
|
||||
if (!ws) return;
|
||||
if (ws.readyState === WebSocket.CLOSED) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.terminate();
|
||||
resolve();
|
||||
}, 2_000);
|
||||
ws.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
ws.close(1000, 'bridge-close');
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user