- Copy cwmp-1-0..4.ts source classes from cwmp-connector (papaye) into app/protocol/ as local files (ts-nocheck, external dependency) - Add soap_envelope.ts: SoapEnvelope, SoapEnvelopeHeader, CwmpID, NAMESPACES - Add tr069_types.ts: re-exports all CWMP classes + CwmpMessage discriminated union + typed task payload interfaces (CWMPTaskType, CWMPTaskPayload, etc.) - Rewrite cwmp_message_builder.ts to use class-based builders instead of raw XML template strings; add transferCompleteResponse() and soapFault() - soap.ts: extractCwmpMessage returns CwmpMessage union, adds runtime allowlist guard against unknown CWMP method names from CPE - cwmp_middleware.ts: envelope typed SoapEnvelope|null, message CwmpMessage|null - cwmp_controller.ts: handleInform(informData: Inform), remove any, migrate TransferComplete and fault to new builder path, drop buildSoapEnvelope import - task_queue.ts: CWMPTask.type → CWMPTaskType literal union, payload → CWMPTaskPayload, result → CWMPTaskResultData - device_action_executor.ts: GPV/GPN/GPA responses typed, no more any - main_state_machine.ts: DeviceContext.message.data → CWMPTaskResultData, organizeParameters receives ParameterInfoStruct[] directly - device.ts: tmpParamValues typed ParameterValueStruct[], organizeParameters signature updated, updateFromValues typed - device_id.ts: fromInform typed with DeviceIdStruct - soap_envelope.ts: remove unconditional <cwmp:noMoreRequests> header (CPE→ACS only per TR-069 §3.7.1.4)
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import type { HttpContext } from '@adonisjs/core/http'
|
|
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'
|
|
import type { Inform, CwmpMessage, ParameterValueStruct } from '../protocol/tr069_types.js'
|
|
import { ParameterType } from '../domain/shared/types.js'
|
|
import { FaultCodeType } from '../protocol/tr069_types.js'
|
|
|
|
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)
|
|
.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
|
|
}
|
|
}
|
|
}
|