diff --git a/.kiro/steering/feature-references-client.md b/.kiro/steering/feature-references-client.md new file mode 100644 index 0000000..ed103bf --- /dev/null +++ b/.kiro/steering/feature-references-client.md @@ -0,0 +1,228 @@ +# Feature : Références Client — Système de Providers Pluggable + +## Contexte + +Dans adam-acs, on veut un système de **références externes** (informations client) associées aux devices. Inspiré de Wassup (karma-com) mais conçu comme un **système de plugins** permettant de brancher N fournisseurs de données. + +## Principe : N Providers + +Chaque device peut être enrichi par **plusieurs** providers indépendants. Chaque provider est un service externe interrogé via HTTP qui retourne des paires clé-valeur. + +``` + ┌─────────────────────────────────────┐ + │ adam-acs │ + │ │ + │ DeviceRecord │ + │ └─ references: [ │ + Inform ──────► │ { provider: "wassup", ... } │ + │ { provider: "crm", ... } │ + │ { provider: "noc", ... } │ + │ ] │ + │ │ + │ ReferenceEngine │ + │ ├─ WassupProvider │───► Wassup (HTTP) + │ ├─ CRMProvider │───► CRM API + │ └─ NOCProvider │───► NOC API + └─────────────────────────────────────┘ +``` + +## Architecture + +### Interface Provider + +Chaque provider implémente une interface commune : + +```typescript +interface ReferenceProvider { + /** Identifiant unique du provider (ex: "wassup", "crm") */ + name: string + + /** Résout les références pour un device. Retourne null si non applicable. */ + resolve(device: DeviceRecord, context: InformContext): Promise + + /** Indique si ce provider est activé */ + enabled: boolean +} + +interface ResolvedReference { + provider: string + resolvedAt: Date + resolvedBy: string // ex: "ip:10.0.0.1" + properties: Record + status: 'ok' | 'unknown' | 'error' + error?: string +} + +interface InformContext { + events: string[] + remoteIp: string + headers: Record +} +``` + +### ReferenceEngine (orchestrateur) + +```typescript +class ReferenceEngine { + private providers: ReferenceProvider[] = [] + + register(provider: ReferenceProvider): void + + /** Résout tous les providers pour un device (en parallèle) */ + async resolveAll(device: DeviceRecord, context: InformContext): Promise + + /** Résout un seul provider par nom */ + async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise +} +``` + +### Modèle de données (MongoDB) + +```typescript +// Ajouté à DeviceRecord +interface DeviceRecord { + // ... champs existants ... + references?: DeviceReference[] +} + +interface DeviceReference { + provider: string + resolvedAt: Date + resolvedBy: string + status: 'ok' | 'unknown' | 'error' + properties: Record + error?: string +} +``` + +## Configuration : `config/references.json` + +Fichier de config qui déclare les providers actifs et leur paramétrage : + +```json +{ + "providers": [ + { + "name": "wassup", + "type": "wassup", + "enabled": true, + "url": "http://wassup-mock:9999", + "timeout": 3000, + "trigger": ["BOOT", "BOOTSTRAP"], + "resolveBy": "ip", + "requestedProperties": ["sai", "spr", "clo", "puid", "utz"] + }, + { + "name": "crm", + "type": "http-json", + "enabled": false, + "url": "http://crm-api:8080/api/customers", + "timeout": 5000, + "trigger": ["BOOT"], + "resolveBy": "serialNumber", + "mapping": { + "requestParam": "serial", + "responseMapping": { + "customerId": "id", + "customerName": "name", + "offer": "subscription.offer" + } + } + } + ] +} +``` + +### Types de providers supportés + +| Type | Description | Protocole | +|------|-------------|-----------| +| `wassup` | Protocole Wassup (HTTP GET + headers + text/plain response) | Conforme au format Wassup Orange | +| `http-json` | API REST standard (GET avec query params, réponse JSON) | Générique | +| `http-properties` | HTTP GET avec réponse key=value (Java properties) | Générique | +| `static` | Valeurs statiques déclarées dans la config (pas d'appel externe) | Local | + +### Champ `resolveBy` — Comment identifier le device auprès du provider + +| Valeur | Source dans le device | Exemple | +|--------|----------------------|---------| +| `ip` | Header `x-forwarded-for` de l'Inform ou IP source | `10.0.0.1` | +| `serialNumber` | `device.deviceId.serialNumber` | `LB4-TEST-001` | +| `oui` | `device.deviceId.oui` | `F8B7FF` | +| `mac` | Paramètre MACAddress du device | `F8:B7:FF:00:11:22` | +| `parameter:` | Valeur d'un paramètre du device | `Device.ManagementServer.Username` | + +### Champ `trigger` — Quand déclencher la résolution + +| Valeur | Événement | +|--------|-----------| +| `BOOT` | Event "1 BOOT" | +| `BOOTSTRAP` | Event "0 BOOTSTRAP" | +| `PERIODIC` | Event "2 PERIODIC" | +| `SCHEDULED` | Event "3 SCHEDULED" | +| `CONNECTION_REQUEST` | Event "6 CONNECTION REQUEST" | +| `MANUAL` | Uniquement via API (pas automatique) | + +## Intégration dans le flux Inform + +```typescript +// Dans cwmp_controller.ts → handleInform(), après persist device : +const informContext: InformContext = { events, remoteIp: request.ip(), headers: request.headers() } +const references = await referenceEngine.resolveAll(device, informContext) +if (references.length > 0) { + await DeviceModel.updateReferences(hashId, references) +} +``` + +## API REST + +| Méthode | Route | Description | +|---------|-------|-------------| +| `GET` | `/api/v1/devices/:id/references` | Toutes les références du device | +| `GET` | `/api/v1/devices/:id/references/:provider` | Références d'un provider spécifique | +| `POST` | `/api/v1/devices/:id/references/resolve` | Déclencher la résolution (tous les providers) | +| `POST` | `/api/v1/devices/:id/references/resolve/:provider` | Résoudre un seul provider | +| `PUT` | `/api/v1/devices/:id/references/:provider` | Mettre à jour manuellement | +| `DELETE` | `/api/v1/devices/:id/references/:provider` | Supprimer les références d'un provider | + +## UI (DeviceDetail.vue) + +Nouvelle section collapsible "Références" dans la page device : + +``` +┌─────────────────────────────────────────────────┐ +│ Références (2 providers) ▾ │ +├─────────────────────────────────────────────────┤ +│ 🟢 wassup — résolu le 02/06/2026 via ip:10.0.0.1│ +│ SAI: 088913491 │ +│ SPR: EN │ +│ CLO: pppLB.user │ +│ [Rafraîchir] │ +│ │ +│ ⚪ crm — non résolu (désactivé) │ +└─────────────────────────────────────────────────┘ +``` + +## Fichiers à créer/modifier dans adam-acs + +| Fichier | Action | +|---------|--------| +| `config/references.json` | Créer — configuration des providers | +| `app/domain/device/device_record.ts` | Modifier — ajouter `references?: DeviceReference[]` | +| `app/services/references/reference_engine.ts` | Créer — orchestrateur | +| `app/services/references/providers/wassup_provider.ts` | Créer — provider Wassup | +| `app/services/references/providers/http_json_provider.ts` | Créer — provider HTTP JSON générique | +| `app/services/references/types.ts` | Créer — interfaces et types | +| `app/controllers/references_controller.ts` | Créer — API REST | +| `app/controllers/cwmp_controller.ts` | Modifier — appel ReferenceEngine dans handleInform | +| `inertia/pages/DeviceDetail.vue` | Modifier — section Références | +| `start/routes.ts` | Modifier — ajouter routes references | + +## Mock Wassup (gogowassupgo) + +Déjà implémenté et testé. Le mock tourne dans Docker (`wassup-mock:9999`) et est provisionné automatiquement par le simulateur lors de la création de devices. + +- **Protocole** : identique à Wassup (HTTP GET + `X_WASSUP_USER_IP`) +- **Provisioning** : API `/admin/entries` (POST/GET/DELETE) +- **Stockage** : `data/entries.json` +- **Modes d'erreur** : `--error http-500`, `--error-status KO`, `--delay 2s` diff --git a/.kiro/tasks/references-providers.md b/.kiro/tasks/references-providers.md new file mode 100644 index 0000000..ac4b84a --- /dev/null +++ b/.kiro/tasks/references-providers.md @@ -0,0 +1,65 @@ +# Tâches : Références Client — Providers Pluggable + +## Tâche 1 : Types et interfaces +- [ ] Créer `app/services/references/types.ts` + - Interface `ReferenceProvider` + - Interface `ResolvedReference` + - Interface `InformContext` + - Interface `ProviderConfig` (issue de `config/references.json`) + - Type `ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static'` + +## Tâche 2 : Modèle de données +- [ ] Modifier `app/domain/device/device_record.ts` — ajouter `references?: DeviceReference[]` +- [ ] Créer le type `DeviceReference` (provider, resolvedAt, resolvedBy, status, properties, error?) +- [ ] Modifier le modèle MongoDB `app/models/device.ts` pour persister/lire `references` + +## Tâche 3 : Configuration +- [ ] Créer `config/references.json` avec un provider Wassup exemple (pointant vers `http://wassup-mock:9999`) +- [ ] Créer un loader de config `app/services/references/config_loader.ts` qui lit et valide le fichier + +## Tâche 4 : ReferenceEngine (orchestrateur) +- [ ] Créer `app/services/references/reference_engine.ts` + - Méthode `register(provider)` pour enregistrer un provider + - Méthode `resolveAll(device, context)` — résout tous les providers actifs en parallèle + - Méthode `resolveOne(providerName, device, context)` — résout un seul + - Chargement automatique des providers depuis la config au démarrage + - Extraction du `resolveBy` (ip, serialNumber, mac, parameter:path) + +## Tâche 5 : Provider Wassup +- [ ] Créer `app/services/references/providers/wassup_provider.ts` + - Implémente `ReferenceProvider` + - Construit la requête HTTP GET avec header `X_WASSUP_USER_IP` + - Parse la réponse text/plain (key=value) + - Gère les statuts UNKNOWN_USER et erreurs + - Timeout configurable + +## Tâche 6 : Intégration dans le flux Inform +- [ ] Modifier `app/controllers/cwmp_controller.ts` → `handleInform()` + - Après persist device, appeler `referenceEngine.resolveAll(device, context)` si les events matchent un trigger + - Stocker les résultats dans le device via `DeviceModel.updateReferences(hashId, references)` + +## Tâche 7 : API REST +- [ ] Créer `app/controllers/references_controller.ts` + - `GET /api/v1/devices/:id/references` + - `GET /api/v1/devices/:id/references/:provider` + - `POST /api/v1/devices/:id/references/resolve` + - `POST /api/v1/devices/:id/references/resolve/:provider` +- [ ] Ajouter les routes dans `start/routes.ts` + +## Tâche 8 : UI — Section Références dans DeviceDetail +- [ ] Modifier `inertia/pages/DeviceDetail.vue` + - Ajouter une section collapsible "Références" + - Afficher chaque provider avec son statut (🟢 ok / 🔴 error / ⚪ non résolu) + - Afficher les propriétés clé-valeur + - Bouton "Rafraîchir" par provider + +## Tâche 9 : Tests +- [ ] Test unitaire `reference_engine.spec.ts` — resolve all, resolve one, provider non activé +- [ ] Test unitaire `wassup_provider.spec.ts` — succès, unknown user, timeout, erreur HTTP +- [ ] Test intégration Docker — créer device dans simulateur → vérifier références provisionnées + +## Ordre d'exécution recommandé + +``` +T1 (types) → T2 (modèle) → T3 (config) → T4 (engine) → T5 (wassup) → T6 (inform) → T7 (api) → T8 (ui) → T9 (tests) +``` diff --git a/app/controllers/cwmp_controller.ts b/app/controllers/cwmp_controller.ts index c427cbd..3faf291 100644 --- a/app/controllers/cwmp_controller.ts +++ b/app/controllers/cwmp_controller.ts @@ -59,7 +59,8 @@ export default class CwmpController { sessionId, envelope?.header?.id, remoteKey, - manager + manager, + request.header('x-forwarded-for') || '' ) ctx.cwmpDeviceId = hashId return this.sendSoap(response, xml) @@ -102,7 +103,8 @@ export default class CwmpController { session: string, cwmpId: string | undefined, remoteKey: string, - manager: StateMachineManager + manager: StateMachineManager, + xForwardedFor?: string ): Promise<{ xml: string; hashId: string }> { logger.info( { informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, @@ -159,11 +161,14 @@ export default class CwmpController { logger.info({ hashId }, 'Inform received') // Extract events - const events: string[] = Array.isArray(informData?.Event?.EventStruct) - ? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter( - (c: any): c is string => !!c - ) - : [] + const rawEventStruct = informData?.Event?.EventStruct + const eventStructArray = Array.isArray(rawEventStruct) + ? rawEventStruct + : rawEventStruct ? [rawEventStruct] : [] + const events: string[] = eventStructArray + .map((e: any) => e?.EventCode) + .filter((c: any) => c != null) + .map((c: any) => String(c)) logger.info({ events }, 'CWMP Inform events extracted') @@ -171,6 +176,27 @@ export default class CwmpController { await manager.startStateMachine(device, session, { events }) manager.bindRemoteToDevice(remoteKey, device.deviceId) + // Resolve references from configured providers (async, non-blocking) + try { + logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution') + const { getReferenceEngine } = await import('../services/references/reference_engine.js') + const engine = getReferenceEngine() + // Use x-forwarded-for header (set by simulator) or fall back to TCP remote IP + const cpeIp = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0] + const informContext = { events, remoteIp: cpeIp, headers: {} } + const references = await engine.resolveAll( + { identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()) }, + informContext + ) + if (references.length > 0) { + await DeviceModel.updateOne({ identifier: hashId }, { $set: { references, updatedAt: new Date() } }).catch(() => {}) + logger.debug({ hashId, providers: references.map((r) => r.provider) }, '[References] Resolved') + } + } catch (_e: any) { + // Non-critical — don't block Inform processing + logger.error({ error: _e?.message, stack: _e?.stack }, '[References] Error during resolution') + } + // After Inform for onboarding events, enqueue critical reads using the detected data model const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST') if (!onlyConnReq) { diff --git a/app/controllers/references_controller.ts b/app/controllers/references_controller.ts new file mode 100644 index 0000000..39e2fb4 --- /dev/null +++ b/app/controllers/references_controller.ts @@ -0,0 +1,87 @@ +import type { HttpContext } from '@adonisjs/core/http' +import DeviceModel from '#models/device' +import { getReferenceEngine } from '../services/references/reference_engine.js' + +export default class ReferencesController { + /** + * GET /api/v1/devices/:identifier/references + */ + async index({ params, response }: HttpContext) { + const device = await DeviceModel.findByIdentifier(params.identifier) + if (!device) { + return response.status(404).json({ message: 'Device not found' }) + } + return response.json({ references: device.references || [] }) + } + + /** + * GET /api/v1/devices/:identifier/references/:provider + */ + async show({ params, response }: HttpContext) { + const device = await DeviceModel.findByIdentifier(params.identifier) + if (!device) { + return response.status(404).json({ message: 'Device not found' }) + } + const ref = (device.references || []).find((r: any) => r.provider === params.provider) + if (!ref) { + return response.status(404).json({ message: `No reference from provider '${params.provider}'` }) + } + return response.json(ref) + } + + /** + * POST /api/v1/devices/:identifier/references/resolve + * Trigger resolution for all applicable providers. + */ + async resolveAll({ params, response }: HttpContext) { + const device = await DeviceModel.findByIdentifier(params.identifier) + if (!device) { + return response.status(404).json({ message: 'Device not found' }) + } + + const engine = getReferenceEngine() + const context = { events: ['MANUAL'], remoteIp: '', headers: {} } + + // For manual resolution, use all providers regardless of trigger + const results = await engine.resolveAll(device, { ...context, events: ['0 BOOTSTRAP'] }) + + if (results.length > 0) { + // Merge with existing references (replace by provider name) + const existing = (device.references || []).filter( + (r: any) => !results.some((nr) => nr.provider === r.provider) + ) + const merged = [...existing, ...results] + await DeviceModel.updateOne({ identifier: params.identifier }, { $set: { references: merged, updatedAt: new Date() } }) + } + + return response.json({ resolved: results.length, references: results }) + } + + /** + * POST /api/v1/devices/:identifier/references/resolve/:provider + * Trigger resolution for a single provider. + */ + async resolveOne({ params, response }: HttpContext) { + const device = await DeviceModel.findByIdentifier(params.identifier) + if (!device) { + return response.status(404).json({ message: 'Device not found' }) + } + + const engine = getReferenceEngine() + const context = { events: ['0 BOOTSTRAP'], remoteIp: '', headers: {} } + const result = await engine.resolveOne(params.provider, device, context) + + if (!result) { + return response.status(404).json({ message: `Provider '${params.provider}' not found or cannot resolve` }) + } + + // Merge into device references + const existing = (device.references || []).filter((r: any) => r.provider !== params.provider) + await DeviceModel.updateOne( + { identifier: params.identifier }, + { $set: { references: [...existing, result], updatedAt: new Date() } } + ) + + return response.json(result) + } +} diff --git a/app/domain/device/device_record.ts b/app/domain/device/device_record.ts index 6cfbeb0..f0eceb6 100644 --- a/app/domain/device/device_record.ts +++ b/app/domain/device/device_record.ts @@ -2,6 +2,7 @@ import { DeviceId } from './device_id.js' import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js' import { DeviceComplianceInfo } from '../configuration/compliance.js' import { DataModel, GpnStatus } from './device.js' +import type { DeviceReference } from '../../services/references/types.js' export interface DeviceRecord { identifier?: string @@ -12,6 +13,7 @@ export interface DeviceRecord { dataModel?: DataModel compliance?: DeviceComplianceInfo provisioningCode?: string + references?: DeviceReference[] updatedAt?: Date createdAt?: Date } diff --git a/app/services/references/config_loader.ts b/app/services/references/config_loader.ts new file mode 100644 index 0000000..7f6371f --- /dev/null +++ b/app/services/references/config_loader.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { ReferencesConfig } from './types.js' + +const CONFIG_PATH = join(process.cwd(), 'config', 'references.json') + +let cachedConfig: ReferencesConfig | null = null + +export function loadReferencesConfig(): ReferencesConfig { + if (cachedConfig) return cachedConfig + + try { + const raw = readFileSync(CONFIG_PATH, 'utf-8') + cachedConfig = JSON.parse(raw) as ReferencesConfig + return cachedConfig + } catch (_error: any) { + cachedConfig = { providers: [] } + return cachedConfig + } +} + +export function reloadReferencesConfig(): ReferencesConfig { + cachedConfig = null + return loadReferencesConfig() +} diff --git a/app/services/references/providers/wassup_provider.ts b/app/services/references/providers/wassup_provider.ts new file mode 100644 index 0000000..b9ff611 --- /dev/null +++ b/app/services/references/providers/wassup_provider.ts @@ -0,0 +1,158 @@ +import type { DeviceRecord } from '../../../domain/device/device_record.js' +import type { + ReferenceProvider, + ResolvedReference, + InformContext, + ProviderConfig, +} from '../types.js' +import { ReferenceEngine } from '../reference_engine.js' + + +/** + * Wassup Provider — resolves customer identity via the Wassup protocol. + * + * Protocol: + * - HTTP GET to {url}/wassup?info=sai,spr,... + * - Header X_WASSUP_USER_IP with the CPE IP address + * - Response: text/plain with key=value lines (X_WASSUP_STATUS=OK, X_WASSUP_SAI=..., etc.) + */ +export class WassupProvider implements ReferenceProvider { + name: string + enabled: boolean + + private url: string + private timeout: number + private service: string + private requestedProperties: string[] + private resolveBy: string + + constructor(config: ProviderConfig) { + this.name = config.name + this.enabled = config.enabled + this.url = config.url || '' + this.timeout = config.timeout || 3000 + this.resolveBy = config.resolveBy || 'ip' + this.service = config.options?.service || 'KRMSCO' + this.requestedProperties = config.options?.requestedProperties || ['sai', 'spr', 'clo', 'puid', 'utz'] + } + + async resolve(device: DeviceRecord, context: InformContext): Promise { + const resolveValue = ReferenceEngine.extractResolveValue(device, this.resolveBy, context) + if (!resolveValue) { + console.debug({ provider: this.name, resolveBy: this.resolveBy }, '[Wassup] Cannot resolve: no value') + return null + } + + const infoParam = this.requestedProperties.join(',') + const wassupUrl = `${this.url}/wassup?wassup=ident&prot=200&charset=utf8&serv=${this.service}&info=${infoParam}` + + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeout) + + const response = await fetch(wassupUrl, { + method: 'GET', + headers: { + 'X_WASSUP_USER_IP': resolveValue, + }, + signal: controller.signal, + }) + + clearTimeout(timer) + + if (!response.ok) { + return { + provider: this.name, + resolvedAt: new Date(), + resolvedBy: `${this.resolveBy}:${resolveValue}`, + status: 'error', + properties: {}, + error: `HTTP ${response.status}`, + } + } + + const body = await response.text() + const parsed = this.parseResponse(body) + + const status = parsed['X_WASSUP_STATUS'] + if (status === 'UNKNOWN_USER') { + return { + provider: this.name, + resolvedAt: new Date(), + resolvedBy: `${this.resolveBy}:${resolveValue}`, + status: 'unknown', + properties: {}, + } + } + + if (status !== 'OK') { + return { + provider: this.name, + resolvedAt: new Date(), + resolvedBy: `${this.resolveBy}:${resolveValue}`, + status: 'error', + properties: {}, + error: parsed['X_WASSUP_ERROR'] || `Unexpected status: ${status}`, + } + } + + // Map X_WASSUP_* keys to short names + const properties: Record = {} + const keyMapping: Record = { + 'X_WASSUP_SAI': 'sai', + 'X_WASSUP_SPR': 'spr', + 'X_WASSUP_CLO': 'clo', + 'X_WASSUP_PUID': 'puid', + 'X_WASSUP_UTZ': 'utz', + 'X_WASSUP_IUR': 'iur', + 'X_WASSUP_IUS': 'ius', + } + + for (const [wassupKey, shortKey] of Object.entries(keyMapping)) { + if (parsed[wassupKey] && parsed[wassupKey] !== '') { + properties[shortKey] = parsed[wassupKey] + } + } + + console.info({ provider: this.name, resolveBy: `${this.resolveBy}:${resolveValue}`, keys: Object.keys(properties) }, '[Wassup] Resolved') + + return { + provider: this.name, + resolvedAt: new Date(), + resolvedBy: `${this.resolveBy}:${resolveValue}`, + status: 'ok', + properties, + } + } catch (error: any) { + const isTimeout = error.name === 'AbortError' + console.error({ provider: this.name, error: error.message, isTimeout }, '[Wassup] Resolution failed') + + return { + provider: this.name, + resolvedAt: new Date(), + resolvedBy: `${this.resolveBy}:${resolveValue}`, + status: 'error', + properties: {}, + error: isTimeout ? 'Timeout' : error.message, + } + } + } + + /** + * Parse a Wassup text/plain response into a key-value map. + * Format: "X_WASSUP_STATUS=OK\nX_WASSUP_SAI=111111111\n..." + */ + private parseResponse(body: string): Record { + const result: Record = {} + const lines = body.split('\n') + for (const line of lines) { + const idx = line.indexOf('=') + if (idx > 0) { + const key = line.slice(0, idx).trim() + const value = line.slice(idx + 1).trim() + result[key] = value + } + } + return result + } +} diff --git a/app/services/references/reference_engine.ts b/app/services/references/reference_engine.ts new file mode 100644 index 0000000..6321f1b --- /dev/null +++ b/app/services/references/reference_engine.ts @@ -0,0 +1,156 @@ +import type { DeviceRecord } from '../../domain/device/device_record.js' +import type { + ReferenceProvider, + ResolvedReference, + InformContext, + ProviderConfig, +} from './types.js' +import { TRIGGER_EVENT_MAP } from './types.js' +import { loadReferencesConfig } from './config_loader.js' +import { WassupProvider } from './providers/wassup_provider.js' + +export class ReferenceEngine { + private providers: ReferenceProvider[] = [] + private configs: ProviderConfig[] = [] + + constructor() { + this.loadProviders() + } + + private loadProviders(): void { + const config = loadReferencesConfig() + this.configs = config.providers + + for (const providerConfig of config.providers) { + if (!providerConfig.enabled) continue + + const provider = this.createProvider(providerConfig) + if (provider) { + this.providers.push(provider) + console.info({ name: providerConfig.name, type: providerConfig.type }, '[References] Provider registered') + } + } + } + + private createProvider(config: ProviderConfig): ReferenceProvider | null { + switch (config.type) { + case 'wassup': + return new WassupProvider(config) + case 'http-json': + // TODO: implement + console.warn({ name: config.name }, '[References] http-json provider not yet implemented') + return null + case 'http-properties': + console.warn({ name: config.name }, '[References] http-properties provider not yet implemented') + return null + case 'static': + console.warn({ name: config.name }, '[References] static provider not yet implemented') + return null + default: + console.error({ name: config.name, type: config.type }, '[References] Unknown provider type') + return null + } + } + + /** + * Resolve all providers for a device in parallel. + * Only triggers providers whose trigger events match the inform events. + */ + async resolveAll(device: DeviceRecord, context: InformContext): Promise { + const applicable = this.getApplicableProviders(context.events) + if (applicable.length === 0) return [] + + const results = await Promise.allSettled( + applicable.map((p) => p.resolve(device, context)) + ) + + const resolved: ResolvedReference[] = [] + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + resolved.push(result.value) + } else if (result.status === 'rejected') { + console.error({ error: result.reason }, '[References] Provider resolution failed') + } + } + + return resolved + } + + /** + * Resolve a single provider by name. + */ + async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise { + const provider = this.providers.find((p) => p.name === providerName) + if (!provider) { + console.warn({ providerName }, '[References] Provider not found') + return null + } + return provider.resolve(device, context) + } + + /** + * Returns providers whose trigger events intersect with the inform events. + */ + private getApplicableProviders(informEvents: string[]): ReferenceProvider[] { + return this.providers.filter((provider) => { + const config = this.configs.find((c) => c.name === provider.name) + if (!config) return false + + // Check if any trigger event matches the inform events + for (const trigger of config.trigger) { + const mappedEvents = TRIGGER_EVENT_MAP[trigger] || [trigger] + for (const mapped of mappedEvents) { + if (informEvents.some((e) => String(e).toUpperCase().includes(mapped.toUpperCase()))) { + return true + } + } + } + return false + }) + } + + /** + * Extracts the resolve value from a device based on the resolveBy config. + */ + static extractResolveValue(device: DeviceRecord, resolveBy: string, context: InformContext): string | null { + switch (resolveBy) { + case 'ip': + return context.remoteIp || null + case 'serialNumber': + return device.deviceId?.serialNumber || null + case 'oui': + return device.deviceId?.oui || null + case 'mac': { + const allParams = [...(device.properties || []), ...(device.parameters || [])] + const macParam = allParams.find((p) => p.name?.includes('MACAddress')) + return macParam?.value as string || null + } + default: + if (resolveBy.startsWith('parameter:')) { + const path = resolveBy.slice('parameter:'.length) + const allParams = [...(device.properties || []), ...(device.parameters || [])] + const param = allParams.find((p) => p.name === path) + return param?.value as string || null + } + return null + } + } + + getProviderNames(): string[] { + return this.providers.map((p) => p.name) + } + + getProviderConfigs(): ProviderConfig[] { + return this.configs + } +} + +// Singleton +let instance: ReferenceEngine | null = null + +export function getReferenceEngine(): ReferenceEngine { + if (!instance) { + instance = new ReferenceEngine() + } + return instance +} diff --git a/app/services/references/types.ts b/app/services/references/types.ts new file mode 100644 index 0000000..43217b1 --- /dev/null +++ b/app/services/references/types.ts @@ -0,0 +1,123 @@ +/** + * Reference Providers — Types and Interfaces + * + * A pluggable system for resolving external customer/device information + * from N independent providers (Wassup, CRM, NOC, etc.) + */ + +import type { DeviceRecord } from '../../domain/device/device_record.js' + +// ─── Provider Interface ──────────────────────────────────────────────────────── + +export interface ReferenceProvider { + /** Unique identifier for this provider (e.g., "wassup", "crm") */ + name: string + + /** Whether this provider is currently enabled */ + enabled: boolean + + /** + * Resolve references for a device. + * Returns null if the provider cannot resolve (e.g., missing input data). + */ + resolve(device: DeviceRecord, context: InformContext): Promise +} + +// ─── Resolution Result ───────────────────────────────────────────────────────── + +export interface ResolvedReference { + /** Provider name that produced this result */ + provider: string + + /** Timestamp of resolution */ + resolvedAt: Date + + /** Criterion used to resolve (e.g., "ip:10.0.0.1", "serial:LB4-001") */ + resolvedBy: string + + /** Resolution outcome */ + status: ReferenceStatus + + /** Key-value properties returned by the provider */ + properties: Record + + /** Error message if status is 'error' */ + error?: string +} + +export type ReferenceStatus = 'ok' | 'unknown' | 'error' + +// ─── Stored Reference (persisted in DeviceRecord) ────────────────────────────── + +export interface DeviceReference { + provider: string + resolvedAt: Date + resolvedBy: string + status: ReferenceStatus + properties: Record + error?: string +} + +// ─── Inform Context (passed to providers during resolution) ──────────────────── + +export interface InformContext { + /** CWMP events from the Inform (e.g., ["1 BOOT", "0 BOOTSTRAP"]) */ + events: string[] + + /** Remote IP of the CPE (from TCP socket or x-forwarded-for) */ + remoteIp: string + + /** HTTP headers from the Inform request */ + headers: Record +} + +// ─── Provider Configuration (from config/references.json) ────────────────────── + +export type ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static' + +export interface ProviderConfig { + /** Unique name for this provider instance */ + name: string + + /** Provider type (determines which implementation to use) */ + type: ReferenceProviderType + + /** Whether this provider is active */ + enabled: boolean + + /** Base URL of the external service */ + url?: string + + /** HTTP timeout in milliseconds */ + timeout?: number + + /** Events that trigger automatic resolution (e.g., ["BOOT", "BOOTSTRAP"]) */ + trigger: string[] + + /** + * How to identify the device for this provider. + * - "ip": use remoteIp from InformContext + * - "serialNumber": use device.deviceId.serialNumber + * - "oui": use device.deviceId.oui + * - "mac": look for a MACAddress parameter + * - "parameter:": use value of a specific device parameter + */ + resolveBy: string + + /** Provider-specific options */ + options?: Record +} + +export interface ReferencesConfig { + providers: ProviderConfig[] +} + +// ─── Trigger Event Mapping ───────────────────────────────────────────────────── + +export const TRIGGER_EVENT_MAP: Record = { + BOOT: ['1 BOOT', '1'], + BOOTSTRAP: ['0 BOOTSTRAP', '0'], + PERIODIC: ['2 PERIODIC', '2'], + SCHEDULED: ['3 SCHEDULED', '3'], + CONNECTION_REQUEST: ['6 CONNECTION REQUEST', '6'], +} diff --git a/config/references.json b/config/references.json new file mode 100644 index 0000000..d697208 --- /dev/null +++ b/config/references.json @@ -0,0 +1,17 @@ +{ + "providers": [ + { + "name": "wassup", + "type": "wassup", + "enabled": true, + "url": "http://wassup-mock:9999", + "timeout": 3000, + "trigger": ["BOOT", "BOOTSTRAP"], + "resolveBy": "ip", + "options": { + "service": "KRMSCO", + "requestedProperties": ["sai", "spr", "clo", "puid", "utz"] + } + } + ] +} diff --git a/data/entries.json b/data/entries.json new file mode 100644 index 0000000..b434ed9 --- /dev/null +++ b/data/entries.json @@ -0,0 +1,6 @@ +{ + "10.0.0.1": { + "sai": "999", + "spr": "FR" + } +} \ No newline at end of file diff --git a/docker-compose.simulator.yml b/docker-compose.simulator.yml index 928db90..53d7b87 100644 --- a/docker-compose.simulator.yml +++ b/docker-compose.simulator.yml @@ -25,6 +25,9 @@ services: PROXY: "false" BSE_PROXY: "false" AUTO_DELETE_ENABLED: "false" + # URL du mock Wassup pour provisionner les fiches client à la création d'un device + # Laisser vide pour désactiver + WASSUP_MOCK_URL: "http://wassup-mock:9999" # Vide = mode permissif : envoie tous les paramètres dans l'Inform (utile pour le dev/test) # Sinon, liste les catégories sémantiques du simulateur (ex: HardwareVersion,SoftwareVersion,...) INFORM_ACCEPTED_PARAMS: "" @@ -85,5 +88,19 @@ services: depends_on: - simulator + wassup-mock: + build: + context: ../grelieu/gogowassupgo + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "9999:9999" + environment: + PORT: "9999" + DATA_FILE: "/app/data/entries.json" + volumes: + - wassup_data:/app/data + volumes: simulator_logs: + wassup_data: diff --git a/inertia/pages/DeviceDetail.vue b/inertia/pages/DeviceDetail.vue index 358d409..8deaee5 100644 --- a/inertia/pages/DeviceDetail.vue +++ b/inertia/pages/DeviceDetail.vue @@ -148,6 +148,27 @@ function getPropertyValue(name: string): string { const prop = device.properties?.find((p: any) => p.name === name) return prop?.value ?? '' } + +// References resolution +const resolvingProvider = ref(null) + +async function resolveProvider(providerName: string) { + resolvingProvider.value = providerName + try { + await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' }) + router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true }) + } catch { /* ignore */ } + finally { resolvingProvider.value = null } +} + +async function resolveAllProviders() { + resolvingProvider.value = '__all__' + try { + await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' }) + router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true }) + } catch { /* ignore */ } + finally { resolvingProvider.value = null } +}