140 lines
3.7 KiB
TypeScript
140 lines
3.7 KiB
TypeScript
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<string, TraceEntry>()
|
|
private writeQueues = new Map<string, Promise<void>>()
|
|
|
|
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<void> {
|
|
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<TraceEntry, 'startedAt'> & { startedAt?: string }): Promise<void> {
|
|
const traceEntry: TraceEntry = {
|
|
...entry,
|
|
startedAt: entry.startedAt ?? new Date().toISOString(),
|
|
}
|
|
|
|
this.traceMap.set(entry.deviceId, traceEntry)
|
|
await this.persistConfig()
|
|
}
|
|
|
|
async removeDevice(deviceId: string): Promise<boolean> {
|
|
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<void> {
|
|
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.
|
|
}
|
|
}
|
|
}
|