21 changed files with 5852 additions and 189 deletions
+12
View File
@@ -0,0 +1,12 @@
implementation/node_modules
implementation/node_modules/
implementation/dist
implementation/dist/
implementation/coverage
implementation/coverage/
openclaw-plugin/node_modules
openclaw-plugin/node_modules/
openclaw-plugin/dist
openclaw-plugin/dist/
openclaw-plugin/coverage
openclaw-plugin/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');
@@ -0,0 +1,32 @@
import { MeshCtrlAction } from '../meshctrl-client';
import { normalizeGroupedNodes, normalizeMeshes } from '../upstream-transport';
describe('upstream MeshCentral response normalization', () => {
it('normalizes native mesh identifiers and descriptions', () => {
expect(normalizeMeshes({
action: MeshCtrlAction.Meshes,
meshes: [{ _id: 'mesh-servers', name: 'Servers', desc: 'Managed servers' }],
})).toEqual([{ id: 'mesh-servers', name: 'Servers', description: 'Managed servers' }]);
});
it('flattens grouped nodes and maps native node fields', () => {
expect(normalizeGroupedNodes({
action: MeshCtrlAction.Nodes,
nodes: {
'mesh-servers': [{
_id: 'node-pve3',
name: 'PVE3',
conn: 1,
osdesc: 'Ubuntu',
ip: ['10.10.10.13'],
}],
},
})).toEqual([{
id: 'node-pve3',
name: 'PVE3',
meshid: 'mesh-servers',
online: true,
device: { os: 'Ubuntu', ip: ['10.10.10.13'] },
}]);
});
});
+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();
}
}
+109 -66
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
// Install the pending request before sending. MeshCentral may answer
// immediately once its bootstrap phase has settled.
setTimeout(() => {
try {
if (this.ws) {
if (this.ws && this.connected) {
this.logger.debug('MeshCentral request sent', { action: request.action });
this.ws.send(JSON.stringify(finalRequest));
} else {
this.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(new Error('Not connected'));
}
} catch (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
return;
}
// Set up response listener with bounded size validation
const handleResponse = (data: Buffer | string): void => {
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) {
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);
}
}
} catch (error) {
clearTimeout(timeout);
this._cleanup();
reject(error instanceof Error ? error : new Error('Unknown error'));
}
};
if (this.ws) {
this.ws.on('message', handleResponse);
}
}, sendDelay);
});
}
+172
View File
@@ -0,0 +1,172 @@
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();
}
}
/** Parse and normalize MeshCentral's native grouped node response. */
export function normalizeGroupedNodes(response: MeshCtrlResponse): Array<{
id: string;
name: string;
meshid: string;
online: boolean;
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
}> {
const grouped = (response.nodes ?? {}) as Record<string, Array<{
id?: string;
_id?: string;
name: string;
meshid?: string;
online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
device?: { os?: string; ip?: string[]; uptime?: number; lastseen?: number; agentver?: string };
}>>;
return Object.entries(grouped).flatMap(([groupId, nodes]) => nodes.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 !== '');
}
/** Normalize MeshCentral's native mesh identifiers and description field. */
export function normalizeMeshes(response: MeshCtrlResponse): Array<{
id: string;
name: string;
description?: string;
}> {
return ((response.meshes ?? []) as Array<{
id?: string;
_id?: string;
name: string;
description?: string;
desc?: string;
}>).map((mesh) => ({
id: mesh.id ?? mesh._id ?? '',
name: mesh.name,
description: mesh.description ?? mesh.desc,
})).filter((mesh) => mesh.id !== '');
}
+28
View File
@@ -0,0 +1,28 @@
# MeshCentral Read-only OpenClaw plugin
This package exposes a deliberately narrow OpenClaw tool surface for one approved MeshCentral group and device:
- `meshcentral_health`
- `meshcentral_list_groups`
- `meshcentral_list_devices`
- `meshcentral_device_status`
The group and device are configured, not selected by tool parameters. The password is read only from the configured environment-variable name at execution time and is never stored in plugin configuration or returned in results.
## Safety boundary
This plugin is inventory/status only. It does not expose MeshCentral commands, diagnostics, console, remote control, file transfer, power operations, relay, administration, or state-changing actions. TLS certificate validation is enabled, response sizes are bounded, requests time out, and the connection is closed after each operation.
Example configuration values for a trusted runtime:
```json
{
"url": "https://mesh.ledrew.me",
"username": "sa_clawdia",
"passwordEnv": "MESHCENTRAL_READONLY_PASSWORD",
"groupName": "Servers",
"deviceName": "PVE3"
}
```
Do not put the password itself in OpenClaw configuration, source, logs, or this repository. This package has been built and statically validated locally; it has not been installed or enabled in the OpenClaw Gateway.
+50
View File
@@ -0,0 +1,50 @@
{
"id": "meshcentral-readonly",
"name": "MeshCentral Read-only Inventory",
"description": "Read-only inventory and status for one approved MeshCentral group and device.",
"activation": {
"onStartup": true
},
"configSchema": {
"type": "object",
"required": [
"url",
"username",
"passwordEnv",
"groupName",
"deviceName"
],
"properties": {
"url": {
"type": "string",
"description": "MeshCentral base URL."
},
"username": {
"type": "string",
"description": "Dedicated least-privilege MeshCentral username."
},
"passwordEnv": {
"type": "string",
"description": "Environment variable containing the password; the password itself is never configured here."
},
"groupName": {
"type": "string",
"description": "Approved MeshCentral group name."
},
"deviceName": {
"type": "string",
"description": "Approved device name within the approved group."
}
},
"additionalProperties": false
},
"version": "0.1.0",
"contracts": {
"tools": [
"meshcentral_health",
"meshcentral_list_groups",
"meshcentral_list_devices",
"meshcentral_device_status"
]
}
}
+5080
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@ledrew/openclaw-meshcentral-readonly",
"version": "0.1.0",
"description": "OpenClaw read-only MeshCentral inventory and status tools",
"type": "module",
"main": "dist/index.js",
"files": [
"dist",
"openclaw.plugin.json",
"package.json",
"README.md"
],
"scripts": {
"build": "tsc",
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
"plugin:validate": "npm run plugin:build && openclaw plugins validate --entry ./dist/index.js",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"typebox": "^1.1.39",
"ws": "^8.21.3"
},
"peerDependencies": {
"openclaw": ">=2026.5.17"
},
"openclaw": {
"extensions": [
"./dist/index.js"
],
"compat": {
"pluginApi": ">=2026.5.17",
"minGatewayVersion": ">=2026.5.17"
}
},
"devDependencies": {
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Type } from 'typebox';
import { defineToolPlugin } from 'openclaw/plugin-sdk/tool-plugin';
import { inventory, type PluginConfig } from './meshcentral-readonly.js';
const configSchema = Type.Object({
url: Type.String({ description: 'MeshCentral base URL.' }),
username: Type.String({ description: 'Dedicated least-privilege MeshCentral username.' }),
passwordEnv: Type.String({ description: 'Environment variable containing the password; the password itself is never configured here.' }),
groupName: Type.String({ description: 'Approved MeshCentral group name.' }),
deviceName: Type.String({ description: 'Approved device name within the approved group.' }),
}, { additionalProperties: false });
const emptyParameters = Type.Object({}, { additionalProperties: false });
export default defineToolPlugin({
id: 'meshcentral-readonly',
name: 'MeshCentral Read-only Inventory',
description: 'Read-only inventory and status for one approved MeshCentral group and device.',
configSchema,
tools: (tool) => [
tool({
name: 'meshcentral_health',
label: 'MeshCentral Health',
description: 'Check authenticated read-only access to the approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return { healthy: result.groups.length === 1, group: config.groupName };
},
}),
tool({
name: 'meshcentral_list_groups',
label: 'MeshCentral Groups',
description: 'List the configured approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return result.groups;
},
}),
tool({
name: 'meshcentral_list_devices',
label: 'MeshCentral Devices',
description: 'List the configured approved device in the approved MeshCentral group.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
return result.devices;
},
}),
tool({
name: 'meshcentral_device_status',
label: 'MeshCentral Device Status',
description: 'Get status and safe metadata for the configured approved device.',
parameters: emptyParameters,
async execute(_params, config) {
const result = await inventory(config as PluginConfig);
const device = result.devices[0];
if (!device) throw new Error('Approved MeshCentral device was not found');
return device;
},
}),
],
});
@@ -0,0 +1,93 @@
import { WebSocket } from 'ws';
const CONTROL_PATH = '/control.ashx';
const MAX_RESPONSE_SIZE = 1024 * 1024;
const STARTUP_DELAY_MS = 750;
const REQUEST_TIMEOUT_MS = 30_000;
export type PluginConfig = {
url: string;
username: string;
passwordEnv: string;
groupName: string;
deviceName: string;
};
type MeshResponse = {
action?: string;
responseid?: string;
meshes?: Array<{ id?: string; _id?: string; name: string; description?: string; desc?: string }>;
nodes?: Record<string, Array<{
id?: string; _id?: string; name: string; meshid?: string; online?: boolean;
conn?: number; osdesc?: string; ip?: string[]; device?: Record<string, unknown>;
}>>;
};
function authHeader(username: string, password: string): string {
return `${Buffer.from(username).toString('base64')},${Buffer.from(password).toString('base64')}`;
}
function endpoint(url: string): string {
const wsUrl = url.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
return wsUrl.endsWith(CONTROL_PATH) ? wsUrl : `${wsUrl.replace(/\/$/, '')}${CONTROL_PATH}`;
}
export async function inventory(config: PluginConfig): Promise<{ groups: unknown[]; devices: unknown[] }> {
const password = process.env[config.passwordEnv];
if (!password) throw new Error('MeshCentral credential environment variable is not configured');
const ws = new WebSocket(endpoint(config.url), {
rejectUnauthorized: true,
perMessageDeflate: false,
headers: { 'x-meshauth': authHeader(config.username, password) },
});
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { ws.terminate(); reject(new Error('MeshCentral connection timed out')); }, REQUEST_TIMEOUT_MS);
ws.once('open', () => { clearTimeout(timeout); setTimeout(resolve, STARTUP_DELAY_MS); });
ws.once('error', (error: Error) => { clearTimeout(timeout); reject(error); });
});
const request = async (action: string, extra: Record<string, unknown> = {}): Promise<MeshResponse> => {
const responseid = `openclaw-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { ws.removeListener('message', onMessage); reject(new Error('MeshCentral request timed out')); }, REQUEST_TIMEOUT_MS);
const onMessage = (raw: Buffer | string): void => {
const text = Buffer.isBuffer(raw) ? raw.toString() : raw;
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE) return;
let parsed: MeshResponse;
try { parsed = JSON.parse(text) as MeshResponse; } catch { return; }
if (parsed.responseid !== responseid && !(action === 'meshes' && parsed.action === 'meshes')) return;
clearTimeout(timeout); ws.removeListener('message', onMessage); resolve(parsed);
};
ws.on('message', onMessage);
ws.send(JSON.stringify({ action, responseid, ...extra }));
});
};
const meshesResponse = await request('meshes');
const meshes = (meshesResponse.meshes ?? []).map((mesh) => ({
id: mesh.id ?? mesh._id ?? '', name: mesh.name, description: mesh.description ?? mesh.desc,
})).filter((mesh) => mesh.id && mesh.name.toLowerCase() === config.groupName.toLowerCase());
if (meshes.length !== 1) throw new Error('Approved MeshCentral group was not found uniquely');
const group = meshes[0];
const nodesResponse = await request('nodes', { meshid: group.id });
const devices = Object.entries(nodesResponse.nodes ?? {}).flatMap(([groupId, nodes]) => nodes.map((node) => ({
id: node.id ?? node._id ?? '', name: node.name, groupId: node.meshid ?? groupId,
online: node.online ?? node.conn === 1, os: node.device?.os ?? node.osdesc,
ipAddresses: node.device?.ip ?? node.ip,
// The nodes request is already scoped to the approved group. MeshCentral
// may omit or normalize the per-node meshid differently, so do not apply
// a second brittle group-id comparison here.
}))).filter((node) => node.id && node.name.toLowerCase() === config.deviceName.toLowerCase());
return { groups: meshes, devices };
} finally {
await new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) return resolve();
const timeout = setTimeout(() => { ws.terminate(); resolve(); }, 2_000);
ws.once('close', () => { clearTimeout(timeout); resolve(); });
ws.close(1000, 'bridge-close');
});
}
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const entry = await readFile(new URL('../src/index.ts', import.meta.url), 'utf8');
const transport = await readFile(new URL('../src/meshcentral-readonly.ts', import.meta.url), 'utf8');
test('declares exactly the four read-only tools', () => {
for (const name of [
'meshcentral_health',
'meshcentral_list_groups',
'meshcentral_list_devices',
'meshcentral_device_status',
]) assert.match(entry, new RegExp(`name: '${name}'`));
assert.doesNotMatch(entry, /runcommand|power|console|filetransfer|remotecontrol|diagnostic/i);
});
test('uses fixed configured targets and an environment variable for the password', () => {
assert.match(entry, /passwordEnv/);
assert.match(transport, /process\.env\[config\.passwordEnv\]/);
assert.match(transport, /groupName/);
assert.match(transport, /deviceName/);
assert.doesNotMatch(transport, /rejectUnauthorized:\s*false/);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}