Files
adam-acs/app/services/configuration_engine.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

490 lines
16 KiB
TypeScript

import ConfigurationModel from '#models/configuration'
import DeviceModel from '#models/device'
import FirmwareCatalogService from './firmware_catalog_service.js'
import type { Configuration, ParameterCriteria, ParameterValueType } from '../domain/configuration/configuration.js'
import type { Device } from '../domain/device/device.js'
import type { MigrationStep } from '../domain/firmware/migration_step.js'
import { isWithinSchedule } from '../utils/schedule_window.js'
import { matchGlob } from '../utils/glob_match.js'
import { computeContentHash, parseProvisioningCode } from '../utils/provisioning_code.js'
export interface ComplianceDeviceResult {
identifier: string
deviceId: any
compliant: boolean
parameterDiffs: ParameterDiff[]
firmwareMigrationPath: MigrationStep[]
}
export interface ComplianceReport {
configurationId: string
configurationName: string
totalDevices: number
compliantCount: number
nonCompliantCount: number
devices: ComplianceDeviceResult[]
}
/**
* Result of a single device configuration evaluation.
* Contains all the actions to be executed by the state machine.
*/
export interface EngineResult {
configuration: Configuration | null
inventoryPaths: string[]
parameterDiffs: ParameterDiff[]
firmwareMigrationPath: MigrationStep[]
isAlreadyCompliant: boolean
skipFirmware: boolean // true if outside schedule window
}
/**
* A single parameter difference (desired vs current).
*/
export interface ParameterDiff {
name: string
currentValue: string | number | boolean | null
targetValue: string | number | boolean
type: ParameterValueType
}
/**
* Result of a dry-run evaluation.
*/
export interface DryRunResult {
configurationId: string
deviceIdentifier: string
evaluation: EngineResult
timestamp: Date
}
/**
* ConfigurationEngine evaluates device compliance against configurations.
*
* Responsibilities:
* - Find matching enabled configurations for a device
* - Select the highest priority configuration
* - Compute parameter differences (desired vs current)
* - Handle policy "once" via ProvisioningCode comparison
* - Resolve firmware migration paths
* - Check schedule windows for firmware operations
*/
export default class ConfigurationEngine {
private firmwareService: FirmwareCatalogService
constructor() {
this.firmwareService = new FirmwareCatalogService()
}
/**
* Evaluate device compliance against all matching configurations.
*
* Steps:
* 1. Load all enabled configurations
* 2. Filter by criteria (glob matching against DeviceId)
* 3. Select highest priority configuration
* 4. Check if already compliant via ProvisioningCode (policy "once")
* 5. Compute parameter differences
* 6. Resolve firmware migration path
* 7. Check schedule window for firmware operations
*/
async evaluate(device: Device): Promise<EngineResult> {
// 1. Load all enabled configurations
const allConfigs = await ConfigurationModel.find({ enabled: true })
// 2. Filter by criteria (glob matching against DeviceId)
const matchingConfigs = allConfigs.filter((config) => this.matchesCriteria(device, config))
// 3. Select highest priority configuration
let selectedConfig: Configuration | null = null
if (matchingConfigs.length > 0) {
selectedConfig = matchingConfigs.reduce((prev, curr) =>
curr.priority > prev.priority ? curr : prev
)
}
// If no matching configuration, device is already compliant (nothing to do)
if (!selectedConfig) {
return {
configuration: null,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// 4. Check if already compliant via ProvisioningCode (policy "once")
if (!this.shouldApplyOnce(device, selectedConfig)) {
return {
configuration: selectedConfig,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// 5. Compute parameter differences
const parameterDiffs = await this.computeParameterDiffs(device, selectedConfig)
// 6. Resolve firmware migration path
let firmwareMigrationPath: MigrationStep[] = []
let skipFirmware = false
if (selectedConfig.firmwareTarget) {
// Check schedule window for firmware operations
if (!isWithinSchedule(selectedConfig.schedule)) {
skipFirmware = true
} else {
// Get current device version (from properties or parameter)
const currentVersion = this.getDeviceVersion(device, selectedConfig.firmwareTarget.productClass)
if (currentVersion && currentVersion !== selectedConfig.firmwareTarget.targetVersion) {
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
selectedConfig.firmwareTarget.productClass,
currentVersion,
selectedConfig.firmwareTarget.targetVersion
)
}
}
}
// Check if compliant (no diffs and no firmware needed)
const isCompliant =
parameterDiffs.length === 0 &&
firmwareMigrationPath.length === 0
return {
configuration: selectedConfig,
inventoryPaths: selectedConfig.inventory.map((inv) => inv.path),
parameterDiffs,
firmwareMigrationPath,
isAlreadyCompliant: isCompliant,
skipFirmware,
}
}
/**
* Dry-run evaluation for a specific configuration on a device.
* Similar to evaluate() but can be called explicitly by the user.
*/
async dryRun(configId: string, deviceIdentifier: string): Promise<DryRunResult> {
const configuration = await ConfigurationModel.findOne({ identifier: configId })
if (!configuration) {
throw new Error(`Configuration ${configId} not found`)
}
const device = await DeviceModel.findOne({
$or: [{ identifier: deviceIdentifier }, { 'deviceId.id': deviceIdentifier }],
})
if (!device) {
throw new Error(`Device ${deviceIdentifier} not found`)
}
// Check if configuration matches device criteria
if (!this.matchesCriteria(device, configuration)) {
throw new Error(`Configuration ${configId} does not match device ${deviceIdentifier}`)
}
const evaluation = await this.evaluateSpecific(device, configuration)
return {
configurationId: configId,
deviceIdentifier,
evaluation,
timestamp: new Date(),
}
}
/**
* Evaluate a specific configuration against a device.
* Used by dry-run, compliance check, and potentially other flows.
*/
async evaluateSpecific(device: Device, config: Configuration): Promise<EngineResult> {
// Check policy "once"
if (!this.shouldApplyOnce(device, config)) {
return {
configuration: config,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
}
// Compute parameter differences
const parameterDiffs = await this.computeParameterDiffs(device, config)
// Resolve firmware migration path
let firmwareMigrationPath: MigrationStep[] = []
let skipFirmware = false
if (config.firmwareTarget) {
if (!isWithinSchedule(config.schedule)) {
skipFirmware = true
} else {
const currentVersion = this.getDeviceVersion(device, config.firmwareTarget.productClass)
if (currentVersion && currentVersion !== config.firmwareTarget.targetVersion) {
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
config.firmwareTarget.productClass,
currentVersion,
config.firmwareTarget.targetVersion
)
}
}
}
const isCompliant = parameterDiffs.length === 0 && firmwareMigrationPath.length === 0
return {
configuration: config,
inventoryPaths: config.inventory.map((inv) => inv.path),
parameterDiffs,
firmwareMigrationPath,
isAlreadyCompliant: isCompliant,
skipFirmware,
}
}
/**
* Check if a device matches all criteria of a configuration.
* Uses glob matching for each criterion.
*/
private matchesCriteria(device: Device, config: Configuration): boolean {
if (!device?.deviceId) return false
const criteria = config.criteria
// manufacturer
if (criteria.manufacturer) {
const deviceValue = device.deviceId.manufacturer || ''
if (!matchGlob(criteria.manufacturer, deviceValue)) {
return false
}
}
// oui
if (criteria.oui) {
const deviceValue = device.deviceId.oui || ''
if (!matchGlob(criteria.oui, deviceValue)) {
return false
}
}
// productClass
if (criteria.productClass) {
const deviceValue = device.deviceId.productClass || ''
if (!matchGlob(criteria.productClass, deviceValue)) {
return false
}
}
// serialNumber
if (criteria.serialNumber) {
const deviceValue = device.deviceId.serialNumber || ''
if (!matchGlob(criteria.serialNumber, deviceValue)) {
return false
}
}
// parameterCriteria: all conditions must match a known device parameter value
if (Array.isArray(criteria.parameterCriteria) && criteria.parameterCriteria.length > 0) {
const hasGetters = typeof (device as any).getParameters === 'function'
const allParams = hasGetters
? [...(device as any).getParameters(), ...(device as any).getProperties()]
: [...((device as any).parameters ?? []), ...((device as any).properties ?? [])]
for (const pc of criteria.parameterCriteria as ParameterCriteria[]) {
if (!pc.name) continue
const known = allParams.find((p: any) => p.name === pc.name)
if (known === undefined || known.value === null || known.value === undefined) {
return false
}
if (String(known.value) !== String(pc.value)) {
return false
}
}
}
return true
}
/**
* Determine if policy "once" should allow application of the configuration.
*
* - For policy "maintain": always apply
* - For policy "once": apply if device doesn't have the provisioning code
* (indicating content has changed or never applied)
*/
private shouldApplyOnce(device: Device, config: Configuration): boolean {
if (config.policy === 'maintain') {
return true
}
if (config.policy !== 'once') {
return true
}
// For "once" policy, check if device has matching provisioning code
const deviceProvCode = (device as any).provisioningCode
if (!deviceProvCode) {
return true // No provisioning code yet, apply it
}
// Parse the provisioning code
const parsed = parseProvisioningCode(deviceProvCode)
if (!parsed) {
return true // Invalid format, re-apply
}
// Check if identifiers match and content hasn't changed
if (parsed.identifier !== config.identifier) {
return true // Different configuration, apply
}
const currentHash = computeContentHash(config)
if (parsed.hash !== currentHash) {
return true // Content changed, re-apply
}
// Same configuration, same content hash => already applied
return false
}
/**
* Compute parameter differences between desired and current state.
*/
private async computeParameterDiffs(
device: Device,
config: Configuration
): Promise<ParameterDiff[]> {
const diffs: ParameterDiff[] = []
for (const desired of config.desiredParameters) {
// Get current value from device
const currentValue = this.getDeviceParameterValue(device, desired.name)
// Compare values (loose: both as strings to handle type mismatches)
// If the desired type is numeric, compare numerically
const desiredType: string = desired.type || ''
const isNumeric = desiredType === 'unsignedInt' || desiredType === 'int' ||
desiredType === 'xsd:unsignedInt' || desiredType === 'xsd:int'
const isBool = desiredType === 'boolean' || desiredType === 'xsd:boolean'
let valuesMatch: boolean
if (isNumeric) {
const numCurrent = Number(currentValue)
const numDesired = Number(desired.value)
valuesMatch = (!isNaN(numCurrent) && !isNaN(numDesired))
? numCurrent === numDesired
: String(currentValue) === String(desired.value)
} else if (isBool) {
const toBool = (v: any) => v === true || v === 'true' || v === '1'
valuesMatch = toBool(currentValue) === toBool(desired.value)
} else {
valuesMatch = String(currentValue) === String(desired.value)
}
if (!valuesMatch) {
diffs.push({
name: desired.name,
currentValue,
targetValue: desired.value,
type: desired.type,
})
}
}
return diffs
}
/**
* Get the current value of a parameter from the device.
* Returns null if not found.
*/
private getDeviceParameterValue(device: Device, paramName: string): string | number | boolean | null {
// Try method-based access first (Device class instances)
if (typeof (device as any).getParameter === 'function') {
const param = (device as any).getParameter(paramName)
if (param) {
const value = param.value
if (value === undefined || value === null) return null
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value
return String(value)
}
return null
}
// Fallback: plain DeviceRecord objects (parameters + properties arrays)
const allEntries = [...((device as any).parameters ?? []), ...((device as any).properties ?? [])]
const entry = allEntries.find((p: any) => p.name === paramName)
if (!entry || entry.value === undefined || entry.value === null) return null
if (typeof entry.value === 'boolean') return entry.value
if (typeof entry.value === 'number') return entry.value
return String(entry.value)
}
/**
* Get the device's current firmware version.
* Looks in device properties or parameters (e.g., "Device.DeviceInfo.SoftwareVersion")
*/
private getDeviceVersion(device: Device, productClass: string): string | null {
const versionParams = [
'Device.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
`Device.${productClass}.FirmwareVersion`,
]
for (const paramName of versionParams) {
const value = this.getDeviceParameterValue(device, paramName)
if (value && typeof value === 'string') {
return value
}
}
return null
}
/**
* Generate a compliance report for a configuration.
* Returns all devices matching the criteria with their compliance status.
*/
async complianceReport(configId: string): Promise<ComplianceReport> {
const configuration = await ConfigurationModel.findOne({ identifier: configId })
if (!configuration) {
throw new Error(`Configuration ${configId} not found`)
}
// Load all devices
const collection = DeviceModel.getCollection()
const allDevices = (await collection.find({}).toArray()) as unknown as Device[]
// Filter by criteria
const matchingDevices = allDevices.filter((d) => this.matchesCriteria(d, configuration))
// Evaluate each matching device
const devices: ComplianceDeviceResult[] = await Promise.all(
matchingDevices.map(async (device) => {
const evaluation = await this.evaluateSpecific(device, configuration)
return {
identifier: (device as any).identifier ?? (device as any).deviceId?.serialNumber ?? '?',
deviceId: (device as any).deviceId,
compliant: evaluation.isAlreadyCompliant,
parameterDiffs: evaluation.parameterDiffs,
firmwareMigrationPath: evaluation.firmwareMigrationPath,
}
})
)
return {
configurationId: configId,
configurationName: configuration.name,
totalDevices: devices.length,
compliantCount: devices.filter((d) => d.compliant).length,
nonCompliantCount: devices.filter((d) => !d.compliant).length,
devices,
}
}
}