Files
Kevin L'Hereec f9e56c78b1 refactor: migrate all internal imports to package.json subpath aliases
- Add #domain, #protocol, #utils aliases to package.json imports map
- Replace all relative imports (./, ../, ../..) in app/ and
  database/mongo_migrations/ with the corresponding #alias imports,
  eliminating relative path traversal across the codebase
- Remove dead code: ConfigurationVersionModel.findByConfigurationIdentifier
  (unused, superseded by paginateVersions) and unused exception classes
  (NotFoundException, UnprocessableEntityException, DatabaseException)
  from app/exceptions/index.ts, keeping only BadRequestException
- Fix pre-existing syntax error in configuration_version.ts (mismatched
  parenthesis) that broke tsc
- Fix eqeqeq violation in configuration_service.ts and isNaN ->
  Number.isNaN in configuration_engine.ts / cwmp-1-0.ts / exchange_controller.ts
- Fix no-shadow violation in cwmp-1-0.ts EventStruct constructor
- Prettier formatting pass on touched files

Validated: tsc --noEmit clean, eslint app/ clean, full test suite green.
2026-07-22 11:45:14 +02:00

348 lines
12 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import { CWMPMessageBuilder } from '#protocol/common/cwmp_message_builder'
import { StateMachineManager } from '#services/cwmp/state_machine_manager'
import { Device, detectDataModelFromParams } from '#domain/device/device'
import { DeviceId } from '#domain/device/device_id'
import { DeviceParameter, DeviceProperty } from '#domain/device/device_parameter'
import DeviceModel from '#models/device'
import { getDeviceHashIdentifier } from '#utils/device_utils'
import { getReferenceEngine } from '#services/references/reference_engine'
import { normalizeEventCode } from '#utils/cwmp-events'
import logger from '@adonisjs/core/services/logger'
import type { Inform, CwmpMessage, ParameterValueStruct } from '#protocol/tr069_types'
import { ParameterType } from '#domain/shared/types'
import { FaultCodeType } from '#protocol/tr069_types'
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) ?? 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 informMsg = message as Extract<CwmpMessage, { type: 'Inform' }>
const { xml, hashId } = await this.handleInform(
informMsg.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) ?? manager.getHashIdForRemoteIp(request.ip())
if (hashId) ctx.cwmpDeviceId = hashId
logger.debug({ sessionId, hashId }, 'CWMP sending TransferCompleteResponse')
return this.sendSoap(response, CWMPMessageBuilder.transferCompleteResponse())
}
manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId =
manager.getHashIdForRemote(remoteKey) ?? manager.getHashIdForRemoteIp(request.ip())
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: unknown) {
const err = error as Error
logger.error({ error: err?.message, stack: err?.stack }, 'CWMP error handling request')
const faultXml = this.buildSoapFault(err?.message || 'Internal error')
return this.sendSoap(response, faultXml)
}
}
// ─── Inform Handling ───────────────────────────────────────────────────────
private async handleInform(
informData: Inform,
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: ParameterValueStruct[] =
informData?.ParameterList?.ParameterValueStruct || []
if (paramValues.length > 0) {
device.updateFromValues(paramValues)
device.reclassifyInformWritables(paramValues)
}
device.setTmpParamValues(paramValues)
// Detect TR data model
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) => e?.EventCode)
.filter((c) => c !== null && c !== undefined)
.map((c) => String(c))
logger.info({ events }, 'CWMP Inform events extracted')
// Track last periodic inform timestamp + last remote IP
const normalizedEvents = events.map((e) => normalizeEventCode(e))
const isPeriodic = normalizedEvents.some((e) => e === 'PERIODIC')
const isBootstrap = normalizedEvents.some((e) => e === 'BOOTSTRAP')
const cpeIpForStorage = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0]
const dbUpdate: Record<string, unknown> = { lastRemoteIp: cpeIpForStorage }
if (isPeriodic || isBootstrap) dbUpdate.lastPeriodicInformAt = new Date()
await DeviceModel.updateOne({ identifier: hashId }, { $set: dbUpdate }).catch(() => {})
// 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 (async, non-blocking)
try {
logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution')
const engine = getReferenceEngine()
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: unknown) {
const err = _e as Error
logger.error(
{ error: err?.message, stack: err?.stack },
'[References] Error during resolution'
)
}
return { xml: CWMPMessageBuilder.informResponse(cwmpId), hashId }
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private sendSoap(response: HttpContext['response'], xml: string) {
response.header('Content-Type', 'text/xml; charset=utf-8')
response.header('SOAPAction', '""')
return response.send(xml)
}
private getMessageInfo(message: CwmpMessage): { success: boolean; payload: CwmpMessage['data'] } {
return {
success: message.type !== 'Fault',
payload: message.data,
}
}
private buildSoapFault(reason: string): string {
return CWMPMessageBuilder.soapFault(FaultCodeType.ACSInternalError, reason)
}
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: { name: string; value: unknown; type: string }) =>
device.setProperty(
new DeviceProperty(p.name, p.value, (p.type as ParameterType) ?? ParameterType.UNKNOWN)
)
)
record.parameters?.forEach((p: { name: string; value: unknown; type: string }) =>
device.setParameter(
new DeviceParameter(p.name, p.value, (p.type as ParameterType) ?? ParameterType.UNKNOWN)
)
)
device.setGpnStatus(record.gpnStatus ?? 'unknown')
device.setDataModel(record.dataModel ?? 'unknown')
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 {
return null
}
}
}