Integrate upstream-compatible read-only MeshCentral transport #1

Merged
ledadmin merged 1 commits from feature/upstream-readonly-transport into main 2026-09-03 18:49:36 -04:00
12 changed files with 369 additions and 189 deletions
Showing only changes of commit 589e96ccf2 - Show all commits
+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", "project": "meshcentral-openclaw-bridge",
"status": "blocked", "status": "working",
"worker": { "worker": {
"taskName": "meshcentral-bridge-worker", "taskName": "meshcentral-bridge-worker",
"sessionKey": null, "sessionKey": null,
@@ -10,11 +10,19 @@
"lastMeaningfulChangeAt": "2026-08-26T14:53:00-04:00", "lastMeaningfulChangeAt": "2026-08-26T14:53:00-04:00",
"pid": 139480 "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": [ "completedMilestones": [
{ {
"task": "Added WebSocket cleanup-state instrumentation", "task": "Added WebSocket cleanup-state instrumentation",
"completedAt": "2026-08-26T14:53:00-04:00" "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": { "lastVerification": {
@@ -22,10 +30,10 @@
"build": "passed", "build": "passed",
"lint": "passed" "lint": "passed"
}, },
"liveTestFailures": 3, "liveTestFailures": 0,
"maxConsecutiveLiveTestFailures": 3, "maxConsecutiveLiveTestFailures": 3,
"blocker": "Read-only live probe failed at the known safe stage; consecutive failures: 3.", "blocker": null,
"nextAction": "Live testing stopped at the configured failure limit.", "nextAction": "Review and commit the local implementation changes; keep diagnostics and state-changing actions disabled.",
"safety": { "safety": {
"meshcentral": "read-only only", "meshcentral": "read-only only",
"allowedOperations": [ "allowedOperations": [
@@ -46,5 +54,5 @@
"PVE2" "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 # MeshCentral ↔ OpenClaw Bridge — Resume Handoff
**Checkpoint:** 2026-08-25 09:18 EDT **Checkpoint:** 2026-09-03 18:25 EDT
**Owner:** Rob / Clawdia **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 ## What was completed in the resumed work session
@@ -49,17 +62,14 @@ Last successful result:
## Current phase ## 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 ## Remaining work, in order
1. Add bounded request concurrency/rate limiting to the MeshCtrl client. 1. Review the uncommitted diff and commit on a feature branch.
2. Add structured audit logging for authentication, requests, targets, results, timestamps, and failures; never log passwords, TOTP tokens, session tokens, or raw sensitive output. 2. Add/complete transport-level regression tests for bootstrap messages and grouped node responses.
3. Add negative/security tests for retries, timeouts, connection failures, allowlist enforcement, and logging redaction. 3. Reconcile the final implementation with the security review and deployment documentation.
4. Finish deployment and rollback documentation, including the live-test checklist. 4. Keep diagnostics mock-only; do not add remote commands or state-changing actions until separately designed and reviewed.
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.
## Live-test facts already documented ## Live-test facts already documented
+16 -20
View File
@@ -41,36 +41,32 @@
## Verification Status (Complete) ## 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] TypeScript build passes with no errors
- [x] ESLint passes with no warnings - [x] ESLint passes with no warnings
- [x] Security review findings reconciled in `review/FINAL-HARDENING-REVIEW.md` - [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** 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.
- [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`
2. **Live-Test Checklist (See DEPLOYMENT.md)** ## Remaining Deployment/Review Work
- [ ] 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
3. **Explicit Approval** 1. Review the uncommitted diff and commit on a feature branch.
- [x] Rob's project-level authorization for read-only live access (2026-08-26) 2. Complete an independent final security review against the newly integrated transport.
- [ ] No credentials stored in prompts, commands, or logs 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/ Rollback Checklist
**Deployment:** **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) 2. Configure runtime secrets via protected environment file (mode 0600)
3. Start bridge service with audit logging enabled 3. Start bridge service with audit logging enabled
4. Verify health endpoint returns healthy=true 4. Verify health endpoint returns healthy=true
+3 -2
View File
@@ -23,6 +23,7 @@ implementation/
│ ├── contracts.ts # Typed schemas and interfaces │ ├── contracts.ts # Typed schemas and interfaces
│ ├── adapter.ts # Adapter factory and base interface │ ├── adapter.ts # Adapter factory and base interface
│ ├── mock-adapter.ts # Mock implementation for development │ ├── mock-adapter.ts # Mock implementation for development
│ ├── upstream-transport.ts # Proven read-only MeshCentral transport
│ └── __tests__/ # Test suite │ └── __tests__/ # Test suite
├── package.json # Node.js dependencies ├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration ├── tsconfig.json # TypeScript configuration
@@ -115,7 +116,7 @@ main().catch(console.error);
| `timeoutMs` | number | `10_000` | Request timeout in milliseconds | | `timeoutMs` | number | `10_000` | Request timeout in milliseconds |
| `maxOutputLength` | number | `65_536` | Maximum output bytes for diagnostics (truncates if exceeded) | | `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 ## 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. > 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 - **Device/group inventory and status** - Implemented locally
- **Response size and schema validation** - Implemented and unit-tested - **Response size and schema validation** - Implemented and unit-tested
- **Real-time status updates** - Not implemented - **Real-time status updates** - Not implemented
@@ -3,7 +3,6 @@
*/ */
import { createMeshCentralAdapter } from '../adapter'; import { createMeshCentralAdapter } from '../adapter';
import { BridgeError } from '../contracts';
describe('createMeshCentralAdapter', () => { describe('createMeshCentralAdapter', () => {
it('should return mock adapter by default', () => { it('should return mock adapter by default', () => {
@@ -16,11 +15,9 @@ describe('createMeshCentralAdapter', () => {
expect(adapter.name).toBe('mock'); expect(adapter.name).toBe('mock');
}); });
it('should throw for real adapter without credentials', async () => { it('should allow real adapter construction before protected credentials are injected', () => {
// Real adapter requires credentials const adapter = createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' });
expect(() => expect(adapter.name).toBe('meshcentral-real');
createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' }),
).toThrow(BridgeError);
}); });
it('should accept custom configuration', () => { 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 () => { it('does not expose a general-purpose command or diagnostic action', async () => {
const source = await import('node:fs').then(({ readFileSync }) => const source = await import('node:fs').then(({ readFileSync }) =>
readFileSync(require.resolve('../live-readonly-test'), 'utf8')); readFileSync(require.resolve('../live-readonly-test'), 'utf8'));
expect(source).toContain("MeshCtrlAction.ServerVersion");
expect(source).toContain("MeshCtrlAction.Meshes"); expect(source).toContain("MeshCtrlAction.Meshes");
expect(source).toContain("MeshCtrlAction.Nodes"); expect(source).toContain("MeshCtrlAction.Nodes");
expect(source).toContain("MeshCtrlAction.DeviceInfo"); expect(source).not.toContain("MeshCtrlAction.ServerVersion");
expect(source).toContain("nodeid: approvedDevice.id"); expect(source).not.toContain("MeshCtrlAction.DeviceInfo");
expect(source).toContain("meshid: groupId");
expect(source).toContain("UpstreamMeshTransport");
expect(source).not.toContain('runcommand'); expect(source).not.toContain('runcommand');
expect(source).not.toContain('diagnostic'); expect(source).not.toContain('diagnostic');
expect(source).not.toContain('process.argv'); expect(source).not.toContain('process.argv');
+49 -50
View File
@@ -19,9 +19,7 @@ import {
validateCredentials, validateCredentials,
} from './contracts'; } from './contracts';
import { createMockAdapter } from './mock-adapter'; import { createMockAdapter } from './mock-adapter';
import { import { UpstreamMeshTransport } from './upstream-transport';
MeshCtrlClient,
} from './meshctrl-client';
import { import {
createAuditEntry, createAuditEntry,
AuditAction, AuditAction,
@@ -40,7 +38,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
readonly version = '0.1.0'; readonly version = '0.1.0';
private config: BridgeConfig; private config: BridgeConfig;
private client: MeshCtrlClient | null = null; private client: UpstreamMeshTransport | null = null;
private credentials: { username: string; password: string; totpToken?: string } | null = null; private credentials: { username: string; password: string; totpToken?: string } | null = null;
private connected = false; private connected = false;
private logger: Logger; private logger: Logger;
@@ -188,12 +186,9 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
if (this.config.adapterType === 'real' && (!hasUsername || !hasPassword)) { // Real-adapter credentials may be supplied later through setCredentials().
throw new BridgeError( // Keep the constructor usable for protected runtime secret injection;
ErrorCodes.ConfigurationInvalid, // _ensureConnected() remains the enforcement point before any connection.
'Real adapter requires authentication credentials',
);
}
// Validate credential formats if present // Validate credential formats if present
if (hasUsername && hasPassword) { if (hasUsername && hasPassword) {
@@ -297,10 +292,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
this.client = new MeshCtrlClient({ this.client = new UpstreamMeshTransport(this.config.meshCentralUrl);
url: this.config.meshCentralUrl,
timeoutMs: this.config.timeoutMs,
});
try { try {
await this.client.connect(this.credentials); await this.client.connect(this.credentials);
@@ -402,34 +394,6 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
groupId: request?.groupId, 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) // Get meshes (device groups)
const meshesResponse = await this.client!.sendRequest({ const meshesResponse = await this.client!.sendRequest({
action: 'meshes', 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 group filter provided, find matching mesh
if (request?.groupId) { 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)) { if (!this._isGroupAllowed(mesh.id)) {
return []; 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 // Filter by group allowlist if configured
const allowedMeshes = this._filterByAllowlist( const allowedMeshes = this._filterByAllowlist(
@@ -660,11 +659,11 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
try { try {
await this._ensureConnected(); await this._ensureConnected();
const versionResponse = await this.client!.sendRequest({ // `meshes` is the least-privileged authenticated inventory operation
action: 'serverversion', // 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 = !versionResponse.err; const healthy = Array.isArray(meshesResponse.meshes);
this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, { this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, {
actor: this._getActorName(), actor: this._getActorName(),
resource: 'health-check', resource: 'health-check',
+1
View File
@@ -17,6 +17,7 @@ export {
BridgeConfig, BridgeConfig,
} from './contracts'; } from './contracts';
export { createMockAdapter } from './mock-adapter'; export { createMockAdapter } from './mock-adapter';
export { UpstreamMeshTransport } from './upstream-transport';
// Protocol client exports // Protocol client exports
export { export {
+19 -25
View File
@@ -3,11 +3,14 @@
* *
* This file intentionally has no CLI credential or target arguments. The * This file intentionally has no CLI credential or target arguments. The
* password is read at runtime from the protected credential file and is never * 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 { 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. */ /* 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> { export async function runReadOnlyCompatibilityTest(): Promise<void> {
const password = readProtectedPassword(); const password = readProtectedPassword();
const client = new MeshCtrlClient({ url: MESH_URL, timeoutMs: 30_000 }); const client = new UpstreamMeshTransport(MESH_URL);
let stage = 'connect'; let stage = 'connect';
try { try {
await client.connect({ username: USERNAME, password }); const credentials = { username: USERNAME, password };
stage = 'serverversion';
const version = await client.sendRequest({ action: MeshCtrlAction.ServerVersion });
stage = 'meshes'; stage = 'meshes';
const meshesResponse = await client.sendRequest({ action: MeshCtrlAction.Meshes }); const meshesResponse = await client.request(credentials, { action: MeshCtrlAction.Meshes });
const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id: string; name: string }> : []; 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); const approvedGroup = meshes.find((mesh) => mesh.name === APPROVED_GROUP_NAME);
if (!approvedGroup) { if (!approvedGroup) {
throw new Error('Approved MeshCentral group was not found'); 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'; stage = 'nodes';
const nodesResponse = await client.sendRequest({ const nodesResponse = await client.request(credentials, {
action: MeshCtrlAction.Nodes, 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 groupedNodes = nodesResponse.nodes ?? {};
const approvedDevice = nodes.find((node) => node.name === APPROVED_DEVICE_NAME); 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) { if (!approvedDevice) {
throw new Error('Approved PVE3 device was not found in the Servers group'); 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. // Deliberately emit only non-sensitive compatibility facts.
console.log(JSON.stringify({ console.log(JSON.stringify({
success: true, success: true,
serverVersionAction: version.action,
serverVersionResult: typeof version.result === 'string' ? version.result : undefined,
group: APPROVED_GROUP_NAME, group: APPROVED_GROUP_NAME,
device: { name: approvedDevice.name, online: approvedDevice.online }, device: { name: approvedDevice.name, online: approvedDevice.online ?? false },
})); }));
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection'; const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection';
throw new Error(`live test failed at ${stage} (${category})`); throw new Error(`live test failed at ${stage} (${category})`);
} finally { } finally {
await client.disconnect(); await client.close();
} }
} }
+110 -67
View File
@@ -322,20 +322,27 @@ export interface UserInfoResponse {
export interface MeshesResponse { export interface MeshesResponse {
action: MeshCtrlAction.Meshes; action: MeshCtrlAction.Meshes;
meshes?: Array<{ meshes?: Array<{
id: string; id?: string;
_id?: string;
name: string; name: string;
description?: string; description?: string;
desc?: string;
}>; }>;
err?: string; err?: string;
} }
export interface NodesResponse { export interface NodesResponse {
action: MeshCtrlAction.Nodes; action: MeshCtrlAction.Nodes;
nodes?: Array<{ nodes?: Record<string, Array<{
id: string; id?: string;
_id?: string;
name: string; name: string;
meshid: string; meshid?: string;
online: boolean; online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
agent?: unknown;
device?: { device?: {
os?: string; os?: string;
uptime?: number; uptime?: number;
@@ -343,7 +350,7 @@ export interface NodesResponse {
ip?: string[]; ip?: string[];
agentver?: string; agentver?: string;
}; };
}>; }>>;
err?: string; err?: string;
} }
@@ -398,19 +405,23 @@ const responseSchemas: Record<string, z.ZodTypeAny> = {
...responseBase, ...responseBase,
action: z.literal(MeshCtrlAction.Meshes), action: z.literal(MeshCtrlAction.Meshes),
meshes: z.array(z.object({ 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()).optional(),
}).passthrough(), }).passthrough(),
[MeshCtrlAction.Nodes]: z.object({ [MeshCtrlAction.Nodes]: z.object({
...responseBase, ...responseBase,
action: z.literal(MeshCtrlAction.Nodes), action: z.literal(MeshCtrlAction.Nodes),
nodes: z.array(z.object({ nodes: z.record(z.array(z.object({
id: z.string(), name: z.string(), meshid: z.string(), online: z.boolean(), 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({ device: z.object({
os: z.string().optional(), uptime: z.number().optional(), lastseen: z.number().optional(), os: z.string().optional(), uptime: z.number().optional(), lastseen: z.number().optional(),
ip: z.array(z.string()).optional(), agentver: z.string().optional(), ip: z.array(z.string()).optional(), agentver: z.string().optional(),
}).passthrough().optional(), }).passthrough().optional(),
}).passthrough()).optional(), }).passthrough())).optional(),
}).passthrough(), }).passthrough(),
[MeshCtrlAction.DeviceInfo]: z.object({ [MeshCtrlAction.DeviceInfo]: z.object({
...responseBase, ...responseBase,
@@ -489,7 +500,10 @@ export function parseResponse(data: string, maxSize: number = MAX_RESPONSE_SIZE)
const result = z.object({ const result = z.object({
action: z.string().min(1), 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(), err: z.string().optional(),
}).passthrough().safeParse(parsed); }).passthrough().safeParse(parsed);
if (!result.success) { if (!result.success) {
@@ -674,6 +688,13 @@ export class MeshCtrlClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private connected = false; private connected = false;
private requestSequence = 0; 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 // Concurrency and rate limiting
private readonly concurrencyLimiter: ConcurrencyLimiter; private readonly concurrencyLimiter: ConcurrencyLimiter;
@@ -741,24 +762,70 @@ export class MeshCtrlClient {
// Setup WebSocket close handler with diagnostics // Setup WebSocket close handler with diagnostics
this.ws.on('close', (code: number, reason: Buffer) => { this.ws.on('close', (code: number, reason: Buffer) => {
this.logger.debug('MeshCentral socket closed', { code, reasonBytes: reason.length });
const reasonText = reason.toString(); const reasonText = reason.toString();
this.logger.warn(`WebSocket close event: code=${code}, reason="${reasonText}", url=${wsUrl}`); this.logger.warn(`WebSocket close event: code=${code}, reason="${reasonText}", url=${wsUrl}`);
this.connected = false;
if (this.connected) { for (const [requestId, pending] of this.pendingRequests) {
this.logger.error('WebSocket closed while still marked as connected'); clearTimeout(pending.timeout);
this._cleanup(); pending.reject(new Error('MeshCentral connection closed'));
this.pendingRequests.delete(requestId);
} }
this.ws = null;
}); });
// Setup WebSocket error handler with diagnostics // Setup WebSocket error handler with diagnostics
this.ws.on('error', (error: Error) => { this.ws.on('error', (error: Error) => {
const errorMessage = error instanceof Error ? error.message : 'unknown error'; const errorMessage = error instanceof Error ? error.message : 'unknown error';
this.logger.error(`WebSocket error event: ${errorMessage}, url=${wsUrl}`); 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 // Wait for connection
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.connected = false;
this._cleanup(); this._cleanup();
reject(new Error(`Connection timed out after ${this.timeoutMs}ms`)); reject(new Error(`Connection timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs); }, this.timeoutMs);
@@ -766,6 +833,10 @@ export class MeshCtrlClient {
this.ws!.once('open', () => { this.ws!.once('open', () => {
clearTimeout(timeout); clearTimeout(timeout);
this.connected = true; 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 // MeshCentral authenticates from the x-meshauth header during the
// WebSocket upgrade; the first application message is a request, // WebSocket upgrade; the first application message is a request,
@@ -773,6 +844,12 @@ export class MeshCtrlClient {
// Log successful connection // Log successful connection
this.logger.audit('CONNECTION_ESTABLISHED - SUCCESS'); 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(); resolve();
}); });
@@ -886,67 +963,33 @@ export class MeshCtrlClient {
} }
return new Promise((resolve, reject) => { 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 requestId = request.responseid ?? `openclaw-${Date.now()}-${this.requestSequence++}`;
const finalRequest = { ...request, responseid: requestId }; 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 // Install the pending request before sending. MeshCentral may answer
try { // immediately once its bootstrap phase has settled.
if (this.ws) { setTimeout(() => {
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 => {
try { try {
clearTimeout(timeout); if (this.ws && this.connected) {
this.logger.debug('MeshCentral request sent', { action: request.action });
// Convert to string if buffer this.ws.send(JSON.stringify(finalRequest));
const dataString = Buffer.isBuffer(data) ? data.toString() : data; } else {
this.pendingRequests.delete(requestId);
// Validate response size before parsing
if (dataString.length > MAX_RESPONSE_SIZE) {
clearTimeout(timeout); clearTimeout(timeout);
this._cleanup(); reject(new Error('Not connected'));
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);
}
} }
} catch (error) { } catch (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout); clearTimeout(timeout);
this._cleanup(); reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
reject(error instanceof Error ? error : new Error('Unknown error'));
} }
}; }, sendDelay);
if (this.ws) {
this.ws.on('message', handleResponse);
}
}); });
} }
+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();
}
}