Files
adam-acs/database/seeders/cwmp_exchanges_seeder.ts
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

553 lines
18 KiB
TypeScript

/**
* Database seeder for cwmp_exchanges collection.
*
* Generates realistic CWMP exchange data for integration/manual testing.
* Can be run standalone via: npx tsx database/seeders/cwmp_exchanges_seeder.ts
*
* Configuration via environment variables:
* SEED_DEVICE_COUNT - Number of devices to generate (default: 5)
* SEED_EXCHANGES_MIN - Minimum exchanges per device (default: 20)
* SEED_EXCHANGES_MAX - Maximum exchanges per device (default: 100)
* MONGODB_URI - MongoDB connection string
* MONGODB_DB_NAME - Database name (default: adam-acs)
*/
import { MongoClient, type Db } from 'mongodb'
// ─── Configuration ──────────────────────────────────────────────────────────
interface SeederConfig {
deviceCount: number
exchangesMin: number
exchangesMax: number
mongoUri: string
dbName: string
}
function getConfig(): SeederConfig {
return {
deviceCount: Number(process.env.SEED_DEVICE_COUNT ?? 5),
exchangesMin: Number(process.env.SEED_EXCHANGES_MIN ?? 20),
exchangesMax: Number(process.env.SEED_EXCHANGES_MAX ?? 100),
mongoUri: process.env.MONGODB_URI ?? 'mongodb://localhost:27017',
dbName: process.env.MONGODB_DB_NAME ?? 'adam-acs',
}
}
// ─── CWMP Method Templates ──────────────────────────────────────────────────
type Direction = 'CPE→ACS' | 'ACS→CPE'
interface ExchangeTemplate {
cwmpMethod: string
direction: Direction
xmlGenerator: () => string
}
const MANUFACTURERS = ['Sagemcom', 'Huawei', 'ZTE', 'Nokia', 'Technicolor']
const PRODUCT_CLASSES = ['Livebox 4', 'BM632w', 'ZXHN H298A', 'G-240W-A', 'TG789vac']
const OUI_LIST = ['F8B7FF', '202BC1', '001D08', '00E0FC', '74BD7B']
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function randomChoice<T>(arr: T[]): T {
return arr[randomInt(0, arr.length - 1)]
}
function generateDeviceId(index: number): string {
const oui = OUI_LIST[index % OUI_LIST.length]
const serial = `DEVICE-${String(index + 1).padStart(3, '0')}`
return `${oui}-${PRODUCT_CLASSES[index % PRODUCT_CLASSES.length]}-${serial}`
}
function generateSessionId(): string {
const srcIp = `10.0.${randomInt(0, 255)}.${randomInt(1, 254)}`
const srcPort = randomInt(30000, 65000)
return `${srcIp}:${srcPort}->192.168.1.1:8080`
}
// ─── XML Body Generators ────────────────────────────────────────────────────
function wrapSoapEnvelope(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Header>
<cwmp:ID soap-env:mustUnderstand="1">${randomInt(100000, 999999)}</cwmp:ID>
</soap-env:Header>
<soap-env:Body>
${body}
</soap-env:Body>
</soap-env:Envelope>`
}
function generateInformXml(deviceIndex: number): string {
const oui = OUI_LIST[deviceIndex % OUI_LIST.length]
const manufacturer = MANUFACTURERS[deviceIndex % MANUFACTURERS.length]
const productClass = PRODUCT_CLASSES[deviceIndex % PRODUCT_CLASSES.length]
const serial = `DEVICE-${String(deviceIndex + 1).padStart(3, '0')}`
const events = randomChoice([
['0 BOOTSTRAP', '1 BOOT'],
['2 PERIODIC'],
['1 BOOT'],
['6 CONNECTION REQUEST'],
])
const eventString = events
.map(
(e, i) =>
`<EventStruct><EventCode>${e}</EventCode><CommandKey>${i}</CommandKey></EventStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:Inform>
<DeviceId>
<Manufacturer>${manufacturer}</Manufacturer>
<OUI>${oui}</OUI>
<ProductClass>${productClass}</ProductClass>
<SerialNumber>${serial}</SerialNumber>
</DeviceId>
<Event soap-enc:arrayType="cwmp:EventStruct[${events.length}]">
${eventString}
</Event>
<MaxEnvelopes>1</MaxEnvelopes>
<CurrentTime>${new Date().toISOString()}</CurrentTime>
<RetryCount>0</RetryCount>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[4]">
<ParameterValueStruct>
<Name>Device.DeviceInfo.HardwareVersion</Name>
<Value xsi:type="xsd:string">v2.1</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.DeviceInfo.SoftwareVersion</Name>
<Value xsi:type="xsd:string">SR40_sip-fr-4.62.0</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.ManagementServer.ConnectionRequestURL</Name>
<Value xsi:type="xsd:string">http://10.0.0.${randomInt(1, 254)}:7547/</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.ManagementServer.ParameterKey</Name>
<Value xsi:type="xsd:string"></Value>
</ParameterValueStruct>
</ParameterList>
</cwmp:Inform>`)
}
function generateInformResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:InformResponse>
<MaxEnvelopes>1</MaxEnvelopes>
</cwmp:InformResponse>`)
}
function generateGetParameterValuesXml(paths: string[]): string {
const paramNames = paths.map((p) => `<string>${p}</string>`).join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterValues>
<ParameterNames soap-enc:arrayType="xsd:string[${paths.length}]">
${paramNames}
</ParameterNames>
</cwmp:GetParameterValues>`)
}
function generateGetParameterValuesResponseXml(paths: string[]): string {
const params = paths
.map(
(p) => `<ParameterValueStruct>
<Name>${p}</Name>
<Value xsi:type="xsd:string">value_${randomInt(1, 9999)}</Value>
</ParameterValueStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterValuesResponse>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[${paths.length}]">
${params}
</ParameterList>
</cwmp:GetParameterValuesResponse>`)
}
function generateSetParameterValuesXml(): string {
return wrapSoapEnvelope(`<cwmp:SetParameterValues>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[2]">
<ParameterValueStruct>
<Name>Device.ManagementServer.PeriodicInformInterval</Name>
<Value xsi:type="xsd:unsignedInt">${randomInt(60, 3600)}</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.DeviceInfo.ProvisioningCode</Name>
<Value xsi:type="xsd:string">PROV-${randomInt(1000, 9999)}</Value>
</ParameterValueStruct>
</ParameterList>
<ParameterKey>key-${randomInt(1, 999)}</ParameterKey>
</cwmp:SetParameterValues>`)
}
function generateSetParameterValuesResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:SetParameterValuesResponse>
<Status>0</Status>
</cwmp:SetParameterValuesResponse>`)
}
function generateGetParameterNamesXml(): string {
const path = randomChoice([
'Device.',
'Device.DeviceInfo.',
'Device.ManagementServer.',
'Device.WiFi.',
])
return wrapSoapEnvelope(`<cwmp:GetParameterNames>
<ParameterPath>${path}</ParameterPath>
<NextLevel>1</NextLevel>
</cwmp:GetParameterNames>`)
}
function generateGetParameterNamesResponseXml(): string {
const params = ['Device.DeviceInfo.', 'Device.ManagementServer.', 'Device.WiFi.', 'Device.IP.']
const paramList = params
.map(
(p) => `<ParameterInfoStruct>
<Name>${p}</Name>
<Writable>0</Writable>
</ParameterInfoStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterNamesResponse>
<ParameterList soap-enc:arrayType="cwmp:ParameterInfoStruct[${params.length}]">
${paramList}
</ParameterList>
</cwmp:GetParameterNamesResponse>`)
}
function generateDownloadXml(): string {
return wrapSoapEnvelope(`<cwmp:Download>
<CommandKey>firmware-update-${randomInt(1, 99)}</CommandKey>
<FileType>1 Firmware Upgrade Image</FileType>
<URL>http://firmware.example.com/fw-v${randomInt(1, 5)}.${randomInt(0, 9)}.bin</URL>
<Username>admin</Username>
<Password>****</Password>
<FileSize>${randomInt(1000000, 50000000)}</FileSize>
<TargetFileName></TargetFileName>
<DelaySeconds>0</DelaySeconds>
<SuccessURL></SuccessURL>
<FailureURL></FailureURL>
</cwmp:Download>`)
}
function generateDownloadResponseXml(): string {
const status = randomChoice([0, 1]) // 0=completed, 1=in progress
return wrapSoapEnvelope(`<cwmp:DownloadResponse>
<Status>${status}</Status>
<StartTime>${new Date().toISOString()}</StartTime>
<CompleteTime>0001-01-01T00:00:00Z</CompleteTime>
</cwmp:DownloadResponse>`)
}
function generateRebootXml(): string {
return wrapSoapEnvelope(`<cwmp:Reboot>
<CommandKey>reboot-${randomInt(1, 999)}</CommandKey>
</cwmp:Reboot>`)
}
function generateRebootResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:RebootResponse/>`)
}
function generateFaultXml(): string {
const faultCode = randomChoice(['9001', '9002', '9003', '9005', '9007'])
const faultMessages: Record<string, string> = {
'9001': 'Request denied',
'9002': 'Internal error',
'9003': 'Invalid arguments',
'9005': 'Invalid parameter name',
'9007': 'Invalid parameter value',
}
return wrapSoapEnvelope(`<soap-env:Fault>
<faultcode>Client</faultcode>
<faultstring>CWMP fault</faultstring>
<detail>
<cwmp:Fault>
<FaultCode>${faultCode}</FaultCode>
<FaultString>${faultMessages[faultCode]}</FaultString>
</cwmp:Fault>
</detail>
</soap-env:Fault>`)
}
function generateTransferCompleteXml(): string {
return wrapSoapEnvelope(`<cwmp:TransferComplete>
<CommandKey>firmware-update-${randomInt(1, 99)}</CommandKey>
<FaultStruct>
<FaultCode>0</FaultCode>
<FaultString></FaultString>
</FaultStruct>
<StartTime>${new Date().toISOString()}</StartTime>
<CompleteTime>${new Date().toISOString()}</CompleteTime>
</cwmp:TransferComplete>`)
}
function generateTransferCompleteResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:TransferCompleteResponse/>`)
}
// ─── Exchange Sequence Patterns ─────────────────────────────────────────────
/** Realistic CWMP session exchange patterns (request/response pairs) */
const SESSION_PATTERNS: ExchangeTemplate[][] = [
// Inform session start
[
{ cwmpMethod: 'Inform', direction: 'CPE→ACS', xmlGenerator: () => '' },
{ cwmpMethod: 'InformResponse', direction: 'ACS→CPE', xmlGenerator: generateInformResponseXml },
],
// GetParameterValues
[
{
cwmpMethod: 'GetParameterValues',
direction: 'ACS→CPE',
xmlGenerator: () =>
generateGetParameterValuesXml([
'Device.DeviceInfo.SoftwareVersion',
'Device.DeviceInfo.HardwareVersion',
'Device.ManagementServer.PeriodicInformInterval',
]),
},
{
cwmpMethod: 'GetParameterValuesResponse',
direction: 'CPE→ACS',
xmlGenerator: () =>
generateGetParameterValuesResponseXml([
'Device.DeviceInfo.SoftwareVersion',
'Device.DeviceInfo.HardwareVersion',
'Device.ManagementServer.PeriodicInformInterval',
]),
},
],
// SetParameterValues
[
{
cwmpMethod: 'SetParameterValues',
direction: 'ACS→CPE',
xmlGenerator: generateSetParameterValuesXml,
},
{
cwmpMethod: 'SetParameterValuesResponse',
direction: 'CPE→ACS',
xmlGenerator: generateSetParameterValuesResponseXml,
},
],
// GetParameterNames
[
{
cwmpMethod: 'GetParameterNames',
direction: 'ACS→CPE',
xmlGenerator: generateGetParameterNamesXml,
},
{
cwmpMethod: 'GetParameterNamesResponse',
direction: 'CPE→ACS',
xmlGenerator: generateGetParameterNamesResponseXml,
},
],
// Download (firmware)
[
{ cwmpMethod: 'Download', direction: 'ACS→CPE', xmlGenerator: generateDownloadXml },
{
cwmpMethod: 'DownloadResponse',
direction: 'CPE→ACS',
xmlGenerator: generateDownloadResponseXml,
},
],
// Reboot
[
{ cwmpMethod: 'Reboot', direction: 'ACS→CPE', xmlGenerator: generateRebootXml },
{ cwmpMethod: 'RebootResponse', direction: 'CPE→ACS', xmlGenerator: generateRebootResponseXml },
],
// TransferComplete (CPE-initiated after download)
[
{
cwmpMethod: 'TransferComplete',
direction: 'CPE→ACS',
xmlGenerator: generateTransferCompleteXml,
},
{
cwmpMethod: 'TransferCompleteResponse',
direction: 'ACS→CPE',
xmlGenerator: generateTransferCompleteResponseXml,
},
],
// Fault response
[{ cwmpMethod: 'Fault', direction: 'CPE→ACS', xmlGenerator: generateFaultXml }],
// Empty POST (session end)
[{ cwmpMethod: 'Empty', direction: 'CPE→ACS', xmlGenerator: () => '' }],
]
// ─── Exchange Generator ─────────────────────────────────────────────────────
interface CwmpExchangeDoc {
deviceId: string
direction: Direction
timestamp: Date
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt: Date
}
function generateExchangesForDevice(
deviceId: string,
deviceIndex: number,
count: number
): CwmpExchangeDoc[] {
const exchanges: CwmpExchangeDoc[] = []
const now = Date.now()
const twentyFourHoursMs = 24 * 60 * 60 * 1000
// Distribute exchanges over the last 24h
let sequenceNumber = 1
let currentTimeMs = now - twentyFourHoursMs + randomInt(0, 60000)
while (exchanges.length < count) {
// Start a new session
const sessionId = generateSessionId()
// Each session has an Inform + some exchanges
const sessionSize = Math.min(randomInt(4, 12), count - exchanges.length)
// Always start session with Inform
const informXml = generateInformXml(deviceIndex)
const informTimestamp = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: 'CPE→ACS',
timestamp: informTimestamp,
sessionId,
cwmpMethod: 'Inform',
rawXml: informXml,
sequenceNumber: sequenceNumber++,
createdAt: informTimestamp,
})
currentTimeMs += randomInt(50, 200) // 50-200ms between messages
if (exchanges.length >= count) break
// InformResponse
const informRespTimestamp = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: 'ACS→CPE',
timestamp: informRespTimestamp,
sessionId,
cwmpMethod: 'InformResponse',
rawXml: generateInformResponseXml(),
sequenceNumber: sequenceNumber++,
createdAt: informRespTimestamp,
})
currentTimeMs += randomInt(50, 200)
// Fill rest of session with random patterns
let remaining = sessionSize - 2
while (remaining > 0 && exchanges.length < count) {
// Pick a random pattern (skip Inform pattern at index 0)
const patternIndex = randomInt(1, SESSION_PATTERNS.length - 1)
const pattern = SESSION_PATTERNS[patternIndex]
for (const template of pattern) {
if (exchanges.length >= count) break
remaining--
const ts = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: template.direction,
timestamp: ts,
sessionId,
cwmpMethod: template.cwmpMethod,
rawXml: template.xmlGenerator(),
sequenceNumber: sequenceNumber++,
createdAt: ts,
})
currentTimeMs += randomInt(50, 300)
}
}
// Gap between sessions (30s to 30min)
currentTimeMs += randomInt(30_000, 1_800_000)
}
return exchanges.slice(0, count)
}
// ─── Seeder Runner ──────────────────────────────────────────────────────────
export async function seed(db: Db, config?: Partial<SeederConfig>): Promise<void> {
const cfg = { ...getConfig(), ...config }
const collection = db.collection('cwmp_exchanges')
const countersCollection = db.collection('cwmp_exchange_counters')
console.log(`[Seeder] Seeding cwmp_exchanges with:`)
console.log(` - ${cfg.deviceCount} devices`)
console.log(` - ${cfg.exchangesMin}-${cfg.exchangesMax} exchanges per device`)
// Clear existing data
await collection.deleteMany({})
await countersCollection.deleteMany({})
console.log(`[Seeder] Cleared existing cwmp_exchanges and counters`)
let totalInserted = 0
for (let i = 0; i < cfg.deviceCount; i++) {
const deviceId = generateDeviceId(i)
const exchangeCount = randomInt(cfg.exchangesMin, cfg.exchangesMax)
const exchanges = generateExchangesForDevice(deviceId, i, exchangeCount)
// Bulk insert exchanges
if (exchanges.length > 0) {
await collection.insertMany(exchanges)
// Update counter for the device
const maxSeq = exchanges[exchanges.length - 1].sequenceNumber
await countersCollection.updateOne(
{ _id: deviceId as any },
{ $set: { seq: maxSeq } },
{ upsert: true }
)
totalInserted += exchanges.length
console.log(`[Seeder] Device "${deviceId}": ${exchanges.length} exchanges (seq 1-${maxSeq})`)
}
}
console.log(`[Seeder] Done. Total exchanges inserted: ${totalInserted}`)
}
// ─── Standalone Execution ───────────────────────────────────────────────────
async function main(): Promise<void> {
const config = getConfig()
console.log(`[Seeder] Connecting to MongoDB at ${config.mongoUri}/${config.dbName}...`)
const client = new MongoClient(config.mongoUri)
await client.connect()
const db = client.db(config.dbName)
try {
await seed(db, config)
} finally {
await client.close()
console.log(`[Seeder] MongoDB connection closed.`)
}
}
// Run directly if executed as a script
const isMainModule = process.argv[1]?.includes('cwmp_exchanges_seeder')
if (isMainModule) {
main().catch((err) => {
console.error('[Seeder] Fatal error:', err)
process.exit(1)
})
}