Author SHA1 Message Date
Clawdia 589e96ccf2 Integrate upstream-compatible read-only MeshCentral transport 2026-09-03 18:35:43 -04:00
12 changed files with 369 additions and 189 deletions
+6
View File
@@ -0,0 +1,6 @@
implementation/node_modules
implementation/node_modules/
implementation/dist
implementation/dist/
implementation/coverage
implementation/coverage/
+14 -6
View File
@@ -1,6 +1,6 @@
{
"project": "meshcentral-openclaw-bridge",
"status": "blocked",
"status": "working",
"worker": {
"taskName": "meshcentral-bridge-worker",
"sessionKey": null,
@@ -10,11 +10,19 @@
"lastMeaningfulChangeAt": "2026-08-26T14:53:00-04:00",
"pid": 139480
},
"currentTask": "Run local verification, then perform the bounded read-only compatibility probe.",
"currentTask": "Integrate and document the upstream-compatible read-only MeshCentral transport.",
"completedMilestones": [
{
"task": "Added WebSocket cleanup-state instrumentation",
"completedAt": "2026-08-26T14:53:00-04:00"
},
{
"task": "Authenticated read-only inventory probe passed for Servers/PVE3",
"completedAt": "2026-09-03T18:20:00-04:00"
},
{
"task": "Integrated upstream-compatible transport into real adapter",
"completedAt": "2026-09-03T18:25:00-04:00"
}
],
"lastVerification": {
@@ -22,10 +30,10 @@
"build": "passed",
"lint": "passed"
},
"liveTestFailures": 3,
"liveTestFailures": 0,
"maxConsecutiveLiveTestFailures": 3,
"blocker": "Read-only live probe failed at the known safe stage; consecutive failures: 3.",
"nextAction": "Live testing stopped at the configured failure limit.",
"blocker": null,
"nextAction": "Review and commit the local implementation changes; keep diagnostics and state-changing actions disabled.",
"safety": {
"meshcentral": "read-only only",
"allowedOperations": [
@@ -46,5 +54,5 @@
"PVE2"
]
},
"lastVerificationAt": "2026-08-26T19:12:36.937Z"
"lastVerificationAt": "2026-09-03T18:25:00-04:00"
}
+20 -10
View File
@@ -1,8 +1,21 @@
# MeshCentral ↔ OpenClaw Bridge — Resume Handoff
**Checkpoint:** 2026-08-25 09:18 EDT
**Checkpoint:** 2026-09-03 18:25 EDT
**Owner:** Rob / Clawdia
**Project state:** Active. Rob authorized read-only MeshCentral access for this project on 2026-08-26. A corrected live test is pending.
**Project state:** Read-only inventory path verified live. Local changes are uncommitted and require review before any push or deployment.
## Current verified result
- Authenticated WSS connection to `mesh.ledrew.me`: passed.
- `meshes` inventory request: passed; `Servers` group found.
- `nodes` request for `Servers`: passed; PVE3 found.
- Real adapter end-to-end check: passed; 3 groups, 5 `Servers` devices, PVE3 mapped as online.
- Local gate: 91 tests passed, TypeScript build passed, ESLint passed.
- No commands, diagnostics, file transfer, power operations, administration, or state changes were performed.
## Transport decision
MeshCentral 1.2.5 sends unsolicited `serverinfo`, `userinfo`, and event messages after login. Its `meshes` response omits `responseid`, and its `nodes` response is grouped by mesh ID. The project now uses `upstream-transport.ts`, which follows the working upstream `meshctrl.js` connection timing and response handling while retaining typed validation at the bridge boundary.
## What was completed in the resumed work session
@@ -49,17 +62,14 @@ Last successful result:
## Current phase
Post-hardening protocol compatibility validation. The implementation is authorized for the narrow read-only live test described in `LIVE-TEST-PROCEDURE.md`.
Read-only inventory MVP verified. Diagnostics remain mock-only and state-changing actions remain disabled.
## Remaining work, in order
1. Add bounded request concurrency/rate limiting to the MeshCtrl client.
2. Add structured audit logging for authentication, requests, targets, results, timestamps, and failures; never log passwords, TOTP tokens, session tokens, or raw sensitive output.
3. Add negative/security tests for retries, timeouts, connection failures, allowlist enforcement, and logging redaction.
4. Finish deployment and rollback documentation, including the live-test checklist.
5. Run an independent security review against the updated source and address findings.
6. Execute the authorized read-only live-test procedure; do not expand its scope.
7. Keep diagnostics mock-only; do not add remote commands or state-changing actions until separately designed and reviewed.
1. Review the uncommitted diff and commit on a feature branch.
2. Add/complete transport-level regression tests for bootstrap messages and grouped node responses.
3. Reconcile the final implementation with the security review and deployment documentation.
4. Keep diagnostics mock-only; do not add remote commands or state-changing actions until separately designed and reviewed.
## Live-test facts already documented
+16 -20
View File
@@ -41,36 +41,32 @@
## Verification Status (Complete)
- [x] Test suite passes: 90/90 tests
- [x] Test suite passes: 91/91 tests
- [x] TypeScript build passes with no errors
- [x] ESLint passes with no warnings
- [x] Security review findings reconciled in `review/FINAL-HARDENING-REVIEW.md`
## Remaining Live-Test Gates
## Live Verification (Complete)
### Pre-Live Requirements (Must Complete Before Any Live Test)
- [x] `mesh.ledrew.me` resolves to the intended MeshCentral endpoint
- [x] TLS validation and `/control.ashx` WSS upgrade verified
- [x] `sa_clawdia` authenticated successfully with the protected credential file
- [x] `Servers` group inventory returned successfully
- [x] PVE3 was found in the `Servers` group
- [x] Real adapter mapped the live node correctly
- [x] No credentials, tokens, commands, diagnostics, or state-changing operations were exposed or performed
1. **Security Review Address Findings**
- [x] Review findings in `review/LIVE-ADAPTER-REVIEW.md` reconciled against the current source
- [x] Address or accept all high-severity findings
- [x] Document risk acceptance for deferred non-blocking items in `review/FINAL-HARDENING-REVIEW.md`
The live probe uses the upstream-compatible transport in `implementation/src/upstream-transport.ts`. MeshCentral's `serverversion` action is not used as the least-privilege health check because it requires server-upgrade capability; authenticated `meshes` inventory is the health check instead.
2. **Live-Test Checklist (See DEPLOYMENT.md)**
- [ ] Confirm mesh.ledrew.me resolves to intended MeshCentral instance
- [ ] Verify control.ashx WebSocket endpoint availability
- [ ] Confirm sa_clawdia has read-only permissions only
- [ ] Verify PVE3 is the sole allowlisted test target
- [ ] Test secret retrieval mechanism (no credentials in code/logs)
- [ ] Verify audit logging captures all security events
## Remaining Deployment/Review Work
3. **Explicit Approval**
- [x] Rob's project-level authorization for read-only live access (2026-08-26)
- [ ] No credentials stored in prompts, commands, or logs
### Deployment/ Rollback Checklist
1. Review the uncommitted diff and commit on a feature branch.
2. Complete an independent final security review against the newly integrated transport.
3. Complete deployment/rollback documentation for a future non-production deployment.
4. Add an OpenClaw-facing tool/MCP wrapper only after the read-only adapter contract is finalized.
**Deployment:**
1. Deploy bridge to host with restricted firewall (outbound only to mesh.ledrew.me:80)
1. Deploy bridge to an approved trusted host with restricted firewall (outbound only to the MeshCentral endpoint)
2. Configure runtime secrets via protected environment file (mode 0600)
3. Start bridge service with audit logging enabled
4. Verify health endpoint returns healthy=true
+3 -2
View File
@@ -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');
+49 -50
View File
@@ -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',
+1
View File
@@ -17,6 +17,7 @@ export {
BridgeConfig,
} from './contracts';
export { createMockAdapter } from './mock-adapter';
export { UpstreamMeshTransport } from './upstream-transport';
// Protocol client exports
export {
+19 -25
View File
@@ -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();
}
}
+110 -67
View File
@@ -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);
});
}
+124
View File
@@ -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();
}
}