/** * 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 { UpstreamMeshTransport } from './upstream-transport'; 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: UpstreamMeshTransport | null = null; private credentials: { username: string; password: string; totpToken?: string } | null = null; private connected = false; private logger: Logger; constructor(config?: Partial) { 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, ): 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', ); } // Real-adapter credentials may be supplied later through setCredentials(). // Keep the constructor usable for protected runtime secret injection; // _ensureConnected() remains the enforcement point before any connection. // Validate credential formats if present if (hasUsername && hasPassword) { 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 { 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 UpstreamMeshTransport(this.config.meshCentralUrl); 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 { 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(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 { await this._ensureConnected(); // Log device listing attempt this._auditLog(AuditAction.DEVICE_LISTED, AuditStatus.PENDING, { actor: this._getActorName(), resource: 'all-devices', groupId: request?.groupId, }); // 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; _id?: string; name: string; description?: string; desc?: string }>).map((mesh) => ({ ...mesh, id: mesh.id ?? mesh._id ?? '', })).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string }>; // If group filter provided, find matching mesh if (request?.groupId) { 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 { 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 groupedNodes = (nodesResponse.nodes ?? {}) as Record>; const nodes = Object.entries(groupedNodes).flatMap(([groupId, groupNodes]) => groupNodes.map((node) => ({ id: node.id ?? node._id ?? '', name: node.name, meshid: node.meshid ?? groupId, online: node.online ?? node.conn === 1, device: node.device ?? { os: node.osdesc, ip: node.ip, }, })), ).filter((node) => node.id !== '') as Array<{ id: string; name: string; online: boolean; meshid?: string; device?: { os?: string; uptime?: number; lastseen?: number; ip?: string[]; agentver?: string } }>; if (!this._isGroupAllowed(mesh.id)) { return []; } return nodes .map((node) => this._mapNodeToDevice(node, mesh)) .filter((device) => this._isDeviceAllowed(device.id)); } async getDevice(deviceId: string): Promise { 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 { 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; _id?: string; name: string; description?: string; desc?: string; }>).map((mesh) => ({ ...mesh, id: mesh.id ?? mesh._id ?? '', description: mesh.description ?? mesh.desc, })).filter((mesh) => mesh.id !== '') as Array<{ id: string; name: string; description?: string }>; // Filter by group allowlist if configured const allowedMeshes = this._filterByAllowlist( meshes, (m) => m.id, this.config.groupAllowlist, ); return allowedMeshes.map((mesh) => this._mapMeshToGroup(mesh)); } async getGroup(groupId: string): Promise { 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 { 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 { // 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(); // `meshes` is the least-privileged authenticated inventory operation // and is available without the server-upgrade site permission required // by MeshCentral's `serverversion` command. const meshesResponse = await this.client!.sendRequest({ action: 'meshes' }); const healthy = Array.isArray(meshesResponse.meshes); this._auditLog(AuditAction.SERVICE_START, healthy ? AuditStatus.SUCCESS : AuditStatus.FAILURE, { actor: this._getActorName(), resource: 'health-check', 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 { await this._disconnect(); } } /** * Factory function to create the appropriate adapter based on configuration. */ export function createMeshCentralAdapter(config: Partial = {}): MeshCentralAdapter { const adapterType = config.adapterType ?? 'mock'; switch (adapterType) { case 'real': return new MeshCentralRealAdapter(config); case 'mock': default: return createMockAdapter(config); } }