Initial project documentation and status

This commit is contained in:
Clawdia
2026-08-30 14:46:23 -04:00
commit 765f372c72
50 changed files with 12916 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"no-console": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-unused-expressions": "warn"
}
}
+202
View File
@@ -0,0 +1,202 @@
# MeshCentral ↔ OpenClaw Bridge - Implementation
Read-only bridge for device inventory and diagnostics between MeshCentral and OpenClaw.
> **⚠️ READ-ONLY LIVE TESTING** - The real MeshCentral control-channel adapter is authorized for the project's narrowly scoped read-only compatibility test. Diagnostics remain mock-only. Commands, state changes, and production-cluster access are prohibited.
## Overview
This implementation provides:
- **Typed contracts** - Zod schemas for all data structures
- **Adapter pattern** - Clean boundary between bridge and MeshCentral client
- **Mock-backed** - Works offline with sample data for development/testing
- **Structured errors** - Consistent error handling with codes and metadata
- **Timeouts/output limits** - Configurable safeguards for operations
## Project Layout
```
implementation/
├── src/
│ ├── index.ts # Main entry point
│ ├── contracts.ts # Typed schemas and interfaces
│ ├── adapter.ts # Adapter factory and base interface
│ ├── mock-adapter.ts # Mock implementation for development
│ └── __tests__/ # Test suite
├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration
├── jest.config.js # Jest test configuration
├── example-config.json # Development/testing config
└── example-config-real.json # Real MeshCentral integration config (needs credentials)
```
## Quick Start
### Install Dependencies
```bash
cd implementation
npm install
```
### Run Tests (Mock Data Only)
```bash
# All tests - uses mock data, no MeshCentral connection required
npm test
# Watch mode (development)
npm run test:watch
# With coverage
npm test -- --coverage
```
### Build
```bash
npm run build
```
### Lint
```bash
npm run lint
```
## Usage Example
```typescript
import { createMeshCentralAdapter, DeviceStatus } from './src/index';
async function main() {
// Create adapter with configuration
const adapter = createMeshCentralAdapter({
meshCentralUrl: 'http://10.10.10.60:8080',
adapterType: 'mock', // Safe local mode; real mode is read-only and PVE3-scoped
timeoutMs: 10_000,
maxOutputLength: 65_536,
});
// List all devices (uses mock data)
const devices = await adapter.listDevices();
console.log(`Found ${devices.length} devices`);
// Filter by group
const onlineDevices = await adapter.listDevices({ groupId: 'grp-servers' });
console.log(`Online servers: ${onlineDevices.length}`);
// Get device details
const device = await adapter.getDevice('dev-001');
console.log(`Device: ${device.name} (${device.status})`);
// Run diagnostics (mock data only - no real system commands executed)
const diskSpace = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: 'diskSpace',
});
console.log(diskSpace.output);
// Health check (mock always returns healthy)
const healthy = await adapter.healthCheck();
console.log(`Bridge healthy: ${healthy}`);
}
main().catch(console.error);
```
## Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `meshCentralUrl` | string | `'http://localhost:8080'` | MeshCentral server URL (unused in mock mode) |
| `adapterType` | `'real' \| 'mock'` | `'mock'` | Use `mock` for local tests; `real` requires credentials and a reviewed live-test environment |
| `timeoutMs` | number | `10_000` | Request timeout in milliseconds |
| `maxOutputLength` | number | `65_536` | Maximum output bytes for diagnostics (truncates if exceeded) |
> **Note:** The real adapter uses MeshCentral's `/control.ashx` WebSocket protocol for read-only inventory/status calls. Diagnostics still return mock data; no remote commands or state-changing operations are implemented.
## Supported Diagnostic Checks
> **⚠️ MOCK DATA ONLY** - All diagnostic outputs are simulated static strings. No real system commands are executed.
- `diskSpace` - Simulated filesystem usage statistics
- `services` - Simulated system service status
- `processes` - Simulated running process list
- `osDetails` - Simulated operating system version info
- `networkState` - Simulated network interface configuration
- `uptime` - Simulated system uptime and load average
## Error Handling
All errors include a code from the `ErrorCodes` enum:
| Code | Description |
|------|-------------|
| `ADAPTER_NOT_FOUND` | Adapter method not implemented |
| `CONFIGURATION_INVALID` | Invalid configuration provided |
| `TIMEOUT_EXCEEDED` | Request timed out |
| `OUTPUT_LIMIT_EXCEEDED` | Output exceeded maxOutputLength (truncated) |
| `AUTHENTICATION_FAILED` | Authentication error (**not implemented**) |
| `CONNECTION_FAILED` | Network connection failed (**not implemented**) |
| `RESOURCE_NOT_FOUND` | Device or group not found |
| `MESHCENTRAL_ERROR` | Generic MeshCentral API error (**not implemented**) |
## Adapter Status
### Mock Adapter ✅
- Device listing and filtering by group
- Device details retrieval
- Group listing and lookup
- Diagnostic execution with simulated output
- Health check
### Real Adapter ⚠️ (**Read-only scope only**)
> 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`
- **Device/group inventory and status** - Implemented locally
- **Response size and schema validation** - Implemented and unit-tested
- **Real-time status updates** - Not implemented
- **Actual command execution** - Not implemented (diagnostics use mock data)
## Testing Strategy
Tests are organized into:
1. **contract.test.ts** - Schema validation and type safety
2. **mock-adapter.test.ts** - Mock adapter behavior and edge cases
3. **integration.test.ts** - Full workflow validation
4. **adapter-factory.test.ts** - Adapter creation and configuration
## Future Implementation Notes
### Real MeshCentral Integration
The adapter uses the MeshCentral control WebSocket rather than the illustrative HTTP endpoints below. The protocol implementation is still subject to version-specific live verification:
```typescript
// Historical/illustrative endpoints; not used by the current adapter
const ENDPOINTS = {
devices: '/api/devices',
device: (id: string) => `/api/device/${id}`,
groups: '/api/groups',
diagnostics: (deviceId: string) => `/api/device/${deviceId}/diagnostics`,
};
```
Authentication will use MeshCentral's token-based auth.
> **⚠️ SECURITY REQUIRED** - Do not enable TLS validation bypass (`rejectUnauthorized: false`). Certificate verification must be enabled for production use.
### Adding New Diagnostic Checks
1. Add check type to `DiagnosticCheckType` enum in `contracts.ts`
2. Implement output in `DIAGNOSTIC_OUTPUTS` map in `mock-adapter.ts`
3. Add test coverage in `integration.test.ts`
## License
MIT
+6
View File
@@ -0,0 +1,6 @@
{
"meshCentralUrl": "http://10.10.10.60:8080",
"adapterType": "real",
"timeoutMs": 30000,
"maxOutputLength": 65536
}
+6
View File
@@ -0,0 +1,6 @@
{
"meshCentralUrl": "http://192.168.1.10:8080",
"adapterType": "mock",
"timeoutMs": 10000,
"maxOutputLength": 65536
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Jest configuration for bridge tests.
*/
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.ts$': ['ts-jest', { useESM: false }],
},
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
moduleFileExtensions: ['ts', 'js'],
};
+5570
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "meshcentral-openclaw-bridge",
"version": "0.1.0",
"description": "MeshCentral ↔ OpenClaw Bridge - Read-only inventory and diagnostics",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint 'src/**/*.ts' && tsc --noEmit",
"live-test": "node dist/live-readonly-test.js"
},
"keywords": [
"meshcentral",
"openclaw",
"bridge"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.13.5",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"eslint": "^8.57.1",
"jest": "^29.7.0",
"jest-extended": "^4.0.2",
"ts-jest": "^29.3.0",
"typescript": "^5.7.3"
},
"dependencies": {
"@types/ws": "^8.18.1",
"axios": "^1.8.1",
"ws": "^8.21.3",
"zod": "^3.24.2"
}
}
@@ -0,0 +1,39 @@
/**
* Tests for the adapter factory function.
*/
import { createMeshCentralAdapter } from '../adapter';
import { BridgeError } from '../contracts';
describe('createMeshCentralAdapter', () => {
it('should return mock adapter by default', () => {
const adapter = createMeshCentralAdapter();
expect(adapter.name).toBe('mock');
});
it('should return mock adapter when adapterType is mock', () => {
const adapter = createMeshCentralAdapter({ adapterType: 'mock' });
expect(adapter.name).toBe('mock');
});
it('should throw for real adapter without credentials', async () => {
// Real adapter requires credentials
expect(() =>
createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' }),
).toThrow(BridgeError);
});
it('should accept custom configuration', () => {
const config = {
meshCentralUrl: 'http://custom-url:8080',
timeoutMs: 5_000,
maxOutputLength: 32_768,
};
const adapter = createMeshCentralAdapter(config);
expect(adapter.name).toBe('mock');
// Verify mock adapter receives config (would be used by real adapter)
expect(adapter).toBeDefined();
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* Unit tests for audit logging and secret redaction.
*/
import {
createAuditEntry,
sanitizeForLogging,
redactSecrets,
isSecurityCritical,
AuditAction,
AuditStatus,
AuditLogEntry,
} from '../audit';
describe('Audit Logging - Redaction', () => {
describe('redactSecrets', () => {
it('should redact base64-encoded credentials', () => {
const input = 'Authorization: Basic dXNlcjpwYXNzd29yZA==';
const result = redactSecrets(input);
expect(result).not.toContain('dXNlcjpwYXNzd29yZA==');
expect(result).toContain('[REDACTED_BASE64]');
});
it('should redact hex-encoded secrets', () => {
// Use a 32+ char hex string that doesn't look like base64
const input = 'secret: 1234567890abcdef1234567890abcdef';
const result = redactSecrets(input);
expect(result).not.toContain('1234567890abcdef1234567890abcdef');
expect(result).toContain('[REDACTED_HEX]');
});
it('should redact passwords in JSON objects', () => {
const input = { username: 'admin', password: 'secret123' };
const result = redactSecrets(input);
expect(result).not.toContain('secret123');
});
it('should redact sensitive header values', () => {
const input = { 'x-meshauth': 'dXNlcjpwYXNzd29yZA==' };
const result = redactSecrets(input);
expect(result).not.toContain('dXNlcjpwYXNzd29yZA==');
});
it('should preserve non-sensitive data', () => {
const input = { username: 'admin', url: 'http://example.com' };
const result = redactSecrets(input);
expect(result).toContain('admin');
expect(result).toContain('http://example.com');
});
});
describe('sanitizeForLogging', () => {
it('should redact known secret keys', () => {
const input = {
username: 'admin',
password: 'secret123',
token: 'abc123',
secret: 'hidden',
};
const result = sanitizeForLogging(input);
expect(result.password).toBe('[REDACTED]');
expect(result.token).toBe('[REDACTED]');
expect(result.secret).toBe('[REDACTED]');
expect(result.username).toBe('admin');
});
it('should handle nested objects', () => {
const input = {
credentials: {
username: 'admin',
password: 'secret123',
},
url: 'http://example.com',
};
const result = sanitizeForLogging(input);
expect((result as { credentials: { password: unknown } }).credentials.password).toBe('[REDACTED]');
expect(result.url).toBe('http://example.com');
});
it('should handle arrays', () => {
const input = [
{ username: 'admin1', password: 'secret1' },
{ username: 'admin2', password: 'secret2' },
];
const result = sanitizeForLogging(input);
expect((result as Array<{ password: unknown }>)[0].password).toBe('[REDACTED]');
expect((result as Array<{ password: unknown }>)[1].password).toBe('[REDACTED]');
});
it('should return primitives unchanged', () => {
expect(sanitizeForLogging('string')).toBe('string');
expect(sanitizeForLogging(123)).toBe(123);
expect(sanitizeForLogging(true)).toBe(true);
});
});
});
describe('Audit Logging - Entry Creation', () => {
it('should create a complete audit entry', () => {
const entry = createAuditEntry({
action: AuditAction.AUTH_ATTEMPT,
status: AuditStatus.SUCCESS,
actor: 'admin',
resource: 'device-001',
details: { reason: 'login', success: true },
});
expect(entry.timestamp).toBeDefined();
expect(entry.action).toBe(AuditAction.AUTH_ATTEMPT);
expect(entry.status).toBe(AuditStatus.SUCCESS);
expect(entry.actor).toBe('admin');
expect(entry.resource).toBe('device-001');
expect(entry.details).toBeDefined();
expect(entry.details?.reason).toBe('login');
});
it('should redact secrets in details', () => {
const entry = createAuditEntry({
action: AuditAction.AUTH_ATTEMPT,
status: AuditStatus.SUCCESS,
actor: 'admin',
resource: 'device-001',
details: {
password: 'secret123',
username: 'admin',
},
});
expect(entry.details?.password).toBe('[REDACTED]');
expect(entry.details?.username).toBe('admin');
});
it('should use defaults when fields are missing', () => {
const entry = createAuditEntry({
action: AuditAction.CONFIG_CHANGE,
status: AuditStatus.PENDING,
});
expect(entry.actor).toBe('unknown');
expect(entry.resource).toBe('unknown');
});
});
describe('Audit Logging - Security Critical Actions', () => {
it('should identify security-critical actions', () => {
expect(isSecurityCritical(AuditAction.AUTH_ATTEMPT)).toBe(true);
expect(isSecurityCritical(AuditAction.CREDENTIAL_UPDATE)).toBe(true);
expect(isSecurityCritical(AuditAction.CONNECTION_ATTEMPT)).toBe(true);
expect(isSecurityCritical(AuditAction.CONFIG_CHANGE)).toBe(true);
});
it('should identify non-security-critical actions', () => {
expect(isSecurityCritical(AuditAction.DEVICE_LISTED)).toBe(false);
expect(isSecurityCritical(AuditAction.GROUP_LISTED)).toBe(false);
expect(isSecurityCritical(AuditAction.SERVICE_START)).toBe(false);
});
});
describe('Audit Logging - Schema Validation', () => {
it('should validate a proper audit entry', () => {
const validEntry: AuditLogEntry = {
timestamp: new Date().toISOString(),
action: AuditAction.AUTH_ATTEMPT,
status: AuditStatus.SUCCESS,
actor: 'admin',
resource: 'device-001',
};
// Import isAuditLogEntry from types if available, or test manually
const isValid = validateEntry(validEntry);
expect(isValid).toBe(true);
});
it('should reject invalid entries', () => {
const validTimestamp = new Date().toISOString();
// Invalid timestamp
const invalidEntry1 = {
timestamp: 'invalid-date',
action: AuditAction.AUTH_ATTEMPT,
status: AuditStatus.SUCCESS,
};
const isValid1 = validateEntry(invalidEntry1);
expect(isValid1).toBe(false);
// Invalid action string (not in enum)
const invalidEntry2 = {
timestamp: validTimestamp,
action: 'INVALID_ACTION' as AuditAction,
status: AuditStatus.SUCCESS,
};
const isValid2 = validateEntry(invalidEntry2);
expect(isValid2).toBe(false);
// Missing required fields
const invalidEntry3 = {
timestamp: validTimestamp,
};
const isValid3 = validateEntry(invalidEntry3);
expect(isValid3).toBe(false);
});
});
/**
* Helper to validate an entry against the audit schema.
*/
function validateEntry(entry: unknown): boolean {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const e = entry as Record<string, unknown>;
// Check required fields
if (typeof e.timestamp !== 'string' || Number.isNaN(Date.parse(e.timestamp))) return false;
if (!e.action || typeof e.action !== 'string') return false;
if (!e.status || typeof e.status !== 'string') return false;
// Validate action enum
const validActions = Object.values(AuditAction);
if (!validActions.includes(e.action as AuditAction)) return false;
// Validate status enum
const validStatuses = Object.values(AuditStatus);
if (!validStatuses.includes(e.status as AuditStatus)) return false;
// Actor and resource are optional but must be strings if present
if (e.actor !== undefined && typeof e.actor !== 'string') return false;
if (e.resource !== undefined && typeof e.resource !== 'string') return false;
// Details must be an object or undefined
if (e.details !== undefined && typeof e.details !== 'object') return false;
return true;
}
@@ -0,0 +1,127 @@
/**
* Unit tests for bridge contracts (schemas, types, errors).
*/
import { DeviceSchema, DeviceGroupSchema, DiagnosticResultSchema } from '../contracts';
describe('Contracts - Schemas', () => {
describe('DeviceSchema', () => {
it('should validate a complete device object', () => {
const validDevice = {
id: 'dev-001',
name: 'test-server',
groupId: 'grp-servers',
groupName: 'Servers',
status: 'online',
os: 'Ubuntu 24.04 LTS',
uptimeSeconds: 86400,
lastSeen: '2026-08-24T10:00:00Z',
ipAddresses: ['192.168.1.10'],
agentVersion: '2.4.1',
};
const result = DeviceSchema.safeParse(validDevice);
expect(result.success).toBe(true);
});
it('should validate a minimal device object', () => {
const minimalDevice = {
id: 'dev-002',
name: 'minimal-device',
groupId: 'grp-devices',
groupName: 'Devices',
status: 'offline',
};
const result = DeviceSchema.safeParse(minimalDevice);
expect(result.success).toBe(true);
});
it('should reject invalid status', () => {
const invalidDevice = {
id: 'dev-001',
name: 'test-server',
groupId: 'grp-servers',
groupName: 'Servers',
status: 'unknown-status' as const,
};
const result = DeviceSchema.safeParse(invalidDevice);
expect(result.success).toBe(false);
});
it('should validate device status enum values', () => {
const statuses = ['online', 'offline', 'unknown'];
for (const status of statuses) {
const device = {
id: `dev-${status}`,
name: `device-${status}`,
groupId: 'grp-test',
groupName: 'Test',
status,
};
const result = DeviceSchema.safeParse(device);
expect(result.success).toBe(true);
}
});
});
describe('DeviceGroupSchema', () => {
it('should validate a complete group object', () => {
const validGroup = {
id: 'grp-servers',
name: 'Servers',
description: 'Production servers',
deviceCount: 10,
};
const result = DeviceGroupSchema.safeParse(validGroup);
expect(result.success).toBe(true);
});
it('should validate a minimal group object', () => {
const minimalGroup = {
id: 'grp-devices',
name: 'Devices',
deviceCount: 5,
};
const result = DeviceGroupSchema.safeParse(minimalGroup);
expect(result.success).toBe(true);
});
});
describe('DiagnosticResultSchema', () => {
it('should validate a complete diagnostic result', () => {
const validResult = {
deviceId: 'dev-001',
checkType: 'diskSpace',
timestamp: '2026-08-24T10:00:00Z',
success: true,
output: 'Filesystem Size Used Avail Use% ...\n',
exitCode: 0,
outputLength: 1234,
maxOutputLength: 65_536,
};
const result = DiagnosticResultSchema.safeParse(validResult);
expect(result.success).toBe(true);
});
it('should validate with truncated flag', () => {
const result = DiagnosticResultSchema.safeParse({
deviceId: 'dev-001',
checkType: 'diskSpace',
timestamp: '2026-08-24T10:00:00Z',
success: true,
output: 'truncated output...',
exitCode: 0,
outputLength: 65_536,
maxOutputLength: 65_536,
truncated: true,
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,73 @@
/**
* Tests for device status handling.
*/
import { createMockAdapter } from '../index';
describe('Device Status', () => {
let adapter: ReturnType<typeof createMockAdapter>;
beforeEach(() => {
adapter = createMockAdapter();
});
it('should include all device statuses in mock data', async () => {
const devices = await adapter.listDevices();
const statuses = devices.map((d: { status: string }) => d.status);
expect(statuses).toContain('online');
expect(statuses).toContain('offline');
// Verify status enum values
for (const device of devices) {
expect(['online', 'offline', 'unknown']).toContain(device.status);
}
});
it('should handle offline devices correctly', async () => {
const devices = await adapter.listDevices();
const offlineDevice = devices.find((d: { status: string }) => d.status === 'offline');
expect(offlineDevice).toBeDefined();
// Offline devices should still be retrievable
if (offlineDevice) {
const detailed = await adapter.getDevice(offlineDevice.id);
expect(detailed.status).toBe('offline');
// Uptime may not be available for offline devices
}
});
it('should include lastSeen timestamp', async () => {
const devices = await adapter.listDevices();
for (const device of devices) {
expect(device.lastSeen).toBeDefined();
// Verify ISO 8601 format
expect(Date.parse(device.lastSeen!)).toBeGreaterThan(0);
}
});
it('should include IP addresses when available', async () => {
const devices = await adapter.listDevices();
// At least some devices should have IP addresses
const devicesWithIPs = devices.filter((d: { ipAddresses?: string[] }) => d.ipAddresses && d.ipAddresses.length > 0);
expect(devicesWithIPs.length).toBeGreaterThan(0);
// Verify IP format
for (const device of devicesWithIPs) {
if (device.ipAddresses) {
for (const ip of device.ipAddresses) {
expect(typeof ip).toBe('string');
// Basic IP address format validation
if (ip.includes(':')) {
expect(ip).toMatch(/^[0-9a-fA-F:]+$/); // IPv6
} else {
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/); // IPv4
}
}
}
}
});
});
@@ -0,0 +1,71 @@
{
"devices": [
{
"id": "dev-001",
"name": "home-server",
"groupId": "grp-servers",
"groupName": "Servers",
"status": "online",
"os": "Ubuntu 24.04 LTS",
"uptimeSeconds": 1296000,
"lastSeen": "2026-08-24T21:30:00Z",
"ipAddresses": ["192.168.1.10", "fd00::1"],
"agentVersion": "2.4.1"
},
{
"id": "dev-002",
"name": "proxmox-prod",
"groupId": "grp-servers",
"groupName": "Servers",
"status": "online",
"os": "Proxmox VE 8.2",
"uptimeSeconds": 259200,
"lastSeen": "2026-08-24T21:28:00Z",
"ipAddresses": ["192.168.1.13", "fd00::2"],
"agentVersion": "2.4.1"
},
{
"id": "dev-003",
"name": "test-lab-client",
"groupId": "grp-workstations",
"groupName": "Workstations",
"status": "offline",
"os": "Windows 11 Pro",
"lastSeen": "2026-08-23T21:30:00Z",
"ipAddresses": ["192.168.1.50"],
"agentVersion": "2.4.1"
},
{
"id": "dev-004",
"name": "raspberry-pi-01",
"groupId": "grp-devices",
"groupName": "IoT Devices",
"status": "online",
"os": "Raspberry Pi OS 12 (Bookworm)",
"uptimeSeconds": 604800,
"lastSeen": "2026-08-24T21:35:00Z",
"ipAddresses": ["192.168.1.100"],
"agentVersion": "2.4.1"
}
],
"groups": [
{
"id": "grp-servers",
"name": "Servers",
"description": "Production and infrastructure servers",
"deviceCount": 2
},
{
"id": "grp-workstations",
"name": "Workstations",
"description": "User workstations and laptops",
"deviceCount": 1
},
{
"id": "grp-devices",
"name": "IoT Devices",
"description": "Raspberry Pi and other IoT devices",
"deviceCount": 1
}
]
}
@@ -0,0 +1,52 @@
/**
* Tests for group filtering functionality.
*/
import { createMockAdapter } from '../mock-adapter';
describe('Group Filtering', () => {
let adapter: ReturnType<typeof createMockAdapter>;
beforeEach(() => {
adapter = createMockAdapter();
});
it('should list groups with correct device counts', async () => {
const groups = await adapter.listGroups();
expect(groups).toHaveLength(3);
// Verify device counts match actual devices in each group
for (const group of groups) {
const devices = await adapter.listDevices({ groupId: group.id });
expect(devices.length).toBe(group.deviceCount);
}
});
it('should filter devices by group ID', async () => {
const groups = await adapter.listGroups();
const serversGroup = groups.find((g) => g.name === 'Servers');
expect(serversGroup).toBeDefined();
if (serversGroup) {
const devices = await adapter.listDevices({ groupId: serversGroup.id });
expect(devices).toHaveLength(2);
expect(devices.every((d) => d.groupId === serversGroup.id)).toBe(true);
}
});
it('should return empty array for non-existent group', async () => {
const devices = await adapter.listDevices({ groupId: 'nonexistent' });
expect(devices).toHaveLength(0);
});
it('should include group name in device objects', async () => {
const devices = await adapter.listDevices();
for (const device of devices) {
expect(device.groupName).toBeDefined();
expect(typeof device.groupName).toBe('string');
}
});
});
@@ -0,0 +1,137 @@
/**
* Integration tests that validate the full bridge workflow.
*/
import { createMeshCentralAdapter, DiagnosticCheckType } from '../index';
describe('Bridge Integration', () => {
let adapter: ReturnType<typeof createMeshCentralAdapter>;
beforeEach(() => {
adapter = createMeshCentralAdapter({ adapterType: 'mock' });
});
describe('Device Discovery Workflow', () => {
it('should list all devices and groups, then fetch device details', async () => {
// Step 1: List all devices
const allDevices = await adapter.listDevices();
expect(allDevices).toHaveLength(4);
// Step 2: Get a specific device
const serverDevice = allDevices.find((d) => d.groupId === 'grp-servers');
if (!serverDevice) {
throw new Error('No server device found for testing');
}
const detailedDevice = await adapter.getDevice(serverDevice.id);
expect(detailedDevice.name).toBe(serverDevice.name);
// Step 3: Verify device belongs to expected group
expect(detailedDevice.groupId).toBe('grp-servers');
const group = await adapter.getGroup(detailedDevice.groupId);
expect(group.name).toBe('Servers');
});
it('should handle offline devices gracefully', async () => {
const allDevices = await adapter.listDevices();
const offlineDevices = allDevices.filter((d: { status: string }) => d.status === 'offline');
expect(offlineDevices).toHaveLength(1);
const offlineDevice = offlineDevices[0];
// Offline devices should still be retrievable
const detailed = await adapter.getDevice(offlineDevice.id);
expect(detailed.status).toBe('offline');
});
});
describe('Diagnostics Workflow', () => {
it('should run multiple diagnostic checks on a device', async () => {
const testDeviceId = 'dev-001';
const checks: DiagnosticCheckType[] = [
DiagnosticCheckType.DiskSpace,
DiagnosticCheckType.OSDetails,
DiagnosticCheckType.Uptime,
];
for (const checkType of checks) {
const result = await adapter.runDiagnostic({
deviceId: testDeviceId,
checkType,
});
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
expect(result.output.length).toBeGreaterThan(0);
}
});
it('should handle diagnostics on offline devices', async () => {
const offlineDevice = await adapter.getDevice('dev-003');
expect(offlineDevice.status).toBe('offline');
// Diagnostics should still run (mock returns consistent data)
const result = await adapter.runDiagnostic({
deviceId: offlineDevice.id,
checkType: DiagnosticCheckType.OSDetails,
});
expect(result.success).toBe(true);
});
});
describe('Error Handling', () => {
it('should provide clear error messages for missing resources', async () => {
await expect(adapter.getDevice('nonexistent-device'))
.rejects
.toThrow('Device not found: nonexistent-device');
await expect(adapter.getGroup('nonexistent-group'))
.rejects
.toThrow('Group not found: nonexistent-group');
});
it('should include error codes in exceptions', async () => {
try {
await adapter.getDevice('nonexistent');
} catch (error: unknown) {
expect(error).toBeInstanceOf(Error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((error as any).code).toBe('RESOURCE_NOT_FOUND');
}
});
});
describe('Health Check', () => {
it('should report adapter health status', async () => {
const healthy = await adapter.healthCheck();
expect(healthy).toBe(true);
});
});
describe('Output Limits', () => {
it('should truncate large output when maxOutputLength is specified', async () => {
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
maxOutputLength: 100,
});
expect(result.truncated).toBe(true);
expect(result.output.length).toBeLessThan(200); // 100 + truncation marker
});
it('should report output length and truncation status', async () => {
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
maxOutputLength: 500,
});
expect(result.outputLength).toBeGreaterThan(0);
expect(result.maxOutputLength).toBe(500);
});
});
});
@@ -0,0 +1,14 @@
describe('live read-only test safety contract', () => {
it('does not expose a general-purpose command or diagnostic action', async () => {
const source = await import('node:fs').then(({ readFileSync }) =>
readFileSync(require.resolve('../live-readonly-test'), 'utf8'));
expect(source).toContain("MeshCtrlAction.ServerVersion");
expect(source).toContain("MeshCtrlAction.Meshes");
expect(source).toContain("MeshCtrlAction.Nodes");
expect(source).toContain("MeshCtrlAction.DeviceInfo");
expect(source).toContain("nodeid: approvedDevice.id");
expect(source).not.toContain('runcommand');
expect(source).not.toContain('diagnostic');
expect(source).not.toContain('process.argv');
});
});
@@ -0,0 +1,245 @@
/**
* Unit tests for bounded request concurrency and rate limiting in MeshCtrlClient.
*/
import { TokenBucket, ConcurrencyLimiter } from '../meshctrl-client';
describe('ConcurrencyLimiter safe defaults', () => {
it('should have a safe default of 5 max concurrent requests', () => {
const limiter = new ConcurrencyLimiter();
expect(limiter.getMaxPending()).toBe(5);
});
it('should allow entry up to the safe default', () => {
const limiter = new ConcurrencyLimiter();
for (let i = 0; i < 5; i++) {
expect(limiter.tryEnter()).toBe(true);
}
// 6th should fail
expect(limiter.tryEnter()).toBe(false);
});
});
describe('TokenBucket rate limiter', () => {
describe('acquire()', () => {
it('should consume tokens and return true when available', () => {
const bucket = new TokenBucket(5, 0.01, 30_000); // Minimum valid refill rate
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(true);
});
it('should return false when no tokens are available', () => {
const bucket = new TokenBucket(2, 0.01, 30_000); // Minimum valid refill rate
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(true);
// Force reset to ensure no tokens left
(bucket as unknown as { tokens: number }).tokens = 0;
expect(bucket.acquire()).toBe(false); // No tokens left
});
it('should refill tokens over time based on rate', async () => {
// Create bucket with 2 tokens, refill at 1 token/second
const bucket = new TokenBucket(2, 1, 30_000);
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(false);
// Wait 1.5 seconds for ~1.5 tokens to refill
await new Promise((resolve) => setTimeout(resolve, 1500));
// Should be able to acquire again
expect(bucket.acquire()).toBe(true);
});
it('should respect maxTokens limit on refill', async () => {
const bucket = new TokenBucket(3, 0.01, 30_000); // Minimum valid refill rate
// Consume all tokens
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(true);
expect(bucket.acquire()).toBe(true);
// Force set tokens to 0 and lastRefill to now to prevent refilling during test
(bucket as unknown as { tokens: number; lastRefill: number }).tokens = 0;
(bucket as unknown as { tokens: number; lastRefill: number }).lastRefill = Date.now();
expect(bucket.acquire()).toBe(false); // No tokens left
// Wait for more than enough time (50ms -> 0.05 tokens at 0.01/sec, still < 1)
await new Promise((resolve) => setTimeout(resolve, 50));
// Should not have gained any tokens due to very slow refill rate
expect(bucket.acquire()).toBe(false);
});
});
describe('acquireWithWait()', () => {
it('should resolve immediately when tokens are available', async () => {
const bucket = new TokenBucket(5, 10, 30_000);
const start = Date.now();
await bucket.acquireWithWait();
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(10); // Should be nearly instant
});
it('should wait for tokens and resolve when available', async () => {
const bucket = new TokenBucket(1, 10, 30_000); // 1 token, refill at 10/sec
bucket.acquire(); // Consume the only token
const start = Date.now();
await bucket.acquireWithWait(); // Should wait ~100ms for refill
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(50); // Allow some timing variance
expect(elapsed).toBeLessThan(300);
});
it('should reject on timeout', async () => {
const bucket = new TokenBucket(1, 1, 100); // 1 token, refill at 1/sec, timeout after 100ms
bucket.acquire(); // Consume the only token
await expect(bucket.acquireWithWait()).rejects.toThrow(/Rate limit wait timeout/);
});
});
describe('integration with MeshCtrlClient options', () => {
it('should accept rate limit configuration via options', async () => {
// Test that the types are properly exported and usable
const config = {
maxRequestsPerSecond: 5,
burstCapacity: 3,
rateLimitTimeoutMs: 5000,
};
expect(config.maxRequestsPerSecond).toBe(5);
});
});
});
describe('ConcurrencyLimiter semaphore', () => {
describe('tryEnter()', () => {
it('should allow entry up to maxPending', () => {
const limiter = new ConcurrencyLimiter(2);
expect(limiter.tryEnter()).toBe(true);
expect(limiter.tryEnter()).toBe(true);
expect(limiter.tryEnter()).toBe(false); // At capacity
});
it('should track pending count correctly', () => {
const limiter = new ConcurrencyLimiter(3);
expect(limiter.tryEnter()).toBe(true);
expect(limiter.pendingCount).toBe(1);
expect(limiter.tryEnter()).toBe(true);
expect(limiter.pendingCount).toBe(2);
limiter.exit();
expect(limiter.pendingCount).toBe(1);
});
});
describe('enter() and exit()', () => {
it('should block until slot is available', async () => {
const limiter = new ConcurrencyLimiter(1);
await limiter.enter();
let entered = false;
const enterPromise = limiter.enter().then(() => { entered = true; });
// Should not have entered yet
await new Promise((resolve) => setTimeout(resolve, 50));
expect(entered).toBe(false);
// Exit to allow the waiting caller
limiter.exit();
await enterPromise;
expect(entered).toBe(true);
});
it('should properly exit slots', () => {
const limiter = new ConcurrencyLimiter(2);
expect(limiter.tryEnter()).toBe(true);
expect(limiter.tryEnter()).toBe(true);
limiter.exit();
expect(limiter.pendingCount).toBe(1);
limiter.exit();
expect(limiter.pendingCount).toBe(0);
});
});
describe('integration with MeshCtrlClient options', () => {
it('should accept concurrency configuration via options', async () => {
const config = {
maxConcurrentRequests: 10,
};
expect(config.maxConcurrentRequests).toBe(10);
});
});
});
describe('TokenBucket + ConcurrencyLimiter combined', () => {
it('should work together for rate limiting and concurrency control', async () => {
const bucket = new TokenBucket(2, 10, 30_000);
const limiter = new ConcurrencyLimiter(2);
// Both should allow 2 initial operations
expect(bucket.acquire()).toBe(true);
expect(limiter.tryEnter()).toBe(true);
expect(bucket.acquire()).toBe(true);
expect(limiter.tryEnter()).toBe(true);
// Both should be at capacity
expect(bucket.acquire()).toBe(false);
expect(limiter.tryEnter()).toBe(false);
// Exit one from each
limiter.exit();
expect(limiter.tryEnter()).toBe(true);
});
});
describe('concurrency with async enter/exit', () => {
it('should properly serialize concurrent requests when using async enter', async () => {
const limiter = new ConcurrencyLimiter(2);
let concurrentCount = 0;
let maxConcurrent = 0;
const simulateRequest = async (_id: number): Promise<void> => {
await limiter.enter();
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate some async work
await new Promise((resolve) => setTimeout(resolve, 10));
concurrentCount--;
limiter.exit();
};
// Launch 5 concurrent requests
const promises = Array.from({ length: 5 }, (_, i) => simulateRequest(i));
await Promise.all(promises);
// Should never exceed maxPending (2)
expect(maxConcurrent).toBeLessThanOrEqual(2);
});
it('should handle rapid enter/exit cycles without deadlock', async () => {
const limiter = new ConcurrencyLimiter(3);
let completed = 0;
const processItem = async (_id: number): Promise<void> => {
await limiter.enter();
try {
// Simulate quick work
await new Promise((resolve) => setTimeout(resolve, 5));
completed++;
} finally {
limiter.exit();
}
};
// Process 10 items with limited concurrency
const promises = Array.from({ length: 10 }, (_, i) => processItem(i));
await Promise.all(promises);
expect(completed).toBe(10);
expect(limiter.pendingCount).toBe(0); // All slots released
});
});
@@ -0,0 +1,47 @@
import {
MeshCtrlAction,
encodeAuthHeader,
parseResponse,
sanitizeCredentials,
validateResponseForAction,
} from '../meshctrl-client';
describe('MeshCtrl client protocol validation', () => {
it('encodes valid credentials without exposing them in the returned metadata', () => {
expect(encodeAuthHeader({ username: 'bridge-user', password: 'strong-pass' })).toBe(
`${Buffer.from('bridge-user').toString('base64')},${Buffer.from('strong-pass').toString('base64')}`,
);
expect(encodeAuthHeader({ username: 'bridge-user', password: 'strong-pass', totpToken: '123456' })).toBe(
`${Buffer.from('bridge-user').toString('base64')},${Buffer.from('strong-pass').toString('base64')},${Buffer.from('123456').toString('base64')}`,
);
expect(sanitizeCredentials({ username: 'bridge-user', password: 'strong-pass' })).toEqual({
hasCredentials: true,
redacted: true,
});
});
it('rejects malformed credentials', () => {
expect(() => encodeAuthHeader({ username: 'bad user', password: 'strong-pass' })).toThrow();
expect(() => encodeAuthHeader({ username: 'bridge-user', password: 'short' })).toThrow();
});
it('rejects malformed and oversized protocol responses', () => {
expect(() => parseResponse('{"id":1}')).toThrow(/protocol validation/);
expect(() => parseResponse('x'.repeat(20), 10)).toThrow(/size limit/);
});
it('validates the response shape and action', () => {
const response = parseResponse(JSON.stringify({
action: MeshCtrlAction.ServerVersion,
id: 1,
ver: '1.2.5',
}));
expect(validateResponseForAction(response, MeshCtrlAction.ServerVersion)).toEqual(response);
expect(() => validateResponseForAction(response, MeshCtrlAction.Nodes)).toThrow(/action/);
expect(() => validateResponseForAction(parseResponse(JSON.stringify({
action: MeshCtrlAction.Nodes,
id: 1,
nodes: [{ id: 'n1', name: 'node', meshid: 'm1', online: 'yes' }],
})), MeshCtrlAction.Nodes)).toThrow(/schema/);
});
});
@@ -0,0 +1,167 @@
/**
* Unit tests for the mock adapter.
*/
import { createMockAdapter, DiagnosticCheckType } from '../index';
describe('MockAdapter', () => {
let adapter: ReturnType<typeof createMockAdapter>;
beforeEach(() => {
adapter = createMockAdapter();
});
describe('listDevices', () => {
it('should return all devices when no filter provided', async () => {
const devices = await adapter.listDevices();
expect(devices).toHaveLength(4);
});
it('should filter by group ID', async () => {
const devices = await adapter.listDevices({ groupId: 'grp-servers' });
expect(devices).toHaveLength(2);
expect(devices.every((d: { groupId: string }) => d.groupId === 'grp-servers')).toBe(true);
});
it('should return empty array for non-existent group', async () => {
const devices = await adapter.listDevices({ groupId: 'nonexistent' });
expect(devices).toHaveLength(0);
});
it('should include expected device properties', async () => {
const devices = await adapter.listDevices();
const device = devices[0];
expect(device).toMatchObject({
id: expect.any(String),
name: expect.any(String),
groupId: expect.any(String),
groupName: expect.any(String),
status: expect.stringMatching(/online|offline|unknown/),
});
// Properties that may be undefined for offline devices
expect(device.os).toBeDefined();
});
});
describe('getDevice', () => {
it('should return a specific device by ID', async () => {
const device = await adapter.getDevice('dev-001');
expect(device.id).toBe('dev-001');
expect(device.name).toBe('home-server');
});
it('should throw error for non-existent device', async () => {
await expect(adapter.getDevice('nonexistent')).rejects.toThrow('Device not found');
});
});
describe('listGroups', () => {
it('should return all groups', async () => {
const groups = await adapter.listGroups();
expect(groups).toHaveLength(3);
});
it('should include expected group properties', async () => {
const groups = await adapter.listGroups();
const group = groups[0];
expect(group).toMatchObject({
id: expect.any(String),
name: expect.any(String),
deviceCount: expect.any(Number),
});
});
});
describe('getGroup', () => {
it('should return a specific group by ID', async () => {
const group = await adapter.getGroup('grp-servers');
expect(group.id).toBe('grp-servers');
expect(group.name).toBe('Servers');
});
it('should throw error for non-existent group', async () => {
await expect(adapter.getGroup('nonexistent')).rejects.toThrow('Group not found');
});
});
describe('runDiagnostic', () => {
it('should return diagnostic result for diskSpace check', async () => {
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
});
expect(result).toMatchObject({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
success: true,
exitCode: 0,
outputLength: expect.any(Number),
});
expect(result.output).toContain('Filesystem');
});
it('should support all diagnostic check types', async () => {
const checkTypes = [
DiagnosticCheckType.DiskSpace,
DiagnosticCheckType.Services,
DiagnosticCheckType.Processes,
DiagnosticCheckType.OSDetails,
DiagnosticCheckType.NetworkState,
DiagnosticCheckType.Uptime,
];
for (const checkType of checkTypes) {
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType,
});
expect(result.checkType).toBe(checkType);
expect(result.success).toBe(true);
}
});
it('should truncate output when maxOutputLength is exceeded', async () => {
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
maxOutputLength: 50,
});
expect(result.truncated).toBe(true);
expect(result.output.length).toBeLessThan(200); // 50 + truncation marker
});
it('should respect timeoutMs parameter', async () => {
const startTime = Date.now();
await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
timeoutMs: 50,
});
const elapsed = Date.now() - startTime;
// Should complete within timeout + small buffer
expect(elapsed).toBeLessThan(100);
});
});
describe('healthCheck', () => {
it('should return true for mock adapter', async () => {
const healthy = await adapter.healthCheck();
expect(healthy).toBe(true);
});
});
describe('adapter metadata', () => {
it('should have name and version properties', () => {
expect(adapter.name).toBe('mock');
expect(adapter.version).toBe('0.1.0');
});
});
});
@@ -0,0 +1,38 @@
/**
* Tests for the mock data loader.
*/
import { loadTestDeviceData } from './mock-data-loader';
describe('MockDataLoader', () => {
it('should load test device data from JSON file', () => {
const data = loadTestDeviceData();
expect(Array.isArray(data.devices)).toBe(true);
expect(Array.isArray(data.groups)).toBe(true);
expect(data.devices.length).toBeGreaterThan(0);
expect(data.groups.length).toBeGreaterThan(0);
});
it('should load valid device structures', () => {
const data = loadTestDeviceData();
for (const device of data.devices) {
expect(typeof device.id).toBe('string');
expect(typeof device.name).toBe('string');
expect(typeof device.groupId).toBe('string');
expect(typeof device.groupName).toBe('string');
expect(['online', 'offline', 'unknown']).toContain(device.status);
}
});
it('should load valid group structures', () => {
const data = loadTestDeviceData();
for (const group of data.groups) {
expect(typeof group.id).toBe('string');
expect(typeof group.name).toBe('string');
expect(typeof group.deviceCount).toBe('number');
}
});
});
@@ -0,0 +1,33 @@
/**
* Test data loader for consistent test fixtures.
*/
import { readFileSync } from 'fs';
import { join } from 'path';
export interface TestDeviceData {
devices: Array<{
id: string;
name: string;
groupId: string;
groupName: string;
status: 'online' | 'offline' | 'unknown';
os?: string;
uptimeSeconds?: number;
lastSeen?: string;
ipAddresses?: string[];
agentVersion?: string;
}>;
groups: Array<{
id: string;
name: string;
description?: string;
deviceCount: number;
}>;
}
export function loadTestDeviceData(): TestDeviceData {
const dataPath = join(__dirname, 'example-device-data.json');
const content = readFileSync(dataPath, 'utf-8');
return JSON.parse(content) as TestDeviceData;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Jest setup file.
*/
import 'jest-extended';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Expect {
toBeArray(): void;
toBeString(): void;
toBeNumber(): void;
}
}
}
// Custom matchers
expect.extend({
toBeArray(received: unknown) {
const pass = Array.isArray(received);
return {
message: () => `expected ${received} to be an array`,
pass,
};
},
toBeString(received: unknown) {
const pass = typeof received === 'string';
return {
message: () => `expected ${received} to be a string`,
pass,
};
},
toBeNumber(received: unknown) {
const pass = typeof received === 'number';
return {
message: () => `expected ${received} to be a number`,
pass,
};
},
});
@@ -0,0 +1,58 @@
/**
* Tests for timeout and output limit handling.
*/
import { createMockAdapter, DiagnosticCheckType } from '../index';
describe('Timeout and Output Limits', () => {
it('should respect timeoutMs parameter', async () => {
const adapter = createMockAdapter({ timeoutMs: 100 });
const startTime = Date.now();
await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
timeoutMs: 50,
});
const elapsed = Date.now() - startTime;
expect(elapsed).toBeLessThan(100);
});
it('should truncate output when maxOutputLength is small', async () => {
const adapter = createMockAdapter({ maxOutputLength: 100 });
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
maxOutputLength: 50,
});
expect(result.truncated).toBe(true);
expect(result.output.length).toBeLessThan(200); // 50 + truncation marker
});
it('should report maxOutputLength in result', async () => {
const adapter = createMockAdapter({ maxOutputLength: 65_536 });
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
});
expect(result.maxOutputLength).toBe(65_536);
});
it('should handle zero timeout gracefully', async () => {
const adapter = createMockAdapter({ timeoutMs: 1000 });
// Zero or negative timeout should not cause errors
const result = await adapter.runDiagnostic({
deviceId: 'dev-001',
checkType: DiagnosticCheckType.DiskSpace,
timeoutMs: 0,
});
expect(result.success).toBe(true);
});
});
+708
View File
@@ -0,0 +1,708 @@
/**
* Adapter pattern for MeshCentral integration.
*
* Defines the boundary where a real MeshCentral client can be swapped in.
*/
import {
MeshCentralAdapter,
Device,
DeviceGroup,
DiagnosticResult,
ListDevicesRequest,
RunDiagnosticRequest,
BridgeError,
ErrorCodes,
defaultConfig,
BridgeConfig,
DeviceStatus,
validateCredentials,
} from './contracts';
import { createMockAdapter } from './mock-adapter';
import {
MeshCtrlClient,
} from './meshctrl-client';
import {
createAuditEntry,
AuditAction,
AuditStatus,
} from './audit';
import { Logger, getGlobalLogger } from './logger';
/**
* Real MeshCentral adapter using the official meshctrl.js protocol.
*
* Connects to MeshCentral's control.ashx WebSocket and uses the
* same protocol as meshctrl.js for inventory and diagnostics.
*/
export class MeshCentralRealAdapter implements MeshCentralAdapter {
readonly name = 'meshcentral-real';
readonly version = '0.1.0';
private config: BridgeConfig;
private client: MeshCtrlClient | null = null;
private credentials: { username: string; password: string; totpToken?: string } | null = null;
private connected = false;
private logger: Logger;
constructor(config?: Partial<BridgeConfig>) {
this.config = { ...defaultConfig, ...(config ?? {}) };
// Use a dedicated logger for this adapter instance
// In production, you could inject a custom logger via config
this.logger = getGlobalLogger();
// Set the logger name to be more descriptive
this.logger.setName('meshcentral-adapter');
// Validate configuration and credentials
this._validateConfig();
}
/**
* Set credentials for authentication.
* Must be called before connecting to the real adapter.
*/
setCredentials(credentials: { username: string; password: string; totpToken?: string }): void {
// Log audit entry for credential update attempt
this._auditLog(AuditAction.CREDENTIAL_UPDATE, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: 'adapter-credentials',
});
// Validate credentials before storing
validateCredentials(credentials);
this.credentials = {
username: credentials.username,
password: credentials.password,
totpToken: credentials.totpToken,
};
// Log successful credential update (redacted)
this._auditLog(AuditAction.CREDENTIAL_UPDATE, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: 'adapter-credentials',
detailsUpdated: true,
});
}
/**
* Generate a redacted credentials summary for logging.
*/
private _getCredentialsSummary(): { hasCredentials: boolean; hasTotp: boolean } {
if (!this.credentials) {
return { hasCredentials: false, hasTotp: false };
}
return {
hasCredentials: !!this.credentials.username && !!this.credentials.password,
hasTotp: !!this.credentials.totpToken,
};
}
/**
* Get a safe actor name for audit logs (no secrets).
*/
private _getActorName(): string {
if (!this.credentials?.username) {
return 'unknown';
}
// Use username without exposing password or totp
return this.credentials.username;
}
/**
* Log an audit entry.
*/
private _auditLog(
action: AuditAction,
status: AuditStatus,
extraDetails?: Record<string, unknown>,
): void {
const details = {
adapter: this.name,
version: this.version,
configUrl: this.config.meshCentralUrl,
...extraDetails,
};
// Create and format audit entry (secrets already redacted by sanitizeForLogging)
const entry = createAuditEntry({
action,
status,
actor: typeof extraDetails?.actor === 'string' ? extraDetails.actor : this._getActorName(),
resource: typeof extraDetails?.resource === 'string'
? extraDetails.resource
: 'meshcentral-adapter',
details: Object.fromEntries(
Object.entries(details).filter(([key]) => key !== 'actor' && key !== 'resource'),
),
});
// Output audit log via structured logger
this.logger.audit(`AUDIT - ${entry.action} - ${entry.status}`, {
action: entry.action,
status: entry.status,
actor: typeof extraDetails?.actor === 'string' ? extraDetails.actor : this._getActorName(),
resource: typeof extraDetails?.resource === 'string'
? extraDetails.resource
: 'meshcentral-adapter',
...Object.fromEntries(
Object.entries(details).filter(([key]) => key !== 'actor' && key !== 'resource'),
),
});
if (process.env.AUDIT_DEBUG === 'true') {
this.logger.debug(JSON.stringify(entry, null, 2));
}
}
private _validateConfig(): void {
// Log config load attempt
this._auditLog(AuditAction.CONFIG_LOAD, AuditStatus.PENDING, {
adapter: this.name,
resource: 'adapter-configuration',
});
if (!this.config.meshCentralUrl) {
this._auditLog(AuditAction.CONFIG_LOAD, AuditStatus.FAILURE, {
adapter: this.name,
resource: 'adapter-configuration',
error: 'meshCentralUrl is required',
});
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Configuration is incomplete: meshCentralUrl is required',
);
}
// Check for credentials in environment/config
const hasUsername = !!this.config.username;
const hasPassword = !!this.config.password;
// Generic error message that doesn't reveal which credential is missing
if (hasUsername !== hasPassword) {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Configuration is incomplete: authentication credentials must be provided together',
);
}
if (this.config.adapterType === 'real' && (!hasUsername || !hasPassword)) {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Real adapter requires authentication credentials',
);
}
// Validate credential formats if present
if (hasUsername && hasPassword) {
try {
validateCredentials({
username: this.config.username,
password: this.config.password,
totpToken: this.config.totpToken,
});
} catch {
// Re-throw as generic error to prevent credential enumeration
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Authentication credentials are invalid',
);
}
}
// Validate allowlist formats if provided
this._validateAllowlist(this.config.pveAllowlist, 'pveAllowlist');
this._validateAllowlist(this.config.deviceAllowlist, 'deviceAllowlist');
this._validateAllowlist(this.config.groupAllowlist, 'groupAllowlist');
}
/**
* Validate allowlist entries.
*/
private _validateAllowlist(allowlist: string[] | undefined, name: string): void {
if (!allowlist) {
return;
}
for (const entry of allowlist) {
if (!entry || typeof entry !== 'string' || entry.trim() === '') {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
`Allowlist entry in ${name} is empty or invalid`,
);
}
}
}
/**
* Check if a device is in the allowlist.
*/
private _isDeviceAllowed(deviceId: string): boolean {
const { deviceAllowlist, groupAllowlist } = this.config;
// If no allowlists configured, all devices are allowed
if (!deviceAllowlist && !groupAllowlist) {
return true;
}
// Check device allowlist first (specific devices)
if (deviceAllowlist && deviceAllowlist.includes(deviceId)) {
return true;
}
// If group allowlist is configured, device must be in an allowed group
if (groupAllowlist && groupAllowlist.length > 0) {
// We'll check this after we have device info
return true; // Will be checked in getDevice
}
return false;
}
private _isGroupAllowed(groupId: string): boolean {
return !this.config.groupAllowlist || this.config.groupAllowlist.length === 0
|| this.config.groupAllowlist.includes(groupId);
}
/**
* Connect to MeshCentral if not already connected.
*/
private async _ensureConnected(): Promise<void> {
if (this.connected) {
return;
}
const credSummary = this._getCredentialsSummary();
// Log connection attempt (redacted)
this._auditLog(AuditAction.CONNECTION_ATTEMPT, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
hasCredentials: credSummary.hasCredentials,
hasTotp: credSummary.hasTotp,
});
if (!this.credentials) {
this._auditLog(AuditAction.CONNECTION_ATTEMPT, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
error: 'Adapter is not configured for authentication',
});
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Adapter is not configured for authentication',
);
}
this.client = new MeshCtrlClient({
url: this.config.meshCentralUrl,
timeoutMs: this.config.timeoutMs,
});
try {
await this.client.connect(this.credentials);
this.connected = true;
// Log successful connection
this._auditLog(AuditAction.CONNECTION_ESTABLISHED, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
});
} catch (error) {
this._auditLog(AuditAction.CONNECTION_ATTEMPT, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
error: error instanceof Error ? error.message : 'unknown',
});
throw error;
}
}
/**
* Disconnect from MeshCentral.
*/
private async _disconnect(): Promise<void> {
if (this.connected && this.client) {
try {
await this.client.disconnect();
this._auditLog(AuditAction.CONNECTION_CLOSED, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
});
} finally {
this.connected = false;
this.client = null;
}
} else if (this.connected) {
this._auditLog(AuditAction.CONNECTION_CLOSED, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: `meshcentral://${this.config.meshCentralUrl}`,
error: 'Client was null when attempting disconnect',
});
}
}
/**
* Convert MeshCtrl node response to Device type.
*/
private _mapNodeToDevice(node: { id: string; name: string; online: boolean; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }, group: { id: string; name: string }): Device {
const status = node.online ? DeviceStatus.Online : DeviceStatus.Offline;
return {
id: node.id,
name: node.name,
groupId: group.id,
groupName: group.name,
status,
os: node.device?.os ?? undefined,
uptimeSeconds: node.device?.uptime ?? undefined,
lastSeen: node.device?.lastseen
? new Date(node.device.lastseen * 1000).toISOString()
: undefined,
ipAddresses: node.device?.ip ?? undefined,
agentVersion: node.device?.agentver ?? undefined,
};
}
/**
* Convert MeshCtrl meshes response to DeviceGroup type.
*/
private _mapMeshToGroup(mesh: { id: string; name: string; description?: string }): DeviceGroup {
return {
id: mesh.id,
name: mesh.name,
description: mesh.description ?? undefined,
deviceCount: 0, // Will be populated on demand
};
}
/**
* Filter an array by allowlist.
*/
private _filterByAllowlist<T>(items: T[], getId: (item: T) => string, allowlist?: string[]): T[] {
if (!allowlist || allowlist.length === 0) {
return items;
}
const allowedIds = new Set(allowlist);
return items.filter((item) => allowedIds.has(getId(item)));
}
async listDevices(request?: ListDevicesRequest): Promise<Device[]> {
await this._ensureConnected();
// Log device listing attempt
this._auditLog(AuditAction.DEVICE_LISTED, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: 'all-devices',
groupId: request?.groupId,
});
// Get server version first to verify connection
try {
const versionResponse = await this.client!.sendRequest({
action: 'serverversion',
});
if (versionResponse.err) {
this._auditLog(AuditAction.MESHCENTRAL_REQUEST, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: 'serverversion',
error: versionResponse.err,
});
throw new BridgeError(
ErrorCodes.MeshCentralError,
'Failed to establish connection with MeshCentral server',
);
} else {
this._auditLog(AuditAction.MESHCENTRAL_RESPONSE, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: 'serverversion',
});
}
} catch (error) {
await this._disconnect();
throw error;
}
// Get meshes (device groups)
const meshesResponse = await this.client!.sendRequest({
action: 'meshes',
});
if (meshesResponse.err) {
throw new BridgeError(
ErrorCodes.MeshCentralError,
'Failed to retrieve device groups from MeshCentral server',
);
}
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string }>;
// If group filter provided, find matching mesh
if (request?.groupId) {
const targetMesh = meshes.find((m) => m.id === request.groupId);
if (!targetMesh) {
throw new BridgeError(
ErrorCodes.ResourceNotFound,
'Requested device group could not be found',
);
}
if (!this._isGroupAllowed(targetMesh.id)) {
throw new BridgeError(
ErrorCodes.ResourceNotFound,
'Requested device group could not be found',
);
}
return this._getDevicesForMesh(targetMesh);
}
// Get all devices from all meshes
const allDevices: Device[] = [];
for (const mesh of meshes) {
const devices = await this._getDevicesForMesh(mesh);
allDevices.push(...devices);
}
return allDevices;
}
// Get devices for a specific mesh/group.
private async _getDevicesForMesh(mesh: { id: string; name: string }): Promise<Device[]> {
const nodesResponse = await this.client!.sendRequest({
action: 'nodes',
meshid: mesh.id,
});
if (nodesResponse.err) {
throw new BridgeError(
ErrorCodes.MeshCentralError,
`Failed to retrieve devices for group: ${mesh.name}`,
);
}
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 } }>;
if (!this._isGroupAllowed(mesh.id)) {
return [];
}
return nodes
.map((node) => this._mapNodeToDevice(node, mesh))
.filter((device) => this._isDeviceAllowed(device.id));
}
async getDevice(deviceId: string): Promise<Device> {
await this._ensureConnected();
// Log device access attempt
this._auditLog(AuditAction.DEVICE_ACCESS, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: deviceId,
});
// First get all devices to validate allowlist and find the device
const allDevices = await this.listDevices();
const device = allDevices.find((d) => d.id === deviceId);
if (!device) {
this._auditLog(AuditAction.DEVICE_ACCESS, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: deviceId,
error: 'Device could not be found',
});
throw new BridgeError(
ErrorCodes.ResourceNotFound,
'Device could not be found',
);
}
// Check allowlist again after getting device info
if (!this._isDeviceAllowed(deviceId)) {
this._auditLog(AuditAction.ACCESS_DENIED, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: deviceId,
error: 'Device is not authorized for access',
});
throw new BridgeError(
ErrorCodes.AdapterNotFound,
'Device is not authorized for access',
);
}
this._auditLog(AuditAction.DEVICE_ACCESS, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: deviceId,
});
return device;
}
async listGroups(): Promise<DeviceGroup[]> {
await this._ensureConnected();
const meshesResponse = await this.client!.sendRequest({
action: 'meshes',
});
if (meshesResponse.err) {
throw new BridgeError(
ErrorCodes.MeshCentralError,
'Failed to retrieve device groups from MeshCentral server',
);
}
const meshes = (meshesResponse.meshes ?? []) as Array<{ id: string; name: string; description?: string }>;
// Filter by group allowlist if configured
const allowedMeshes = this._filterByAllowlist(
meshes,
(m) => m.id,
this.config.groupAllowlist,
);
return allowedMeshes.map((mesh) => this._mapMeshToGroup(mesh));
}
async getGroup(groupId: string): Promise<DeviceGroup> {
const groups = await this.listGroups();
const group = groups.find((g) => g.id === groupId);
if (!group) {
throw new BridgeError(
ErrorCodes.ResourceNotFound,
'Device group could not be found',
);
}
return group;
}
async runDiagnostic(request: RunDiagnosticRequest): Promise<DiagnosticResult> {
const { deviceId, checkType } = request;
// Log diagnostic attempt
this._auditLog(AuditAction.MESHCENTRAL_REQUEST, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: deviceId,
checkType,
});
// Validate device is in allowlist
if (!this._isDeviceAllowed(deviceId)) {
this._auditLog(AuditAction.ACCESS_DENIED, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: deviceId,
checkType,
error: 'Device is not authorized for access',
});
throw new BridgeError(
ErrorCodes.AdapterNotFound,
'Device is not authorized for access',
);
}
// Get device to verify it exists and get group info
await this.getDevice(deviceId);
// For now, return a placeholder diagnostic result
// Real implementation would execute commands via MeshCtrl's deviceinfo or events API
const result = {
deviceId,
checkType,
timestamp: new Date().toISOString(),
success: true,
output: '[Real adapter diagnostic not yet implemented - device is online]',
exitCode: 0,
outputLength: 49,
maxOutputLength: request.maxOutputLength ?? this.config.maxOutputLength,
truncated: false,
};
this._auditLog(AuditAction.MESHCENTRAL_RESPONSE, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: deviceId,
checkType,
});
return result;
}
async healthCheck(): Promise<boolean> {
// Log health check attempt
this._auditLog(AuditAction.SERVICE_START, AuditStatus.PENDING, {
actor: this._getActorName(),
resource: 'health-check',
});
if (this.config.adapterType !== 'real') {
this._auditLog(AuditAction.SERVICE_START, AuditStatus.SUCCESS, {
actor: this._getActorName(),
resource: 'health-check',
adapterType: this.config.adapterType,
});
return true; // Mock adapter is always healthy
}
try {
await this._ensureConnected();
const versionResponse = await this.client!.sendRequest({
action: 'serverversion',
});
const healthy = !versionResponse.err;
this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: 'health-check',
healthy,
});
return healthy;
} catch {
this._auditLog(AuditAction.SERVICE_START, AuditStatus.FAILURE, {
actor: this._getActorName(),
resource: 'health-check',
healthy: false,
});
await this._disconnect();
return false;
}
}
/**
* Cleanup on destroy.
*/
async destroy(): Promise<void> {
await this._disconnect();
}
}
/**
* Factory function to create the appropriate adapter based on configuration.
*/
export function createMeshCentralAdapter(config: Partial<BridgeConfig> = {}): MeshCentralAdapter {
const adapterType = config.adapterType ?? 'mock';
switch (adapterType) {
case 'real':
return new MeshCentralRealAdapter(config);
case 'mock':
default:
return createMockAdapter(config);
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Audit logging types and constants.
*
* Defines the schema for audit log entries and
* supported action/status enumerations.
*/
// ========== Audit Actions ==========
/**
* Enumeration of audit actions tracked by the system.
*/
export enum AuditAction {
// Authentication and authorization
AUTH_ATTEMPT = 'AUTH_ATTEMPT',
CREDENTIAL_UPDATE = 'CREDENTIAL_UPDATE',
// Connection events
CONNECTION_ATTEMPT = 'CONNECTION_ATTEMPT',
CONNECTION_ESTABLISHED = 'CONNECTION_ESTABLISHED',
CONNECTION_CLOSED = 'CONNECTION_CLOSED',
// Configuration
CONFIG_LOAD = 'CONFIG_LOAD',
CONFIG_CHANGE = 'CONFIG_CHANGE',
// MeshCentral operations
MESHCENTRAL_REQUEST = 'MESHCENTRAL_REQUEST',
MESHCENTRAL_RESPONSE = 'MESHCENTRAL_RESPONSE',
// Device operations
DEVICE_LISTED = 'DEVICE_LISTED',
DEVICE_ACCESS = 'DEVICE_ACCESS',
// Group operations
GROUP_LISTED = 'GROUP_LISTED',
GROUP_ACCESS = 'GROUP_ACCESS',
// Error events
AUTH_FAILURE = 'AUTH_FAILURE',
ACCESS_DENIED = 'ACCESS_DENIED',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
// Lifecycle
SERVICE_START = 'SERVICE_START',
SERVICE_STOP = 'SERVICE_STOP',
}
/**
* Determine if an action is security-critical.
*/
export function isSecurityAction(action: AuditAction): boolean {
const securityActions = [
AuditAction.AUTH_ATTEMPT,
AuditAction.CREDENTIAL_UPDATE,
AuditAction.CONNECTION_ATTEMPT,
AuditAction.AUTH_FAILURE,
AuditAction.ACCESS_DENIED,
];
return securityActions.includes(action);
}
// ========== Audit Status ==========
/**
* Enumeration of audit status values.
*/
export enum AuditStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE',
PENDING = 'PENDING',
CANCELLED = 'CANCELLED',
}
// ========== Audit Log Entry Schema ==========
/**
* Core audit log entry structure.
*
* All entries must include these fields to ensure
* consistent audit trail quality.
*/
export interface AuditLogEntry {
/**
* ISO 8601 timestamp of the event.
*/
timestamp: string;
/**
* The action that was performed.
*/
action: AuditAction;
/**
*Outcome of the action (SUCCESS, FAILURE, etc.).
*/
status: AuditStatus;
/**
* Actor responsible for the action (username, service name, or 'unknown').
*/
actor: string;
/**
* Resource affected by the action (device ID, group name, etc.).
*/
resource: string;
/**
* Additional context-specific details.
* Secrets are already redacted before this object is passed here.
*/
details?: Record<string, unknown>;
}
/**
* Optional audit log entry for scenarios where some fields may be unavailable.
*/
export interface OptionalAuditLogEntry extends Omit<AuditLogEntry, 'actor' | 'resource'> {
actor?: string;
resource?: string;
}
/**
* Validate that a plain object conforms to the AuditLogEntry schema.
*/
export function isAuditLogEntry(value: unknown): value is AuditLogEntry {
if (typeof value !== 'object' || value === null) {
return false;
}
const entry = value as Record<string, unknown>;
// Check required fields and require a valid ISO-8601 date.
if (typeof entry.timestamp !== 'string' || Number.isNaN(Date.parse(entry.timestamp))) {
return false;
}
if (!entry.action || typeof entry.action !== 'string') {
return false;
}
// Validate action is a known enum value
const validActions = Object.values(AuditAction);
if (!validActions.includes(entry.action as AuditAction)) {
return false;
}
// Check status
if (!entry.status || typeof entry.status !== 'string') {
return false;
}
const validStatuses = Object.values(AuditStatus);
if (!validStatuses.includes(entry.status as AuditStatus)) {
return false;
}
// Actor and resource should be strings if present
if (entry.actor !== undefined && typeof entry.actor !== 'string') {
return false;
}
if (entry.resource !== undefined && typeof entry.resource !== 'string') {
return false;
}
// Details, if present, should be an object or undefined
if (entry.details !== undefined && typeof entry.details !== 'object') {
return false;
}
return true;
}
/**
* Create a partial audit entry with only required fields.
*/
export function createMinimalAuditEntry(params: {
action: AuditAction;
status: AuditStatus;
}): AuditLogEntry {
return {
timestamp: new Date().toISOString(),
action: params.action,
status: params.status,
actor: 'unknown',
resource: 'unknown',
};
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Structured audit logging with secret redaction.
*
* Provides typed log entries for security-relevant operations
* and utilities to sanitize sensitive data before logging.
*/
import { AuditLogEntry, AuditAction, AuditStatus } from './audit-types';
// ========== Redaction Utilities ==========
/**
* Redact sensitive values from a string or object.
* Replaces secrets with "[REDACTED]" while preserving structure.
*/
export function redactSecrets(value: string | object): string {
let text = typeof value === 'string' ? value : JSON.stringify(value);
// Redact header values for common sensitive headers (before base64/hex)
text = text.replace(
/("(?:Authorization|X-Auth|Bearer|Token|Secret|Key|x-meshauth)"\s*:\s*)"[^"]*"/gi,
'$1"[REDACTED]"',
);
// Redact passwords in known key patterns (before base64/hex)
text = text.replace(
/("(?:password|secret|token|totp)"\s*:\s*)"[^"]*"/gi,
'$1"[REDACTED]"',
);
// Redact hex secrets (32+ chars, hex only) - more specific than base64
text = text.replace(
/\b([a-fA-F0-9]{32,})\b/g,
() => '[REDACTED_HEX]',
);
// Redact base64 credentials (16+ chars including = padding)
text = text.replace(
/([A-Za-z0-9+/]{16,}={0,2})/g,
(match) => {
// Only redact if it looks like base64 credentials
// Don't double-redact hex strings (which are also valid base64 chars)
if (match.length >= 20 && match.length <= 128) {
// Check if it's all hex (would have been caught above)
if (/^[a-fA-F0-9]+$/.test(match)) {
return match; // Let hex redaction handle it
}
return '[REDACTED_BASE64]';
}
return match;
},
);
return text;
}
/**
* Sanitize an object for safe logging by recursively redacting secrets.
*/
export function sanitizeForLogging<T>(data: T): T {
if (typeof data !== 'object' || data === null) {
return data;
}
if (Array.isArray(data)) {
return data.map((item) => sanitizeForLogging(item)) as T;
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase();
// Redact known secret keys
if (lowerKey.includes('password') ||
lowerKey.includes('secret') ||
lowerKey.includes('token') ||
lowerKey.includes('auth') ||
lowerKey === 'totp' ||
lowerKey === 'cred') {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeForLogging(value);
} else if (typeof value === 'string') {
sanitized[key] = redactSecrets(value);
} else {
sanitized[key] = value;
}
}
return sanitized as T;
}
// ========== Audit Log Entry Utilities ==========
/**
* Create a new audit log entry with required fields.
*/
export function createAuditEntry(params: {
action: AuditAction;
status: AuditStatus;
actor?: string;
resource?: string;
details?: Record<string, unknown>;
}): AuditLogEntry {
const { action, status, actor, resource, details } = params;
// Redact any secrets in details
const sanitizedDetails = details ? sanitizeForLogging(details) : undefined;
return {
timestamp: new Date().toISOString(),
action,
status,
actor: actor ?? 'unknown',
resource: resource ?? 'unknown',
details: sanitizedDetails,
};
}
/**
* Format an audit log entry as a JSON string for output.
*/
export function formatAuditEntry(entry: AuditLogEntry): string {
return JSON.stringify(entry, null, 2);
}
/**
* Check if an audit action is security-critical.
*/
export function isSecurityCritical(action: AuditAction): boolean {
const securityActions: AuditAction[] = [
AuditAction.AUTH_ATTEMPT,
AuditAction.CREDENTIAL_UPDATE,
AuditAction.DEVICE_ACCESS,
AuditAction.GROUP_ACCESS,
AuditAction.ACCESS_DENIED,
AuditAction.CONFIG_CHANGE,
AuditAction.CONNECTION_ATTEMPT,
];
return securityActions.includes(action);
}
// ========== Export Types ==========
export {
AuditLogEntry,
AuditAction,
AuditStatus,
};
+263
View File
@@ -0,0 +1,263 @@
/**
* Typed contracts for MeshCentral ↔ OpenClaw Bridge
*
* Defines the interface between the bridge and OpenClaw.
*/
import { z } from 'zod';
// ========== Credential Validation Schemas ==========
/**
* Validate username: alphanumeric, hyphens, underscores only, 1-64 chars
*/
export const UsernameSchema = z.string()
.min(1, 'Username is required')
.max(64, 'Username must be 64 characters or less')
.regex(/^[a-zA-Z0-9_-]+$/, 'Username must contain only alphanumeric characters, hyphens, and underscores');
/**
* Validate password: 8-128 chars, any printable characters except colons
*/
export const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password must be 128 characters or less');
/**
* Validate TOTP token: 6-8 digits only
*/
export const TotpSchema = z.string()
.optional()
.refine((val) => val === undefined || /^[0-9]{6,8}$/.test(val), {
message: 'TOTP token must be 6-8 digits',
});
/**
* Sanitize credentials for safe logging.
* Returns a redacted object without exposing actual values.
*/
export function sanitizeCredentials(credentials?: {
username?: string;
password?: string;
totpToken?: string;
}): { hasCredentials: boolean; redacted: true } {
if (!credentials) {
return { hasCredentials: false, redacted: true };
}
const { username, password } = credentials;
// Return redacted representation - never log actual values
return {
hasCredentials: !!username && !!password,
redacted: true,
};
}
/**
* Validate credentials structure without exposing values in errors.
* Generic error messages prevent credential enumeration attacks.
*/
export function validateCredentials(credentials: {
username?: string;
password?: string;
totpToken?: string;
}): void {
const hasUsername = !!credentials.username;
const hasPassword = !!credentials.password;
// Generic error message that doesn't reveal which credential is missing
if (hasUsername !== hasPassword) {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Authentication configuration is incomplete',
);
}
if (hasUsername && hasPassword) {
// Validate format only, don't log values
try {
UsernameSchema.parse(credentials.username);
} catch {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Invalid authentication credentials',
);
}
try {
PasswordSchema.parse(credentials.password);
} catch {
throw new BridgeError(
ErrorCodes.ConfigurationInvalid,
'Invalid authentication credentials',
);
}
}
}
// ========== Device Status ==========
export enum DeviceStatus {
Online = 'online',
Offline = 'offline',
Unknown = 'unknown',
}
// ========== Device Schema ==========
export const DeviceSchema = z.object({
id: z.string(),
name: z.string(),
groupId: z.string(),
groupName: z.string(),
status: z.nativeEnum(DeviceStatus),
os: z.string().optional(),
uptimeSeconds: z.number().optional(),
lastSeen: z.string().datetime().optional(),
ipAddresses: z.array(z.string()).optional(),
agentVersion: z.string().optional(),
});
export type Device = z.infer<typeof DeviceSchema>;
// ========== Device Group Schema ==========
export const DeviceGroupSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
deviceCount: z.number(),
});
export type DeviceGroup = z.infer<typeof DeviceGroupSchema>;
// ========== Diagnostic Check Types ==========
export enum DiagnosticCheckType {
DiskSpace = 'diskSpace',
Services = 'services',
Processes = 'processes',
OSDetails = 'osDetails',
NetworkState = 'networkState',
Uptime = 'uptime',
}
// ========== Diagnostic Result Schema ==========
export const DiagnosticResultSchema = z.object({
deviceId: z.string(),
checkType: z.nativeEnum(DiagnosticCheckType),
timestamp: z.string().datetime(),
success: z.boolean(),
output: z.string(),
exitCode: z.number().nullable(),
truncated: z.boolean().optional(),
outputLength: z.number(),
maxOutputLength: z.number().optional(),
});
export type DiagnosticResult = z.infer<typeof DiagnosticResultSchema>;
// ========== Error Handling ==========
export enum ErrorCodes {
// Bridge errors
AdapterNotFound = 'ADAPTER_NOT_FOUND',
ConfigurationInvalid = 'CONFIGURATION_INVALID',
TimeoutExceeded = 'TIMEOUT_EXCEEDED',
OutputLimitExceeded = 'OUTPUT_LIMIT_EXCEEDED',
// Adapter communication errors
AuthenticationFailed = 'AUTHENTICATION_FAILED',
ConnectionFailed = 'CONNECTION_FAILED',
ResourceNotFound = 'RESOURCE_NOT_FOUND',
// MeshCentral-specific errors
MeshCentralError = 'MESHCENTRAL_ERROR',
}
export class BridgeError extends Error {
constructor(
public code: ErrorCodes,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'BridgeError';
}
}
export const BridgeErrorResponseSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
}),
});
export type BridgeErrorResponse = z.infer<typeof BridgeErrorResponseSchema>;
// ========== Request/Response Contracts ==========
export const ListDevicesRequestSchema = z.object({
groupId: z.string().optional(),
});
export type ListDevicesRequest = z.infer<typeof ListDevicesRequestSchema>;
export const GetDeviceDetailsRequestSchema = z.object({
deviceId: z.string(),
});
export type GetDeviceDetailsRequest = z.infer<
typeof GetDeviceDetailsRequestSchema
>;
export const RunDiagnosticRequestSchema = z.object({
deviceId: z.string(),
checkType: z.nativeEnum(DiagnosticCheckType),
timeoutMs: z.number().optional(),
maxOutputLength: z.number().optional(),
});
export type RunDiagnosticRequest = z.infer<
typeof RunDiagnosticRequestSchema
>;
// ========== Adapter Interface ==========
export interface MeshCentralAdapter {
readonly name: string;
readonly version: string;
// Device listing
listDevices(request?: ListDevicesRequest): Promise<Device[]>;
getDevice(deviceId: string): Promise<Device>;
// Device groups
listGroups(): Promise<DeviceGroup[]>;
getGroup(groupId: string): Promise<DeviceGroup>;
// Diagnostics (read-only)
runDiagnostic(request: RunDiagnosticRequest): Promise<DiagnosticResult>;
// Health check
healthCheck(): Promise<boolean>;
}
// ========== Mock Configuration ==========
export interface BridgeConfig {
meshCentralUrl: string;
adapterType: 'real' | 'mock';
timeoutMs: number;
maxOutputLength: number;
// Security: Allowlist for PVE3 and specific devices
pveAllowlist?: string[]; // PVE node IDs (e.g., ['pve3'])
deviceAllowlist?: string[]; // Device IDs (e.g., ['dev-001', 'dev-002'])
groupAllowlist?: string[]; // Group IDs (e.g., ['grp-servers'])
// Authentication (never logged, set at runtime)
username?: string;
password?: string;
totpToken?: string; // Optional 2FA token
}
export const defaultConfig: BridgeConfig = {
meshCentralUrl: 'http://localhost:8080',
adapterType: 'mock',
timeoutMs: 10_000,
maxOutputLength: 65_536, // 64 KB
};
+55
View File
@@ -0,0 +1,55 @@
/**
* MeshCentral ↔ OpenClaw Bridge
*
* Read-only integration for device inventory and diagnostics.
*/
export { createMeshCentralAdapter } from './adapter';
export {
Device,
DeviceGroup,
DeviceStatus,
DiagnosticResult,
DiagnosticCheckType,
ErrorCodes,
BridgeError,
MeshCentralAdapter,
BridgeConfig,
} from './contracts';
export { createMockAdapter } from './mock-adapter';
// Protocol client exports
export {
MeshCtrlClient,
encodeAuthHeader,
createRequest,
parseResponse,
validateResponseForAction,
isErrorResponse,
getErrorResponseMessage,
CONTROL_PATH,
} from './meshctrl-client';
// Audit logging exports
export {
createAuditEntry,
sanitizeForLogging,
redactSecrets,
isSecurityCritical,
AuditAction,
AuditStatus,
AuditLogEntry,
} from './audit';
// Structured logger exports
export {
Logger,
ConsoleLogger,
LogLevel,
createLogger,
getGlobalLogger,
setGlobalLogger,
resetGlobalLogger,
redactSecrets as redact,
sanitizeForLogging as sanitize,
} from './logger';
+97
View File
@@ -0,0 +1,97 @@
/**
* Narrow MeshCentral compatibility probe.
*
* This file intentionally has no CLI credential or target arguments. The
* password is read at runtime from the protected credential file and is never
* printed. Only serverversion, meshes, and nodes requests are sent.
*/
import { readFileSync, statSync } from 'node:fs';
import { MeshCtrlAction, MeshCtrlClient } from './meshctrl-client';
/* eslint-disable no-console -- CLI probe intentionally emits one safe result or generic failure. */
const DEFAULT_CREDENTIAL_FILE = '/home/ledadmin/.openclaw/workspace/Credentials/.meshcentral-credentials';
const MESH_URL = 'https://mesh.ledrew.me';
const USERNAME = 'sa_clawdia';
const APPROVED_GROUP_NAME = 'Servers';
const APPROVED_DEVICE_NAME = 'PVE3';
function readProtectedPassword(filePath = DEFAULT_CREDENTIAL_FILE): string {
const mode = statSync(filePath).mode & 0o777;
if (mode !== 0o600) {
throw new Error('Credential file permissions must be exactly 0600');
}
const password = readFileSync(filePath, 'utf8').trim();
if (!password) {
throw new Error('Credential file is empty');
}
return password;
}
export async function runReadOnlyCompatibilityTest(): Promise<void> {
const password = readProtectedPassword();
const client = new MeshCtrlClient({ url: MESH_URL, timeoutMs: 30_000 });
let stage = 'connect';
try {
await client.connect({ username: USERNAME, password });
stage = 'serverversion';
const version = await client.sendRequest({ action: MeshCtrlAction.ServerVersion });
stage = 'meshes';
const meshesResponse = await client.sendRequest({ action: MeshCtrlAction.Meshes });
const meshes = Array.isArray(meshesResponse.meshes) ? meshesResponse.meshes as Array<{ id: string; name: string }> : [];
const approvedGroup = meshes.find((mesh) => mesh.name === APPROVED_GROUP_NAME);
if (!approvedGroup) {
throw new Error('Approved MeshCentral group was not found');
}
stage = 'nodes';
const nodesResponse = await client.sendRequest({
action: MeshCtrlAction.Nodes,
meshid: approvedGroup.id,
});
const nodes = Array.isArray(nodesResponse.nodes) ? nodesResponse.nodes as Array<{ id: string; name: string; meshid: string; online: boolean }> : [];
const approvedDevice = nodes.find((node) => node.name === APPROVED_DEVICE_NAME);
if (!approvedDevice) {
throw new Error('Approved PVE3 device was not found in the Servers group');
}
stage = 'deviceinfo';
const deviceInfo = await client.sendRequest({
action: MeshCtrlAction.DeviceInfo,
nodeid: approvedDevice.id,
});
if (deviceInfo.err || !deviceInfo.device) {
throw new Error('Approved PVE3 metadata could not be retrieved');
}
// Deliberately emit only non-sensitive compatibility facts.
console.log(JSON.stringify({
success: true,
serverVersionAction: version.action,
serverVersionResult: typeof version.result === 'string' ? version.result : undefined,
group: APPROVED_GROUP_NAME,
device: { name: approvedDevice.name, online: approvedDevice.online },
}));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '';
const category = message.includes('timed out') ? 'timeout' : 'protocol-or-connection';
throw new Error(`live test failed at ${stage} (${category})`);
} finally {
await client.disconnect();
}
}
if (require.main === module) {
runReadOnlyCompatibilityTest().catch((error: unknown) => {
// Report only a generic error class and stage; never server text.
const message = error instanceof Error ? error.message : '';
const safeMessage = /^live test failed at (connect|serverversion|meshes|nodes|deviceinfo) \((timeout|protocol-or-connection)\)$/.exec(message)?.[0]
?? 'Read-only MeshCentral compatibility test failed';
console.error(safeMessage);
process.exitCode = 1;
});
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Structured logger with redaction utilities.
*
* Provides a minimal, structured logging abstraction with
* log levels and automatic secret redaction for safe output.
*/
// ========== Log Levels ==========
/**
* Log severity levels in order of increasing verbosity.
*/
export enum LogLevel {
Audit = 'audit',
Error = 'error',
Warn = 'warn',
Info = 'info',
Debug = 'debug',
}
/**
* Check if a log level is enabled (for filtering).
*/
export function isLevelEnabled(level: LogLevel, minLevel: LogLevel): boolean {
const order = [
LogLevel.Audit,
LogLevel.Error,
LogLevel.Warn,
LogLevel.Info,
LogLevel.Debug,
];
return order.indexOf(level) >= order.indexOf(minLevel);
}
// ========== Redaction Utilities ==========
/**
* Redact sensitive values from a string or object.
* Replaces secrets with "[REDACTED]" while preserving structure.
*/
export function redactSecrets(value: string | object): string {
let text = typeof value === 'string' ? value : JSON.stringify(value);
// Redact header values for common sensitive headers (before base64/hex)
text = text.replace(
/("(?:Authorization|X-Auth|Bearer|Token|Secret|Key|x-meshauth)"\s*:\s*)"[^"]*"/gi,
'$1"[REDACTED]"',
);
// Redact passwords in known key patterns (before base64/hex)
text = text.replace(
/("(?:password|secret|token|totp)"\s*:\s*)"[^"]*"/gi,
'$1"[REDACTED]"',
);
// Redact hex secrets (32+ chars, hex only) - more specific than base64
text = text.replace(
/\b([a-fA-F0-9]{32,})\b/g,
() => '[REDACTED_HEX]',
);
// Redact base64 credentials (16+ chars including = padding)
text = text.replace(
/([A-Za-z0-9+/]{16,}={0,2})/g,
(match) => {
// Only redact if it looks like base64 credentials
// Don't double-redact hex strings (which are also valid base64 chars)
if (match.length >= 20 && match.length <= 128) {
// Check if it's all hex (would have been caught above)
if (/^[a-fA-F0-9]+$/.test(match)) {
return match; // Let hex redaction handle it
}
return '[REDACTED_BASE64]';
}
return match;
},
);
return text;
}
/**
* Sanitize an object for safe logging by recursively redacting secrets.
*/
export function sanitizeForLogging<T>(data: T): T {
if (typeof data !== 'object' || data === null) {
return data;
}
if (Array.isArray(data)) {
return data.map((item) => sanitizeForLogging(item)) as T;
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase();
// Redact known secret keys
if (lowerKey.includes('password') ||
lowerKey.includes('secret') ||
lowerKey.includes('token') ||
lowerKey.includes('auth') ||
lowerKey === 'totp' ||
lowerKey === 'cred') {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeForLogging(value);
} else if (typeof value === 'string') {
sanitized[key] = redactSecrets(value);
} else {
sanitized[key] = value;
}
}
return sanitized as T;
}
// ========== Log Entry ==========
/**
* A structured log entry.
*/
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: Record<string, unknown>;
error?: Error | string;
}
/**
* Format a log entry for output.
*/
export function formatLogEntry(entry: LogEntry): string {
const timestamp = entry.timestamp;
const level = entry.level.toUpperCase().padEnd(5);
let output = `[${timestamp}] ${level} ${entry.message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
const contextStr = JSON.stringify(sanitizeForLogging(entry.context));
output += ` ${contextStr}`;
}
if (entry.error) {
const errorStr = entry.error instanceof Error
? entry.error.message
: String(entry.error);
output += ` [error: ${redactSecrets(errorStr)}]`;
}
return output;
}
// ========== Logger Interface ==========
/**
* Core logger interface with log levels.
*/
export interface Logger {
audit(message: string, context?: Record<string, unknown>): void;
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, contextOrError?: Record<string, unknown> | Error): void;
// Methods to configure the logger
setLevel(level: LogLevel): void;
getLevel(): LogLevel;
setName(name: string): void;
}
// ========== Default Logger Implementation ==========
/**
* Default logger implementation with console output.
*/
export class ConsoleLogger implements Logger {
private level: LogLevel;
private name: string;
constructor(options?: { level?: LogLevel; name?: string }) {
this.level = options?.level ?? LogLevel.Info;
this.name = options?.name ?? 'app';
}
debug(message: string, context?: Record<string, unknown>): void {
if (isLevelEnabled(LogLevel.Debug, this.level)) {
this._log(LogLevel.Debug, message, context);
}
}
info(message: string, context?: Record<string, unknown>): void {
if (isLevelEnabled(LogLevel.Info, this.level)) {
this._log(LogLevel.Info, message, context);
}
}
warn(message: string, context?: Record<string, unknown>): void {
if (isLevelEnabled(LogLevel.Warn, this.level)) {
this._log(LogLevel.Warn, message, context);
}
}
error(message: string, contextOrError?: Record<string, unknown> | Error): void {
if (isLevelEnabled(LogLevel.Error, this.level)) {
const context = (contextOrError instanceof Error || typeof contextOrError !== 'object')
? undefined
: contextOrError;
const error = (contextOrError instanceof Error || typeof contextOrError === 'string')
? contextOrError
: undefined;
this._log(LogLevel.Error, message, context, error);
}
}
setLevel(level: LogLevel): void {
this.level = level;
}
getLevel(): LogLevel {
return this.level;
}
setName(name: string): void {
this.name = name;
}
audit(message: string, context?: Record<string, unknown>): void {
if (isLevelEnabled(LogLevel.Audit, this.level)) {
this._log(LogLevel.Audit, message, context);
}
}
private _log(
level: LogLevel,
message: string,
context?: Record<string, unknown>,
error?: Error | string,
): void {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message: `[${this.name}] ${message}`,
context: context ? sanitizeForLogging(context) : undefined,
error,
};
const output = formatLogEntry(entry);
switch (level) {
case LogLevel.Audit:
// eslint-disable-next-line no-console
console.log(output);
break;
case LogLevel.Debug:
// eslint-disable-next-line no-console
console.debug(output);
break;
case LogLevel.Info:
// eslint-disable-next-line no-console
console.info(output);
break;
case LogLevel.Warn:
// eslint-disable-next-line no-console
console.warn(output);
break;
case LogLevel.Error:
// eslint-disable-next-line no-console
console.error(output);
break;
}
}
}
// ========== Factory Functions ==========
/**
* Create a new logger instance.
*/
export function createLogger(options?: { level?: LogLevel; name?: string }): Logger {
return new ConsoleLogger(options);
}
/**
* Global logger instance (singleton pattern).
*/
let _globalLogger: Logger | null = null;
/**
* Get or create the global logger.
*/
export function getGlobalLogger(): Logger {
if (!_globalLogger) {
_globalLogger = createLogger({
level: (process.env.LOG_LEVEL as LogLevel) ?? LogLevel.Info,
name: process.env.LOG_NAME ?? 'bridge',
});
}
return _globalLogger;
}
/**
* Set the global logger instance.
*/
export function setGlobalLogger(logger: Logger): void {
_globalLogger = logger;
}
/**
* Reset the global logger (useful for testing).
*/
export function resetGlobalLogger(): void {
_globalLogger = null;
}
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
/**
* Mock adapter for development and testing.
*
* Simulates MeshCentral device data without actual connectivity.
*/
import {
MeshCentralAdapter,
Device,
DeviceGroup,
DeviceStatus,
DiagnosticResult,
DiagnosticCheckType,
BridgeError,
ErrorCodes,
ListDevicesRequest,
RunDiagnosticRequest,
defaultConfig,
} from './contracts';
const MOCK_DEVICES: Device[] = [
{
id: 'dev-001',
name: 'home-server',
groupId: 'grp-servers',
groupName: 'Servers',
status: DeviceStatus.Online,
os: 'Ubuntu 24.04 LTS',
uptimeSeconds: 86400 * 15, // 15 days
lastSeen: new Date(Date.now() - 60_000).toISOString(),
ipAddresses: ['192.168.1.10', 'fd00::1'],
agentVersion: '2.4.1',
},
{
id: 'dev-002',
name: 'proxmox-prod',
groupId: 'grp-servers',
groupName: 'Servers',
status: DeviceStatus.Online,
os: 'Proxmox VE 8.2',
uptimeSeconds: 86400 * 3, // 3 days
lastSeen: new Date(Date.now() - 120_000).toISOString(),
ipAddresses: ['192.168.1.13', 'fd00::2'],
agentVersion: '2.4.1',
},
{
id: 'dev-003',
name: 'test-lab-client',
groupId: 'grp-workstations',
groupName: 'Workstations',
status: DeviceStatus.Offline,
os: 'Windows 11 Pro',
uptimeSeconds: null as unknown as number, // Not available when offline
lastSeen: new Date(Date.now() - 86400_000).toISOString(), // 1 day ago
ipAddresses: ['192.168.1.50'],
agentVersion: '2.4.1',
},
{
id: 'dev-004',
name: 'raspberry-pi-01',
groupId: 'grp-devices',
groupName: 'IoT Devices',
status: DeviceStatus.Online,
os: 'Raspberry Pi OS 12 (Bookworm)',
uptimeSeconds: 86400 * 7, // 7 days
lastSeen: new Date(Date.now() - 30_000).toISOString(),
ipAddresses: ['192.168.1.100'],
agentVersion: '2.4.1',
},
];
const MOCK_GROUPS: DeviceGroup[] = [
{
id: 'grp-servers',
name: 'Servers',
description: 'Production and infrastructure servers',
deviceCount: 2,
},
{
id: 'grp-workstations',
name: 'Workstations',
description: 'User workstations and laptops',
deviceCount: 1,
},
{
id: 'grp-devices',
name: 'IoT Devices',
description: 'Raspberry Pi and other IoT devices',
deviceCount: 1,
},
];
const DIAGNOSTIC_OUTPUTS: Record<DiagnosticCheckType, string> = {
[DiagnosticCheckType.DiskSpace]: `Filesystem Size Used Avail Use% Mounted on
/dev/sda1 1008G 234G 724G 25% /
tmpfs 7.7G 0 7.7G 0% /dev/shm
/dev/sda2 938M 156M 724M 18% /boot
`,
[DiagnosticCheckType.Services]: `● ssh.service - OpenSSH daemon
Loaded: loaded (/lib/systemd/system/ssh.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-08-24 07:00:00 UTC; 15h ago
Main PID: 1234 (sshd)
● nginx.service - A high performance web server and reverse proxy server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-08-24 07:01:00 UTC; 15h ago
● postgresql.service - PostgreSQL RDBMS
Loaded: loaded (/lib/systemd/system/postgresql.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-08-24 07:02:00 UTC; 15h ago
`,
[DiagnosticCheckType.Processes]: `USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1234 0.0 0.1 16856 5432 ? Ss 07:00 0:01 sshd: /usr/sbin/sshd -D
www-data 234567 1.2 0.8 245632 32768 ? Sl 07:01 1:23 nginx: worker process
postgres 345678 0.5 1.2 567890 49152 ? Ss 07:02 0:45 postgres: main process
`,
[DiagnosticCheckType.OSDetails]: `NAME="Ubuntu"
VERSION="24.04 LTS (Noble Numbat)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 24.04 LTS"
VERSION_ID="24.04"
KERNEL_VERSION="6.8.0-45-generic"
`,
[DiagnosticCheckType.NetworkState]: `1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
inet 192.168.1.10/24 brd 192.168.1.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fd00::1/64 scope global
valid_lft forever preferred_lft forever
`,
[DiagnosticCheckType.Uptime]: `22:30:00 up 15 days, 4:30, 2 users, load average: 0.15, 0.25, 0.30
`,
};
export class MockAdapter implements MeshCentralAdapter {
readonly name = 'mock';
readonly version = '0.1.0';
private config: typeof defaultConfig;
constructor(config: Partial<typeof defaultConfig> = {}) {
this.config = { ...defaultConfig, ...config };
}
async listDevices(request?: ListDevicesRequest): Promise<Device[]> {
let devices = MOCK_DEVICES;
if (request?.groupId) {
devices = devices.filter((d) => d.groupId === request.groupId);
}
return devices;
}
async getDevice(deviceId: string): Promise<Device> {
const device = MOCK_DEVICES.find((d) => d.id === deviceId);
if (!device) {
throw new BridgeError(
ErrorCodes.ResourceNotFound,
`Device not found: ${deviceId}`,
{ deviceId },
);
}
return device;
}
async listGroups(): Promise<DeviceGroup[]> {
return MOCK_GROUPS;
}
async getGroup(groupId: string): Promise<DeviceGroup> {
const group = MOCK_GROUPS.find((g) => g.id === groupId);
if (!group) {
throw new BridgeError(
ErrorCodes.ResourceNotFound,
`Group not found: ${groupId}`,
{ groupId },
);
}
return group;
}
async runDiagnostic(request: RunDiagnosticRequest): Promise<DiagnosticResult> {
const { deviceId, checkType, timeoutMs = this.config.timeoutMs, maxOutputLength = this.config.maxOutputLength } =
request;
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 100)));
const output = DIAGNOSTIC_OUTPUTS[checkType] || '';
// Truncate if needed
const truncated = output.length > maxOutputLength;
const trimmedOutput = truncated ? output.slice(0, maxOutputLength) + '\n...[truncated]' : output;
return {
deviceId,
checkType,
timestamp: new Date().toISOString(),
success: true,
output: trimmedOutput,
exitCode: 0,
outputLength: trimmedOutput.length,
maxOutputLength,
truncated,
};
}
async healthCheck(): Promise<boolean> {
// Mock always healthy
return true;
}
}
export function createMockAdapter(config?: Partial<typeof defaultConfig>): MeshCentralAdapter {
return new MockAdapter(config);
}
export { DiagnosticCheckType };
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"inlineSources": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}