diff --git a/app/controllers/tr069_trace_controller.ts b/app/controllers/tr069_trace_controller.ts index 2f5652e..3e08876 100644 --- a/app/controllers/tr069_trace_controller.ts +++ b/app/controllers/tr069_trace_controller.ts @@ -2,10 +2,13 @@ import type { HttpContext } from '@adonisjs/core/http' import logger from '@adonisjs/core/services/logger' import DeviceModel from '#models/device' import { Tr069TraceService } from '#services/tr069_trace_service' -import { readFileSync, existsSync } from 'node:fs' +import { readFileSync, existsSync, mkdirSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' import { join } from 'node:path' -const LOG_DIR = join(process.cwd(), 'logs', 'tr069') +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_TAIL_LINES = 1000 export default class Tr069TraceController { @@ -77,4 +80,45 @@ export default class Tr069TraceController { return response.status(200).json({ deviceId, content: tail, lines: allLines.length }) } + + async rawConfig({ response }: HttpContext) { + try { + if (!existsSync(CONFIG_PATH)) { + return response.status(200).json({ raw: JSON.stringify({ entries: [] }, null, 2) }) + } + const raw = readFileSync(CONFIG_PATH, 'utf-8') + return response.status(200).json({ raw }) + } catch (error) { + return response.status(500).json({ error: 'Failed to read config file' }) + } + } + + async saveRawConfig({ request, response }: HttpContext) { + const raw = request.input('raw') as string | undefined + + if (!raw) { + return response.status(422).json({ error: 'raw JSON content is required' }) + } + + try { + const parsed = JSON.parse(raw) + if (!parsed.entries || !Array.isArray(parsed.entries)) { + return response.status(422).json({ error: 'JSON must contain an "entries" array' }) + } + + mkdirSync(join(APP_ROOT, 'data'), { recursive: true }) + await writeFile(CONFIG_PATH, JSON.stringify(parsed, null, 2), 'utf-8') + + // Reload the service in-memory state + this.service.reload() + + logger.info('TR-069 trace config updated via raw editor') + return response.status(200).json({ entries: this.service.list() }) + } catch (error) { + if (error instanceof SyntaxError) { + return response.status(422).json({ error: `Invalid JSON: ${error.message}` }) + } + return response.status(500).json({ error: 'Failed to save config file' }) + } + } } diff --git a/app/models/device.ts b/app/models/device.ts index dd3d245..45d218a 100644 --- a/app/models/device.ts +++ b/app/models/device.ts @@ -26,9 +26,11 @@ export default class DeviceModel extends BaseModel { filter: Filter = {} ): Promise<{ data: DeviceRecord[]; total: number }> { const collection = this.getCollection() - const total = await collection.countDocuments(filter) + // Only return documents created by the ACS (must have an identifier field) + const safeFilter: Filter = { ...filter, identifier: { $exists: true, $ne: null } } + const total = await collection.countDocuments(safeFilter) const data = (await collection - .find(filter, { projection: { _id: 0 } }) + .find(safeFilter, { projection: { _id: 0 } }) .skip(offset) .limit(limit) .sort({ updatedAt: -1 } as Sort) diff --git a/app/services/cwmp/configuration_evaluation_state.ts b/app/services/cwmp/configuration_evaluation_state.ts index c103f50..58392fe 100644 --- a/app/services/cwmp/configuration_evaluation_state.ts +++ b/app/services/cwmp/configuration_evaluation_state.ts @@ -87,7 +87,8 @@ export class ConfigurationEvaluationState { private async processNextAction(context: DeviceContext): Promise { // 1. Inventory phase 1: GetParameterNames per subtree path - if (this.inventoryQueue.length > 0) { + // Skip if device does not support GPN (avoids useless round-trips that will fail) + if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported') { const path = this.inventoryQueue.shift()! logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames') const response = await context.services.deviceAction.getParameterNames( @@ -106,6 +107,10 @@ export class ConfigurationEvaluationState { } ) return { response } + } else if (this.inventoryQueue.length > 0) { + // GPN unsupported — clear inventory queue and skip to next phase + logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported)') + this.inventoryQueue = [] } // 2. Inventory phase 2: GetParameterValues for discovered leaf names diff --git a/app/services/cwmp/device_action_executor.ts b/app/services/cwmp/device_action_executor.ts index 9bb7722..1431fbc 100644 --- a/app/services/cwmp/device_action_executor.ts +++ b/app/services/cwmp/device_action_executor.ts @@ -1,8 +1,18 @@ import { Device } from '../../domain/device/device.js' +import { DeviceParameter } from '../../domain/device/device_parameter.js' import { ParameterType } from '../../domain/shared/types.js' import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js' +import { normalizePath } from '../parameter_writability.js' import DeviceModel from '../../models/device.js' import logger from '@adonisjs/core/services/logger' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const tr181Catalog: Array<{ path: string; writable: boolean; deviceClass: string }> = + require('../../../config/tr181-catalog.json') +const catalogWritableIndex = new Set( + tr181Catalog.filter((e) => e.writable).map((e) => e.path) +) // Parameter types (simplified from legacy provisioning domain) interface SetParameterValue { @@ -82,6 +92,34 @@ export class DeviceActionExecutor { } } + /** + * Reclassify device properties as parameters using the TR-181 catalog. + * Called when GPN is unsupported — properties that are writable per the catalog + * should be moved to parameters so the configuration engine can write to them. + */ + private reclassifyFromCatalog(device: Device): void { + const properties = device.getProperties() + let reclassified = 0 + + for (const prop of properties) { + const normalized = normalizePath(prop.name) + if (catalogWritableIndex.has(normalized)) { + // Move from properties to parameters (writable) + const snap = prop.toSnapshot() + device.setParameter( + new DeviceParameter(snap.name, snap.value, snap.type, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList) + ) + // Remove from properties map + ;(device as any)._properties.delete(prop.name) + reclassified++ + } + } + + if (reclassified > 0) { + logger.debug({ identifier: device.identifier, reclassified }, '[DeviceAction] Reclassified properties → parameters via catalog') + } + } + async getParameterValues( device: Device, parameterPaths: string[], @@ -169,6 +207,8 @@ export class DeviceActionExecutor { const code = typeof faultCode === 'string' ? parseInt(faultCode, 10) : faultCode if (code === 9000 || code === 9001) { device.setGpnStatus('unsupported') + // Reclassify parameters using TR-181 catalog writability + this.reclassifyFromCatalog(device) await this.persistDevice(device) } // Autres erreurs (réseau, timeout, 9002+) → gpnStatus inchangé diff --git a/app/services/cwmp/main_state_machine.ts b/app/services/cwmp/main_state_machine.ts index b46a42e..a68edb7 100644 --- a/app/services/cwmp/main_state_machine.ts +++ b/app/services/cwmp/main_state_machine.ts @@ -40,11 +40,15 @@ class IdleState extends DeviceState { name = 'idle' async handle(context: DeviceContext): Promise { - const parameterQueue = context.device.tmpParamValues.map((p: any) => p.Name).filter((n: any): n is string => !!n) - if (parameterQueue.length === 0) { - return { nextState: new ConfigurationEvaluationState(), response: '' } + // Test GPN support using the ManagementServer object path (not leaf params from Inform) + const dataModel = context.device.dataModel + if (dataModel === 'tr-181') { + return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' } + } else if (dataModel === 'tr-098') { + return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' } } - return { nextState: new DiscoveringParametersState(parameterQueue), response: '' } + // Unknown data model — skip GPN test + return { nextState: new ConfigurationEvaluationState(), response: '' } } } diff --git a/app/services/cwmp/state_machine_manager.ts b/app/services/cwmp/state_machine_manager.ts index bf18d95..20a65c8 100644 --- a/app/services/cwmp/state_machine_manager.ts +++ b/app/services/cwmp/state_machine_manager.ts @@ -264,7 +264,43 @@ export class StateMachineManager extends EventEmitter { if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return '' try { - // Serve queued tasks first + // If a task is in-flight and CPE sends empty body, it means the CPE + // cannot/will not respond to our request. Treat as task failure. + if (machineInstance.lastTaskId) { + const failedTaskId = machineInstance.lastTaskId + machineInstance.lastTaskId = undefined + + logger.warn( + { session, taskId: failedTaskId }, + 'CPE sent empty body while task in-flight — treating as task failure' + ) + + this.taskQueue.failTask(failedTaskId, 'CPE sent empty body (no response)') + try { + await this.taskQueue.executeTaskCallback(failedTaskId, ResultStatus.Error, { + FaultCode: 9000, + FaultString: 'CPE did not respond (empty body)', + }) + } catch (cbErr) { + logger.error(`Task callback error on empty-body failure: ${cbErr}`) + } + + // Let the state machine handle the failure and advance + const response = await machineInstance.stateMachine.handleResponse(false, { + FaultCode: 9000, + FaultString: 'CPE did not respond (empty body)', + }) + machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName() + + if (machineInstance.stateMachine.isCompleted()) { + this.completeMachine(machineInstance) + return '' + } + + return response + } + + // Serve queued tasks const pendingTask = this.taskQueue.getTaskForDevice(machineInstance.deviceId) if (pendingTask) { const builder = CWMPTaskQueue.task_map[pendingTask.task] @@ -275,9 +311,6 @@ export class StateMachineManager extends EventEmitter { } } - // If a task is in-flight, wait - if (machineInstance.lastTaskId) return '' - // Let the state machine continue const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false }) machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName() diff --git a/app/services/tr069_trace_service.ts b/app/services/tr069_trace_service.ts index 1a729a4..e6b2a4b 100644 --- a/app/services/tr069_trace_service.ts +++ b/app/services/tr069_trace_service.ts @@ -59,6 +59,7 @@ export class Tr069TraceService { 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) } @@ -91,6 +92,10 @@ export class Tr069TraceService { 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) diff --git a/docker-compose.simulator.yml b/docker-compose.simulator.yml index fd7370a..928db90 100644 --- a/docker-compose.simulator.yml +++ b/docker-compose.simulator.yml @@ -31,9 +31,9 @@ services: ADD_IP_AS_HEADER_NAME: "" IPV4_DENY_PREFIX: "" IPV6_PREFIXES: "" - # MongoDB partagée avec l'ACS - MONGO_URL: "mongodb://adam:adam@mongodb:27017/adam-acs?authSource=admin" - MONGO_DATABASE: "adam-acs" + # MongoDB séparée pour le simulateur (évite les collisions de collections avec l'ACS) + MONGO_URL: "mongodb://adam:adam@mongodb:27017/home-device-simulator?authSource=admin" + MONGO_DATABASE: "home-device-simulator" MONGO_CONNECTION_TIMEOUT: "10" MONGO_SOCKET_TIMEOUT: "30" MONGO_MAX_CONNECTION_IDLE_TIME: "60" diff --git a/inertia/pages/Tr069Trace.vue b/inertia/pages/Tr069Trace.vue index 1ae9e38..b69d7d9 100644 --- a/inertia/pages/Tr069Trace.vue +++ b/inertia/pages/Tr069Trace.vue @@ -11,25 +11,11 @@ type TraceEntry = { startedAt: string } -type DeviceRecord = { - identifier: string - deviceId?: { - manufacturer?: string - productClass?: string - serialNumber?: string - } -} - const tracedDevices = ref([]) -const searchTerm = ref('') -const searchResults = ref([]) const loadingEntries = ref(false) -const loadingSearch = ref(false) -const savingDeviceId = ref(null) const removingDeviceId = ref(null) const entriesError = ref('') const actionError = ref('') -let searchTimeout: ReturnType | null = null // Log viewer state const selectedDeviceId = ref(null) @@ -39,10 +25,14 @@ const loadingLog = ref(false) const logError = ref('') let autoRefreshTimer: ReturnType | null = null +// JSON editor modal state +const showJsonModal = ref(false) +const jsonContent = ref('') +const jsonError = ref('') +const savingJson = ref(false) +const loadingJson = ref(false) + const tracedDeviceIds = computed(() => new Set(tracedDevices.value.map((entry) => entry.deviceId))) -const filteredSearchResults = computed(() => - searchResults.value.filter((device) => !tracedDeviceIds.value.has(device.identifier)) -) const selectedEntry = computed(() => tracedDevices.value.find((e) => e.deviceId === selectedDeviceId.value) ?? null ) @@ -51,16 +41,6 @@ onMounted(async () => { await loadEntries() }) -watch(searchTerm, (value) => { - if (searchTimeout) clearTimeout(searchTimeout) - if (!value.trim()) { - searchResults.value = [] - actionError.value = '' - return - } - searchTimeout = setTimeout(() => { void searchDevices(value) }, 250) -}) - watch(selectedDeviceId, async (id) => { stopAutoRefresh() logContent.value = '' @@ -101,52 +81,6 @@ async function loadEntries() { } } -async function searchDevices(query: string) { - loadingSearch.value = true - actionError.value = '' - try { - const response = await fetch('/api/v1/devices?offset=0&limit=200') - if (!response.ok) throw new Error('Failed to search devices') - const devices = (await response.json()) as DeviceRecord[] - const normalizedQuery = query.trim().toLowerCase() - searchResults.value = devices.filter((device) => { - const manufacturer = device.deviceId?.manufacturer?.toLowerCase() ?? '' - const productClass = device.deviceId?.productClass?.toLowerCase() ?? '' - const serialNumber = device.deviceId?.serialNumber?.toLowerCase() ?? '' - const identifier = device.identifier.toLowerCase() - return [manufacturer, productClass, serialNumber, identifier].some((v) => v.includes(normalizedQuery)) - }) - } catch (error) { - actionError.value = error instanceof Error ? error.message : 'Failed to search devices' - searchResults.value = [] - } finally { - loadingSearch.value = false - } -} - -async function addDevice(deviceId: string) { - savingDeviceId.value = deviceId - actionError.value = '' - try { - const response = await fetch('/api/v1/tr069-trace', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceId }), - }) - if (!response.ok) { - const payload = await response.json().catch(() => ({})) - throw new Error(payload.error || 'Failed to enable tracing') - } - searchTerm.value = '' - searchResults.value = [] - await loadEntries() - } catch (error) { - actionError.value = error instanceof Error ? error.message : 'Failed to enable tracing' - } finally { - savingDeviceId.value = null - } -} - async function removeDevice(deviceId: string) { removingDeviceId.value = deviceId actionError.value = '' @@ -189,164 +123,237 @@ async function loadLog(deviceId: string) { function clearLog() { logContent.value = '' } + +// JSON editor modal functions +async function openJsonEditor() { + showJsonModal.value = true + loadingJson.value = true + jsonError.value = '' + try { + const response = await fetch('/api/v1/tr069-trace/config') + if (!response.ok) throw new Error('Failed to load config') + const data = await response.json() + jsonContent.value = data.raw + } catch (error) { + jsonError.value = error instanceof Error ? error.message : 'Failed to load config' + } finally { + loadingJson.value = false + } +} + +function closeJsonEditor() { + showJsonModal.value = false + jsonContent.value = '' + jsonError.value = '' +} + +async function saveJsonConfig() { + savingJson.value = true + jsonError.value = '' + try { + // Validate JSON locally first + JSON.parse(jsonContent.value) + } catch (e) { + jsonError.value = `Invalid JSON: ${(e as Error).message}` + savingJson.value = false + return + } + + try { + const response = await fetch('/api/v1/tr069-trace/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ raw: jsonContent.value }), + }) + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error(payload.error || 'Failed to save config') + } + closeJsonEditor() + await loadEntries() + } catch (error) { + jsonError.value = error instanceof Error ? error.message : 'Failed to save config' + } finally { + savingJson.value = false + } +} + +function formatJson() { + try { + const parsed = JSON.parse(jsonContent.value) + jsonContent.value = JSON.stringify(parsed, null, 2) + jsonError.value = '' + } catch (e) { + jsonError.value = `Invalid JSON: ${(e as Error).message}` + } +} diff --git a/start/routes.ts b/start/routes.ts index dd4e6ba..bb61b08 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -36,6 +36,8 @@ router router.get('/tr069-trace', [Tr069TraceController, 'index']) router.post('/tr069-trace', [Tr069TraceController, 'store']) + router.get('/tr069-trace/config', [Tr069TraceController, 'rawConfig']) + router.put('/tr069-trace/config', [Tr069TraceController, 'saveRawConfig']) router.delete('/tr069-trace/:id', [Tr069TraceController, 'destroy']) router.get('/tr069-trace/:id/log', [Tr069TraceController, 'showLog'])