import type { HttpContext } from '@adonisjs/core/http' import { buildSoapEnvelope } from '../protocol/soap.js' import { CWMPMessageBuilder } from '../protocol/common/cwmp_message_builder.js' import { StateMachineManager } from '../services/cwmp/state_machine_manager.js' import { Device, detectDataModelFromParams } from '../domain/device/device.js' import { DeviceId } from '../domain/device/device_id.js' import { DeviceParameter, DeviceProperty } from '../domain/device/device_parameter.js' import DeviceModel from '../models/device.js' import { getDeviceHashIdentifier } from '../utils/device_utils.js' import { getReferenceEngine } from '../services/references/reference_engine.js' import { normalizeEventCode } from '../utils/cwmp-events.js' import logger from '@adonisjs/core/services/logger' export default class CwmpController { private static managerInitialized = false private getManager(): StateMachineManager { const manager = StateMachineManager.getInstance() if (!CwmpController.managerInitialized) { manager.startCleanupTimer(60000) CwmpController.managerInitialized = true } return manager } /** * POST /cwmp10 * * Main CWMP/TR-069 endpoint. The cwmp_middleware has already: * - Parsed the raw XML into a SOAP envelope * - Extracted the CWMP message type and data * - Validated content-type * - Attached everything to request.cwmp */ async handle(ctx: HttpContext) { const { request, response } = ctx const manager = this.getManager() const { sessionId, remoteKey, envelope, message, isEmpty } = request.cwmp try { if (isEmpty) { const rebound = manager.ensureSessionForRemote(remoteKey, sessionId) // Try exact remoteKey first (same TCP connection), then fall back to IP-only // (CPE may open a new TCP port for the empty POST) const hashId = manager.getHashIdForRemote(remoteKey) ?? manager.getHashIdForRemoteIp(request.ip()) if (hashId) ctx.cwmpDeviceId = hashId logger.debug( { remoteKey, sessionId, rebound, hashId, hasSession: manager.hasSession(sessionId) }, 'CWMP empty POST — CPE polling for next task' ) if (manager.hasSession(sessionId)) { const result = await manager.continue(sessionId) if (result && result !== '') { return this.sendSoap(response, result) } } return response.status(204).send('') } logger.debug( { sessionId, remoteKey, messageType: message?.type ?? 'none', cwmpId: envelope?.header?.id }, 'CWMP message routing' ) if (message?.type === 'Inform') { const { xml, hashId } = await this.handleInform( message.data, sessionId, envelope?.header?.id, remoteKey, manager, request.header('x-forwarded-for') || '' ) ctx.cwmpDeviceId = hashId logger.debug({ sessionId, hashId, responseLength: xml.length }, 'CWMP sending InformResponse') return this.sendSoap(response, xml) } if (message?.type === 'TransferComplete') { const hashId = manager.getHashIdForRemote(remoteKey) if (hashId) ctx.cwmpDeviceId = hashId logger.debug({ sessionId, hashId }, 'CWMP sending TransferCompleteResponse') const resp = buildSoapEnvelope( undefined, '' ) return this.sendSoap(response, resp) } manager.ensureSessionForRemote(remoteKey, sessionId) const hashId = manager.getHashIdForRemote(remoteKey) if (hashId) ctx.cwmpDeviceId = hashId if (message && manager.hasSession(sessionId)) { const { success, payload } = this.getMessageInfo(message) logger.debug( { sessionId, hashId, messageType: message.type, success }, 'CWMP forwarding response to state machine' ) const result = await manager.handleCWMPResponse(sessionId, success, payload) if (result && result !== '') { logger.debug({ sessionId, hashId, responseLength: result.length }, 'CWMP state machine produced response') return this.sendSoap(response, result) } } return response.status(204).send('') } catch (error: any) { logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request') const faultXml = this.buildSoapFault(error?.message || 'Internal error') return this.sendSoap(response, faultXml) } } // ─── Inform Handling ─────────────────────────────────────────────────────── private async handleInform( informData: any, session: string, cwmpId: string | undefined, remoteKey: string, manager: StateMachineManager, xForwardedFor?: string ): Promise<{ xml: string; hashId: string }> { logger.info( { informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, 'CWMP handleInform called' ) const deviceIdStruct = informData?.DeviceId || {} const deviceId = DeviceId.fromInform(deviceIdStruct) // Load or create device let device = await this.loadDevice(deviceId) if (!device) { device = new Device(deviceId) logger.info({ deviceId: deviceId.toIdentifier() }, '[handleInform] Device not found in DB — creating new') } else { logger.info( { deviceId: deviceId.toIdentifier(), gpnStatus: device.gpnStatus, circuitBreaker: device.gpnCircuitBreaker }, '[handleInform] Device loaded from DB' ) } device.updateLastSeen() // Process inform parameter values const paramValues = informData?.ParameterList?.ParameterValueStruct || [] if (paramValues.length > 0) { device.updateFromValues(paramValues) // Some writable parameters arrive in the Inform before GPN has classified them. // updateFromValues falls back to storing unknowns as read-only properties. // Reclassify known-writable Inform parameters as parameters (writable). device.reclassifyInformWritables(paramValues) } device.setTmpParamValues(paramValues) // Detect TR data model from Inform parameter names (Device.* = TR-181, InternetGatewayDevice.* = TR-098) // Only update if currently unknown — once detected, keep it stable across sessions if (device.dataModel === 'unknown') { const detectedModel = detectDataModelFromParams(paramValues) if (detectedModel !== 'unknown') { device.setDataModel(detectedModel) } } // Persist device const hashId = getDeviceHashIdentifier({ manufacturer: deviceId.manufacturer, oui: deviceId.oui, productClass: deviceId.productClass, serialNumber: deviceId.serialNumber, }) await DeviceModel.createOrUpdate({ identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()), gpnStatus: device.gpnStatus, dataModel: device.dataModel, gpnCircuitBreaker: device.gpnCircuitBreaker, lastGpnSentDate: device.lastGpnSentDate, updatedAt: new Date(), }) logger.info({ hashId }, 'Inform received') // Extract events const rawEventStruct = informData?.Event?.EventStruct const eventStructArray = Array.isArray(rawEventStruct) ? rawEventStruct : rawEventStruct ? [rawEventStruct] : [] const events: string[] = eventStructArray .map((e: any) => e?.EventCode) .filter((c: any) => c != null) .map((c: any) => String(c)) logger.info({ events }, 'CWMP Inform events extracted') // Track last periodic inform timestamp const normalizedEvents = events.map((e) => normalizeEventCode(e)) const isPeriodic = normalizedEvents.some((e) => e === 'PERIODIC') const isBootstrap = normalizedEvents.some((e) => e === 'BOOTSTRAP') if (isPeriodic || isBootstrap) { await DeviceModel.updateOne({ identifier: hashId }, { $set: { lastPeriodicInformAt: new Date() } }).catch(() => {}) } // Phase 12: Crash pattern detection const hasBoot = normalizedEvents.some((e) => e === 'BOOT') if (hasBoot && device.lastGpnSentDate) { const timeSinceGpn = Date.now() - device.lastGpnSentDate.getTime() if (timeSinceGpn < 60000) { device.recordCrashPattern() logger.warn({ hashId, timeSinceGpn }, 'Possible GPN-induced crash detected for device') } } // Start state machine await manager.startStateMachine(device, session, { events }) manager.bindRemoteToDevice(remoteKey, device.deviceId) // Resolve references from configured providers (async, non-blocking) try { logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution') const engine = getReferenceEngine() // Use x-forwarded-for header (set by simulator) or fall back to TCP remote IP const cpeIp = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0] const informContext = { events, remoteIp: cpeIp, headers: {} } const references = await engine.resolveAll( { identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()) }, informContext ) if (references.length > 0) { await DeviceModel.updateOne({ identifier: hashId }, { $set: { references, updatedAt: new Date() } }).catch(() => {}) logger.debug({ hashId, providers: references.map((r) => r.provider) }, '[References] Resolved') } } catch (_e: any) { // Non-critical — don't block Inform processing logger.error({ error: _e?.message, stack: _e?.stack }, '[References] Error during resolution') } return { xml: CWMPMessageBuilder.informResponse(cwmpId), hashId } } // ─── Helpers ─────────────────────────────────────────────────────────────── private sendSoap(response: any, xml: string) { response.header('Content-Type', 'text/xml; charset=utf-8') response.header('SOAPAction', '""') return response.send(xml) } private getMessageInfo(message: { type: string; data: any }): { success: boolean; payload: any } { const failTypes = ['Fault'] return { success: !failTypes.includes(message.type), payload: message.data, } } private buildSoapFault(reason: string): string { const body = ` Server ${reason} 8002 ${reason} ` return buildSoapEnvelope(undefined, body) } private async loadDevice(deviceId: DeviceId): Promise { try { const record = await DeviceModel.findByDeviceId(deviceId) if (!record) return null const device = new Device( new DeviceId( record.deviceId.manufacturer, record.deviceId.oui, record.deviceId.productClass, record.deviceId.serialNumber ), record.createdAt ) record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)) ) record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)) ) device.setGpnStatus(record.gpnStatus ?? 'unknown') device.setDataModel(record.dataModel ?? 'unknown') // Restore circuit breaker state if (record.gpnCircuitBreaker) { device.setGpnCircuitBreaker({ failureCount: record.gpnCircuitBreaker.failureCount ?? 0, lastFailureDate: record.gpnCircuitBreaker.lastFailureDate ? new Date(record.gpnCircuitBreaker.lastFailureDate) : null, state: record.gpnCircuitBreaker.state ?? 'closed', cooldownUntil: record.gpnCircuitBreaker.cooldownUntil ? new Date(record.gpnCircuitBreaker.cooldownUntil) : null, }) } if (record.lastGpnSentDate) { device.lastGpnSentDate = new Date(record.lastGpnSentDate) } return device } catch (_error) { return null } } }