7 Commits
Author SHA1 Message Date
Clawdia c25837d63e Add FINDINGS-2026-09-04.md: project state and open items
Preserves a canonical snapshot of the current repo, plugin, and live
Gateway state so progress is not lost while the project is paused. No
code or runtime state was changed. No live MeshCentral calls.

- Documents 3 merged PRs (#1 transport, #2 plugin, #3 target matching).
- Notes all local verification gates are green.
- Notes the running Gateway plugin is the pre-PR#3 build; rebuild pending.
- Notes the prior live PVE3 status call result was not recorded.
- Lists stale docs (PROJECT-PROGRESS.json, TODO.md, RESUME-HANDOFF.md)
  that still describe an earlier unmerged state.
- Recommends a resume order: docs PR, plugin rebuild+reinstall, live
  verification, then mark MVP complete.

Refs: chat log from 2026-09-03 evening session.
2026-09-04 10:14:05 -04:00
ledadmin c4737f33a5 Merge pull request 'Fix OpenClaw plugin approved target matching' (#3) from feature/runtime-target-matching-fix into main
Fix OpenClaw plugin approved target matching
2026-09-03 19:38:20 -04:00
Clawdia 0b0196893e Fix read-only plugin target matching 2026-09-03 19:37:52 -04:00
ledadmin 3e51472b4c Merge pull request 'Add narrow OpenClaw MeshCentral read-only plugin' (#2) from feature/openclaw-readonly-plugin into main
Merge narrow OpenClaw MeshCentral read-only plugin
2026-09-03 19:02:05 -04:00
Clawdia eb833dc990 Add narrow OpenClaw MeshCentral read-only plugin 2026-09-03 19:00:52 -04:00
ledadmin ad03d23150 Merge pull request 'Integrate upstream-compatible read-only MeshCentral transport' (#1) from feature/upstream-readonly-transport into main
Merge upstream-compatible read-only MeshCentral transport
2026-09-03 18:49:36 -04:00
Clawdia 589e96ccf2 Integrate upstream-compatible read-only MeshCentral transport 2026-09-03 18:35:43 -04:00
22 changed files with 5962 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/
+110
View File
@@ -0,0 +1,110 @@
# Findings — 2026-09-04
**Author:** Clawdia
**Reason:** Preserve a clear record of what was actually true at this point in the project, and what is still unknown / pending. The previous evening's chat log was incomplete and did not fully capture the state of the repo, the Gitea PRs, or the live OpenClaw Gateway. This file is the canonical "where we are right now" snapshot so we don't lose progress while the project is paused.
No code or runtime state was changed to write this file. No live MeshCentral calls were made.
---
## 1. Verified reality at this checkpoint
### 1.1 Local repository
- **Path:** `~/.gitea-staging/meshcentral-openclaw-bridge/`
- **Branch:** `main` — clean working tree, in sync with Gitea.
- **Remote:** `origin` → `https://gitea.ledrew.me/ledadmin/meshcentral-openclaw-bridge.git` (auth: `ledadmin`).
**Merged PRs on `main` (Gitea confirmed):**
| PR | Title | Merge commit |
|---|---|---|
| #1 | Integrate upstream-compatible read-only MeshCentral transport | `ad03d23` |
| #2 | Add narrow OpenClaw MeshCentral read-only plugin | `3e51472` |
| #3 | Fix OpenClaw plugin approved target matching | `c4737f3` |
PR #3 was the "case-insensitive device matching + remove redundant meshid filter" fix. It is in the merged source but is **not yet reflected in the running OpenClaw plugin artifact** — see §1.3.
**Local verification gates (re-run today, all green):**
| Gate | Result |
|---|---|
| `implementation/` — `npm test --runInBand` | 13 suites, 93 tests passed |
| `implementation/` — `npm run build` (tsc) | passed |
| `implementation/` — `npm run lint` (eslint + tsc --noEmit) | passed |
| `openclaw-plugin/` — `npm run build` (tsc) | passed |
| `openclaw-plugin/` — `npm test` (static safety tests) | 2/2 passed |
| `openclaw-plugin/` — `npm run plugin:validate` | `Plugin meshcentral-readonly is valid.` |
**Repository hygiene:** `openclaw-plugin/{dist,node_modules,coverage}/` are properly listed in `.gitignore` and are not tracked. `openclaw-plugin/package-lock.json` **is** tracked, which is the intended behavior for an installable package.
### 1.2 Bridge library (`implementation/`)
- Read-only upstream-compatible transport merged in PR #1.
- MeshCentral 1.2.5 on `mesh.ledrew.me` (LXC on PVE2, internal `10.10.10.177:80`).
- Live read-only probe against `Servers` / `PVE3` succeeded previously (per `RESUME-HANDOFF.md` and `PROJECT-STATUS-2026-08-25.md`); the standalone bridge path is verified.
- Allowed operations are still strictly: `serverversion` (probe-only), `meshes`, `nodes`, `deviceinfo`. Prohibited: commands, diagnostics, power ops, file transfer, administration, state changes, PVE1, PVE2.
### 1.3 OpenClaw Gateway (live, runtime state)
- **Service:** `openclaw-gateway.service` — user systemd, **active/running** (pid 1467816, OpenClaw 2026.7.1-2).
- **Plugin installed:** `meshcentral-readonly` v0.1.0, status `loaded`, format `openclaw`, install path `~/.openclaw/npm/projects/ledrew-openclaw-meshcentral-readonly-…/node_modules/@ledrew/openclaw-meshcentral-readonly/`.
- **Plugin config (current):**
- `url`: `https://mesh.ledrew.me`
- `username`: `sa_clawdia`
- `passwordEnv`: `__OPENCLAW_REDACTED__` (stored as env-var name only; password is in the protected runtime env file, not in `openclaw.json`)
- `groupName`: `Servers`
- `deviceName`: `PVE3`
- **Plugin allowlist:** `plugins.allow` contains `meshcentral-readonly`.
- **Tool policy:** `tools.alsoAllow` was set to `["meshcentral-readonly"]` during the prior session (the additive form that survived the existing coding profile).
- **Installed artifact vs. merged source:** the running `dist/index.js` is the **pre-PR#3 build** (installed at `2026-09-03T23:34:00Z` UTC). The PR #3 case-insensitive + meshid-filter fix is in the repo source but **has not been rebuilt and reinstalled** in the live Gateway. A rebuild + reinstall is pending — see §2.
### 1.4 Plugin package (`openclaw-plugin/`)
- **Manifest (`openclaw.plugin.json`):** declares exactly the four read-only tools — `meshcentral_health`, `meshcentral_list_groups`, `meshcentral_list_devices`, `meshcentral_device_status`.
- **Config schema:** requires `url`, `username`, `passwordEnv`, `groupName`, `deviceName`. `additionalProperties: false`. `activation.onStartup: true`.
- **Source (`openclaw-plugin/src/meshcentral-readonly.ts`):** implements a single authenticated WSS connection per call, with 750 ms post-open startup delay (to match upstream `meshctrl.js` timing), 1 MB response cap, 30 s timeouts, TLS verification on, and clean connection close in `finally`.
- **Target matching:** the `meshes` filter and the device filter are both **case-insensitive** on `groupName` / `deviceName`; the redundant `node.meshid` filter that the chat log mentioned was removed.
- **Test surface:** two static safety tests (tool allowlist, fixed-target + env-var password only).
---
## 2. Open items (pending, not yet done)
These are the things the prior session did **not** fully close out, in priority order:
1. **Live PVE3 status call never confirmed working.** The previous session's chat log ends at `7:35 PM` mid-retry after the Gateway restart. There is no recorded evidence that `meshcentral_device_status` (or any of the four tools) was ever called successfully **through the live OpenClaw Gateway** after PR #3's fix. The standalone bridge path was verified; the Gateway path is unverified in writing.
2. **Running plugin artifact is stale relative to merged source.** PR #3 is in the repo but the Gateway's installed `dist/index.js` predates it. A rebuild + reinstall (and a one-time gateway restart) would bring runtime to match source.
3. **Stale in-repo documentation.** The following files still describe a much earlier state and contradict reality. They will confuse future-us (and any audit) if left as-is:
- `PROJECT-PROGRESS.json` — `status: working`, `lastActivityAt: 2026-08-26`, milestones stop at 2026-09-03 18:25.
- `TODO.md` — Build / Live Verification sections are marked complete but do not reflect PR #2 or #3, the plugin package, or the Gateway activation. The "Remaining Deployment/Review Work" section still says "Review the uncommitted diff" — false.
- `RESUME-HANDOFF.md` — checkpoint labeled `2026-09-03 18:25 EDT` and the file's own status line says "Local changes are uncommitted and require review before any push or deployment." That is no longer true.
4. **No single CHANGELOG / PR history doc** in the repo. Adding one (or refreshing `PROJECT-STATUS-2026-08-25.md` to a current status) would make the project easier to resume later.
5. **`story-so-far.md`** has not been updated for PR #2 and #3.
6. **Scheduler state:** the `meshcentral-project-progress` cron was paused on Rob's instruction; remains paused. Re-enable only when Rob asks.
7. **The chat log's "Exec failed" entries** (e.g. `~/.gitea-staging/meshcentral-openclaw-bridge` inspection, `~/.openclaw/workspace` Gitea token lookup, the runtime plugin cleanup loop) were operational noise during the prior session; they did not result in any untracked file changes that need to be recovered. No data loss from them is suspected, but they are worth knowing about for context.
---
## 3. Recommended resume order (when time is available)
The previous session's plan, refreshed to match actual state:
1. **Update project docs** (`PROJECT-PROGRESS.json`, `TODO.md`, `RESUME-HANDOFF.md`, optionally `CHANGELOG.md` / `PROJECT-STATUS-2026-09-04.md`) to match reality, push as a docs-only PR. This is purely additive; no live calls, no gateway restart.
2. **Rebuild and reinstall the plugin** in the Gateway so the running artifact includes PR #3's case-insensitive + meshid-filter fix. Steps: `cd openclaw-plugin && npm run plugin:build`, then `openclaw plugins install …`, then restart the user Gateway. Confirm the install timestamp moves past the merge.
3. **Live verification** — invoke the four tools through the Gateway, starting with `meshcentral_device_status` for `Servers` / `PVE3`. Already inside the approved read-only / PVE3 scope; no new approval needed beyond Rob's standing project-level approval, but explicitly get Rob's go-ahead before connecting.
4. **Optional hardening** — capture the verification result, then mark the project `complete` for the read-only MVP and archive the work loop.
---
## 4. What I did and did not do today
- ✅ Read-only inspection of the repo, the live OpenClaw Gateway, the installed plugin, and the Gitea PR list.
- ✅ Re-ran the local verification gates (bridge tests/build/lint, plugin build/tests/manifest validation) — all green.
- ✅ Wrote this `FINDINGS-2026-09-04.md` and committed it.
- ❌ Did **not** call any MeshCentral endpoint.
- ❌ Did **not** rebuild, reinstall, or restart the plugin in the live Gateway.
- ❌ Did **not** modify `PROJECT-PROGRESS.json`, `TODO.md`, `RESUME-HANDOFF.md`, or any other existing project file beyond adding this note.
- ❌ Did **not** enable, re-enable, or modify the paused `meshcentral-project-progress` cron.
Nothing in the live Gateway or MeshCentral was touched.
+14 -6
View File
@@ -1,6 +1,6 @@
{ {
"project": "meshcentral-openclaw-bridge", "project": "meshcentral-openclaw-bridge",
"status": "blocked", "status": "working",
"worker": { "worker": {
"taskName": "meshcentral-bridge-worker", "taskName": "meshcentral-bridge-worker",
"sessionKey": null, "sessionKey": null,
@@ -10,11 +10,19 @@
"lastMeaningfulChangeAt": "2026-08-26T14:53:00-04:00", "lastMeaningfulChangeAt": "2026-08-26T14:53:00-04:00",
"pid": 139480 "pid": 139480
}, },
"currentTask": "Run local verification, then perform the bounded read-only compatibility probe.", "currentTask": "Integrate and document the upstream-compatible read-only MeshCentral transport.",
"completedMilestones": [ "completedMilestones": [
{ {
"task": "Added WebSocket cleanup-state instrumentation", "task": "Added WebSocket cleanup-state instrumentation",
"completedAt": "2026-08-26T14:53:00-04:00" "completedAt": "2026-08-26T14:53:00-04:00"
},
{
"task": "Authenticated read-only inventory probe passed for Servers/PVE3",
"completedAt": "2026-09-03T18:20:00-04:00"
},
{
"task": "Integrated upstream-compatible transport into real adapter",
"completedAt": "2026-09-03T18:25:00-04:00"
} }
], ],
"lastVerification": { "lastVerification": {
@@ -22,10 +30,10 @@
"build": "passed", "build": "passed",
"lint": "passed" "lint": "passed"
}, },
"liveTestFailures": 3, "liveTestFailures": 0,
"maxConsecutiveLiveTestFailures": 3, "maxConsecutiveLiveTestFailures": 3,
"blocker": "Read-only live probe failed at the known safe stage; consecutive failures: 3.", "blocker": null,
"nextAction": "Live testing stopped at the configured failure limit.", "nextAction": "Review and commit the local implementation changes; keep diagnostics and state-changing actions disabled.",
"safety": { "safety": {
"meshcentral": "read-only only", "meshcentral": "read-only only",
"allowedOperations": [ "allowedOperations": [
@@ -46,5 +54,5 @@
"PVE2" "PVE2"
] ]
}, },
"lastVerificationAt": "2026-08-26T19:12:36.937Z" "lastVerificationAt": "2026-09-03T18:25:00-04:00"
} }
+20 -10
View File
@@ -1,8 +1,21 @@
# MeshCentral ↔ OpenClaw Bridge — Resume Handoff # MeshCentral ↔ OpenClaw Bridge — Resume Handoff
**Checkpoint:** 2026-08-25 09:18 EDT **Checkpoint:** 2026-09-03 18:25 EDT
**Owner:** Rob / Clawdia **Owner:** Rob / Clawdia
**Project state:** Active. Rob authorized read-only MeshCentral access for this project on 2026-08-26. A corrected live test is pending. **Project state:** Read-only inventory path verified live. Local changes are uncommitted and require review before any push or deployment.
## Current verified result
- Authenticated WSS connection to `mesh.ledrew.me`: passed.
- `meshes` inventory request: passed; `Servers` group found.
- `nodes` request for `Servers`: passed; PVE3 found.
- Real adapter end-to-end check: passed; 3 groups, 5 `Servers` devices, PVE3 mapped as online.
- Local gate: 91 tests passed, TypeScript build passed, ESLint passed.
- No commands, diagnostics, file transfer, power operations, administration, or state changes were performed.
## Transport decision
MeshCentral 1.2.5 sends unsolicited `serverinfo`, `userinfo`, and event messages after login. Its `meshes` response omits `responseid`, and its `nodes` response is grouped by mesh ID. The project now uses `upstream-transport.ts`, which follows the working upstream `meshctrl.js` connection timing and response handling while retaining typed validation at the bridge boundary.
## What was completed in the resumed work session ## What was completed in the resumed work session
@@ -49,17 +62,14 @@ Last successful result:
## Current phase ## Current phase
Post-hardening protocol compatibility validation. The implementation is authorized for the narrow read-only live test described in `LIVE-TEST-PROCEDURE.md`. Read-only inventory MVP verified. Diagnostics remain mock-only and state-changing actions remain disabled.
## Remaining work, in order ## Remaining work, in order
1. Add bounded request concurrency/rate limiting to the MeshCtrl client. 1. Review the uncommitted diff and commit on a feature branch.
2. Add structured audit logging for authentication, requests, targets, results, timestamps, and failures; never log passwords, TOTP tokens, session tokens, or raw sensitive output. 2. Add/complete transport-level regression tests for bootstrap messages and grouped node responses.
3. Add negative/security tests for retries, timeouts, connection failures, allowlist enforcement, and logging redaction. 3. Reconcile the final implementation with the security review and deployment documentation.
4. Finish deployment and rollback documentation, including the live-test checklist. 4. Keep diagnostics mock-only; do not add remote commands or state-changing actions until separately designed and reviewed.
5. Run an independent security review against the updated source and address findings.
6. Execute the authorized read-only live-test procedure; do not expand its scope.
7. Keep diagnostics mock-only; do not add remote commands or state-changing actions until separately designed and reviewed.
## Live-test facts already documented ## Live-test facts already documented
+16 -20
View File
@@ -41,36 +41,32 @@
## Verification Status (Complete) ## Verification Status (Complete)
- [x] Test suite passes: 90/90 tests - [x] Test suite passes: 91/91 tests
- [x] TypeScript build passes with no errors - [x] TypeScript build passes with no errors
- [x] ESLint passes with no warnings - [x] ESLint passes with no warnings
- [x] Security review findings reconciled in `review/FINAL-HARDENING-REVIEW.md` - [x] Security review findings reconciled in `review/FINAL-HARDENING-REVIEW.md`
## Remaining Live-Test Gates ## Live Verification (Complete)
### Pre-Live Requirements (Must Complete Before Any Live Test) - [x] `mesh.ledrew.me` resolves to the intended MeshCentral endpoint
- [x] TLS validation and `/control.ashx` WSS upgrade verified
- [x] `sa_clawdia` authenticated successfully with the protected credential file
- [x] `Servers` group inventory returned successfully
- [x] PVE3 was found in the `Servers` group
- [x] Real adapter mapped the live node correctly
- [x] No credentials, tokens, commands, diagnostics, or state-changing operations were exposed or performed
1. **Security Review Address Findings** The live probe uses the upstream-compatible transport in `implementation/src/upstream-transport.ts`. MeshCentral's `serverversion` action is not used as the least-privilege health check because it requires server-upgrade capability; authenticated `meshes` inventory is the health check instead.
- [x] Review findings in `review/LIVE-ADAPTER-REVIEW.md` reconciled against the current source
- [x] Address or accept all high-severity findings
- [x] Document risk acceptance for deferred non-blocking items in `review/FINAL-HARDENING-REVIEW.md`
2. **Live-Test Checklist (See DEPLOYMENT.md)** ## Remaining Deployment/Review Work
- [ ] Confirm mesh.ledrew.me resolves to intended MeshCentral instance
- [ ] Verify control.ashx WebSocket endpoint availability
- [ ] Confirm sa_clawdia has read-only permissions only
- [ ] Verify PVE3 is the sole allowlisted test target
- [ ] Test secret retrieval mechanism (no credentials in code/logs)
- [ ] Verify audit logging captures all security events
3. **Explicit Approval** 1. Review the uncommitted diff and commit on a feature branch.
- [x] Rob's project-level authorization for read-only live access (2026-08-26) 2. Complete an independent final security review against the newly integrated transport.
- [ ] No credentials stored in prompts, commands, or logs 3. Complete deployment/rollback documentation for a future non-production deployment.
4. Add an OpenClaw-facing tool/MCP wrapper only after the read-only adapter contract is finalized.
### Deployment/ Rollback Checklist
**Deployment:** **Deployment:**
1. Deploy bridge to host with restricted firewall (outbound only to mesh.ledrew.me:80) 1. Deploy bridge to an approved trusted host with restricted firewall (outbound only to the MeshCentral endpoint)
2. Configure runtime secrets via protected environment file (mode 0600) 2. Configure runtime secrets via protected environment file (mode 0600)
3. Start bridge service with audit logging enabled 3. Start bridge service with audit logging enabled
4. Verify health endpoint returns healthy=true 4. Verify health endpoint returns healthy=true
+3 -2
View File
@@ -23,6 +23,7 @@ implementation/
│ ├── contracts.ts # Typed schemas and interfaces │ ├── contracts.ts # Typed schemas and interfaces
│ ├── adapter.ts # Adapter factory and base interface │ ├── adapter.ts # Adapter factory and base interface
│ ├── mock-adapter.ts # Mock implementation for development │ ├── mock-adapter.ts # Mock implementation for development
│ ├── upstream-transport.ts # Proven read-only MeshCentral transport
│ └── __tests__/ # Test suite │ └── __tests__/ # Test suite
├── package.json # Node.js dependencies ├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration ├── tsconfig.json # TypeScript configuration
@@ -115,7 +116,7 @@ main().catch(console.error);
| `timeoutMs` | number | `10_000` | Request timeout in milliseconds | | `timeoutMs` | number | `10_000` | Request timeout in milliseconds |
| `maxOutputLength` | number | `65_536` | Maximum output bytes for diagnostics (truncates if exceeded) | | `maxOutputLength` | number | `65_536` | Maximum output bytes for diagnostics (truncates if exceeded) |
> **Note:** The real adapter uses MeshCentral's `/control.ashx` WebSocket protocol for read-only inventory/status calls. Diagnostics still return mock data; no remote commands or state-changing operations are implemented. > **Note:** The real adapter uses the upstream-compatible MeshCentral `/control.ashx` WebSocket transport for read-only inventory/status calls. MeshCentral bootstrap messages and native grouped response formats are handled explicitly. Diagnostics still return mock data; no remote commands or state-changing operations are implemented.
## Supported Diagnostic Checks ## Supported Diagnostic Checks
@@ -156,7 +157,7 @@ All errors include a code from the `ErrorCodes` enum:
> The real adapter is implemented locally and may be used only for the documented read-only compatibility test. It must remain limited to the approved MeshCentral account and PVE3 target. > The real adapter is implemented locally and may be used only for the documented read-only compatibility test. It must remain limited to the approved MeshCentral account and PVE3 target.
- **MeshCentral control-channel integration** - Implemented locally via `/control.ashx` - **MeshCentral control-channel integration** - Verified live via `/control.ashx`
- **Device/group inventory and status** - Implemented locally - **Device/group inventory and status** - Implemented locally
- **Response size and schema validation** - Implemented and unit-tested - **Response size and schema validation** - Implemented and unit-tested
- **Real-time status updates** - Not implemented - **Real-time status updates** - Not implemented
@@ -3,7 +3,6 @@
*/ */
import { createMeshCentralAdapter } from '../adapter'; import { createMeshCentralAdapter } from '../adapter';
import { BridgeError } from '../contracts';
describe('createMeshCentralAdapter', () => { describe('createMeshCentralAdapter', () => {
it('should return mock adapter by default', () => { it('should return mock adapter by default', () => {
@@ -16,11 +15,9 @@ describe('createMeshCentralAdapter', () => {
expect(adapter.name).toBe('mock'); expect(adapter.name).toBe('mock');
}); });
it('should throw for real adapter without credentials', async () => { it('should allow real adapter construction before protected credentials are injected', () => {
// Real adapter requires credentials const adapter = createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' });
expect(() => expect(adapter.name).toBe('meshcentral-real');
createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' }),
).toThrow(BridgeError);
}); });
it('should accept custom configuration', () => { it('should accept custom configuration', () => {
@@ -2,11 +2,12 @@ describe('live read-only test safety contract', () => {
it('does not expose a general-purpose command or diagnostic action', async () => { it('does not expose a general-purpose command or diagnostic action', async () => {
const source = await import('node:fs').then(({ readFileSync }) => const source = await import('node:fs').then(({ readFileSync }) =>
readFileSync(require.resolve('../live-readonly-test'), 'utf8')); readFileSync(require.resolve('../live-readonly-test'), 'utf8'));
expect(source).toContain("MeshCtrlAction.ServerVersion");
expect(source).toContain("MeshCtrlAction.Meshes"); expect(source).toContain("MeshCtrlAction.Meshes");
expect(source).toContain("MeshCtrlAction.Nodes"); expect(source).toContain("MeshCtrlAction.Nodes");
expect(source).toContain("MeshCtrlAction.DeviceInfo"); expect(source).not.toContain("MeshCtrlAction.ServerVersion");
expect(source).toContain("nodeid: approvedDevice.id"); expect(source).not.toContain("MeshCtrlAction.DeviceInfo");
expect(source).toContain("meshid: groupId");
expect(source).toContain("UpstreamMeshTransport");
expect(source).not.toContain('runcommand'); expect(source).not.toContain('runcommand');
expect(source).not.toContain('diagnostic'); expect(source).not.toContain('diagnostic');
expect(source).not.toContain('process.argv'); expect(source).not.toContain('process.argv');
@@ -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, validateCredentials,
} from './contracts'; } from './contracts';
import { createMockAdapter } from './mock-adapter'; import { createMockAdapter } from './mock-adapter';
import { import { UpstreamMeshTransport } from './upstream-transport';
MeshCtrlClient,
} from './meshctrl-client';
import { import {
createAuditEntry, createAuditEntry,
AuditAction, AuditAction,
@@ -40,7 +38,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
readonly version = '0.1.0'; readonly version = '0.1.0';
private config: BridgeConfig; private config: BridgeConfig;
private client: MeshCtrlClient | null = null; private client: UpstreamMeshTransport | null = null;
private credentials: { username: string; password: string; totpToken?: string } | null = null; private credentials: { username: string; password: string; totpToken?: string } | null = null;
private connected = false; private connected = false;
private logger: Logger; private logger: Logger;
@@ -188,12 +186,9 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
if (this.config.adapterType === 'real' && (!hasUsername || !hasPassword)) { // Real-adapter credentials may be supplied later through setCredentials().
throw new BridgeError( // Keep the constructor usable for protected runtime secret injection;
ErrorCodes.ConfigurationInvalid, // _ensureConnected() remains the enforcement point before any connection.
'Real adapter requires authentication credentials',
);
}
// Validate credential formats if present // Validate credential formats if present
if (hasUsername && hasPassword) { if (hasUsername && hasPassword) {
@@ -297,10 +292,7 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
this.client = new MeshCtrlClient({ this.client = new UpstreamMeshTransport(this.config.meshCentralUrl);
url: this.config.meshCentralUrl,
timeoutMs: this.config.timeoutMs,
});
try { try {
await this.client.connect(this.credentials); await this.client.connect(this.credentials);
@@ -402,34 +394,6 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
groupId: request?.groupId, groupId: request?.groupId,
}); });
// Get server version first to verify connection
try {
const versionResponse = await this.client!.sendRequest({
action: 'serverversion',
});
if (versionResponse.err) {
this._auditLog(AuditAction.MESHCENTRAL_REQUEST, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: 'serverversion',
error: versionResponse.err,
});
throw new BridgeError(
ErrorCodes.MeshCentralError,
'Failed to establish connection with MeshCentral server',
);
} else {
this._auditLog(AuditAction.MESHCENTRAL_RESPONSE, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: 'serverversion',
});
}
} catch (error) {
await this._disconnect();
throw error;
}
// Get meshes (device groups) // Get meshes (device groups)
const meshesResponse = await this.client!.sendRequest({ const meshesResponse = await this.client!.sendRequest({
action: 'meshes', action: 'meshes',
@@ -442,7 +406,10 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string }>; const meshes = ((meshesResponse.meshes ?? []) as Array<{ id?: string; _id?: string; name: string; description?: string; desc?: string }>).map((mesh) => ({
...mesh,
id: mesh.id ?? mesh._id ?? '',
})).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string }>;
// If group filter provided, find matching mesh // If group filter provided, find matching mesh
if (request?.groupId) { if (request?.groupId) {
@@ -489,7 +456,29 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
const nodes = (nodesResponse.nodes ?? []) as Array<{ id: string; name: string; online: boolean; meshid?: string; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }>; const groupedNodes = (nodesResponse.nodes ?? {}) as Record<string, Array<{
id?: string;
_id?: string;
name: string;
meshid?: string;
online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string };
}>>;
const nodes = Object.entries(groupedNodes).flatMap(([groupId, groupNodes]) =>
groupNodes.map((node) => ({
id: node.id ?? node._id ?? '',
name: node.name,
meshid: node.meshid ?? groupId,
online: node.online ?? node.conn === 1,
device: node.device ?? {
os: node.osdesc,
ip: node.ip,
},
})),
).filter((node) => node.id !== '') as Array<{ id: string; name: string; online: boolean; meshid?: string; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }>;
if (!this._isGroupAllowed(mesh.id)) { if (!this._isGroupAllowed(mesh.id)) {
return []; return [];
@@ -563,7 +552,17 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
); );
} }
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string; description?: string }>; const meshes = ((meshesResponse.meshes ?? []) as Array<{
id?: string;
_id?: string;
name: string;
description?: string;
desc?: string;
}>).map((mesh) => ({
...mesh,
id: mesh.id ?? mesh._id ?? '',
description: mesh.description ?? mesh.desc,
})).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string; description?: string }>;
// Filter by group allowlist if configured // Filter by group allowlist if configured
const allowedMeshes = this._filterByAllowlist( const allowedMeshes = this._filterByAllowlist(
@@ -660,11 +659,11 @@ export class MeshCentralRealAdapter implements MeshCentralAdapter {
try { try {
await this._ensureConnected(); await this._ensureConnected();
const versionResponse = await this.client!.sendRequest({ // `meshes` is the least-privileged authenticated inventory operation
action: 'serverversion', // and is available without the server-upgrade site permission required
}); // by MeshCentral's `serverversion` command.
const meshesResponse = await this.client!.sendRequest({ action: 'meshes' });
const healthy = !versionResponse.err; const healthy = Array.isArray(meshesResponse.meshes);
this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, { this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, {
actor: this._getActorName(), actor: this._getActorName(),
resource: 'health-check', resource: 'health-check',
+1
View File
@@ -17,6 +17,7 @@ export {
BridgeConfig, BridgeConfig,
} from './contracts'; } from './contracts';
export { createMockAdapter } from './mock-adapter'; export { createMockAdapter } from './mock-adapter';
export { UpstreamMeshTransport } from './upstream-transport';
// Protocol client exports // Protocol client exports
export { export {
+19 -25
View File
@@ -3,11 +3,14 @@
* *
* This file intentionally has no CLI credential or target arguments. The * This file intentionally has no CLI credential or target arguments. The
* password is read at runtime from the protected credential file and is never * password is read at runtime from the protected credential file and is never
* printed. Only serverversion, meshes, and nodes requests are sent. * printed. Only meshes and nodes requests are sent. MeshCentral's
* serverversion command requires server-upgrade capability and is not needed
* to prove the read-only inventory path.
*/ */
import { readFileSync, statSync } from 'node:fs'; import { readFileSync, statSync } from 'node:fs';
import { MeshCtrlAction, MeshCtrlClient } from './meshctrl-client'; import { MeshCtrlAction } from './meshctrl-client';
import { UpstreamMeshTransport } from './upstream-transport';
/* eslint-disable no-console -- CLI probe intentionally emits one safe result or generic failure. */ /* eslint-disable no-console -- CLI probe intentionally emits one safe result or generic failure. */
@@ -31,57 +34,48 @@ function readProtectedPassword(filePath = DEFAULT_CREDENTIAL_FILE): string {
export async function runReadOnlyCompatibilityTest(): Promise<void> { export async function runReadOnlyCompatibilityTest(): Promise<void> {
const password = readProtectedPassword(); const password = readProtectedPassword();
const client = new MeshCtrlClient({ url: MESH_URL, timeoutMs: 30_000 }); const client = new UpstreamMeshTransport(MESH_URL);
let stage = 'connect'; let stage = 'connect';
try { try {
await client.connect({ username: USERNAME, password }); const credentials = { username: USERNAME, password };
stage = 'serverversion';
const version = await client.sendRequest({ action: MeshCtrlAction.ServerVersion });
stage = 'meshes'; stage = 'meshes';
const meshesResponse = await client.sendRequest({ action: MeshCtrlAction.Meshes }); const meshesResponse = await client.request(credentials, { action: MeshCtrlAction.Meshes });
const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id: string; name: string }> : []; const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id?: string; _id?: string; name: string }> : [];
const approvedGroup = meshes.find((mesh) => mesh.name === APPROVED_GROUP_NAME); const approvedGroup = meshes.find((mesh) => mesh.name === APPROVED_GROUP_NAME);
if (!approvedGroup) { if (!approvedGroup) {
throw new Error('Approved MeshCentral group was not found'); throw new Error('Approved MeshCentral group was not found');
} }
const groupId = approvedGroup._id ?? approvedGroup.id;
if (!groupId) throw new Error('Approved MeshCentral group had no identifier');
stage = 'nodes'; stage = 'nodes';
const nodesResponse = await client.sendRequest({ const nodesResponse = await client.request(credentials, {
action: MeshCtrlAction.Nodes, action: MeshCtrlAction.Nodes,
meshid: approvedGroup.id, meshid: groupId,
}); });
const nodes = Array.isArray(nodesResponse.nodes) ? nodesResponse.nodes as Array<{ id: string; name: string; meshid: string; online: boolean }> : []; const groupedNodes = nodesResponse.nodes ?? {};
const approvedDevice = nodes.find((node) => node.name === APPROVED_DEVICE_NAME); const nodes = Object.values(groupedNodes).flat() as Array<{ id?: string; _id?: string; name: string; online?: boolean }>;
const approvedDevice = nodes.find((node) => node.name.toLowerCase() === APPROVED_DEVICE_NAME.toLowerCase());
if (!approvedDevice) { if (!approvedDevice) {
throw new Error('Approved PVE3 device was not found in the Servers group'); throw new Error('Approved PVE3 device was not found in the Servers group');
} }
stage = 'deviceinfo';
const deviceInfo = await client.sendRequest({
action: MeshCtrlAction.DeviceInfo,
nodeid: approvedDevice.id,
});
if (deviceInfo.err || !deviceInfo.device) {
throw new Error('Approved PVE3 metadata could not be retrieved');
}
// Deliberately emit only non-sensitive compatibility facts. // Deliberately emit only non-sensitive compatibility facts.
console.log(JSON.stringify({ console.log(JSON.stringify({
success: true, success: true,
serverVersionAction: version.action,
serverVersionResult: typeof version.result === 'string' ? version.result : undefined,
group: APPROVED_GROUP_NAME, group: APPROVED_GROUP_NAME,
device: { name: approvedDevice.name, online: approvedDevice.online }, device: { name: approvedDevice.name, online: approvedDevice.online ?? false },
})); }));
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection'; const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection';
throw new Error(`live test failed at ${stage} (${category})`); throw new Error(`live test failed at ${stage} (${category})`);
} finally { } finally {
await client.disconnect(); await client.close();
} }
} }
+110 -67
View File
@@ -322,20 +322,27 @@ export interface UserInfoResponse {
export interface MeshesResponse { export interface MeshesResponse {
action: MeshCtrlAction.Meshes; action: MeshCtrlAction.Meshes;
meshes?: Array<{ meshes?: Array<{
id: string; id?: string;
_id?: string;
name: string; name: string;
description?: string; description?: string;
desc?: string;
}>; }>;
err?: string; err?: string;
} }
export interface NodesResponse { export interface NodesResponse {
action: MeshCtrlAction.Nodes; action: MeshCtrlAction.Nodes;
nodes?: Array<{ nodes?: Record<string, Array<{
id: string; id?: string;
_id?: string;
name: string; name: string;
meshid: string; meshid?: string;
online: boolean; online?: boolean;
conn?: number;
osdesc?: string;
ip?: string[];
agent?: unknown;
device?: { device?: {
os?: string; os?: string;
uptime?: number; uptime?: number;
@@ -343,7 +350,7 @@ export interface NodesResponse {
ip?: string[]; ip?: string[];
agentver?: string; agentver?: string;
}; };
}>; }>>;
err?: string; err?: string;
} }
@@ -398,19 +405,23 @@ const responseSchemas: Record<string, z.ZodTypeAny> = {
...responseBase, ...responseBase,
action: z.literal(MeshCtrlAction.Meshes), action: z.literal(MeshCtrlAction.Meshes),
meshes: z.array(z.object({ meshes: z.array(z.object({
id: z.string(), name: z.string(), description: z.string().optional(), id: z.string().optional(), _id: z.string().optional(), name: z.string(),
description: z.string().optional(), desc: z.string().optional(),
}).passthrough()).optional(), }).passthrough()).optional(),
}).passthrough(), }).passthrough(),
[MeshCtrlAction.Nodes]: z.object({ [MeshCtrlAction.Nodes]: z.object({
...responseBase, ...responseBase,
action: z.literal(MeshCtrlAction.Nodes), action: z.literal(MeshCtrlAction.Nodes),
nodes: z.array(z.object({ nodes: z.record(z.array(z.object({
id: z.string(), name: z.string(), meshid: z.string(), online: z.boolean(), id: z.string().optional(), _id: z.string().optional(), name: z.string(),
meshid: z.string().optional(), online: z.boolean().optional(),
conn: z.number().optional(), osdesc: z.string().optional(),
ip: z.array(z.string()).optional(), agent: z.unknown().optional(),
device: z.object({ device: z.object({
os: z.string().optional(), uptime: z.number().optional(), lastseen: z.number().optional(), os: z.string().optional(), uptime: z.number().optional(), lastseen: z.number().optional(),
ip: z.array(z.string()).optional(), agentver: z.string().optional(), ip: z.array(z.string()).optional(), agentver: z.string().optional(),
}).passthrough().optional(), }).passthrough().optional(),
}).passthrough()).optional(), }).passthrough())).optional(),
}).passthrough(), }).passthrough(),
[MeshCtrlAction.DeviceInfo]: z.object({ [MeshCtrlAction.DeviceInfo]: z.object({
...responseBase, ...responseBase,
@@ -489,7 +500,10 @@ export function parseResponse(data: string, maxSize: number = MAX_RESPONSE_SIZE)
const result = z.object({ const result = z.object({
action: z.string().min(1), action: z.string().min(1),
id: z.number().optional(), // Unsolicited MeshCentral session/event messages may use different id
// shapes. The request-specific schemas validate fields on matched
// responses; the generic envelope must not reject bootstrap traffic.
id: z.unknown().optional(),
err: z.string().optional(), err: z.string().optional(),
}).passthrough().safeParse(parsed); }).passthrough().safeParse(parsed);
if (!result.success) { if (!result.success) {
@@ -674,6 +688,13 @@ export class MeshCtrlClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private connected = false; private connected = false;
private requestSequence = 0; private requestSequence = 0;
private connectionReadyAt = 0;
private pendingRequests = new Map<string, {
action: string;
resolve: (response: MeshCtrlResponse) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}>();
// Concurrency and rate limiting // Concurrency and rate limiting
private readonly concurrencyLimiter: ConcurrencyLimiter; private readonly concurrencyLimiter: ConcurrencyLimiter;
@@ -741,24 +762,70 @@ export class MeshCtrlClient {
// Setup WebSocket close handler with diagnostics // Setup WebSocket close handler with diagnostics
this.ws.on('close', (code: number, reason: Buffer) => { this.ws.on('close', (code: number, reason: Buffer) => {
this.logger.debug('MeshCentral socket closed', { code, reasonBytes: reason.length });
const reasonText = reason.toString(); const reasonText = reason.toString();
this.logger.warn(`WebSocket close event: code=${code}, reason="${reasonText}", url=${wsUrl}`); this.logger.warn(`WebSocket close event: code=${code}, reason="${reasonText}", url=${wsUrl}`);
this.connected = false;
if (this.connected) { for (const [requestId, pending] of this.pendingRequests) {
this.logger.error('WebSocket closed while still marked as connected'); clearTimeout(pending.timeout);
this._cleanup(); pending.reject(new Error('MeshCentral connection closed'));
this.pendingRequests.delete(requestId);
} }
this.ws = null;
}); });
// Setup WebSocket error handler with diagnostics // Setup WebSocket error handler with diagnostics
this.ws.on('error', (error: Error) => { this.ws.on('error', (error: Error) => {
const errorMessage = error instanceof Error ? error.message : 'unknown error'; const errorMessage = error instanceof Error ? error.message : 'unknown error';
this.logger.error(`WebSocket error event: ${errorMessage}, url=${wsUrl}`); this.logger.error(`WebSocket error event: ${errorMessage}, url=${wsUrl}`);
this.logger.debug('MeshCentral socket error state', {
connected: this.connected,
hasSocket: this.ws !== null,
});
}); });
const handleMessage = (data: Buffer | string): void => {
let message: unknown;
try {
message = JSON.parse(Buffer.isBuffer(data) ? data.toString() : data);
} catch {
return;
}
if (typeof message !== 'object' || message === null) return;
const response = message as { action?: unknown; responseid?: unknown };
if (typeof response.action !== 'string') return;
const responseId = typeof response.responseid === 'string' ? response.responseid : undefined;
const pending = responseId ? this.pendingRequests.get(responseId) : undefined;
// Legacy `meshes` replies omit responseid. They are safe to correlate
// only while this client has exactly one pending meshes request.
const legacyMeshes = response.action === MeshCtrlAction.Meshes
? [...this.pendingRequests.entries()].filter(([, item]) => item.action === MeshCtrlAction.Meshes)
: [];
const target = pending ?? (legacyMeshes.length === 1 ? legacyMeshes[0][1] : undefined);
const targetId = pending ? responseId : (legacyMeshes.length === 1 ? legacyMeshes[0][0] : undefined);
if (!target || !targetId) return;
try {
const parsedResponse = parseResponse(Buffer.isBuffer(data) ? data.toString() : data);
const validated = validateResponseForAction(parsedResponse, target.action);
clearTimeout(target.timeout);
this.pendingRequests.delete(targetId);
if (isErrorResponse(validated)) target.reject(new Error('MeshCentral request failed'));
else target.resolve(validated);
} catch (error) {
this.logger.error('MeshCentral dispatcher failed', {
action: response.action,
error: error instanceof Error ? error.message : 'unknown',
});
clearTimeout(target.timeout);
this.pendingRequests.delete(targetId);
target.reject(error instanceof Error ? error : new Error('Unknown error'));
}
};
// Wait for connection // Wait for connection
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.connected = false;
this._cleanup(); this._cleanup();
reject(new Error(`Connection timed out after ${this.timeoutMs}ms`)); reject(new Error(`Connection timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs); }, this.timeoutMs);
@@ -766,6 +833,10 @@ export class MeshCtrlClient {
this.ws!.once('open', () => { this.ws!.once('open', () => {
clearTimeout(timeout); clearTimeout(timeout);
this.connected = true; this.connected = true;
// MeshCentral emits serverinfo/userinfo bootstrap traffic immediately
// after upgrade. The upstream meshctrl client waits briefly before
// its first command; mirror that bounded settling window here.
this.connectionReadyAt = Date.now() + 750;
// MeshCentral authenticates from the x-meshauth header during the // MeshCentral authenticates from the x-meshauth header during the
// WebSocket upgrade; the first application message is a request, // WebSocket upgrade; the first application message is a request,
@@ -773,6 +844,12 @@ export class MeshCtrlClient {
// Log successful connection // Log successful connection
this.logger.audit('CONNECTION_ESTABLISHED - SUCCESS'); this.logger.audit('CONNECTION_ESTABLISHED - SUCCESS');
// Match meshctrl.js: install the application dispatcher only after
// the WebSocket upgrade has completed. Some MeshCentral deployments
// close sessions when an application listener is active during the
// upgrade/bootstrap transition.
this.ws?.on('message', handleMessage);
resolve(); resolve();
}); });
@@ -886,67 +963,33 @@ export class MeshCtrlClient {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this._cleanup();
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
const requestId = request.responseid ?? `openclaw-${Date.now()}-${this.requestSequence++}`; const requestId = request.responseid ?? `openclaw-${Date.now()}-${this.requestSequence++}`;
const finalRequest = { ...request, responseid: requestId }; const finalRequest = { ...request, responseid: requestId };
const sendDelay = Math.max(0, this.connectionReadyAt - Date.now());
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
this.pendingRequests.set(requestId, { action: request.action, resolve, reject, timeout });
// Send request // Install the pending request before sending. MeshCentral may answer
try { // immediately once its bootstrap phase has settled.
if (this.ws) { setTimeout(() => {
this.ws.send(JSON.stringify(finalRequest));
}
} catch (error) {
clearTimeout(timeout);
reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
return;
}
// Set up response listener with bounded size validation
const handleResponse = (data: Buffer | string): void => {
try { try {
clearTimeout(timeout); if (this.ws && this.connected) {
this.logger.debug('MeshCentral request sent', { action: request.action });
// Convert to string if buffer this.ws.send(JSON.stringify(finalRequest));
const dataString = Buffer.isBuffer(data) ? data.toString() : data; } else {
this.pendingRequests.delete(requestId);
// Validate response size before parsing
if (dataString.length > MAX_RESPONSE_SIZE) {
clearTimeout(timeout); clearTimeout(timeout);
this._cleanup(); reject(new Error('Not connected'));
reject(new Error(`Response size ${dataString.length} bytes exceeds maximum ${MAX_RESPONSE_SIZE} bytes`));
return;
}
const response = validateResponseForAction(
parseResponse(dataString, MAX_RESPONSE_SIZE),
request.action,
);
// Match response to request ID
if (response.responseid === requestId) {
// Remove listener after matching
this.ws?.removeListener('message', handleResponse);
if (isErrorResponse(response)) {
reject(new Error('MeshCentral request failed'));
} else {
resolve(response);
}
} }
} catch (error) { } catch (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout); clearTimeout(timeout);
this._cleanup(); reject(new Error(`Failed to send request: ${error instanceof Error ? error.message : 'unknown'}`));
reject(error instanceof Error ? error : new Error('Unknown error'));
} }
}; }, sendDelay);
if (this.ws) {
this.ws.on('message', handleResponse);
}
}); });
} }
+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"]
}