Files
adam-acs/app/controllers/cwmp_controller.ts
T

289 lines
11 KiB
TypeScript

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 { reclassifyDeviceFromCatalog } from '../services/parameter_writability.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 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)
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
logger.debug({ remoteKey, sessionId, rebound, hashId, hasSession: manager.hasSession(sessionId) }, 'CWMP Empty POST received')
if (manager.hasSession(sessionId)) {
const result = await manager.continue(sessionId)
if (result && result !== '') {
return this.sendSoap(response, result)
}
}
return response.status(204).send('')
}
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
return this.sendSoap(response, xml)
}
if (message?.type === 'TransferComplete') {
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
const resp = buildSoapEnvelope(
undefined,
'<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>'
)
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)
const result = await manager.handleCWMPResponse(sessionId, success, payload)
if (result && result !== '') {
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)
}
device.updateLastSeen()
// Process inform parameter values
const paramValues = informData?.ParameterList?.ParameterValueStruct || []
if (paramValues.length > 0) {
device.updateFromValues(paramValues)
}
// Reclassify properties → parameters using catalog when GPN is unsupported
if (device.gpnStatus === 'unsupported') {
reclassifyDeviceFromCatalog(device)
}
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,
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 isPeriodic = events.some((e) => String(e).toUpperCase().includes('PERIODIC') || String(e) === '2')
const isBootstrap = events.some((e) => String(e).toUpperCase().includes('BOOTSTRAP') || String(e) === '0')
if (isPeriodic || isBootstrap) {
await DeviceModel.updateOne({ identifier: hashId }, { $set: { lastPeriodicInformAt: new Date() } }).catch(() => {})
}
// 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')
}
// After Inform for onboarding events, enqueue critical reads using the detected data model
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
if (!onlyConnReq) {
try {
const isTr181 = device.dataModel === 'tr-181'
const root = isTr181 ? 'Device' : 'InternetGatewayDevice'
const criticalPaths = [
`${root}.DeviceInfo.HardwareVersion`,
`${root}.DeviceInfo.SoftwareVersion`,
`${root}.ManagementServer.ConnectionRequestURL`,
...(isTr181 ? [] : [`${root}.DeviceInfo.SpecVersion`]),
]
logger.debug({ dataModel: device.dataModel, criticalPaths }, 'Enqueueing critical reads')
await manager.enqueueImmediate(device, 'get_parameter_values', criticalPaths)
} catch (_e) {
// Non-critical
}
}
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 = `<soap-env:Fault>
<faultcode>Server</faultcode>
<faultstring>${reason}</faultstring>
<detail>
<cwmp:Fault>
<FaultCode>8002</FaultCode>
<FaultString>${reason}</FaultString>
</cwmp:Fault>
</detail>
</soap-env:Fault>`
return buildSoapEnvelope(undefined, body)
}
private async loadDevice(deviceId: DeviceId): Promise<Device | null> {
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')
return device
} catch (_error) {
return null
}
}
}