- Tr069TraceService: in-memory Map O(1) lookup, Promise-queue per device for sequential file writes, 10MB rotation (5 files kept) - config/tr069-trace.json: persisted list of traced devices (atomic write) - CwmpLoggerMiddleware: buffers request+response, writes to device log file after next() once ctx.cwmpDeviceId is set by controller - CwmpController: sets ctx.cwmpDeviceId on all code paths (Inform + others) - StateMachineManager: getHashIdForRemote() for non-Inform messages - REST API: GET/POST/DELETE /api/v1/tr069-trace - UI: Tr069Trace Vue page with device search, add/remove tracing - 8 new unit tests (192 total, all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 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 } 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 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) {
|
|
manager.ensureSessionForRemote(remoteKey, sessionId)
|
|
const hashId = manager.getHashIdForRemote(remoteKey)
|
|
if (hashId) ctx.cwmpDeviceId = hashId
|
|
|
|
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
|
|
)
|
|
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
|
|
): 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)
|
|
}
|
|
device.setTmpParamValues(paramValues)
|
|
|
|
// 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,
|
|
updatedAt: new Date(),
|
|
})
|
|
logger.info({ hashId }, 'Inform received')
|
|
|
|
// Extract events
|
|
const events: string[] = Array.isArray(informData?.Event?.EventStruct)
|
|
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter(
|
|
(c: any): c is string => !!c
|
|
)
|
|
: []
|
|
|
|
logger.info({ events }, 'CWMP Inform events extracted')
|
|
|
|
// Start state machine
|
|
await manager.startStateMachine(device, session, { events })
|
|
manager.bindRemoteToDevice(remoteKey, device.deviceId)
|
|
|
|
// After Inform for onboarding events, enqueue critical reads
|
|
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
|
|
if (!onlyConnReq) {
|
|
try {
|
|
const criticalPaths = [
|
|
'InternetGatewayDevice.DeviceInfo.SpecVersion',
|
|
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
|
|
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
|
|
'InternetGatewayDevice.ManagementServer.ConnectionRequestURL',
|
|
]
|
|
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')
|
|
|
|
return device
|
|
} catch (_error) {
|
|
return null
|
|
}
|
|
}
|
|
}
|