Initial project documentation and status
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meshCentralUrl": "http://10.10.10.60:8080",
|
||||
"adapterType": "real",
|
||||
"timeoutMs": 30000,
|
||||
"maxOutputLength": 65536
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meshCentralUrl": "http://192.168.1.10:8080",
|
||||
"adapterType": "mock",
|
||||
"timeoutMs": 10000,
|
||||
"maxOutputLength": 65536
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
Generated
+5570
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user