- Add type field to all 7727 entries in tr181-catalog.json (xsd:string/unsignedInt/boolean/int) - reclassifyDeviceFromCatalog applies catalog type when device type is UNKNOWN - Export getCatalogType() utility - Compliance comparison: numeric types compared as Number, boolean as bool, fallback to String - Handle NaN gracefully (fallback to string comparison)
490 lines
16 KiB
TypeScript
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 = 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,
|
|
}
|
|
}
|
|
}
|