import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' // @ts-ignore - xml-formatter types conflict with ESM interop import formatXml from 'xml-formatter' import { Tr069TraceService } from '#services/tr069_trace_service' import { ExchangeStoreService } from '#services/exchange_store_service' import logger from '@adonisjs/core/services/logger' /** * CWMP Logger Middleware * * Buffers SOAP request/response. After next(): * - Always logs a brief summary (INFO) to the app logger. * - For traced devices: * 1. Persists raw XML to ExchangeStoreService (new structured MongoDB storage) * 2. Writes pretty-printed XML to file-based logs (legacy, kept for Phase 1 dual-write safety) * * The ExchangeStoreService calls are fire-and-forget: they never block CWMP processing. */ export default class CwmpLoggerMiddleware { private prettyXml(xml: string): string { try { return (formatXml as any)(xml, { indentation: ' ', collapseContent: true, lineSeparator: '\n', }) } catch { return xml } } async handle(ctx: HttpContext, next: NextFn) { const { request, response } = ctx const rawBody = request.raw() || '' const socket = request.request?.socket const remoteIp = request.ip() const remotePort = socket?.remotePort || 0 const localAddress = socket?.localAddress || 'unknown' const localPort = socket?.localPort || 0 const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}` let capturedResponseXml: string | null = null let isEmptyResponse = false const originalSend = response.send.bind(response) response.send = (body: any) => { if (typeof body === 'string' && body.includes('soap-env:Envelope')) { capturedResponseXml = body } else if (!body || (typeof body === 'string' && body.trim() === '')) { isEmptyResponse = true } return originalSend(body) } await next() const deviceId = ctx.cwmpDeviceId const traceService = Tr069TraceService.getInstance() const isTraced = deviceId ? traceService.isTraced(deviceId) : false if (isTraced && deviceId) { // New system: persist raw XML to MongoDB (fire-and-forget, never blocks CWMP) // Ordering is guaranteed by the per-device queue in ExchangeStoreService const exchangeStore = ExchangeStoreService.getInstance() // Persist CPE→ACS (including empty POST) exchangeStore.persistExchange({ deviceId, direction: 'CPE→ACS', sessionId, rawXml: rawBody || '', }).catch(() => {}) // Persist ACS→CPE response (will queue after the CPE→ACS above) if (capturedResponseXml) { exchangeStore.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId, rawXml: capturedResponseXml, }).catch(() => {}) } else if (isEmptyResponse) { exchangeStore.persistExchange({ deviceId, direction: 'ACS→CPE', sessionId, rawXml: '', }).catch(() => {}) } // Old system: file-based writes (kept for Phase 1 dual-write rollback safety) if (rawBody && rawBody.trim() !== '') { traceService.writeExchange(deviceId, 'CPE→ACS', sessionId, this.prettyXml(rawBody)) } if (capturedResponseXml) { traceService.writeExchange( deviceId, 'ACS→CPE', sessionId, this.prettyXml(capturedResponseXml) ) } } else if (rawBody && rawBody.trim() !== '') { // Brief app-level log only (no SOAP body) logger.debug({ sessionId, deviceId: deviceId ?? 'unknown' }, 'TR-069 exchange (not traced)') } // Always log full XML at debug level regardless of trace status if (rawBody && rawBody.trim() !== '') { logger.debug( { sessionId, deviceId: deviceId ?? 'unknown', direction: 'CPE→ACS', xml: this.prettyXml(rawBody) }, 'CWMP XML request' ) } if (capturedResponseXml) { logger.debug( { sessionId, deviceId: deviceId ?? 'unknown', direction: 'ACS→CPE', xml: this.prettyXml(capturedResponseXml) }, 'CWMP XML response' ) } else if (isEmptyResponse) { logger.debug( { sessionId, deviceId: deviceId ?? 'unknown', direction: 'ACS→CPE' }, 'CWMP empty response (204)' ) } } }