Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b0196893e | ||
|
|
3e51472b4c | ||
|
|
eb833dc990 | ||
|
|
ad03d23150 | ||
|
|
589e96ccf2 |
+12
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 !== '');
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5080
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user