import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import { join } from 'node:path' const APP_ROOT = process.cwd() const CONFIG_PATH = join(APP_ROOT, 'data', 'tr069-trace.json') const LOG_DIR = join(APP_ROOT, 'logs', 'tr069') const MAX_FILE_BYTES = 10 * 1024 * 1024 const MAX_ROTATED_FILES = 5 type TraceDirection = 'CPE→ACS' | 'ACS→CPE' export interface TraceEntry { deviceId: string manufacturer: string oui: string productClass: string serialNumber: string startedAt: string addedBy?: string } interface TraceConfig { entries: TraceEntry[] } export class Tr069TraceService { private static instance: Tr069TraceService private traceMap = new Map() private writeQueues = new Map>() private constructor() { this.loadConfig() } static getInstance(): Tr069TraceService { if (!Tr069TraceService.instance) { Tr069TraceService.instance = new Tr069TraceService() } return Tr069TraceService.instance } private loadConfig(): void { try { const raw = readFileSync(CONFIG_PATH, 'utf-8') const config = JSON.parse(raw) as TraceConfig this.traceMap.clear() for (const entry of config.entries ?? []) { this.traceMap.set(entry.deviceId, entry) } } catch { this.traceMap.clear() } } private async persistConfig(): Promise { const config: TraceConfig = { entries: Array.from(this.traceMap.values()) } const tmpPath = `${CONFIG_PATH}.tmp` mkdirSync(join(APP_ROOT, 'data'), { recursive: true }) await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8') renameSync(tmpPath, CONFIG_PATH) } isTraced(deviceId: string): boolean { return this.traceMap.has(deviceId) } list(): TraceEntry[] { return Array.from(this.traceMap.values()) } async addDevice(entry: Omit & { startedAt?: string }): Promise { const traceEntry: TraceEntry = { ...entry, startedAt: entry.startedAt ?? new Date().toISOString(), } this.traceMap.set(entry.deviceId, traceEntry) await this.persistConfig() } async removeDevice(deviceId: string): Promise { const existed = this.traceMap.delete(deviceId) if (existed) { await this.persistConfig() } return existed } reload(): void { this.loadConfig() } writeExchange(deviceId: string, direction: TraceDirection, sessionId: string, xml: string): void { const tail = this.writeQueues.get(deviceId) ?? Promise.resolve() const next = tail.then(() => this.doWrite(deviceId, direction, sessionId, xml)).catch(() => undefined) this.writeQueues.set(deviceId, next) } private async doWrite(deviceId: string, direction: TraceDirection, sessionId: string, xml: string): Promise { mkdirSync(LOG_DIR, { recursive: true }) const filePath = join(LOG_DIR, `${deviceId}.log`) this.rotateIfNeeded(filePath) const timestamp = new Date().toISOString() const separator = '═'.repeat(80) const entry = `\n${separator}\n[${timestamp}] ${direction} | ${sessionId}\n${separator}\n${xml}\n` appendFileSync(filePath, entry, 'utf-8') } private rotateIfNeeded(filePath: string): void { try { const { size } = statSync(filePath) if (size < MAX_FILE_BYTES) { return } for (let index = MAX_ROTATED_FILES - 1; index >= 1; index--) { try { renameSync(`${filePath}.${index}`, `${filePath}.${index + 1}`) } catch { // Ignore missing rotated files. } } renameSync(filePath, `${filePath}.1`) } catch { // Ignore missing log file. } } }