Compare commits

...
Author SHA1 Message Date
Kevin L'Hereec 76e75132d5 test: raise business-logic unit test coverage across core services
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Successful in 1m19s
Build & Deploy / deploy (push) Successful in 37s
Adds targeted unit tests to cover previously untested branches/functions
in the business-critical (non-protocol) services:

- device_service.ts: 35.8% -> 98.76% (CRUD, executeCommand, refreshDevice,
  calculatePagination, IPv4/IPv6 Connection Request fallback chain)
- domain/device/device.ts: 76.7% -> 96.59% (GPN circuit breaker state
  machine, session lifecycle, canExecuteCommands, detectDataModelFromParams)
- services/references/reference_engine.ts: 78.66% -> 100% (provider
  loading edge cases, rejection handling, singleton lifecycle)
- services/configuration_engine.ts: 70.96% -> 98.7% (matchesCriteria
  branches, shouldApplyOnce policy variants, dryRun, complianceReport)
- services/cwmp/state_machine_manager.ts: 70.9% -> 87.27% (getHashIdForRemoteIp,
  ensureSessionForRemote IP fallback, task callback error handling)
- services/cwmp/main_state_machine.ts: 57.79% -> 94.29% (ExecutingCommandsState
  full command-type dispatch, MAX_STATE_TRANSITIONS guard, error->
  PartialOperationalState transitions)

Also fixes:
- reference_engine_full.spec.ts previously depended on the real
  config/references.json (production config, wassup provider legitimately
  disabled there) instead of a test fixture — now mocks config_loader
  for deterministic, isolated assertions
- main_state_machine.spec.ts CommandModel.getCollection mock returned a
  new object per call instead of a shared instance, breaking
  setupCommandMock() for the new command-dispatch tests

Overall project coverage (excluding protocol/): 72.76% -> 82.34% statements.
Full suite: 1005/1005 tests passing.
2026-07-22 11:45:50 +02:00
Kevin L'Hereec 576b068cf4 chore: relax naming-convention/filename-case lint rules for legacy CWMP protocol files
app/protocol/cwmp-1-*.ts and app/utils/cwmp-events.ts mirror TR-069 spec
version numbers in their type/class names (e.g. Cwmp_1_0, EventCode_1_1),
which trips @unicorn/filename-case and @typescript-eslint/naming-convention.
Renaming these would be a large, risky refactor across the protocol layer
for no functional benefit, so the two rules are disabled for this specific
file set via an eslint.config.js override.

This unblocks 'eslint app/' from reporting pre-existing, unfixable errors,
allowing a pre-push git hook (lint app/ + tsc --noEmit) to be enabled
without a large unrelated renaming effort.
2026-07-22 11:45:35 +02:00
Kevin L'Hereec f9e56c78b1 refactor: migrate all internal imports to package.json subpath aliases
- Add #domain, #protocol, #utils aliases to package.json imports map
- Replace all relative imports (./, ../, ../..) in app/ and
  database/mongo_migrations/ with the corresponding #alias imports,
  eliminating relative path traversal across the codebase
- Remove dead code: ConfigurationVersionModel.findByConfigurationIdentifier
  (unused, superseded by paginateVersions) and unused exception classes
  (NotFoundException, UnprocessableEntityException, DatabaseException)
  from app/exceptions/index.ts, keeping only BadRequestException
- Fix pre-existing syntax error in configuration_version.ts (mismatched
  parenthesis) that broke tsc
- Fix eqeqeq violation in configuration_service.ts and isNaN ->
  Number.isNaN in configuration_engine.ts / cwmp-1-0.ts / exchange_controller.ts
- Fix no-shadow violation in cwmp-1-0.ts EventStruct constructor
- Prettier formatting pass on touched files

Validated: tsc --noEmit clean, eslint app/ clean, full test suite green.
2026-07-22 11:45:14 +02:00
Kevin L'Hereec 4177f1adbd fix: eslint/prettier formatting and type-only imports
- Use type-only import for HttpContext in exceptions/handler.ts
- Use #services alias instead of relative path in device_record.ts
- Reformat api_key_middleware and cwmp_logger_middleware per prettier
2026-07-22 10:35:49 +02:00
Kevin L'Hereec 9cceb139b7 fix: eslint consistent-type-imports and eqeqeq violations
- Convert value imports used only as types to 'import type' across
  all modified files (cwmp_message_builder, task_queue, device_action_executor,
  main_state_machine, immediate_command_state_machine, configuration_evaluation_state,
  cwmp_controller, cwmp_middleware, device.ts, soap.ts, soap_envelope.ts, tr069_types.ts)
- Replace != null with !== null && !== undefined in main_state_machine and cwmp_controller
2026-07-20 17:14:12 +02:00
Kevin L'Hereec ab45275a89 fix: SetParameterAttributes XML emits 0/1 for boolean fields and drops xsi:type on Notification
NotificationChange and AccessListChange were emitting 'true'/'false' (JS booleans)
instead of '1'/'0' as required by TR-069 spec.
Notification xsi:type='xsd:int' attribute removed — consistent with old builder output.
2026-07-20 16:39:53 +02:00
Kevin L'Hereec b643077e8d fix: correct orphan references after TR-069 typing refactor
- tests/device.spec.ts: fix 6× organizeParameters() calls passing wrapped
  response object instead of flat ParameterInfoStruct[] (runtime break)
- tests/device.spec.ts: fix setTmpParamValues([{x:1}]) with valid shape
- immediate_command_state_machine.ts: type handleResponse message param as
  CWMPTaskResultData (was any, gap in typing contract)
- configuration_evaluation_state.ts: replace fragile ../../../app/ dynamic
  import paths with proper static imports from ../../protocol/tr069_types.js
- main_state_machine.ts: replace all inline dynamic import() type casts with
  static imports (GetParameterNamesResponse, GetParameterValuesResponse,
  ParameterValueStruct, SetParameterAttribute, DownloadParameter, etc.)
2026-07-20 16:27:38 +02:00
Kevin L'Hereec f3eb8e1650 feat: restore strong typing on TR-069/CWMP protocol layer
- Copy cwmp-1-0..4.ts source classes from cwmp-connector (papaye) into
  app/protocol/ as local files (ts-nocheck, external dependency)
- Add soap_envelope.ts: SoapEnvelope, SoapEnvelopeHeader, CwmpID, NAMESPACES
- Add tr069_types.ts: re-exports all CWMP classes + CwmpMessage discriminated
  union + typed task payload interfaces (CWMPTaskType, CWMPTaskPayload, etc.)
- Rewrite cwmp_message_builder.ts to use class-based builders instead of raw
  XML template strings; add transferCompleteResponse() and soapFault()
- soap.ts: extractCwmpMessage returns CwmpMessage union, adds runtime allowlist
  guard against unknown CWMP method names from CPE
- cwmp_middleware.ts: envelope typed SoapEnvelope|null, message CwmpMessage|null
- cwmp_controller.ts: handleInform(informData: Inform), remove any, migrate
  TransferComplete and fault to new builder path, drop buildSoapEnvelope import
- task_queue.ts: CWMPTask.type → CWMPTaskType literal union, payload →
  CWMPTaskPayload, result → CWMPTaskResultData
- device_action_executor.ts: GPV/GPN/GPA responses typed, no more any
- main_state_machine.ts: DeviceContext.message.data → CWMPTaskResultData,
  organizeParameters receives ParameterInfoStruct[] directly
- device.ts: tmpParamValues typed ParameterValueStruct[], organizeParameters
  signature updated, updateFromValues typed
- device_id.ts: fromInform typed with DeviceIdStruct
- soap_envelope.ts: remove unconditional <cwmp:noMoreRequests> header
  (CPE→ACS only per TR-069 §3.7.1.4)
2026-07-20 15:25:26 +02:00
Kevin L'Hereec 8969355688 feat: improve critical reads, device list and modelPaths bug fix
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Successful in 1m4s
Build & Deploy / deploy (push) Successful in 32s
- main_state_machine.ts: add to CriticalReadsState GPV:
  ProvisioningCode (confirm CPE persisted value), UpTime (detect reboots),
  ManagementServer.URL (verify ACS URL), ConnectionRequestUsername/Password
  (ensure wake credentials are always available)
- Home.vue: add Last Seen column (device.updatedAt)
- pages_controller.ts: fix modelPaths bug — was never passed to DeviceDetail,
  now auto-detected from dataModel and passed as predefined TR-181/TR-098 paths
2026-07-17 16:57:08 +02:00
Kevin L'Hereec 7d603da0ca fix: Connection Request fallback to IPv4 when CPE has IPv6-only URL
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 57s
Build & Deploy / deploy (push) Successful in 24s
- device_record.ts: add lastRemoteIp field to store the CPE public IP
  from each Inform (X-Forwarded-For or TCP remote IP)
- cwmp_controller.ts: persist lastRemoteIp on every Inform alongside
  lastPeriodicInformAt (single  operation)
- device_service.ts: sendWakeOnWlan() now tries the original URL first
  (may be IPv6), then falls back to a rebuilt URL using lastRemoteIp
  (IPv4 public IP) if the ACS server has no IPv6 connectivity
2026-07-17 16:45:30 +02:00
Kevin L'Hereec c3bc2902f0 fix: add missing /internal/references routes and clean isGpnAllowed()
Build & Deploy / build (push) Successful in 38s
Build & Deploy / docker (push) Successful in 1m4s
Build & Deploy / deploy (push) Successful in 31s
- start/routes.ts: mirror references routes into /internal group so the
  DeviceDetail.vue resolve buttons work (were returning 404)
- device.ts: isGpnAllowed() now clears cooldownUntil when transitioning
  open→half-open, avoiding stale date in persisted snapshots
2026-07-17 16:36:43 +02:00
Kevin L'Hereec 19caa1a06f feat: read PeriodicInformInterval and PeriodicInformEnable in critical reads
Build & Deploy / build (push) Successful in 33s
Build & Deploy / docker (push) Successful in 1m4s
Build & Deploy / deploy (push) Successful in 24s
Add Device.ManagementServer.PeriodicInformInterval and
Device.ManagementServer.PeriodicInformEnable to the GPV performed
during CriticalReadsState (applies to both TR-181 and TR-098).
2026-07-17 16:31:33 +02:00
Kevin L'Hereec 26ffcbea6b fix: avoid GPN on root path Device. which returns the full parameter tree
Build & Deploy / build (push) Successful in 33s
Build & Deploy / docker (push) Successful in 57s
Build & Deploy / deploy (push) Successful in 33s
Two complementary fixes:
- IdleState: skip root-level parent paths (Device., InternetGatewayDevice.)
  when building the GPN queue. Params like Device.RootDataModelVersion have
  Device. as parent but sending GPN on Device. with NextLevel=0 returns the
  entire TR-181 tree in one response (potentially several MB).
  These root-level params are classified via deeper subtree GPN or catalog.
- task_queue: use NextLevel=1 for root paths (1 segment) as a safety net,
  NextLevel=0 for deeper paths to get all leaves of that subtree.
2026-07-17 16:29:29 +02:00
Kevin L'Hereec cce14d7039 fix: increase raw body limit to 4mb for CWMP XML payloads
Build & Deploy / build (push) Successful in 35s
Build & Deploy / docker (push) Successful in 1m7s
Build & Deploy / deploy (push) Successful in 36s
GetParameterNamesResponse from CPEs with many parameters exceeds the
default 56kb raw body limit. Add a 'raw' section in bodyparser config
covering text/xml, application/xml and application/soap+xml with a 4mb
limit to handle large TR-069 discovery responses.
2026-07-17 16:19:50 +02:00
Kevin L'Hereec 61bfb4e762 fix: rebind CWMP session when CPE sends empty POST on new TCP port
Build & Deploy / build (push) Successful in 33s
Build & Deploy / docker (push) Successful in 1m1s
Build & Deploy / deploy (push) Successful in 33s
TR-069 CPEs routinely open a new TCP connection (new port) for the empty
POST that follows InformResponse. ensureSessionForRemote() only did an
exact remoteKey match, so hasSession() returned false and the state
machine was never continued — GPN discovery was never triggered.

Add IP-only fallback in ensureSessionForRemote(): when the exact
remoteKey is not found, scan RUNNING machines for a unique IP match,
rebind the new remoteKey, and remap sessionMachines to the new sessionId.
Ambiguous cases (NAT/CGNAT with multiple active devices on same IP)
return false safely.
2026-07-17 16:14:38 +02:00
Kevin L'Hereec 7835f99657 fix: correct CWMP session management side effects
Build & Deploy / build (push) Successful in 29s
Build & Deploy / docker (push) Successful in 1m0s
Build & Deploy / deploy (push) Successful in 34s
- state_machine_manager: add deviceToRemotes reverse index (Map<deviceKeyJson, Set<remoteKey>>)
  so completeMachine/failMachine clean up remoteToDevice in O(1) — fixes unbounded memory leak
- state_machine_manager: getHashIdForRemoteIp() now only matches RUNNING sessions and
  returns undefined when multiple candidates exist (NAT/CGNAT safety)
- state_machine_manager: IPv6 bracket normalization in IP comparison
- cwmp_controller: apply IP fallback to TransferComplete and generic response handler
  (same new-TCP-port scenario as empty POST after firmware reboot)
- cwmp_logger_middleware: reuse request.cwmp.sessionId instead of recomputing
  from socket to avoid formula drift
2026-07-17 15:39:41 +02:00
Kevin L'Hereec d44d896556 fix: persist empty POST in TR-069 trace when CPE uses a new TCP port
Build & Deploy / build (push) Successful in 38s
Build & Deploy / docker (push) Successful in 1m5s
Build & Deploy / deploy (push) Successful in 23s
When a CPE sends an empty POST (polling) on a new TCP connection, the
remoteKey (IP:port) differs from the one registered during Inform.
Add StateMachineManager.getHashIdForRemoteIp() to find the device by
IP alone as a fallback, so cwmpDeviceId is resolved and the exchange
is properly stored in the trace (cwmpMethod: Empty, dashed arrow).
2026-07-17 14:24:44 +02:00
Kevin L'Hereec 3bb85d9031 fix: support alias-based IPv6 interface names and add engine reset utility
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 1m4s
Build & Deploy / deploy (push) Successful in 35s
- Home.vue: extend RE_IPV4/RE_IPV6 to match alias-based interface names
  like Device.IP.Interface.[BR_LAN].IPv6Address.[GUA_LAN].IPAddress
  (used by Sagemcom Livebox 4 with AliasBasedAddressing=1)
- reference_engine.ts: add resetReferenceEngine() to allow config reload
  without restart (wassup provider disabled via config/references.json)
2026-07-17 14:21:02 +02:00
Kevin L'Hereec 1fc9715888 fix: display IPv6 address in device table and disable wassup provider
Build & Deploy / build (push) Successful in 31s
Build & Deploy / docker (push) Successful in 1m3s
Build & Deploy / deploy (push) Successful in 24s
- Home.vue: add getIpAddress() with IPv4/IPv6 fallback chain (TR-098, TR-181)
  hoisted regex constants, prefer interface index 1, format IPv6 with brackets
- config/references.json: disable wassup provider (mock URL unreachable)
2026-07-17 11:57:09 +02:00
kevin.lhereec b12141792f fix: supprimer import logger inutilise dans cwmp_middleware (TS6133)
Build & Deploy / build (push) Successful in 40s
Build & Deploy / docker (push) Successful in 1m6s
Build & Deploy / deploy (push) Successful in 25s
2026-07-17 10:39:48 +02:00
kevin.lhereec 7932b43840 chore: passer LOG_LEVEL en debug pour le prochain deploiement
Build & Deploy / build (push) Failing after 34s
Build & Deploy / docker (push) Skipped
Build & Deploy / deploy (push) Skipped
2026-07-17 10:38:29 +02:00
kevin.lhereec 5ea25d462e feat: logs debug sur les echanges XML CWMP
Build & Deploy / build (push) Failing after 36s
Build & Deploy / docker (push) Skipped
Build & Deploy / deploy (push) Skipped
- cwmp_middleware: log XML brut recu (bodyPreview 500 chars), contentType,
  bodyLength, et message extrait (type, cwmpVersion, cwmpId, messageKeys)
  + log body vide (CPE polling)
- cwmp_logger_middleware: log XML pretty-print complet CPE->ACS et ACS->CPE
  en debug, independamment du statut trace
- cwmp_controller: log routage message (messageType), InformResponse,
  TransferCompleteResponse, reponses state machine avec longueur
2026-07-17 10:36:57 +02:00
kevin.lhereec ebe088a179 fix: couleur bandeau trace orange -> pink
Build & Deploy / build (push) Successful in 35s
Build & Deploy / docker (push) Successful in 55s
Build & Deploy / deploy (push) Successful in 22s
2026-07-17 10:33:23 +02:00
kevin.lhereec b00c0c94a8 feat: suppression des commandes PENDING depuis /commands
Build & Deploy / build (push) Successful in 40s
Build & Deploy / docker (push) Successful in 1m4s
Build & Deploy / deploy (push) Successful in 26s
- Ajout de destroy() dans CommandsController (DELETE /commands/:id)
- Routes DELETE /commands/:id dans les groupes /api/v1 et /internal
- Bouton supprimer dans Commands.vue visible uniquement sur les commandes PENDING
- Utilise fetchWithCsrf pour la protection CSRF
2026-07-17 10:31:56 +02:00
kevin.lhereec b22d3e9b56 fix: correction subnet IPv6 ULA (fd00:adam:: invalide -> fd00:ad4a::)
Build & Deploy / build (push) Successful in 33s
Build & Deploy / docker (push) Successful in 15s
Build & Deploy / deploy (push) Successful in 4s
2026-07-17 10:22:06 +02:00
kevin.lhereec 8cddf98093 fix: CSRF token sur les requetes mutantes /internal + IPv6 Docker + tests Connection Request
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 1m6s
Build & Deploy / deploy (push) Failing after 33s
- Ajout de fetchWithCsrf() helper qui lit le cookie XSRF-TOKEN et injecte
  X-XSRF-TOKEN sur toutes les requetes POST/PUT/PATCH/DELETE vers /internal/*
- Remplacement de fetch() par fetchWithCsrf() dans tous les composants Vue
  concernes (Tr069Trace, DeviceDetail, SequenceViewer, Home, configurations,
  firmware_catalog)
- Activation IPv6 sur les reseaux Docker (enable_ipv6: true, subnet fd00:adam::/64)
  dans docker-compose.yml et docker-compose.prod.yml
- Ajout tests unitaires DeviceService.sendWakeOnWlan : IPv4, IPv6, ENETUNREACH,
  credentials fallback chain (11 tests)
2026-07-17 10:16:29 +02:00
L HEREEC Kevin INNOV/PTFM 63ee6f94f1 fix: prefix internal routes group with .as('internal') to avoid duplicate route names
Build & Deploy / build (push) Successful in 40s
Build & Deploy / docker (push) Successful in 1m3s
Build & Deploy / deploy (push) Successful in 21s
2026-07-13 15:06:43 +02:00
L HEREEC Kevin INNOV/PTFM e645157970 feat: auto-run MongoDB migrations at boot in MongodbProvider
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Successful in 1m8s
Build & Deploy / deploy (push) Successful in 24s
2026-07-13 15:03:19 +02:00
L HEREEC Kevin INNOV/PTFM 7ac4e018a2 fix(ci): remove unused ReferencesController import causing TS build error
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Successful in 1m5s
Build & Deploy / deploy (push) Has been cancelled
2026-07-13 15:01:48 +02:00
L HEREEC Kevin INNOV/PTFM 645e845b95 fix: add /internal routes protected by session for Vue frontend fetches, replace /api/v1/ calls in all Vue components
Build & Deploy / build (push) Failing after 35s
Build & Deploy / docker (push) Has been skipped
Build & Deploy / deploy (push) Has been skipped
2026-07-13 14:59:01 +02:00
L HEREEC Kevin INNOV/PTFM 167422be0f Revert "fix(prod): bind port 8080 to localhost only, force traffic through reverse proxy"
Build & Deploy / build (push) Successful in 29s
Build & Deploy / docker (push) Successful in 1s
Build & Deploy / deploy (push) Successful in 4s
This reverts commit 5ce1db42ff.
2026-07-13 14:53:23 +02:00
L HEREEC Kevin INNOV/PTFM 5ce1db42ff fix(prod): bind port 8080 to localhost only, force traffic through reverse proxy
Build & Deploy / build (push) Successful in 33s
Build & Deploy / docker (push) Successful in 12s
Build & Deploy / deploy (push) Successful in 3s
2026-07-13 14:32:20 +02:00
L HEREEC Kevin INNOV/PTFM 83e561fc19 fix(prod): connect to databases_default network where MongoDB actually runs
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 14s
Build & Deploy / deploy (push) Successful in 2s
2026-07-13 14:26:09 +02:00
L HEREEC Kevin INNOV/PTFM e98bc5f0a3 fix(ci): hardcode fixed env values, only keep sensitive vars as secrets
Build & Deploy / build (push) Successful in 29s
Build & Deploy / docker (push) Successful in 2s
Build & Deploy / deploy (push) Successful in 3s
2026-07-13 14:21:28 +02:00
L HEREEC Kevin INNOV/PTFM ba10591150 fix(prod): use databases_backend network and databases-mongo-1 hostname for shared MongoDB
Build & Deploy / build (push) Successful in 35s
Build & Deploy / docker (push) Successful in 14s
Build & Deploy / deploy (push) Successful in 3s
2026-07-13 14:17:38 +02:00
L HEREEC Kevin INNOV/PTFM c1eb606d01 debug(ci): print SSH key fingerprint to verify key integrity
Build & Deploy / build (push) Successful in 32s
Build & Deploy / docker (push) Successful in 17s
Build & Deploy / deploy (push) Failing after 13s
2026-07-13 13:02:16 +02:00
L HEREEC Kevin INNOV/PTFM 420d31176c fix(ci): add SSH ConnectTimeout and BatchMode to fail fast
Build & Deploy / build (push) Successful in 48s
Build & Deploy / docker (push) Successful in 14s
Build & Deploy / deploy (push) Failing after 0s
2026-07-13 12:50:56 +02:00
L HEREEC Kevin INNOV/PTFM b812349ba9 fix(ci): write SSH key to /tmp instead of read-only ~/.ssh
Build & Deploy / build (push) Successful in 44s
Build & Deploy / docker (push) Successful in 14s
Build & Deploy / deploy (push) Has been cancelled
2026-07-13 12:47:45 +02:00
L HEREEC Kevin INNOV/PTFM 1f87eae0b2 fix(ci): inject SSH_PRIVATE_KEY secret with printf to preserve LF line endings
Build & Deploy / build (push) Successful in 38s
Build & Deploy / docker (push) Successful in 1s
Build & Deploy / deploy (push) Failing after 1s
2026-07-13 12:44:32 +02:00
L HEREEC Kevin INNOV/PTFM 2f3d89b3d6 ci: simplify deploy step, remove redundant ssh-keyscan and docker login on NAS
Build & Deploy / build (push) Successful in 42s
Build & Deploy / docker (push) Successful in 16s
Build & Deploy / deploy (push) Has been cancelled
2026-07-13 12:43:01 +02:00
L HEREEC Kevin INNOV/PTFM 790f0b9410 ci: use SSH key mounted directly in runner container instead of secret
Build & Deploy / build (push) Successful in 44s
Build & Deploy / docker (push) Successful in 1s
Build & Deploy / deploy (push) Has been cancelled
2026-07-13 12:37:14 +02:00
L HEREEC Kevin INNOV/PTFM 12e1267a51 sec: ignore SSH key files
Build & Deploy / build (push) Successful in 45s
Build & Deploy / docker (push) Successful in 17s
Build & Deploy / deploy (push) Failing after 1s
2026-07-13 12:36:22 +02:00
L HEREEC Kevin INNOV/PTFM 55a35c9e26 ci: replace sshpass with SSH key authentication for NAS deploy
Build & Deploy / build (push) Successful in 38s
Build & Deploy / docker (push) Successful in 15s
Build & Deploy / deploy (push) Has been cancelled
2026-07-13 12:34:20 +02:00
L HEREEC Kevin INNOV/PTFM 1a00ceb4c4 ci: split docker build and deploy into separate jobs
Build & Deploy / build (push) Successful in 47s
Build & Deploy / docker (push) Successful in 13s
Build & Deploy / deploy (push) Failing after 3s
2026-07-13 12:25:23 +02:00
L HEREEC Kevin INNOV/PTFM d3bd4df934 fix(ci): rename NASPASS secret to SSHPASS
Build & Deploy / build (push) Successful in 38s
Build & Deploy / docker (push) Failing after 16s
2026-07-13 12:19:04 +02:00
L HEREEC Kevin INNOV/PTFM 943ec2683a fix(ci): use USERNAME and TOKEN secrets for Gitea registry login
Build & Deploy / build (push) Successful in 35s
Build & Deploy / docker (push) Failing after 1m30s
2026-07-13 12:18:10 +02:00
L HEREEC Kevin INNOV/PTFM 0a32eeabfe fix: update package-lock.json to sync with package.json
Build & Deploy / build (push) Successful in 34s
Build & Deploy / docker (push) Failing after 1s
2026-07-13 12:15:22 +02:00
L HEREEC Kevin INNOV/PTFM d8e4a9ec01 fix(ci): install git in node:24-alpine build container
Build & Deploy / build (push) Failing after 16s
Build & Deploy / docker (push) Has been skipped
2026-07-13 12:13:34 +02:00
L HEREEC Kevin INNOV/PTFM c493d31e79 fix(prod): use shared MongoDB container via external docker network, remove mongo service
Build & Deploy / build (push) Failing after 2s
Build & Deploy / docker (push) Has been skipped
2026-07-13 11:27:59 +02:00
L HEREEC Kevin INNOV/PTFM 43976194cb fix: trust proxy headers in production (behind SSL-terminating reverse proxy) 2026-07-13 11:23:35 +02:00
L HEREEC Kevin INNOV/PTFM 9f230ffcc9 ci: add Gitea Actions workflow and docker-compose.prod.yml for NAS deployment
Build & Deploy / build (push) Has been cancelled
Build & Deploy / docker (push) Has been cancelled
2026-07-13 11:17:44 +02:00
Kevin L'Hereec 228d382cbf fix: login page standalone sans layout (null layout respecté par Inertia) 2026-07-03 18:42:39 +02:00
Kevin L'Hereec 3693d0c0a5 refactor(base-model): remove unused instance methods save/delete/refresh and instance fields 2026-07-03 15:43:54 +02:00
Kevin L'Hereec 991173aa05 feat(auth): add session login page, API key guard, and remove orphan SQL migrations
- Add session-based authentication for web-UI (Login.vue, AuthController)
- Add AuthSessionMiddleware: redirects unauthenticated users to /login
- Add ApiKeyMiddleware: validates X-Api-Key header on all /api/v1 routes
- Add BasicAuthMiddleware: registered for future M2M use
- Use timingSafeEqual for all credential comparisons (timing attack prevention)
- Regenerate session ID on login (session fixation prevention)
- Validate redirect target after login (open redirect prevention)
- Require WEBUI_USERNAME, WEBUI_PASSWORD, API_KEY as mandatory env vars
- Share auth.username/authenticated via Inertia shared props
- Add user dropdown in navbar (avatar icon + username + logout)
- Add login i18n keys in fr.json and en.json
- Remove orphan SQL migrations (users, auth_access_tokens) and @adonisjs/lucid
2026-07-03 13:59:05 +02:00
131 changed files with 9129 additions and 2489 deletions

No files matched your search

+18
View File
@@ -288,6 +288,24 @@ const routes = {
tokens: [{"old":"/api/v1/firmware-catalog/routes/:id","type":0,"val":"api","end":""},{"old":"/api/v1/firmware-catalog/routes/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/firmware-catalog/routes/:id","type":0,"val":"firmware-catalog","end":""},{"old":"/api/v1/firmware-catalog/routes/:id","type":0,"val":"routes","end":""},{"old":"/api/v1/firmware-catalog/routes/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['firmware_catalog.delete_route']['types'],
},
'login': {
methods: ["GET","HEAD"],
pattern: '/login',
tokens: [{"old":"/login","type":0,"val":"login","end":""}],
types: placeholder as Registry['login']['types'],
},
'auth.login': {
methods: ["POST"],
pattern: '/login',
tokens: [{"old":"/login","type":0,"val":"login","end":""}],
types: placeholder as Registry['auth.login']['types'],
},
'logout': {
methods: ["POST"],
pattern: '/logout',
tokens: [{"old":"/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['logout']['types'],
},
'pages.home': {
methods: ["GET","HEAD"],
pattern: '/',
+36
View File
@@ -571,6 +571,42 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['deleteRoute']>>>
}
}
'login': {
methods: ["GET","HEAD"]
pattern: '/login'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['showLogin']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['showLogin']>>>
}
}
'auth.login': {
methods: ["POST"]
pattern: '/login'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['login']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['login']>>>
}
}
'logout': {
methods: ["POST"]
pattern: '/logout'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['logout']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['logout']>>>
}
}
'pages.home': {
methods: ["GET","HEAD"]
pattern: '/'
+5
View File
@@ -66,6 +66,11 @@ export interface ApiDefinition {
updateRoute: typeof routes['firmware_catalog.update_route']
deleteRoute: typeof routes['firmware_catalog.delete_route']
}
login: typeof routes['login']
auth: {
login: typeof routes['auth.login']
}
logout: typeof routes['logout']
pages: {
home: typeof routes['pages.home']
deviceDetail: typeof routes['pages.device_detail']
+1
View File
@@ -4,6 +4,7 @@
*/
export const controllers = {
Auth: () => import('#controllers/auth_controller'),
Commands: () => import('#controllers/commands_controller'),
ConfigurationsV1: () => import('#controllers/configurations_v1_controller'),
Cwmp: () => import('#controllers/cwmp_controller'),
+7
View File
@@ -51,6 +51,9 @@ export type ScannedRoutes = {
'firmware_catalog.create_route': { paramsTuple?: []; params?: {} }
'firmware_catalog.update_route': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.delete_route': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'login': { paramsTuple?: []; params?: {} }
'auth.login': { paramsTuple?: []; params?: {} }
'logout': { paramsTuple?: []; params?: {} }
'pages.home': { paramsTuple?: []; params?: {} }
'pages.device_detail': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'pages.refresh_device': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
@@ -80,6 +83,8 @@ export type ScannedRoutes = {
'configurations_v_1.dry_run': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.create_entry': { paramsTuple?: []; params?: {} }
'firmware_catalog.create_route': { paramsTuple?: []; params?: {} }
'auth.login': { paramsTuple?: []; params?: {} }
'logout': { paramsTuple?: []; params?: {} }
'pages.refresh_device': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'pages.get_model': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'pages.store_fleet_devices': { paramsTuple?: []; params?: {} }
@@ -110,6 +115,7 @@ export type ScannedRoutes = {
'firmware_catalog.list_entries': { paramsTuple?: []; params?: {} }
'firmware_catalog.show_entry': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.list_routes': { paramsTuple?: []; params?: {} }
'login': { paramsTuple?: []; params?: {} }
'pages.home': { paramsTuple?: []; params?: {} }
'pages.device_detail': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'commands.page': { paramsTuple?: []; params?: {} }
@@ -148,6 +154,7 @@ export type ScannedRoutes = {
'firmware_catalog.list_entries': { paramsTuple?: []; params?: {} }
'firmware_catalog.show_entry': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.list_routes': { paramsTuple?: []; params?: {} }
'login': { paramsTuple?: []; params?: {} }
'pages.home': { paramsTuple?: []; params?: {} }
'pages.device_detail': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'commands.page': { paramsTuple?: []; params?: {} }
+7
View File
@@ -29,3 +29,10 @@ LOG_PROTOCOL=udp4
# Security
HASH_KEY=0123456789abcdef0123456789abcdef
# Web UI Basic Auth (browser prompt)
WEBUI_USERNAME=Admin
WEBUI_PASSWORD=Grenoble:38
# REST API Key (set in X-Api-Key header)
API_KEY=change-me-with-a-strong-random-key
+80
View File
@@ -0,0 +1,80 @@
name: Build & Deploy
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: linux
container: node:24-alpine
steps:
- name: Install git
run: apk add --no-cache git
- name: Checkout repository
run: git clone --depth=1 http://x-access-token:${{ github.token }}@192.168.1.39:3000/${{ github.repository }}.git .
- name: Install dependencies
run: npm ci
- name: Run build
run: node ace build
docker:
needs: build
runs-on: dind
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
steps:
- name: Checkout repository
run: git clone --depth=1 http://x-access-token:${{ github.token }}@192.168.1.39:3000/${{ github.repository }}.git .
- name: Login to Gitea Container Registry
run: echo "${{ secrets.TOKEN }}" | docker login http://192.168.1.39:3000 -u "${{ secrets.USERNAME }}" --password-stdin
- name: Build and push Docker image
run: |
IMAGE=192.168.1.39:3000/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
docker build -t ${IMAGE}:latest -t ${IMAGE}:${{ github.sha }} .
docker push ${IMAGE}:latest
docker push ${IMAGE}:${{ github.sha }}
deploy:
needs: docker
runs-on: dind
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
steps:
- name: Checkout repository
run: git clone --depth=1 http://x-access-token:${{ github.token }}@192.168.1.39:3000/${{ github.repository }}.git .
- name: Deploy on NAS
run: |
apk add --no-cache openssh-client
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/id_ed25519
chmod 600 /tmp/id_ed25519
SSH="ssh -i /tmp/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes Stude12@192.168.1.39"
# Copier docker-compose.prod.yml
cat docker-compose.prod.yml | $SSH "mkdir -p /volume1/docker/adam-acs && cat > /volume1/docker/adam-acs/docker-compose.prod.yml"
# Créer le .env sur le NAS (valeurs fixes + secrets sensibles uniquement)
$SSH "cat > /volume1/docker/adam-acs/.env" <<EOF
APP_KEY=${{ secrets.APP_KEY }}
APP_URL=https://adam-acs.stude12-ugreen.ddns.net
APP_PORT=8080
LOG_LEVEL=info
WEBUI_USERNAME=Admin
WEBUI_PASSWORD=${{ secrets.WEBUI_PASSWORD }}
API_KEY=${{ secrets.API_KEY }}
EOF
# Déployer
$SSH "
cd /volume1/docker/adam-acs &&
echo '${{ secrets.SSHPASS }}' | sudo -S /usr/bin/docker compose -f docker-compose.prod.yml pull &&
echo '${{ secrets.SSHPASS }}' | sudo -S /usr/bin/docker compose -f docker-compose.prod.yml up -d --remove-orphans
"
+4
View File
@@ -26,3 +26,7 @@ yarn-error.log
# Platform specific
.DS_Store
coverage/
# SSH keys
gitea_runner_key
gitea_runner_key.pub
+76
View File
@@ -0,0 +1,76 @@
import { timingSafeEqual } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
/**
* AuthController
*
* Handles web-ui login/logout via session.
* Credentials are validated against WEBUI_USERNAME / WEBUI_PASSWORD env variables.
*
* Security:
* - Constant-time comparison (timingSafeEqual) to prevent timing attacks
* - Session regeneration after login to prevent session fixation
* - Redirect target validated to prevent open redirect
*/
export default class AuthController {
async showLogin({ inertia, session, response }: HttpContext) {
// Already authenticated — redirect to home
if (session.get('authenticated')) {
return response.redirect().toPath('/')
}
const error = session.flashMessages.get('error')
return (inertia as any).render('Login', { error })
}
async login({ request, session, response }: HttpContext) {
const { username, password } = request.only(['username', 'password'])
const expectedUsername = env.get('WEBUI_USERNAME')!
const expectedPassword = env.get('WEBUI_PASSWORD')!
const usernameMatch = safeCompare(username ?? '', expectedUsername)
const passwordMatch = safeCompare(password ?? '', expectedPassword)
if (usernameMatch && passwordMatch) {
// Regenerate session ID to prevent session fixation attacks
session.regenerate()
session.put('authenticated', true)
session.put('username', username)
// Validate redirect target to prevent open redirect
const raw = session.get('redirectAfterLogin', '/')
const redirectTo = isSafeRedirect(raw) ? raw : '/'
session.forget('redirectAfterLogin')
return response.redirect().toPath(redirectTo)
}
session.flash('error', 'invalid_credentials')
return response.redirect().toPath('/login')
}
async logout({ session, response }: HttpContext) {
session.clear()
return response.redirect().toPath('/login')
}
}
/**
* Constant-time string comparison to prevent timing side-channel attacks.
* Returns false immediately if lengths differ (length is not secret here).
*/
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
/**
* Validate that a redirect path is a safe internal path.
* Rejects empty strings, external URLs (//) and protocol-relative URLs.
*/
function isSafeRedirect(value: unknown): value is string {
if (typeof value !== 'string') return false
if (!value.startsWith('/')) return false
if (value.startsWith('//')) return false
return true
}
+8
View File
@@ -28,4 +28,12 @@ export default class CommandsController {
return response.status(201).json(command)
}
async destroy({ params, response }: HttpContext) {
const deleted = await this.commandService.deleteCommand(params.id)
if (!deleted) {
return response.status(404).json({ error: 'Command not found' })
}
return response.status(204).send('')
}
}
+45 -15
View File
@@ -6,7 +6,10 @@ import ConfigurationService, {
} from '#services/configuration_service'
import ConfigurationEngine from '#services/configuration_engine'
import ConfigurationModel from '#models/configuration'
import { createConfigurationValidator, updateConfigurationValidator } from '#validators/configuration'
import {
createConfigurationValidator,
updateConfigurationValidator,
} from '#validators/configuration'
import logger from '@adonisjs/core/services/logger'
/**
@@ -43,7 +46,8 @@ export default class ConfigurationsController {
perPage: Number(request.input('perPage', 20)),
tag: request.input('tag'),
name: request.input('name') || undefined,
enabled: request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
enabled:
request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
productClass: request.input('productClass'),
}
@@ -93,11 +97,21 @@ export default class ConfigurationsController {
*/
async store({ request, response }: HttpContext) {
try {
const data = await request.validateUsing(createConfigurationValidator) as CreateConfigurationDto
const data = (await request.validateUsing(
createConfigurationValidator
)) as CreateConfigurationDto
// Check for overlaps
const overlapResult = await this.configService.detectOverlap(data.criteria || {}, data.priority)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
const overlapResult = await this.configService.detectOverlap(
data.criteria || {},
data.priority
)
if (
overlapResult &&
overlapResult.overlappingConfigurations &&
overlapResult.overlappingConfigurations.length > 0 &&
!request.input('ignoreOverlap')
) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
@@ -122,7 +136,9 @@ export default class ConfigurationsController {
*/
async update({ params, request, response }: HttpContext) {
try {
const data = await request.validateUsing(updateConfigurationValidator) as UpdateConfigurationDto
const data = (await request.validateUsing(
updateConfigurationValidator
)) as UpdateConfigurationDto
// Check for overlaps if priority changed
if (data.priority) {
@@ -131,13 +147,18 @@ export default class ConfigurationsController {
data.priority,
params.id
)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
if (
overlapResult &&
overlapResult.overlappingConfigurations &&
overlapResult.overlappingConfigurations.length > 0 &&
!request.input('ignoreOverlap')
) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
}
}
}
// Update configuration
const config = await this.configService.update(params.id, data)
@@ -147,7 +168,10 @@ export default class ConfigurationsController {
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Update configuration failed')
logger.error(
{ error: error.message, id: params.id },
'[ConfigAPI] Update configuration failed'
)
if (error.field) {
return response.status(422).json({ message: error.message, field: error.field })
}
@@ -164,7 +188,10 @@ export default class ConfigurationsController {
await this.configService.delete(params.id)
return response.status(204).send(null)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Delete configuration failed')
logger.error(
{ error: error.message, id: params.id },
'[ConfigAPI] Delete configuration failed'
)
return response.status(500).json({ message: 'Failed to delete configuration' })
}
}
@@ -182,7 +209,10 @@ export default class ConfigurationsController {
}
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Toggle configuration failed')
logger.error(
{ error: error.message, id: params.id },
'[ConfigAPI] Toggle configuration failed'
)
return response.status(500).json({ message: 'Failed to toggle configuration' })
}
}
+120 -73
View File
@@ -1,15 +1,17 @@
import type { HttpContext } from '@adonisjs/core/http'
import { buildSoapEnvelope } from '../protocol/soap.js'
import { CWMPMessageBuilder } from '../protocol/common/cwmp_message_builder.js'
import { StateMachineManager } from '../services/cwmp/state_machine_manager.js'
import { Device, detectDataModelFromParams } from '../domain/device/device.js'
import { DeviceId } from '../domain/device/device_id.js'
import { DeviceParameter, DeviceProperty } from '../domain/device/device_parameter.js'
import DeviceModel from '../models/device.js'
import { getDeviceHashIdentifier } from '../utils/device_utils.js'
import { getReferenceEngine } from '../services/references/reference_engine.js'
import { normalizeEventCode } from '../utils/cwmp-events.js'
import { CWMPMessageBuilder } from '#protocol/common/cwmp_message_builder'
import { StateMachineManager } from '#services/cwmp/state_machine_manager'
import { Device, detectDataModelFromParams } from '#domain/device/device'
import { DeviceId } from '#domain/device/device_id'
import { DeviceParameter, DeviceProperty } from '#domain/device/device_parameter'
import DeviceModel from '#models/device'
import { getDeviceHashIdentifier } from '#utils/device_utils'
import { getReferenceEngine } from '#services/references/reference_engine'
import { normalizeEventCode } from '#utils/cwmp-events'
import logger from '@adonisjs/core/services/logger'
import type { Inform, CwmpMessage, ParameterValueStruct } from '#protocol/tr069_types'
import { ParameterType } from '#domain/shared/types'
import { FaultCodeType } from '#protocol/tr069_types'
export default class CwmpController {
private static managerInitialized = false
@@ -40,10 +42,14 @@ export default class CwmpController {
try {
if (isEmpty) {
const rebound = manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId = manager.getHashIdForRemote(remoteKey)
const hashId =
manager.getHashIdForRemote(remoteKey) ?? manager.getHashIdForRemoteIp(request.ip())
if (hashId) ctx.cwmpDeviceId = hashId
logger.debug({ remoteKey, sessionId, rebound, hashId, hasSession: manager.hasSession(sessionId) }, 'CWMP Empty POST received')
logger.debug(
{ remoteKey, sessionId, rebound, hashId, hasSession: manager.hasSession(sessionId) },
'CWMP empty POST — CPE polling for next task'
)
if (manager.hasSession(sessionId)) {
const result = await manager.continue(sessionId)
@@ -54,9 +60,20 @@ export default class CwmpController {
return response.status(204).send('')
}
logger.debug(
{
sessionId,
remoteKey,
messageType: message?.type ?? 'none',
cwmpId: envelope?.header?.id,
},
'CWMP message routing'
)
if (message?.type === 'Inform') {
const informMsg = message as Extract<CwmpMessage, { type: 'Inform' }>
const { xml, hashId } = await this.handleInform(
message.data,
informMsg.data,
sessionId,
envelope?.header?.id,
remoteKey,
@@ -64,35 +81,47 @@ export default class CwmpController {
request.header('x-forwarded-for') || ''
)
ctx.cwmpDeviceId = hashId
logger.debug(
{ sessionId, hashId, responseLength: xml.length },
'CWMP sending InformResponse'
)
return this.sendSoap(response, xml)
}
if (message?.type === 'TransferComplete') {
const hashId = manager.getHashIdForRemote(remoteKey)
const hashId =
manager.getHashIdForRemote(remoteKey) ?? manager.getHashIdForRemoteIp(request.ip())
if (hashId) ctx.cwmpDeviceId = hashId
const resp = buildSoapEnvelope(
undefined,
'<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>'
)
return this.sendSoap(response, resp)
logger.debug({ sessionId, hashId }, 'CWMP sending TransferCompleteResponse')
return this.sendSoap(response, CWMPMessageBuilder.transferCompleteResponse())
}
manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId = manager.getHashIdForRemote(remoteKey)
const hashId =
manager.getHashIdForRemote(remoteKey) ?? manager.getHashIdForRemoteIp(request.ip())
if (hashId) ctx.cwmpDeviceId = hashId
if (message && manager.hasSession(sessionId)) {
const { success, payload } = this.getMessageInfo(message)
logger.debug(
{ sessionId, hashId, messageType: message.type, success },
'CWMP forwarding response to state machine'
)
const result = await manager.handleCWMPResponse(sessionId, success, payload)
if (result && result !== '') {
logger.debug(
{ sessionId, hashId, responseLength: result.length },
'CWMP state machine produced response'
)
return this.sendSoap(response, result)
}
}
return response.status(204).send('')
} catch (error: any) {
logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request')
const faultXml = this.buildSoapFault(error?.message || 'Internal error')
} catch (error: unknown) {
const err = error as Error
logger.error({ error: err?.message, stack: err?.stack }, 'CWMP error handling request')
const faultXml = this.buildSoapFault(err?.message || 'Internal error')
return this.sendSoap(response, faultXml)
}
}
@@ -100,7 +129,7 @@ export default class CwmpController {
// ─── Inform Handling ───────────────────────────────────────────────────────
private async handleInform(
informData: any,
informData: Inform,
session: string,
cwmpId: string | undefined,
remoteKey: string,
@@ -111,36 +140,42 @@ export default class CwmpController {
{ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId },
'CWMP handleInform called'
)
const deviceIdStruct = informData?.DeviceId || {}
const deviceIdStruct = informData?.DeviceId
const deviceId = DeviceId.fromInform(deviceIdStruct)
// Load or create device
let device = await this.loadDevice(deviceId)
if (!device) {
device = new Device(deviceId)
logger.info({ deviceId: deviceId.toIdentifier() }, '[handleInform] Device not found in DB — creating new')
logger.info(
{ deviceId: deviceId.toIdentifier() },
'[handleInform] Device not found in DB — creating new'
)
} else {
logger.info(
{ deviceId: deviceId.toIdentifier(), gpnStatus: device.gpnStatus, circuitBreaker: device.gpnCircuitBreaker },
{
deviceId: deviceId.toIdentifier(),
gpnStatus: device.gpnStatus,
circuitBreaker: device.gpnCircuitBreaker,
},
'[handleInform] Device loaded from DB'
)
}
device.updateLastSeen()
// Process inform parameter values
const paramValues = informData?.ParameterList?.ParameterValueStruct || []
const paramValues: ParameterValueStruct[] =
informData?.ParameterList?.ParameterValueStruct || []
if (paramValues.length > 0) {
device.updateFromValues(paramValues)
// Some writable parameters arrive in the Inform before GPN has classified them.
// updateFromValues falls back to storing unknowns as read-only properties.
// Reclassify known-writable Inform parameters as parameters (writable).
device.reclassifyInformWritables(paramValues)
}
device.setTmpParamValues(paramValues)
// Detect TR data model from Inform parameter names (Device.* = TR-181, InternetGatewayDevice.* = TR-098)
// Only update if currently unknown — once detected, keep it stable across sessions
// Detect TR data model
if (device.dataModel === 'unknown') {
const detectedModel = detectDataModelFromParams(paramValues)
if (detectedModel !== 'unknown') {
@@ -172,23 +207,26 @@ export default class CwmpController {
const rawEventStruct = informData?.Event?.EventStruct
const eventStructArray = Array.isArray(rawEventStruct)
? rawEventStruct
: rawEventStruct ? [rawEventStruct] : []
: rawEventStruct
? [rawEventStruct]
: []
const events: string[] = eventStructArray
.map((e: any) => e?.EventCode)
.filter((c: any) => c != null)
.map((c: any) => String(c))
.map((e) => e?.EventCode)
.filter((c) => c !== null && c !== undefined)
.map((c) => String(c))
logger.info({ events }, 'CWMP Inform events extracted')
// Track last periodic inform timestamp
// Track last periodic inform timestamp + last remote IP
const normalizedEvents = events.map((e) => normalizeEventCode(e))
const isPeriodic = normalizedEvents.some((e) => e === 'PERIODIC')
const isBootstrap = normalizedEvents.some((e) => e === 'BOOTSTRAP')
if (isPeriodic || isBootstrap) {
await DeviceModel.updateOne({ identifier: hashId }, { $set: { lastPeriodicInformAt: new Date() } }).catch(() => {})
}
const cpeIpForStorage = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0]
const dbUpdate: Record<string, unknown> = { lastRemoteIp: cpeIpForStorage }
if (isPeriodic || isBootstrap) dbUpdate.lastPeriodicInformAt = new Date()
await DeviceModel.updateOne({ identifier: hashId }, { $set: dbUpdate }).catch(() => {})
// Phase 12: Crash pattern detection
// Crash pattern detection
const hasBoot = normalizedEvents.some((e) => e === 'BOOT')
if (hasBoot && device.lastGpnSentDate) {
const timeSinceGpn = Date.now() - device.lastGpnSentDate.getTime()
@@ -202,24 +240,37 @@ export default class CwmpController {
await manager.startStateMachine(device, session, { events })
manager.bindRemoteToDevice(remoteKey, device.deviceId)
// Resolve references from configured providers (async, non-blocking)
// Resolve references (async, non-blocking)
try {
logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution')
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()) },
{
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')
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')
} catch (_e: unknown) {
const err = _e as Error
logger.error(
{ error: err?.message, stack: err?.stack },
'[References] Error during resolution'
)
}
return { xml: CWMPMessageBuilder.informResponse(cwmpId), hashId }
@@ -227,32 +278,21 @@ export default class CwmpController {
// ─── Helpers ───────────────────────────────────────────────────────────────
private sendSoap(response: any, xml: string) {
private sendSoap(response: HttpContext['response'], xml: string) {
response.header('Content-Type', 'text/xml; charset=utf-8')
response.header('SOAPAction', '""')
return response.send(xml)
}
private getMessageInfo(message: { type: string; data: any }): { success: boolean; payload: any } {
const failTypes = ['Fault']
private getMessageInfo(message: CwmpMessage): { success: boolean; payload: CwmpMessage['data'] } {
return {
success: !failTypes.includes(message.type),
success: message.type !== 'Fault',
payload: message.data,
}
}
private buildSoapFault(reason: string): string {
const body = `<soap-env:Fault>
<faultcode>Server</faultcode>
<faultstring>${reason}</faultstring>
<detail>
<cwmp:Fault>
<FaultCode>8002</FaultCode>
<FaultString>${reason}</FaultString>
</cwmp:Fault>
</detail>
</soap-env:Fault>`
return buildSoapEnvelope(undefined, body)
return CWMPMessageBuilder.soapFault(FaultCodeType.ACSInternalError, reason)
}
private async loadDevice(deviceId: DeviceId): Promise<Device | null> {
@@ -270,22 +310,29 @@ export default class CwmpController {
record.createdAt
)
record.properties?.forEach((p: any) =>
device.setProperty(new DeviceProperty(p.name, p.value, p.type))
record.properties?.forEach((p: { name: string; value: unknown; type: string }) =>
device.setProperty(
new DeviceProperty(p.name, p.value, (p.type as ParameterType) ?? ParameterType.UNKNOWN)
)
)
record.parameters?.forEach((p: any) =>
device.setParameter(new DeviceParameter(p.name, p.value, p.type))
record.parameters?.forEach((p: { name: string; value: unknown; type: string }) =>
device.setParameter(
new DeviceParameter(p.name, p.value, (p.type as ParameterType) ?? ParameterType.UNKNOWN)
)
)
device.setGpnStatus(record.gpnStatus ?? 'unknown')
device.setDataModel(record.dataModel ?? 'unknown')
// Restore circuit breaker state
if (record.gpnCircuitBreaker) {
device.setGpnCircuitBreaker({
failureCount: record.gpnCircuitBreaker.failureCount ?? 0,
lastFailureDate: record.gpnCircuitBreaker.lastFailureDate ? new Date(record.gpnCircuitBreaker.lastFailureDate) : null,
lastFailureDate: record.gpnCircuitBreaker.lastFailureDate
? new Date(record.gpnCircuitBreaker.lastFailureDate)
: null,
state: record.gpnCircuitBreaker.state ?? 'closed',
cooldownUntil: record.gpnCircuitBreaker.cooldownUntil ? new Date(record.gpnCircuitBreaker.cooldownUntil) : null,
cooldownUntil: record.gpnCircuitBreaker.cooldownUntil
? new Date(record.gpnCircuitBreaker.cooldownUntil)
: null,
})
}
if (record.lastGpnSentDate) {
@@ -293,7 +340,7 @@ export default class CwmpController {
}
return device
} catch (_error) {
} catch {
return null
}
}
+8 -4
View File
@@ -1,7 +1,7 @@
import type { HttpContext } from '@adonisjs/core/http'
import DeviceService from '#services/device_service'
import { PaginationData } from '#services/device_service'
import { CommandType } from '../domain/commands/command.js'
import { type PaginationData } from '#services/device_service'
import { type CommandType } from '#domain/commands/command'
import DeviceModel from '#models/device'
export default class DevicesController {
@@ -77,10 +77,14 @@ export default class DevicesController {
const parts: string[] = []
if (pager.first) {
parts.push(`<${baseUrl}?offset=${pager.first.offset}&limit=${pager.first.limit}>; rel="first"`)
parts.push(
`<${baseUrl}?offset=${pager.first.offset}&limit=${pager.first.limit}>; rel="first"`
)
}
if (pager.previous) {
parts.push(`<${baseUrl}?offset=${pager.previous.offset}&limit=${pager.previous.limit}>; rel="prev"`)
parts.push(
`<${baseUrl}?offset=${pager.previous.offset}&limit=${pager.previous.limit}>; rel="prev"`
)
}
if (pager.next) {
parts.push(`<${baseUrl}?offset=${pager.next.offset}&limit=${pager.next.limit}>; rel="next"`)
+6 -6
View File
@@ -247,7 +247,10 @@ export default class ExchangeController {
// Track SSE stream setup time
const setupDurationMs = performance.now() - startTime
ExchangeMetricsService.getInstance().recordApiResponseTime('GET /exchanges/stream', setupDurationMs)
ExchangeMetricsService.getInstance().recordApiResponseTime(
'GET /exchanges/stream',
setupDurationMs
)
const listener = (exchange: any) => {
if (closed) return
@@ -333,10 +336,7 @@ export default class ExchangeController {
*
* Requirement 6.9: Event id equals the exchange's sequenceNumber
*/
private sendSSEEvent(
res: import('node:http').ServerResponse,
exchange: any
): void {
private sendSSEEvent(res: import('node:http').ServerResponse, exchange: any): void {
const data = JSON.stringify(exchange)
res.write(`id: ${exchange.sequenceNumber}\ndata: ${data}\n\n`)
}
@@ -347,7 +347,7 @@ export default class ExchangeController {
*/
private isValidIso8601(value: string): boolean {
const date = new Date(value)
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
return false
}
+11 -3
View File
@@ -3,7 +3,11 @@ import FirmwareCatalogService, {
type CreateFirmwareEntryDto,
type CreateMigrationRouteDto,
} from '#services/firmware_catalog_service'
import { createFirmwareEntryValidator, updateFirmwareEntryValidator, createMigrationRouteValidator } from '#validators/firmware_catalog'
import {
createFirmwareEntryValidator,
updateFirmwareEntryValidator,
createMigrationRouteValidator,
} from '#validators/firmware_catalog'
import logger from '@adonisjs/core/services/logger'
/**
@@ -105,7 +109,9 @@ export default class FirmwareCatalogController {
*/
async createEntry({ request, response }: HttpContext) {
try {
const data = await request.validateUsing(createFirmwareEntryValidator) as CreateFirmwareEntryDto
const data = (await request.validateUsing(
createFirmwareEntryValidator
)) as CreateFirmwareEntryDto
const entry = await this.catalogService.createEntry(data)
return response.status(201).json(entry)
@@ -135,7 +141,9 @@ export default class FirmwareCatalogController {
*/
async createRoute({ request, response }: HttpContext) {
try {
const data = await request.validateUsing(createMigrationRouteValidator) as CreateMigrationRouteDto
const data = (await request.validateUsing(
createMigrationRouteValidator
)) as CreateMigrationRouteDto
const route = await this.catalogService.createMigrationRoute(data)
return response.status(201).json(route)
+23 -1
View File
@@ -31,7 +31,29 @@ export default class PagesController {
)[0]
const commands = lastProcessed ? [...pending, lastProcessed] : pending
return (inertia as any).render('DeviceDetail', { device, commands })
// Predefined model paths for the "Get Model" modal
// Detect data model from device properties to suggest the right root
const isTr181 =
(device as any).dataModel === 'tr-181' ||
(device as any).properties?.some((p: any) => p.name?.startsWith('Device.'))
const modelPaths = isTr181
? [
'Device.',
'Device.DeviceInfo.',
'Device.IP.',
'Device.WiFi.',
'Device.ManagementServer.',
'Device.Hosts.',
]
: [
'InternetGatewayDevice.',
'InternetGatewayDevice.DeviceInfo.',
'InternetGatewayDevice.LANDevice.',
'InternetGatewayDevice.WANDevice.',
'InternetGatewayDevice.ManagementServer.',
]
return (inertia as any).render('DeviceDetail', { device, commands, modelPaths })
}
async commands({ inertia, request }: HttpContext) {
+11 -4
View File
@@ -1,6 +1,6 @@
import type { HttpContext } from '@adonisjs/core/http'
import DeviceModel from '#models/device'
import { getReferenceEngine } from '../services/references/reference_engine.js'
import { getReferenceEngine } from '#services/references/reference_engine'
export default class ReferencesController {
/**
@@ -24,7 +24,9 @@ export default class ReferencesController {
}
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
.status(404)
.json({ message: `No reference from provider '${params.provider}'` })
}
return response.json(ref)
}
@@ -51,7 +53,10 @@ export default class ReferencesController {
(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() } })
await DeviceModel.updateOne(
{ identifier: params.identifier },
{ $set: { references: merged, updatedAt: new Date() } }
)
}
return response.json({ resolved: results.length, references: results })
@@ -72,7 +77,9 @@ export default class ReferencesController {
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` })
return response
.status(404)
.json({ message: `Provider '${params.provider}' not found or cannot resolve` })
}
// Merge into device references
+6 -4
View File
@@ -1,6 +1,6 @@
import { DeviceId } from '../device/device_id.js'
import { ModeType } from '../shared/mode.js'
import { ObjectId } from 'mongodb'
import { type DeviceId } from '#domain/device/device_id'
import { type ModeType } from '#domain/shared/mode'
import { type ObjectId } from 'mongodb'
export type CommandId = string
@@ -136,7 +136,9 @@ export class Command {
}
public isCompleted(): boolean {
return [CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED].includes(this._status)
return [CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED].includes(
this._status
)
}
public hasExpired(timeoutMinutes: number = 30): boolean {
@@ -1,4 +1,4 @@
import { Configuration } from './configuration.js'
import { type Configuration } from '#domain/configuration/configuration'
export interface ConfigurationVersion {
configurationIdentifier: string
+58 -35
View File
@@ -1,7 +1,13 @@
import { DeviceId } from './device_id.js'
import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot } from './device_parameter.js'
import { ParameterType } from '../shared/types.js'
import { type DeviceId } from '#domain/device/device_id'
import {
DeviceParameter,
DeviceProperty,
type ParameterSnapshot,
type PropertySnapshot,
} from '#domain/device/device_parameter'
import { ParameterType } from '#domain/shared/types'
import { createHash } from 'node:crypto'
import type { ParameterValueStruct, ParameterInfoStruct } from '#protocol/tr069_types'
export type GpnStatus = 'supported' | 'unsupported' | 'unknown'
@@ -16,14 +22,15 @@ export interface GpnCircuitBreaker {
export type DataModel = 'tr-181' | 'tr-098' | 'unknown'
/** Extract a string name from a SOAP parsed node (may be a string or an object with _ text node) */
function extractName(raw: any): string | null {
function extractName(raw: string | { _: string } | unknown): string | null {
if (typeof raw === 'string') return raw || null
if (raw && typeof raw === 'object' && typeof raw._ === 'string') return raw._ || null
if (raw && typeof raw === 'object' && typeof (raw as { _?: unknown })._ === 'string')
return (raw as { _: string })._ || null
return null
}
/** Detect which TR data model a device uses from a ParameterValueStruct list */
export function detectDataModelFromParams(paramValues: any[]): DataModel {
export function detectDataModelFromParams(paramValues: ParameterValueStruct[]): DataModel {
for (const pv of paramValues ?? []) {
const name = extractName(pv?.Name)
if (!name) continue
@@ -42,8 +49,8 @@ export enum OnboardingStatus {
}
export class Device {
public tmpParamValues: any[] = []
public tmpPC: any = null
public tmpParamValues: ParameterValueStruct[] = []
public tmpPC: string | null = null
private _parameters: Map<string, DeviceParameter> = new Map()
private _properties: Map<string, DeviceProperty> = new Map()
private _session: any = null
@@ -128,17 +135,20 @@ export class Device {
/**
* Returns true if GPN is allowed based on circuit breaker state.
* Closed = always allowed. Half-open = allowed if cooldown expired. Open = not allowed.
* Closed = always allowed. Half-open = allowed (cooldown expired probe).
* Open → transitions to half-open when cooldown expires (clears cooldownUntil).
*
* NOTE: the open→half-open transition mutates state but does NOT persist to DB.
* The caller (IdleState) is responsible for persisting if needed.
*/
public isGpnAllowed(): boolean {
const { state, cooldownUntil } = this._gpnCircuitBreaker
if (state === 'closed') return true
if (state === 'half-open') {
return cooldownUntil === null || new Date() >= cooldownUntil
}
const cb = this._gpnCircuitBreaker
if (cb.state === 'closed') return true
if (cb.state === 'half-open') return true
// state === 'open': check if cooldown expired → transition to half-open
if (cooldownUntil && new Date() >= cooldownUntil) {
this._gpnCircuitBreaker.state = 'half-open'
if (cb.cooldownUntil && new Date() >= cb.cooldownUntil) {
cb.state = 'half-open'
cb.cooldownUntil = null // clear stale cooldown so snapshot is clean
return true
}
return false
@@ -265,13 +275,10 @@ export class Device {
* - *.ProvisioningCode
* - *.ParameterKey
*/
public reclassifyInformWritables(paramValues: any[]): void {
const WRITABLE_PATTERNS = [
/(^|\.)ProvisioningCode$/,
/(^|\.)ParameterKey$/,
]
public reclassifyInformWritables(paramValues: ParameterValueStruct[]): void {
const WRITABLE_PATTERNS = [/(^|\.)ProvisioningCode$/, /(^|\.)ParameterKey$/]
for (const pv of paramValues || []) {
const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null
const name = typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || null
if (!name) continue
if (!WRITABLE_PATTERNS.some((re) => re.test(name))) continue
// Only act if it ended up as a property (wrong classification)
@@ -294,19 +301,19 @@ export class Device {
return this.isOnline() && this._onboardingStatus === OnboardingStatus.COMPLETED
}
public setTmpParamValues(values: any[]): void {
public setTmpParamValues(values: ParameterValueStruct[]): void {
this.tmpParamValues = values
}
public getTmpParamValues(): any[] {
public getTmpParamValues(): ParameterValueStruct[] {
return this.tmpParamValues
}
public setTmpPC(value: any): void {
public setTmpPC(value: string | null): void {
this.tmpPC = value
}
public getTmpPC(): any {
public getTmpPC(): string | null {
return this.tmpPC
}
@@ -315,8 +322,7 @@ export class Device {
this.tmpPC = null
}
public organizeParameters(response: any, timestamp: Date): void {
const infoList = response?.ParameterList?.ParameterInfoStruct || []
public organizeParameters(infoList: ParameterInfoStruct[], timestamp: Date): void {
for (const info of infoList) {
const name = extractName(info?.Name)
if (!name) continue
@@ -346,14 +352,15 @@ export class Device {
this._lastSeen = timestamp
}
public updateFromValues(paramValues: any[]): void {
public updateFromValues(paramValues: ParameterValueStruct[]): void {
for (const pv of paramValues || []) {
const name = extractName(pv?.Name)
const valueObj = pv?.Value
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj as any, '_')
const hasTypeAttr = valueObj && (valueObj['$xsi:type'] || valueObj?.$?.Type)
const val = hasTextNode ? valueObj._ : (hasTypeAttr ? '' : undefined)
const typeStr = valueObj?.['$xsi:type'] || valueObj?.$?.Type
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj, '_')
const valueObjRaw = valueObj as unknown as Record<string, unknown> | undefined
const hasTypeAttr = valueObjRaw && (valueObjRaw['$xsi:type'] || valueObj?.$?.Type)
const val = hasTextNode ? valueObj?._ : hasTypeAttr ? '' : undefined
const typeStr = (valueObjRaw?.['$xsi:type'] as string | undefined) || valueObj?.$?.Type
const mappedType = this.mapType(typeStr)
if (!name) continue
@@ -365,7 +372,15 @@ export class Device {
const snap = existing.toSnapshot()
this._parameters.set(
name,
new DeviceParameter(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
new DeviceParameter(
name,
snap.value,
mappedType,
snap.createdAt,
snap.updatedAt,
snap.notification,
snap.accessList
)
)
}
this.updateParameter(name, val)
@@ -375,7 +390,15 @@ export class Device {
const snap = existing.toSnapshot()
this._properties.set(
name,
new DeviceProperty(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
new DeviceProperty(
name,
snap.value,
mappedType,
snap.createdAt,
snap.updatedAt,
snap.notification,
snap.accessList
)
)
}
this.updateProperty(name, val)
+6 -1
View File
@@ -30,7 +30,12 @@ export class DeviceId {
)
}
public static fromInform(informData: any): DeviceId {
public static fromInform(informData: {
Manufacturer?: string
OUI?: string
ProductClass?: string
SerialNumber?: string
}): DeviceId {
return new DeviceId(
String(informData?.Manufacturer ?? ''),
String(informData?.OUI ?? ''),
+1 -1
View File
@@ -1,4 +1,4 @@
import { ParameterType } from '../shared/types.js'
import { ParameterType } from '#domain/shared/types'
export interface ParameterSnapshot {
name: string
+6 -5
View File
@@ -1,8 +1,8 @@
import { DeviceId } from './device_id.js'
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
import { DeviceComplianceInfo } from '../configuration/compliance.js'
import { DataModel, GpnStatus, GpnCircuitBreaker } from './device.js'
import type { DeviceReference } from '../../services/references/types.js'
import { type DeviceId } from '#domain/device/device_id'
import { type PropertySnapshot, type ParameterSnapshot } from '#domain/device/device_parameter'
import { type DeviceComplianceInfo } from '#domain/configuration/compliance'
import { type DataModel, type GpnStatus, type GpnCircuitBreaker } from '#domain/device/device'
import type { DeviceReference } from '#services/references/types'
export interface DeviceRecord {
identifier?: string
@@ -19,6 +19,7 @@ export interface DeviceRecord {
provisioningCode?: string
references?: DeviceReference[]
lastPeriodicInformAt?: Date
lastRemoteIp?: string
updatedAt?: Date
createdAt?: Date
}
+1 -1
View File
@@ -1,5 +1,5 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
-15
View File
@@ -1,21 +1,6 @@
import { Exception } from '@adonisjs/core/exceptions'
export class NotFoundException extends Exception {
static status = 404
static code = 'E_NOT_FOUND'
}
export class BadRequestException extends Exception {
static status = 400
static code = 'E_BAD_REQUEST'
}
export class UnprocessableEntityException extends Exception {
static status = 422
static code = 'E_UNPROCESSABLE'
}
export class DatabaseException extends Exception {
static status = 503
static code = 'E_DATABASE_ERROR'
}
+35
View File
@@ -0,0 +1,35 @@
import { timingSafeEqual } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import env from '#start/env'
/**
* ApiKeyMiddleware
*
* Protects REST API routes by validating a fixed API key provided in the
* X-Api-Key request header. The expected key is configured via:
* API_KEY (required env variable)
*
* Security:
* - Constant-time comparison (timingSafeEqual) to prevent timing attacks
*
* Responds with 401 if the header is missing or the key does not match.
*/
export default class ApiKeyMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
const apiKey = request.header('x-api-key') ?? ''
const expectedKey = env.get('API_KEY')!
const provided = Buffer.from(apiKey)
const expected = Buffer.from(expectedKey)
// Lengths must match and content must match in constant time
const valid = provided.length === expected.length && timingSafeEqual(provided, expected)
if (!valid) {
return response.status(401).send('Unauthorized: invalid or missing X-Api-Key header')
}
return next()
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* AuthSessionMiddleware
*
* Protects web-ui routes by requiring an authenticated session.
* If the session is not authenticated, stores the requested URL
* in the session and redirects to /login.
*
* Login is handled by AuthController + session-based authentication.
*/
export default class AuthSessionMiddleware {
async handle({ session, request, response }: HttpContext, next: NextFn) {
if (!session.get('authenticated')) {
// Remember where the user wanted to go
if (request.method() === 'GET') {
session.put('redirectAfterLogin', request.url())
}
return response.redirect().toPath('/login')
}
return next()
}
}
+72
View File
@@ -0,0 +1,72 @@
import { timingSafeEqual } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import env from '#start/env'
/**
* BasicAuthMiddleware
*
* Protects routes with HTTP Basic Authentication.
* Credentials are configured via environment variables:
* WEBUI_USERNAME
* WEBUI_PASSWORD
*
* Security:
* - Constant-time comparison (timingSafeEqual) to prevent timing attacks
*
* Responds with 401 + WWW-Authenticate header if credentials are missing or invalid.
*
* NOTE: This middleware is registered for potential future use (e.g. machine-to-machine
* access to web-ui pages). Web-UI currently uses session-based auth (authSession).
*/
export default class BasicAuthMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
const authHeader = request.header('authorization') || ''
if (!authHeader.startsWith('Basic ')) {
return this.#unauthorized(response)
}
const base64Credentials = authHeader.slice(6)
let decoded: string
try {
decoded = Buffer.from(base64Credentials, 'base64').toString('utf-8')
} catch {
return this.#unauthorized(response)
}
// Split only on the first colon — password may contain colons
const colonIndex = decoded.indexOf(':')
if (colonIndex === -1) {
return this.#unauthorized(response)
}
const username = decoded.slice(0, colonIndex)
const password = decoded.slice(colonIndex + 1)
const expectedUsername = env.get('WEBUI_USERNAME')!
const expectedPassword = env.get('WEBUI_PASSWORD')!
const usernameMatch = safeCompare(username, expectedUsername)
const passwordMatch = safeCompare(password, expectedPassword)
if (!usernameMatch || !passwordMatch) {
return this.#unauthorized(response)
}
return next()
}
#unauthorized(response: HttpContext['response']) {
response.header('WWW-Authenticate', 'Basic realm="ADAM ACS"')
return response.status(401).send('Unauthorized')
}
}
/**
* Constant-time string comparison to prevent timing side-channel attacks.
*/
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
+64 -24
View File
@@ -34,12 +34,17 @@ export default class CwmpLoggerMiddleware {
const { request, response } = ctx
const rawBody = request.raw() || ''
const socket = request.request?.socket
const remoteIp = request.ip()
const remotePort = socket?.remotePort || 0
const localAddress = socket?.localAddress || 'unknown'
const localPort = socket?.localPort || 0
const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
// Reuse sessionId already computed by CwmpMiddleware to avoid drift
const sessionId =
request.cwmp?.sessionId ??
(() => {
const socket = request.request?.socket
const remoteIp = request.ip()
const remotePort = socket?.remotePort || 0
const localAddress = socket?.localAddress || 'unknown'
const localPort = socket?.localPort || 0
return `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
})()
let capturedResponseXml: string | null = null
let isEmptyResponse = false
@@ -65,28 +70,34 @@ export default class CwmpLoggerMiddleware {
const exchangeStore = ExchangeStoreService.getInstance()
// Persist CPE→ACS (including empty POST)
exchangeStore.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId,
rawXml: rawBody || '',
}).catch(() => {})
exchangeStore
.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId,
rawXml: rawBody || '',
})
.catch(() => {})
// Persist ACS→CPE response (will queue after the CPE→ACS above)
if (capturedResponseXml) {
exchangeStore.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: capturedResponseXml,
}).catch(() => {})
exchangeStore
.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: capturedResponseXml,
})
.catch(() => {})
} else if (isEmptyResponse) {
exchangeStore.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: '',
}).catch(() => {})
exchangeStore
.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: '',
})
.catch(() => {})
}
// Old system: file-based writes (kept for Phase 1 dual-write rollback safety)
@@ -105,5 +116,34 @@ export default class CwmpLoggerMiddleware {
// Brief app-level log only (no SOAP body)
logger.debug({ sessionId, deviceId: deviceId ?? 'unknown' }, 'TR-069 exchange (not traced)')
}
// Always log full XML at debug level regardless of trace status
if (rawBody && rawBody.trim() !== '') {
logger.debug(
{
sessionId,
deviceId: deviceId ?? 'unknown',
direction: 'CPE→ACS',
xml: this.prettyXml(rawBody),
},
'CWMP XML request'
)
}
if (capturedResponseXml) {
logger.debug(
{
sessionId,
deviceId: deviceId ?? 'unknown',
direction: 'ACS→CPE',
xml: this.prettyXml(capturedResponseXml),
},
'CWMP XML response'
)
} else if (isEmptyResponse) {
logger.debug(
{ sessionId, deviceId: deviceId ?? 'unknown', direction: 'ACS→CPE' },
'CWMP empty response (204)'
)
}
}
}
+33 -5
View File
@@ -1,6 +1,7 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { parseSoapXml, extractCwmpMessage } from '../protocol/soap.js'
import { parseSoapXml, extractCwmpMessage } from '#protocol/soap'
import type { SoapEnvelope, CwmpMessage } from '#protocol/tr069_types'
/**
* CWMP Middleware
@@ -38,9 +39,21 @@ export default class CwmpMiddleware {
// Empty body = CPE polling for next instruction (valid in TR-069)
if (request.cwmp.isEmpty) {
logger.debug({ sessionId, remoteIp }, 'CWMP empty POST (CPE polling)')
return next()
}
logger.debug(
{
sessionId,
remoteIp,
contentType,
bodyLength: rawBody.length,
bodyPreview: rawBody.slice(0, 500),
},
'CWMP XML received'
)
// Validate content type for non-empty requests
const isXml =
contentType.includes('text/xml') ||
@@ -62,8 +75,23 @@ export default class CwmpMiddleware {
// Extract CWMP message from envelope body
const message = extractCwmpMessage(envelope.body)
request.cwmp.message = message
} catch (error: any) {
logger.error({ sessionId, error: error.message }, 'CWMP XML parse error')
logger.debug(
{
sessionId,
cwmpVersion: envelope.cwmpVersion,
cwmpId: envelope.header?.id,
messageType: message?.type ?? 'unknown',
messageKeys:
message?.data && typeof message.data === 'object'
? Object.keys(message.data as object)
: [],
},
'CWMP XML parsed'
)
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
logger.error({ sessionId, error: msg }, 'CWMP XML parse error')
response.status(400).send('Malformed SOAP/XML')
return
}
@@ -81,8 +109,8 @@ declare module '@adonisjs/core/http' {
sessionId: string
remoteKey: string
rawBody: string
envelope: any | null
message: { type: string; data: any } | null
envelope: SoapEnvelope | null
message: CwmpMessage | null
isEmpty: boolean
}
}
+13
View File
@@ -3,12 +3,25 @@ import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class InertiaMiddleware extends BaseInertiaMiddleware {
/**
* Implements BaseInertiaMiddleware's abstract `share` method.
* Invoked internally by @adonisjs/inertia — not called directly from this codebase.
*/
async share(ctx: HttpContext) {
return {
errors: ctx.session?.flashMessages.get('errors'),
auth: {
username: ctx.session?.get('username') ?? null,
authenticated: ctx.session?.get('authenticated') ?? false,
},
}
}
/**
* Standard AdonisJS middleware entrypoint.
* Invoked dynamically by AdonisJS via the named middleware loader in start/kernel.ts —
* not called directly from this codebase.
*/
async handle(ctx: HttpContext, next: NextFn) {
await this.init(ctx)
await next()
+9 -4
View File
@@ -1,6 +1,6 @@
import { BaseModel } from '#database/base_model'
import { CommandRecord, CommandStatus } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import { type CommandRecord, CommandStatus } from '#domain/commands/command'
import { type ModeType } from '#domain/shared/mode'
import { ObjectId } from 'mongodb'
export default class CommandModel extends BaseModel {
@@ -8,14 +8,19 @@ export default class CommandModel extends BaseModel {
static async findByDevice(deviceIdentifier: string): Promise<CommandRecord[]> {
const collection = this.getCollection()
return collection.find({ deviceIdentifier }).sort({ createdAt: -1 }).toArray() as unknown as Promise<CommandRecord[]>
return (await collection
.find({ deviceIdentifier })
.sort({ createdAt: -1 })
.toArray()) as unknown as Promise<CommandRecord[]>
}
static async findPending(deviceIdentifier: string, mode?: ModeType): Promise<CommandRecord[]> {
const filter: any = { deviceIdentifier, status: CommandStatus.PENDING }
if (mode) filter.mode = mode
const collection = this.getCollection()
return collection.find(filter).sort({ priority: 1 }).toArray() as unknown as Promise<CommandRecord[]>
return collection.find(filter).sort({ priority: 1 }).toArray() as unknown as Promise<
CommandRecord[]
>
}
static async createCommand(command: Omit<CommandRecord, '_id'>): Promise<CommandRecord> {
+13 -5
View File
@@ -1,6 +1,6 @@
import { BaseModel } from '#database/base_model'
import { Configuration } from '../domain/configuration/configuration.js'
import { Filter, Sort } from 'mongodb'
import { type Configuration } from '#domain/configuration/configuration'
import { type Filter, type Sort } from 'mongodb'
export default class ConfigurationModel extends BaseModel {
static collectionName = 'configurations'
@@ -11,7 +11,9 @@ export default class ConfigurationModel extends BaseModel {
static async findAllEnabled(sort: Sort = { priority: -1 }): Promise<Configuration[]> {
const collection = this.getCollection()
return collection.find({ enabled: true }).sort(sort).toArray() as unknown as Promise<Configuration[]>
return (await collection.find({ enabled: true }).sort(sort).toArray()) as unknown as Promise<
Configuration[]
>
}
static async paginateConfigurations(
@@ -19,7 +21,13 @@ export default class ConfigurationModel extends BaseModel {
perPage: number,
filter: Filter<any> = {},
sort: Sort = { priority: -1 }
): Promise<{ data: Configuration[]; total: number; page: number; perPage: number; lastPage: number }> {
): Promise<{
data: Configuration[]
total: number
page: number
perPage: number
lastPage: number
}> {
return this.paginate(page, perPage, filter, sort)
}
@@ -28,7 +36,7 @@ export default class ConfigurationModel extends BaseModel {
if (existing) {
throw new Error(`Configuration with identifier '${data.identifier}' already exists`)
}
return this.create(data) as Promise<Configuration>
return (await this.create(data)) as Configuration
}
static async distinctTags(): Promise<string[]> {
+8 -14
View File
@@ -1,26 +1,20 @@
import { BaseModel } from '#database/base_model'
import { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
import { Sort } from 'mongodb'
import { type ConfigurationVersion } from '#domain/configuration/configuration_version'
export default class ConfigurationVersionModel extends BaseModel {
static collectionName = 'configuration_versions'
static async findByConfigurationIdentifier(
configurationIdentifier: string,
sort: Sort = { versionNumber: -1 }
): Promise<ConfigurationVersion[]> {
const collection = this.getCollection()
return collection
.find({ configurationIdentifier })
.sort(sort)
.toArray() as unknown as Promise<ConfigurationVersion[]>
}
static async paginateVersions(
configurationIdentifier: string,
page: number,
perPage: number
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
): Promise<{
data: ConfigurationVersion[]
total: number
page: number
perPage: number
lastPage: number
}> {
return this.paginate(page, perPage, { configurationIdentifier }, { versionNumber: -1 })
}
+6 -9
View File
@@ -1,8 +1,8 @@
import { BaseModel } from '#database/base_model'
import { DeviceRecord } from '../domain/device/device_record.js'
import { DeviceId } from '../domain/device/device_id.js'
import { DeviceComplianceInfo } from '../domain/configuration/compliance.js'
import { Filter, Sort } from 'mongodb'
import { type DeviceRecord } from '#domain/device/device_record'
import { type DeviceId } from '#domain/device/device_id'
import { type DeviceComplianceInfo } from '#domain/configuration/compliance'
import { type Filter, type Sort } from 'mongodb'
export default class DeviceModel extends BaseModel {
static collectionName = 'devices'
@@ -87,10 +87,7 @@ export default class DeviceModel extends BaseModel {
await this.updateOne({ identifier }, { compliance })
}
static async updateProvisioningCode(
identifier: string,
provisioningCode: string
): Promise<void> {
static async updateProvisioningCode(identifier: string, provisioningCode: string): Promise<void> {
await this.updateOne({ identifier }, { provisioningCode })
}
@@ -102,7 +99,7 @@ export default class DeviceModel extends BaseModel {
$set: {
'compliance.status': 'pending',
'compliance.lastError': null,
updatedAt: new Date(),
'updatedAt': new Date(),
},
}
)
+4 -4
View File
@@ -45,19 +45,19 @@ export default class ExchangeModel extends BaseModel {
// When using `latest` or `before`, we want the most recent messages.
// Query in descending order then reverse to return in ascending order.
if (options?.latest || options?.before) {
const results = await collection
const results = (await collection
.find(filter)
.sort({ timestamp: -1 })
.limit(limit)
.toArray() as unknown as CwmpExchange[]
.toArray()) as unknown as CwmpExchange[]
return results.reverse()
}
return collection
return (await collection
.find(filter)
.sort({ timestamp: 1 })
.limit(limit)
.toArray() as unknown as Promise<CwmpExchange[]>
.toArray()) as unknown as CwmpExchange[]
}
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import { BaseModel } from '#database/base_model'
import { FirmwareEntry } from '../domain/firmware/firmware_catalog.js'
import { type FirmwareEntry } from '#domain/firmware/firmware_catalog'
export default class FirmwareEntryModel extends BaseModel {
static collectionName = 'firmware_entries'
+7 -5
View File
@@ -1,12 +1,12 @@
import { BaseModel } from '#database/base_model'
import { MigrationRoute } from '../domain/firmware/firmware_catalog.js'
import { Sort } from 'mongodb'
import { type MigrationRoute } from '#domain/firmware/firmware_catalog'
import { type Sort } from 'mongodb'
export default class MigrationRouteModel extends BaseModel {
static collectionName = 'migration_routes'
static async findByIdentifier(identifier: string): Promise<MigrationRoute | null> {
return this.findOne({ identifier }) as Promise<MigrationRoute | null>
return (await this.findOne({ identifier })) as Promise<MigrationRoute | null>
}
static async findByProductClass(
@@ -14,7 +14,9 @@ export default class MigrationRouteModel extends BaseModel {
sort: Sort = { priority: -1 }
): Promise<MigrationRoute[]> {
const collection = this.getCollection()
return collection.find({ productClass }).sort(sort).toArray() as unknown as Promise<MigrationRoute[]>
return (await collection.find({ productClass }).sort(sort).toArray()) as unknown as Promise<
MigrationRoute[]
>
}
static async createIfNotExists(data: Partial<MigrationRoute>): Promise<MigrationRoute> {
@@ -22,7 +24,7 @@ export default class MigrationRouteModel extends BaseModel {
if (existing) {
throw new Error(`Migration route with identifier '${data.identifier}' already exists`)
}
return this.create(data) as Promise<MigrationRoute>
return (await this.create(data)) as Promise<MigrationRoute>
}
static async deleteByIdentifier(identifier: string): Promise<boolean> {
+177 -112
View File
@@ -1,147 +1,212 @@
import { buildSoapEnvelope } from '../soap.js'
import { randomBytes } from 'node:crypto'
import {
Cwmp_1_0,
Download,
FactoryReset,
type FaultCodeType,
FaultDetail,
FaultStruct,
Fault,
GetParameterAttributes,
GetParameterNames,
GetParameterValues,
InformResponse,
ParameterValueList,
Reboot,
ScheduleInform,
SetParameterAttributes,
SetParameterAttributesList,
SetParameterAttributesStruct,
AccessList,
SetParameterValues,
TransferCompleteResponse,
Upload,
} from '#protocol/cwmp-1-0'
import {
CwmpID,
NAMESPACES,
SoapEnvelope,
type SoapEnvelopeAttribute,
SoapEnvelopeHeader,
} from '#protocol/soap_envelope'
import type {
DownloadTaskPayload,
UploadTaskPayload,
SetParameterValuesTaskPayload,
SetParameterAttributesTaskPayload,
} from '#protocol/tr069_types'
function generateCwmpID(): string {
return randomBytes(4).toString('hex')
}
function makeEnvelope(cwmpId?: string): SoapEnvelope {
const enve = new SoapEnvelope()
enve.$ = NAMESPACES['1.0'] as SoapEnvelopeAttribute
enve.Header = new SoapEnvelopeHeader()
enve.Header.ID = new CwmpID({
$: { MustUnderstand: '1' },
_: cwmpId || generateCwmpID(),
})
enve.Body = new Cwmp_1_0()
return enve
}
export class CWMPMessageBuilder {
static informResponse(cwmpId?: string): string {
const body = `<cwmp:InformResponse>
<MaxEnvelopes>1</MaxEnvelopes>
</cwmp:InformResponse>`
return buildSoapEnvelope(cwmpId, body)
const enve = makeEnvelope(cwmpId)
enve.Body.InformResponse = new InformResponse({ MaxEnvelopes: 1 })
return enve.getXml()
}
static getParameterNames(cwmpId: string | undefined, parameterPath: string, nextLevel: boolean = true): string {
const body = `<cwmp:GetParameterNames>
<ParameterPath>${parameterPath}</ParameterPath>
<NextLevel>${nextLevel ? '1' : '0'}</NextLevel>
</cwmp:GetParameterNames>`
return buildSoapEnvelope(cwmpId, body)
static getParameterNames(
cwmpId: string | undefined,
parameterPath: string,
nextLevel: boolean = true
): string {
const enve = makeEnvelope(cwmpId)
enve.Body.GetParameterNames = new GetParameterNames({
ParameterPath: parameterPath,
NextLevel: nextLevel,
})
return enve.getXml()
}
static getParameterValues(cwmpId: string | undefined, parameterNames: string[]): string {
const names = parameterNames.map((n) => `<string>${n}</string>`).join('\n ')
const body = `<cwmp:GetParameterValues>
<ParameterNames soap-enc:arrayType="xsd:string[${parameterNames.length}]">
${names}
</ParameterNames>
</cwmp:GetParameterValues>`
return buildSoapEnvelope(cwmpId, body)
const enve = makeEnvelope(cwmpId)
enve.Body.GetParameterValues = new GetParameterValues({ ParameterNames: parameterNames })
return enve.getXml()
}
static setParameterValues(cwmpId: string | undefined, parameters: { name: string; value: string; type: string }[]): string {
const params = parameters
.map(
(p) => `<ParameterValueStruct>
<Name>${p.name}</Name>
<Value xsi:type="${p.type}">${p.value}</Value>
</ParameterValueStruct>`
)
.join('\n ')
const body = `<cwmp:SetParameterValues>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[${parameters.length}]">
${params}
</ParameterList>
<ParameterKey>papaye-v2</ParameterKey>
</cwmp:SetParameterValues>`
return buildSoapEnvelope(cwmpId, body)
}
static download(
static setParameterValues(
cwmpId: string | undefined,
params: {
commandKey?: string
fileType?: string
url: string
username?: string
password?: string
fileSize?: number
targetFileName?: string
delaySeconds?: number
successUrl?: string
failureUrl?: string
}
parameters: Array<{ name: string; value: string; type: string }>
): string {
const body = `<cwmp:Download>
<CommandKey>${params.commandKey || ''}</CommandKey>
<FileType>${params.fileType || '1 Firmware Upgrade Image'}</FileType>
<URL>${params.url}</URL>
<Username>${params.username || ''}</Username>
<Password>${params.password || ''}</Password>
<FileSize>${params.fileSize || 0}</FileSize>
<TargetFileName>${params.targetFileName || ''}</TargetFileName>
<DelaySeconds>${params.delaySeconds || 0}</DelaySeconds>
<SuccessURL>${params.successUrl || ''}</SuccessURL>
<FailureURL>${params.failureUrl || ''}</FailureURL>
</cwmp:Download>`
return buildSoapEnvelope(cwmpId, body)
const pvStructs = parameters.map((p) => ({
Name: p.name,
Value: { _: p.value, $: { Type: p.type } },
}))
const pvList = new ParameterValueList({
$: { ArrayType: `cwmp:ParameterValueStruct[${pvStructs.length}]` },
ParameterValueStruct: pvStructs,
})
const enve = makeEnvelope(cwmpId)
enve.Body.SetParameterValues = new SetParameterValues({
ParameterValueList: pvList,
ParameterKey: 'papaye-v2',
})
return enve.getXml()
}
static upload(
static setParameterValuesFromPayload(
cwmpId: string | undefined,
params: { commandKey?: string; fileType?: string; url: string; username?: string; password?: string; delaySeconds?: number }
payload: SetParameterValuesTaskPayload
): string {
const body = `<cwmp:Upload>
<CommandKey>${params.commandKey || ''}</CommandKey>
<FileType>${params.fileType || '3 Vendor Configuration File'}</FileType>
<URL>${params.url}</URL>
<Username>${params.username || ''}</Username>
<Password>${params.password || ''}</Password>
<DelaySeconds>${params.delaySeconds || 0}</DelaySeconds>
</cwmp:Upload>`
return buildSoapEnvelope(cwmpId, body)
const params = (payload.parameterValueList || []).map((p) => ({
name: p.Name,
value: String(p.Value?._ ?? ''),
type: p.Value?.$?.Type || 'xsd:string',
}))
return CWMPMessageBuilder.setParameterValues(cwmpId, params)
}
static download(cwmpId: string | undefined, params: DownloadTaskPayload): string {
const enve = makeEnvelope(cwmpId)
enve.Body.Download = new Download({
CommandKey: params.commandKey || '',
FileType: params.fileType || '1 Firmware Upgrade Image',
URL: params.url,
Username: params.username || '',
Password: params.password || '',
FileSize: params.fileSize || 0,
TargetFileName: params.targetFileName || '',
DelaySeconds: params.delaySeconds || 0,
SuccessURL: params.successUrl || '',
FailureURL: params.failureUrl || '',
})
return enve.getXml()
}
static upload(cwmpId: string | undefined, params: UploadTaskPayload): string {
const enve = makeEnvelope(cwmpId)
enve.Body.Upload = new Upload({
CommandKey: params.commandKey || '',
FileType: params.fileType || '3 Vendor Configuration File',
URL: params.url,
Username: params.username || '',
Password: params.password || '',
DelaySeconds: params.delaySeconds || 0,
})
return enve.getXml()
}
static reboot(cwmpId?: string, commandKey?: string): string {
const body = `<cwmp:Reboot>
<CommandKey>${commandKey || ''}</CommandKey>
</cwmp:Reboot>`
return buildSoapEnvelope(cwmpId, body)
const enve = makeEnvelope(cwmpId)
enve.Body.Reboot = new Reboot({ CommandKey: commandKey || '' })
return enve.getXml()
}
static factoryReset(cwmpId?: string): string {
const body = `<cwmp:FactoryReset></cwmp:FactoryReset>`
return buildSoapEnvelope(cwmpId, body)
const enve = makeEnvelope(cwmpId)
enve.Body.FactoryReset = new FactoryReset({})
return enve.getXml()
}
static scheduleInform(cwmpId: string | undefined, delaySeconds: number, commandKey?: string): string {
const body = `<cwmp:ScheduleInform>
<DelaySeconds>${delaySeconds}</DelaySeconds>
<CommandKey>${commandKey || ''}</CommandKey>
</cwmp:ScheduleInform>`
return buildSoapEnvelope(cwmpId, body)
static scheduleInform(
cwmpId: string | undefined,
delaySeconds: number,
commandKey?: string
): string {
const enve = makeEnvelope(cwmpId)
enve.Body.ScheduleInform = new ScheduleInform({
DelaySeconds: delaySeconds,
CommandKey: commandKey || '',
})
return enve.getXml()
}
static getParameterAttributes(cwmpId: string | undefined, parameterNames: string[]): string {
const names = parameterNames.map((n) => `<string>${n}</string>`).join('\n ')
const body = `<cwmp:GetParameterAttributes>
<ParameterNames soap-enc:arrayType="xsd:string[${parameterNames.length}]">
${names}
</ParameterNames>
</cwmp:GetParameterAttributes>`
return buildSoapEnvelope(cwmpId, body)
const enve = makeEnvelope(cwmpId)
enve.Body.GetParameterAttributes = new GetParameterAttributes({
ParameterNames: parameterNames,
})
return enve.getXml()
}
static setParameterAttributes(
cwmpId: string | undefined,
attributes: { name: string; notificationChange: boolean; notification: number; accessListChange: boolean; accessList: string[] }[]
attributes: SetParameterAttributesTaskPayload[]
): string {
const attrs = attributes
.map(
(a) => `<SetParameterAttributesStruct>
<Name>${a.name}</Name>
<NotificationChange>${a.notificationChange ? '1' : '0'}</NotificationChange>
<Notification>${a.notification}</Notification>
<AccessListChange>${a.accessListChange ? '1' : '0'}</AccessListChange>
<AccessList soap-enc:arrayType="xsd:string[${a.accessList.length}]">
${a.accessList.map((s) => `<string>${s}</string>`).join('')}
</AccessList>
</SetParameterAttributesStruct>`
)
.join('')
const body = `<cwmp:SetParameterAttributes>
<ParameterList soap-enc:arrayType="cwmp:SetParameterAttributesStruct[${attributes.length}]">
${attrs}
</ParameterList>
</cwmp:SetParameterAttributes>`
return buildSoapEnvelope(cwmpId, body)
const structs = attributes.map(
(a) =>
new SetParameterAttributesStruct({
Name: a.name,
NotificationChange: a.notificationChange,
Notification: a.notification,
AccessListChange: a.accessListChange,
AccessList: new AccessList({ string: a.accessList }),
})
)
const list = new SetParameterAttributesList({ SetParameterAttributesStruct: structs })
const enve = makeEnvelope(cwmpId)
enve.Body.SetParameterAttributes = new SetParameterAttributes({ ParameterList: list })
return enve.getXml()
}
static transferCompleteResponse(cwmpId?: string): string {
const enve = makeEnvelope(cwmpId)
enve.Body.TransferCompleteResponse = new TransferCompleteResponse({})
return enve.getXml()
}
static soapFault(faultCode: FaultCodeType, reason: string): string {
const fault = new Fault()
fault.Faultcode = 'Server'
fault.Faultstring = reason
fault.Detail = new FaultDetail({
Fault: new FaultStruct({ FaultCode: faultCode, FaultString: reason }),
})
return fault.getXml()
}
static emptyResponse(): string {
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
// @ts-nocheck — fichier copié de cwmp-connector (papaye)
import { Cwmp_1_0, FaultCodeType } from '#protocol/cwmp-1-0'
/**
* CWMP Version 1.1 Protocol Classes
*
* This version extends CWMP 1.0 with additional features:
* - Additional fault codes (9014-9019)
* - New event code: "10 AUTONOMOUS TRANSFER COMPLETE"
* - New messages: GetAllQueuedTransfers, AutonomousTransferComplete
* - AllQueuedTransferStruct and AllTransferList types
*/
export class Cwmp_1_1 extends Cwmp_1_0 {
public '@class' = '.Cwmp_1_1'
public 'GetAllQueuedTransfers'?: GetAllQueuedTransfers
public 'GetAllQueuedTransfersResponse'?: GetAllQueuedTransfersResponse
public 'AutonomousTransferComplete'?: AutonomousTransferComplete
public 'AutonomousTransferCompleteResponse'?: AutonomousTransferCompleteResponse
'constructor'(props: any = {}) {
super(props)
this.GetAllQueuedTransfers = props.GetAllQueuedTransfers
? new GetAllQueuedTransfers(props.GetAllQueuedTransfers)
: undefined
this.GetAllQueuedTransfersResponse = props.GetAllQueuedTransfersResponse
? new GetAllQueuedTransfersResponse(props.GetAllQueuedTransfersResponse)
: undefined
this.AutonomousTransferComplete = props.AutonomousTransferComplete
? new AutonomousTransferComplete(props.AutonomousTransferComplete)
: undefined
this.AutonomousTransferCompleteResponse = props.AutonomousTransferCompleteResponse
? new AutonomousTransferCompleteResponse(props.AutonomousTransferCompleteResponse)
: undefined
}
}
// Extended fault codes for CWMP 1.1
export enum FaultCodeType_1_1 {
// All codes from 1.0
MethodNotSupported = 9000,
RequestDenied = 9001,
InternalError = 9002,
InvalidArguments = 9003,
ResourcesExceeded = 9004,
InvalidParameterName = 9005,
InvalidParameterType = 9006,
InvalidParameterValue = 9007,
NonWritableParameter = 9008,
NotificationRequestRejected = 9009,
DownloadFailure = 9010,
UploadFailure = 9011,
FileTransferServerAuthFailure = 9012,
UnsupportedProtocolForFileTransfer = 9013,
// New codes in 1.1
DownloadFailureUnableToJoinMulticastGroup = 9014,
DownloadFailureUnableToContactFileServer = 9015,
DownloadFailureUnableToAccessFile = 9016,
DownloadFailureUnableToCompleteDownload = 9017,
DownloadFailureFileCorrupted = 9018,
DownloadFailureFileAuthenticationFailure = 9019,
// Future expansion fault codes
FutureExpansionMin = 9020,
FutureExpansionMax = 9799,
// CPE Vendor fault codes (range)
VendorFaultCodeMin = 9800,
VendorFaultCodeMax = 9899,
// ACS fault codes
ACSMethodNotSupported = 8000,
ACSRequestDenied = 8001,
ACSInternalError = 8002,
ACSInvalidArguments = 8003,
ACSResourcesExceeded = 8004,
ACSRetryRequest = 8005,
// ACS Future expansion fault codes
ACSFutureExpansionMin = 8006,
ACSFutureExpansionMax = 8799,
// ACS Vendor fault codes (range)
ACSVendorFaultCodeMin = 8800,
ACSVendorFaultCodeMax = 8899,
}
// Extended EventCode enum for CWMP 1.1
export enum EventCode_1_1 {
BOOTSTRAP = '0 BOOTSTRAP',
BOOT = '1 BOOT',
PERIODIC = '2 PERIODIC',
SCHEDULED = '3 SCHEDULED',
VALUE_CHANGE = '4 VALUE CHANGE',
KICKED = '5 KICKED',
CONNECTION_REQUEST = '6 CONNECTION REQUEST',
TRANSFER_COMPLETE = '7 TRANSFER COMPLETE',
DIAGNOSTICS_COMPLETE = '8 DIAGNOSTICS COMPLETE',
REQUEST_DOWNLOAD = '9 REQUEST DOWNLOAD',
AUTONOMOUS_TRANSFER_COMPLETE = '10 AUTONOMOUS TRANSFER COMPLETE', // New in 1.1
REBOOT = 'M Reboot',
SCHEDULE_INFORM = 'M ScheduleInform',
DOWNLOAD = 'M Download',
UPLOAD = 'M Upload',
X_METHOD = 'M X_',
X_PATTERN = 'X [0-9A-F]{6} .*',
}
export class AllQueuedTransferStruct {
public '@class' = '.AllQueuedTransferStruct'
public 'CommandKey': string
public 'State': number
public 'IsDownload': boolean
public 'FileType': string
public 'FileSize': number
public 'TargetFileName': string
'constructor'(props: any = {}) {
this.CommandKey = props.CommandKey || ''
this.State = props.State || 1
this.IsDownload = props.IsDownload || false
this.FileType = props.FileType || ''
this.FileSize = props.FileSize || 0
this.TargetFileName = props.TargetFileName || ''
}
}
export class AllTransferList {
public '@class' = '.AllTransferList'
public 'AllQueuedTransferStruct': AllQueuedTransferStruct[] = []
'constructor'(props: any = {}) {
this.AllQueuedTransferStruct = (props.AllQueuedTransferStruct || []).map(
(item: any) => new AllQueuedTransferStruct(item)
)
}
}
export class GetAllQueuedTransfers {
public '@class' = '.GetAllQueuedTransfers'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:GetAllQueuedTransfers></cwmp:GetAllQueuedTransfers>`
}
}
export class GetAllQueuedTransfersResponse {
public '@class' = '.GetAllQueuedTransfersResponse'
public 'TransferList'?: AllTransferList
'constructor'(props: any = {}) {
this.TransferList = props.TransferList ? new AllTransferList(props.TransferList) : undefined
}
'getXml'() {
const items = this.TransferList?.AllQueuedTransferStruct || []
const listXml = items
.map(
(t) => `
<AllQueuedTransferStruct>
<CommandKey>${t.CommandKey}</CommandKey>
<State>${t.State}</State>
<IsDownload>${t.IsDownload ? 1 : 0}</IsDownload>
<FileType>${t.FileType}</FileType>
<FileSize>${t.FileSize}</FileSize>
<TargetFileName>${t.TargetFileName}</TargetFileName>
</AllQueuedTransferStruct>`
)
.join('')
return `<cwmp:GetAllQueuedTransfersResponse>
<TransferList soap-enc:arrayType="cwmp:AllQueuedTransferStruct[${items.length}]">
${listXml}
</TransferList>
</cwmp:GetAllQueuedTransfersResponse>`
}
}
export class AutonomousTransferComplete {
public '@class' = '.AutonomousTransferComplete'
public 'AnnounceURL': string
public 'TransferURL': string
public 'IsDownload': boolean
public 'FileType': string
public 'FileSize': number
public 'TargetFileName': string
public 'FaultStruct': any // Reusing FaultStruct from base
public 'StartTime': Date
public 'CompleteTime': Date
'constructor'(props: any = {}) {
this.AnnounceURL = props.AnnounceURL || ''
this.TransferURL = props.TransferURL || ''
this.IsDownload = props.IsDownload || false
this.FileType = props.FileType || ''
this.FileSize = props.FileSize || 0
this.TargetFileName = props.TargetFileName || ''
this.FaultStruct = props.FaultStruct
this.StartTime = props.StartTime ? new Date(props.StartTime) : new Date()
this.CompleteTime = props.CompleteTime ? new Date(props.CompleteTime) : new Date()
}
'getXml'() {
const fault = this.FaultStruct
? `<FaultStruct><FaultCode>${this.FaultStruct.FaultCode ?? 0}</FaultCode><FaultString>${this.FaultStruct.FaultString || ''}</FaultString></FaultStruct>`
: ''
return `<cwmp:AutonomousTransferComplete>
<AnnounceURL>${this.AnnounceURL}</AnnounceURL>
<TransferURL>${this.TransferURL}</TransferURL>
<IsDownload>${this.IsDownload ? 1 : 0}</IsDownload>
<FileType>${this.FileType}</FileType>
<FileSize>${this.FileSize}</FileSize>
<TargetFileName>${this.TargetFileName}</TargetFileName>
${fault}
<StartTime>${this.StartTime.toISOString()}</StartTime>
<CompleteTime>${this.CompleteTime.toISOString()}</CompleteTime>
</cwmp:AutonomousTransferComplete>`
}
}
export class AutonomousTransferCompleteResponse {
public '@class' = '.AutonomousTransferCompleteResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return '<cwmp:AutonomousTransferCompleteResponse/>'
}
}
+515
View File
@@ -0,0 +1,515 @@
// @ts-nocheck — fichier copié de cwmp-connector (papaye)
import { Cwmp_1_0, FaultStruct } from '#protocol/cwmp-1-0'
import {
Cwmp_1_1,
AutonomousTransferComplete as AutonomousTransferComplete_1_1,
} from '#protocol/cwmp-1-1'
/**
* CWMP Version 1.2 Protocol Classes
*
* Extends CWMP 1.1 with:
* - ScheduleDownload / ScheduleDownloadResponse
* - CancelTransfer / CancelTransferResponse
* - ChangeDUState / ChangeDUStateResponse
* - DUStateChangeComplete / DUStateChangeCompleteResponse
* - AutonomousDUStateChangeComplete / AutonomousDUStateChangeCompleteResponse
* - TimeWindow structures and Software Module Management structures
*/
export class Cwmp_1_2 extends Cwmp_1_1 {
public '@class' = '.Cwmp_1_2'
// New RPCs
public 'ScheduleDownload'?: ScheduleDownload
public 'ScheduleDownloadResponse'?: ScheduleDownloadResponse
public 'CancelTransfer'?: CancelTransfer
public 'CancelTransferResponse'?: CancelTransferResponse
public 'ChangeDUState'?: ChangeDUState
public 'ChangeDUStateResponse'?: ChangeDUStateResponse
public 'DUStateChangeComplete'?: DUStateChangeComplete
public 'DUStateChangeCompleteResponse'?: DUStateChangeCompleteResponse
public 'AutonomousDUStateChangeComplete'?: AutonomousDUStateChangeComplete
public 'AutonomousDUStateChangeCompleteResponse'?: AutonomousDUStateChangeCompleteResponse
// Override AutonomousTransferComplete to use TransferCompleteFaultStruct
public 'AutonomousTransferComplete'?: AutonomousTransferComplete
'constructor'(props: any = {}) {
super(props)
this.ScheduleDownload = props.ScheduleDownload
? new ScheduleDownload(props.ScheduleDownload)
: undefined
this.ScheduleDownloadResponse = props.ScheduleDownloadResponse
? new ScheduleDownloadResponse(props.ScheduleDownloadResponse)
: undefined
this.CancelTransfer = props.CancelTransfer
? new CancelTransfer(props.CancelTransfer)
: undefined
this.CancelTransferResponse = props.CancelTransferResponse
? new CancelTransferResponse(props.CancelTransferResponse)
: undefined
this.ChangeDUState = props.ChangeDUState ? new ChangeDUState(props.ChangeDUState) : undefined
this.ChangeDUStateResponse = props.ChangeDUStateResponse
? new ChangeDUStateResponse(props.ChangeDUStateResponse)
: undefined
this.DUStateChangeComplete = props.DUStateChangeComplete
? new DUStateChangeComplete(props.DUStateChangeComplete)
: undefined
this.DUStateChangeCompleteResponse = props.DUStateChangeCompleteResponse
? new DUStateChangeCompleteResponse(props.DUStateChangeCompleteResponse)
: undefined
this.AutonomousDUStateChangeComplete = props.AutonomousDUStateChangeComplete
? new AutonomousDUStateChangeComplete(props.AutonomousDUStateChangeComplete)
: undefined
this.AutonomousDUStateChangeCompleteResponse = props.AutonomousDUStateChangeCompleteResponse
? new AutonomousDUStateChangeCompleteResponse(props.AutonomousDUStateChangeCompleteResponse)
: undefined
// Override from 1.1 when provided
this.AutonomousTransferComplete = props.AutonomousTransferComplete
? new AutonomousTransferComplete(props.AutonomousTransferComplete)
: this.AutonomousTransferComplete
}
}
// New/extended Event Codes for 1.2
export enum EventCode_1_2 {
BOOTSTRAP = '0 BOOTSTRAP',
BOOT = '1 BOOT',
PERIODIC = '2 PERIODIC',
SCHEDULED = '3 SCHEDULED',
VALUE_CHANGE = '4 VALUE CHANGE',
KICKED = '5 KICKED',
CONNECTION_REQUEST = '6 CONNECTION REQUEST',
TRANSFER_COMPLETE = '7 TRANSFER COMPLETE',
DIAGNOSTICS_COMPLETE = '8 DIAGNOSTICS COMPLETE',
REQUEST_DOWNLOAD = '9 REQUEST DOWNLOAD',
AUTONOMOUS_TRANSFER_COMPLETE = '10 AUTONOMOUS TRANSFER COMPLETE',
DU_STATE_CHANGE_COMPLETE = '11 DU STATE CHANGE COMPLETE',
AUTONOMOUS_DU_STATE_CHANGE_COMPLETE = '12 AUTONOMOUS DU STATE CHANGE COMPLETE',
REBOOT = 'M Reboot',
SCHEDULE_INFORM = 'M ScheduleInform',
DOWNLOAD = 'M Download',
SCHEDULE_DOWNLOAD = 'M ScheduleDownload',
UPLOAD = 'M Upload',
CHANGE_DU_STATE = 'M ChangeDUState',
X_METHOD = 'M X_',
X_PATTERN = 'X [0-9A-F]{6} .*',
}
// Time window types used by ScheduleDownload
export class TimeWindowStruct {
public '@class' = '.TimeWindowStruct'
public 'WindowStart': number
public 'WindowEnd': number
public 'WindowMode': string
public 'UserMessage': string
public 'MaxRetries': number
'constructor'(props: any = {}) {
this.WindowStart = props.WindowStart ?? 0
this.WindowEnd = props.WindowEnd ?? 0
this.WindowMode = props.WindowMode ?? ''
this.UserMessage = props.UserMessage ?? ''
this.MaxRetries = props.MaxRetries ?? 0
}
}
export class TimeWindowList {
public '@class' = '.TimeWindowList'
public 'TimeWindowStruct': TimeWindowStruct[] = []
'constructor'(props: any = {}) {
this.TimeWindowStruct = (props.TimeWindowStruct || []).map(
(tw: any) => new TimeWindowStruct(tw)
)
}
}
// ScheduleDownload RPC
export class ScheduleDownload {
public '@class' = '.ScheduleDownload'
public 'CommandKey': string
public 'FileType': string
public 'URL': string
public 'Username'?: string
public 'Password'?: string
public 'FileSize'?: number
public 'TargetFileName'?: string
public 'TimeWindowList': TimeWindowList
'constructor'(props: any = {}) {
this.CommandKey = props.CommandKey || ''
this.FileType = props.FileType || ''
this.URL = props.URL || ''
this.Username = props.Username
this.Password = props.Password
this.FileSize = props.FileSize
this.TargetFileName = props.TargetFileName
this.TimeWindowList = new TimeWindowList(props.TimeWindowList || {})
}
'getXml'() {
const tw = this.TimeWindowList?.TimeWindowStruct || []
const twXml = tw
.map(
(w) => `
<TimeWindowStruct>
<WindowStart>${w.WindowStart}</WindowStart>
<WindowEnd>${w.WindowEnd}</WindowEnd>
<WindowMode>${w.WindowMode}</WindowMode>
<UserMessage>${w.UserMessage}</UserMessage>
<MaxRetries>${w.MaxRetries}</MaxRetries>
</TimeWindowStruct>`
)
.join('')
return `<cwmp:ScheduleDownload>
<CommandKey>${this.CommandKey}</CommandKey>
<FileType>${this.FileType}</FileType>
<URL>${this.URL}</URL>
<Username>${this.Username || ''}</Username>
<Password>${this.Password || ''}</Password>
<FileSize>${this.FileSize ?? 0}</FileSize>
<TargetFileName>${this.TargetFileName || ''}</TargetFileName>
<TimeWindowList soap-enc:arrayType="cwmp:TimeWindowStruct[${tw.length}]">
${twXml}
</TimeWindowList>
</cwmp:ScheduleDownload>`
}
}
export class ScheduleDownloadResponse {
public '@class' = '.ScheduleDownloadResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:ScheduleDownloadResponse/>`
}
}
// CancelTransfer RPC
export class CancelTransfer {
public '@class' = '.CancelTransfer'
public 'CommandKey': string
'constructor'(props: any = {}) {
this.CommandKey = props.CommandKey || ''
}
'getXml'() {
return `<cwmp:CancelTransfer>
<CommandKey>${this.CommandKey}</CommandKey>
</cwmp:CancelTransfer>`
}
}
export class CancelTransferResponse {
public '@class' = '.CancelTransferResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:CancelTransferResponse/>`
}
}
// Software Module Management types
export type DeploymentUnitState = 'Installed' | 'Uninstalled' | 'Failed'
export class OperationStruct {
// abstract base
public '@class' = '.OperationStruct'
}
export class InstallOpStruct extends OperationStruct {
public '@class' = '.InstallOpStruct'
public 'URL': string
public 'UUID'?: string
public 'Username'?: string
public 'Password'?: string
public 'ExecutionEnvRef'?: string
'constructor'(props: any = {}) {
super()
this.URL = props.URL || ''
this.UUID = props.UUID
this.Username = props.Username
this.Password = props.Password
this.ExecutionEnvRef = props.ExecutionEnvRef
}
}
export class UpdateOpStruct extends OperationStruct {
public '@class' = '.UpdateOpStruct'
public 'UUID'?: string
public 'Version'?: string
public 'URL'?: string
public 'Username'?: string
public 'Password'?: string
'constructor'(props: any = {}) {
super()
this.UUID = props.UUID
this.Version = props.Version
this.URL = props.URL
this.Username = props.Username
this.Password = props.Password
}
}
export class UninstallOpStruct extends OperationStruct {
public '@class' = '.UninstallOpStruct'
public 'UUID': string
public 'Version'?: string
public 'ExecutionEnvRef'?: string
'constructor'(props: any = {}) {
super()
this.UUID = props.UUID || ''
this.Version = props.Version
this.ExecutionEnvRef = props.ExecutionEnvRef
}
}
export class DeploymentUnitFaultStruct {
public '@class' = '.DeploymentUnitFaultStruct'
public 'FaultCode': number
public 'FaultString'?: string
'constructor'(props: any = {}) {
this.FaultCode = props.FaultCode ?? 0
this.FaultString = props.FaultString
}
}
export class OpResultStruct {
public '@class' = '.OpResultStruct'
public 'UUID': string
public 'DeploymentUnitRef': string
public 'Version': string
public 'CurrentState': DeploymentUnitState
public 'Resolved': boolean
public 'ExecutionUnitRefList': string
public 'StartTime': Date
public 'CompleteTime': Date
public 'Fault': DeploymentUnitFaultStruct
'constructor'(props: any = {}) {
this.UUID = props.UUID || ''
this.DeploymentUnitRef = props.DeploymentUnitRef || ''
this.Version = props.Version || ''
this.CurrentState = props.CurrentState || 'Installed'
this.Resolved = !!props.Resolved
this.ExecutionUnitRefList = props.ExecutionUnitRefList || ''
this.StartTime = props.StartTime ? new Date(props.StartTime) : new Date()
this.CompleteTime = props.CompleteTime ? new Date(props.CompleteTime) : new Date()
this.Fault = new DeploymentUnitFaultStruct(props.Fault || {})
}
}
export class AutonOpResultStruct extends OpResultStruct {
public '@class' = '.AutonOpResultStruct'
public 'OperationPerformed': string
'constructor'(props: any = {}) {
super(props)
this.OperationPerformed = props.OperationPerformed || ''
}
}
// ChangeDUState RPC
export class ChangeDUState {
public '@class' = '.ChangeDUState'
public 'Operations': OperationStruct[] = []
public 'CommandKey'?: string
'constructor'(props: any = {}) {
this.Operations = (props.Operations || []).map((op: any) => {
const type = op?.['@class'] || op?.type
if (type === 'InstallOpStruct' || op?.URL) return new InstallOpStruct(op)
if (type === 'UpdateOpStruct' || op?.Version || (op?.UUID && !op?.ExecutionEnvRef))
return new UpdateOpStruct(op)
if (
type === 'UninstallOpStruct' ||
op?.ExecutionEnvRef ||
(op?.UUID && !op?.URL && !op?.Version)
)
return new UninstallOpStruct(op)
return new OperationStruct()
})
this.CommandKey = props.CommandKey
}
'getXml'() {
const opsXml = this.Operations.map((op) => {
if (op instanceof InstallOpStruct) {
return `<Operations xsi:type="cwmp:InstallOpStruct">
<URL>${op.URL}</URL>
${op.UUID ? `<UUID>${op.UUID}</UUID>` : ''}
${op.Username ? `<Username>${op.Username}</Username>` : ''}
${op.Password ? `<Password>${op.Password}</Password>` : ''}
${op.ExecutionEnvRef ? `<ExecutionEnvRef>${op.ExecutionEnvRef}</ExecutionEnvRef>` : ''}
</Operations>`
}
if (op instanceof UpdateOpStruct) {
return `<Operations xsi:type="cwmp:UpdateOpStruct">
${op.UUID ? `<UUID>${op.UUID}</UUID>` : ''}
${op.Version ? `<Version>${op.Version}</Version>` : ''}
${op.URL ? `<URL>${op.URL}</URL>` : ''}
${op.Username ? `<Username>${op.Username}</Username>` : ''}
${op.Password ? `<Password>${op.Password}</Password>` : ''}
</Operations>`
}
if (op instanceof UninstallOpStruct) {
return `<Operations xsi:type="cwmp:UninstallOpStruct">
<UUID>${op.UUID}</UUID>
${op.Version ? `<Version>${op.Version}</Version>` : ''}
${op.ExecutionEnvRef ? `<ExecutionEnvRef>${op.ExecutionEnvRef}</ExecutionEnvRef>` : ''}
</Operations>`
}
return ''
}).join('')
return `<cwmp:ChangeDUState>
${opsXml}
${this.CommandKey ? `<CommandKey>${this.CommandKey}</CommandKey>` : ''}
</cwmp:ChangeDUState>`
}
}
export class ChangeDUStateResponse {
public '@class' = '.ChangeDUStateResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:ChangeDUStateResponse/>`
}
}
// DUStateChangeComplete RPCs
export class DUStateChangeComplete {
public '@class' = '.DUStateChangeComplete'
public 'Results': OpResultStruct[]
public 'CommandKey'?: string
'constructor'(props: any = {}) {
this.Results = (props.Results || []).map((r: any) => new OpResultStruct(r))
this.CommandKey = props.CommandKey
}
'getXml'() {
const resultsXml = this.Results.map(
(r) => `
<Results>
<UUID>${r.UUID}</UUID>
<DeploymentUnitRef>${r.DeploymentUnitRef}</DeploymentUnitRef>
<Version>${r.Version}</Version>
<CurrentState>${r.CurrentState}</CurrentState>
<Resolved>${r.Resolved ? 1 : 0}</Resolved>
<ExecutionUnitRefList>${r.ExecutionUnitRefList}</ExecutionUnitRefList>
<StartTime>${r.StartTime.toISOString()}</StartTime>
<CompleteTime>${r.CompleteTime.toISOString()}</CompleteTime>
<Fault>
<FaultCode>${r.Fault.FaultCode}</FaultCode>
${r.Fault.FaultString ? `<FaultString>${r.Fault.FaultString}</FaultString>` : ''}
</Fault>
</Results>`
).join('')
return `<cwmp:DUStateChangeComplete>
${resultsXml}
${this.CommandKey ? `<CommandKey>${this.CommandKey}</CommandKey>` : ''}
</cwmp:DUStateChangeComplete>`
}
}
export class DUStateChangeCompleteResponse {
public '@class' = '.DUStateChangeCompleteResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:DUStateChangeCompleteResponse/>`
}
}
export class AutonomousDUStateChangeComplete {
public '@class' = '.AutonomousDUStateChangeComplete'
public 'Results': AutonOpResultStruct[]
'constructor'(props: any = {}) {
this.Results = (props.Results || []).map((r: any) => new AutonOpResultStruct(r))
}
'getXml'() {
const resultsXml = this.Results.map(
(r) => `
<Results>
<UUID>${r.UUID}</UUID>
<DeploymentUnitRef>${r.DeploymentUnitRef}</DeploymentUnitRef>
<Version>${r.Version}</Version>
<CurrentState>${r.CurrentState}</CurrentState>
<Resolved>${r.Resolved ? 1 : 0}</Resolved>
<ExecutionUnitRefList>${r.ExecutionUnitRefList}</ExecutionUnitRefList>
<StartTime>${r.StartTime.toISOString()}</StartTime>
<CompleteTime>${r.CompleteTime.toISOString()}</CompleteTime>
<Fault>
<FaultCode>${r.Fault.FaultCode}</FaultCode>
${r.Fault.FaultString ? `<FaultString>${r.Fault.FaultString}</FaultString>` : ''}
</Fault>
<OperationPerformed>${r.OperationPerformed}</OperationPerformed>
</Results>`
).join('')
return `<cwmp:AutonomousDUStateChangeComplete>
${resultsXml}
</cwmp:AutonomousDUStateChangeComplete>`
}
}
export class AutonomousDUStateChangeCompleteResponse {
public '@class' = '.AutonomousDUStateChangeCompleteResponse'
'constructor'(props: any = {}) {}
'getXml'() {
return `<cwmp:AutonomousDUStateChangeCompleteResponse/>`
}
}
// TransferComplete-specific fault struct for 1.2
export class TransferCompleteFaultStruct {
public '@class' = '.TransferCompleteFaultStruct'
public 'FaultCode': number
public 'FaultString': string
'constructor'(props: any = {}) {
this.FaultCode = props.FaultCode ?? 0
this.FaultString = props.FaultString || ''
}
}
// Override of 1.1 AutonomousTransferComplete to use TransferCompleteFaultStruct
export class AutonomousTransferComplete extends AutonomousTransferComplete_1_1 {
public '@class' = '.AutonomousTransferComplete'
public 'FaultStruct': TransferCompleteFaultStruct
'constructor'(props: any = {}) {
super(props)
this.FaultStruct = new TransferCompleteFaultStruct(props.FaultStruct || {})
}
'getXml'() {
return `<cwmp:AutonomousTransferComplete>
<AnnounceURL>${this.AnnounceURL}</AnnounceURL>
<TransferURL>${this.TransferURL}</TransferURL>
<IsDownload>${this.IsDownload ? 1 : 0}</IsDownload>
<FileType>${this.FileType}</FileType>
<FileSize>${this.FileSize}</FileSize>
<TargetFileName>${this.TargetFileName}</TargetFileName>
<FaultStruct>
<FaultCode>${this.FaultStruct.FaultCode}</FaultCode>
<FaultString>${this.FaultStruct.FaultString}</FaultString>
</FaultStruct>
<StartTime>${this.StartTime.toISOString()}</StartTime>
<CompleteTime>${this.CompleteTime.toISOString()}</CompleteTime>
</cwmp:AutonomousTransferComplete>`
}
}
+32
View File
@@ -0,0 +1,32 @@
// @ts-nocheck — fichier copié de cwmp-connector (papaye)
import { Cwmp_1_2 } from '#protocol/cwmp-1-2'
/**
* CWMP Version 1.3 Protocol Classes
*
* Extends CWMP 1.2 with:
* - New SOAP Header element: SessionTimeout
*/
export class Cwmp_1_3 extends Cwmp_1_2 {
public '@class' = '.Cwmp_1_3'
public 'SessionTimeout'?: SessionTimeout
'constructor'(props: any = {}) {
super(props)
this.SessionTimeout = props.SessionTimeout
? new SessionTimeout(props.SessionTimeout)
: undefined
}
}
// SOAP Header element for CWMP 1.3
export class SessionTimeout {
public '@class' = '.SessionTimeout'
public 'MustUnderstand': string = '0'
public '_': number
'constructor'(props: any = {}) {
this.MustUnderstand = props.MustUnderstand ?? '0'
this._ = props._ ?? 0
}
}
+73
View File
@@ -0,0 +1,73 @@
// @ts-nocheck — fichier copié de cwmp-connector (papaye)
import { Cwmp_1_3 } from '#protocol/cwmp-1-3'
/**
* CWMP Version 1.4 Protocol Classes
*
* Extends CWMP 1.3 with:
* - New ACS Fault Code 8006 (handled at validation level, not needed here)
* - New Event Code: 13 WAKEUP
* - New SOAP Header elements: SupportedCWMPVersions, UseCWMPVersion
*/
export class Cwmp_1_4 extends Cwmp_1_3 {
public '@class' = '.Cwmp_1_4'
public 'SupportedCWMPVersions'?: SupportedCWMPVersions
public 'UseCWMPVersion'?: UseCWMPVersion
'constructor'(props: any = {}) {
super(props)
this.SupportedCWMPVersions = props.SupportedCWMPVersions
? new SupportedCWMPVersions(props.SupportedCWMPVersions)
: undefined
this.UseCWMPVersion = props.UseCWMPVersion
? new UseCWMPVersion(props.UseCWMPVersion)
: undefined
}
}
export enum EventCode_1_4 {
BOOTSTRAP = '0 BOOTSTRAP',
BOOT = '1 BOOT',
PERIODIC = '2 PERIODIC',
SCHEDULED = '3 SCHEDULED',
VALUE_CHANGE = '4 VALUE CHANGE',
KICKED = '5 KICKED',
CONNECTION_REQUEST = '6 CONNECTION REQUEST',
TRANSFER_COMPLETE = '7 TRANSFER COMPLETE',
DIAGNOSTICS_COMPLETE = '8 DIAGNOSTICS COMPLETE',
REQUEST_DOWNLOAD = '9 REQUEST DOWNLOAD',
AUTONOMOUS_TRANSFER_COMPLETE = '10 AUTONOMOUS TRANSFER COMPLETE',
DU_STATE_CHANGE_COMPLETE = '11 DU STATE CHANGE COMPLETE',
AUTONOMOUS_DU_STATE_CHANGE_COMPLETE = '12 AUTONOMOUS DU STATE CHANGE COMPLETE',
WAKEUP = '13 WAKEUP',
REBOOT = 'M Reboot',
SCHEDULE_INFORM = 'M ScheduleInform',
DOWNLOAD = 'M Download',
SCHEDULE_DOWNLOAD = 'M ScheduleDownload',
UPLOAD = 'M Upload',
CHANGE_DU_STATE = 'M ChangeDUState',
X_METHOD = 'M X_',
X_PATTERN = 'X [0-9A-F]{6} .*',
}
export class SupportedCWMPVersions {
public '@class' = '.SupportedCWMPVersions'
public 'MustUnderstand'?: string // should be 0 per spec, optional due to SOAP 1.1 quirks
public '_': string // space or comma separated list per implementation
'constructor'(props: any = {}) {
this.MustUnderstand = props.MustUnderstand ?? '0'
this._ = props._ || ''
}
}
export class UseCWMPVersion {
public '@class' = '.UseCWMPVersion'
public 'MustUnderstand': string = '1'
public '_': string
'constructor'(props: any = {}) {
this.MustUnderstand = props.MustUnderstand ?? '1'
this._ = props._ || ''
}
}
+45 -27
View File
@@ -1,4 +1,7 @@
import { XMLParser } from 'fast-xml-parser'
import type { CwmpMessage, SoapEnvelope, SoapHeader } from '#protocol/tr069_types'
export { type SoapEnvelope, type SoapHeader, type CwmpMessage } from '#protocol/tr069_types'
export const CWMP_VERSIONS: Record<string, string> = {
'urn:dslforum-org:cwmp-1-0': '1.0',
@@ -19,30 +22,25 @@ const xmlParser = new XMLParser({
},
})
export interface SoapEnvelope {
header: SoapHeader
body: any
cwmpVersion: string
}
export interface SoapHeader {
id?: string
holdRequests?: boolean
noMoreRequests?: boolean
}
export interface CwmpMessage {
type: string
data: any
}
export function parseSoapXml(xml: string): SoapEnvelope {
const parsed = xmlParser.parse(xml)
const envelope = parsed['soap-env:Envelope'] || parsed['SOAP-ENV:Envelope'] || parsed['soapenv:Envelope'] || {}
const header = envelope['soap-env:Header'] || envelope['SOAP-ENV:Header'] || envelope['soapenv:Header'] || {}
const body = envelope['soap-env:Body'] || envelope['SOAP-ENV:Body'] || envelope['soapenv:Body'] || {}
const parsed = xmlParser.parse(xml) as Record<string, unknown>
const envelope =
(parsed['soap-env:Envelope'] as Record<string, unknown>) ||
(parsed['SOAP-ENV:Envelope'] as Record<string, unknown>) ||
(parsed['soapenv:Envelope'] as Record<string, unknown>) ||
{}
const header =
(envelope['soap-env:Header'] as Record<string, unknown>) ||
(envelope['SOAP-ENV:Header'] as Record<string, unknown>) ||
(envelope['soapenv:Header'] as Record<string, unknown>) ||
{}
const body =
(envelope['soap-env:Body'] as Record<string, unknown>) ||
(envelope['SOAP-ENV:Body'] as Record<string, unknown>) ||
(envelope['soapenv:Body'] as Record<string, unknown>) ||
{}
// Detect CWMP version from namespace
// Detect CWMP version from namespace attributes
let cwmpVersion = '1.0'
for (const [, value] of Object.entries(envelope)) {
if (typeof value === 'string' && value.startsWith('urn:dslforum-org:cwmp-')) {
@@ -54,26 +52,46 @@ export function parseSoapXml(xml: string): SoapEnvelope {
const soapHeader: SoapHeader = {}
const idNode = header['cwmp:ID'] || header['cwmp:Id']
if (idNode) {
soapHeader.id = typeof idNode === 'string' ? idNode : idNode._ || idNode
soapHeader.id =
typeof idNode === 'string' ? idNode : (idNode as { _?: string })?._ || String(idNode)
}
return { header: soapHeader, body, cwmpVersion }
}
export function extractCwmpMessage(body: any): CwmpMessage | null {
/** Types de méthodes CWMP acceptés en entrée (CPE→ACS) */
const ALLOWED_CWMP_TYPES = new Set([
'Inform',
'TransferComplete',
'GetParameterNamesResponse',
'GetParameterValuesResponse',
'GetParameterAttributesResponse',
'SetParameterValuesResponse',
'SetParameterAttributesResponse',
'RebootResponse',
'FactoryResetResponse',
'DownloadResponse',
'UploadResponse',
'ScheduleInformResponse',
'Fault',
])
export function extractCwmpMessage(body: Record<string, unknown>): CwmpMessage | null {
for (const key of Object.keys(body)) {
if (key.startsWith('cwmp:')) {
const type = key.replace('cwmp:', '')
return { type, data: body[key] }
if (!ALLOWED_CWMP_TYPES.has(type)) return null
const data = body[key]
return { type, data } as CwmpMessage
}
}
// Detect SOAP Fault (CPE sends soap-env:Fault / SOAP-ENV:Fault when it cannot process a request)
// SOAP Fault envoyé par le CPE (soap-env:Fault / SOAP-ENV:Fault)
const faultKey = Object.keys(body).find(
(k) => k.toLowerCase().endsWith(':fault') || k === 'Fault'
)
if (faultKey) {
return { type: 'Fault', data: body[faultKey] }
return { type: 'Fault', data: body[faultKey] } as CwmpMessage
}
return null
+215
View File
@@ -0,0 +1,215 @@
/**
* Classes SOAP Envelope (extraites de cwmp-connector/src/protocol/soap.ts)
*
* Séparées de soap.ts (le module de parsing d'adam-acs) pour éviter les conflits de nom.
*/
import { Cwmp_1_0 } from '#protocol/cwmp-1-0'
import { Cwmp_1_1 } from '#protocol/cwmp-1-1'
import { Cwmp_1_2 } from '#protocol/cwmp-1-2'
import { Cwmp_1_3, SessionTimeout } from '#protocol/cwmp-1-3'
import { Cwmp_1_4, SupportedCWMPVersions, UseCWMPVersion } from '#protocol/cwmp-1-4'
type NamespaceDescriptor = {
SoapEnc: string
SoapEnv: string
Xsd: string
Xsi: string
Cwmp: string
}
export const NAMESPACES: Record<string, NamespaceDescriptor> = {
'1.0': {
SoapEnc: 'http://schemas.xmlsoap.org/soap/encoding/',
SoapEnv: 'http://schemas.xmlsoap.org/soap/envelope/',
Xsd: 'http://www.w3.org/2001/XMLSchema',
Xsi: 'http://www.w3.org/2001/XMLSchema-instance',
Cwmp: 'urn:dslforum-org:cwmp-1-0',
},
'1.1': {
SoapEnc: 'http://schemas.xmlsoap.org/soap/encoding/',
SoapEnv: 'http://schemas.xmlsoap.org/soap/envelope/',
Xsd: 'http://www.w3.org/2001/XMLSchema',
Xsi: 'http://www.w3.org/2001/XMLSchema-instance',
Cwmp: 'urn:dslforum-org:cwmp-1-1',
},
'1.2': {
SoapEnc: 'http://schemas.xmlsoap.org/soap/encoding/',
SoapEnv: 'http://schemas.xmlsoap.org/soap/envelope/',
Xsd: 'http://www.w3.org/2001/XMLSchema',
Xsi: 'http://www.w3.org/2001/XMLSchema-instance',
Cwmp: 'urn:dslforum-org:cwmp-1-2',
},
'1.3': {
SoapEnc: 'http://schemas.xmlsoap.org/soap/encoding/',
SoapEnv: 'http://schemas.xmlsoap.org/soap/envelope/',
Xsd: 'http://www.w3.org/2001/XMLSchema',
Xsi: 'http://www.w3.org/2001/XMLSchema-instance',
Cwmp: 'urn:dslforum-org:cwmp-1-3',
},
'1.4': {
SoapEnc: 'http://schemas.xmlsoap.org/soap/encoding/',
SoapEnv: 'http://schemas.xmlsoap.org/soap/envelope/',
Xsd: 'http://www.w3.org/2001/XMLSchema',
Xsi: 'http://www.w3.org/2001/XMLSchema-instance',
Cwmp: 'urn:dslforum-org:cwmp-1-4',
},
}
export class SoapEnvelopeAttribute {
public SoapEnc: string
public SoapEnv: string
public Xsd: string
public Xsi: string
public Cwmp: string
constructor(props: NamespaceDescriptor | Record<string, string> = NAMESPACES['1.0']) {
this.SoapEnc = props?.SoapEnc || 'http://schemas.xmlsoap.org/soap/encoding/'
this.SoapEnv = props?.SoapEnv || 'http://schemas.xmlsoap.org/soap/envelope/'
this.Xsd = props?.Xsd || 'http://www.w3.org/2001/XMLSchema'
this.Xsi = props?.Xsi || 'http://www.w3.org/2001/XMLSchema-instance'
this.Cwmp = props?.Cwmp || 'urn:dslforum-org:cwmp-1-0'
}
public isEqualTo(namespace: NamespaceDescriptor): boolean {
return (
this.SoapEnc === namespace.SoapEnc &&
this.SoapEnv === namespace.SoapEnv &&
this.Xsd === namespace.Xsd &&
this.Xsi === namespace.Xsi &&
this.Cwmp === namespace.Cwmp
)
}
}
export class SoapCwmpIdAttribute {
MustUnderstand: string
constructor(props: { MustUnderstand?: string } = {}) {
this.MustUnderstand = props.MustUnderstand || '1'
}
getXml(): string {
return ` soap-env:mustUnderstand="${this.MustUnderstand}"`
}
}
export class CwmpID {
$?: SoapCwmpIdAttribute
_?: string
constructor(props: { $?: { MustUnderstand?: string }; _?: string } = {}) {
this.$ = props?.$ ? new SoapCwmpIdAttribute(props.$) : undefined
this._ = props._ || ''
}
getXml(): string {
return `<cwmp:ID${this.$?.getXml() || ''}>${this._}</cwmp:ID>`
}
}
export type CwmpBody = Cwmp_1_0 | Cwmp_1_1 | Cwmp_1_2 | Cwmp_1_3 | Cwmp_1_4
export class SoapEnvelopeHeader {
ID: CwmpID
NoMoreRequests: number
SessionTimeout?: SessionTimeout
SupportedCWMPVersions?: SupportedCWMPVersions
UseCWMPVersion?: UseCWMPVersion
constructor(props: Record<string, unknown> = {}) {
this.ID = new CwmpID(props?.ID as any)
this.NoMoreRequests = (props?.NoMoreRequests as number) || 0
this.SessionTimeout = props?.SessionTimeout
? new SessionTimeout(props.SessionTimeout)
: undefined
this.SupportedCWMPVersions = props?.SupportedCWMPVersions
? new SupportedCWMPVersions(props.SupportedCWMPVersions)
: undefined
this.UseCWMPVersion = props?.UseCWMPVersion
? new UseCWMPVersion(props.UseCWMPVersion)
: undefined
}
}
export class SoapEnvelope {
$?: SoapEnvelopeAttribute
Header: SoapEnvelopeHeader
Body: CwmpBody
version: '1.0' | '1.1' | '1.2' | '1.3' | '1.4'
constructor(props: Record<string, unknown> = {}) {
this.version = (props?.version as '1.0' | '1.1' | '1.2' | '1.3' | '1.4') || '1.0'
this.$ = props?.$
? new SoapEnvelopeAttribute(props.$ as any)
: new SoapEnvelopeAttribute(NAMESPACES[this.version])
this.Header = new SoapEnvelopeHeader((props.Header as Record<string, unknown>) || {})
const bodyProps = (props.Body as Record<string, unknown>) || {}
switch (this.version) {
case '1.4':
this.Body = new Cwmp_1_4(bodyProps)
break
case '1.3':
this.Body = new Cwmp_1_3(bodyProps)
break
case '1.2':
this.Body = new Cwmp_1_2(bodyProps)
break
case '1.1':
this.Body = new Cwmp_1_1(bodyProps)
break
default:
this.Body = new Cwmp_1_0(bodyProps)
break
}
}
getXml(): string {
const headerParts: string[] = []
if (this.Header.ID) headerParts.push(this.Header.ID.getXml())
if (this.Header.SessionTimeout) {
headerParts.push(
`<cwmp:SessionTimeout soap-env:mustUnderstand="${this.Header.SessionTimeout.MustUnderstand}">${this.Header.SessionTimeout._}</cwmp:SessionTimeout>`
)
}
if (this.Header.SupportedCWMPVersions) {
headerParts.push(
`<cwmp:SupportedCWMPVersions${this.Header.SupportedCWMPVersions.MustUnderstand !== undefined ? ` soap-env:mustUnderstand="${this.Header.SupportedCWMPVersions.MustUnderstand}"` : ''}>${this.Header.SupportedCWMPVersions._}</cwmp:SupportedCWMPVersions>`
)
}
if (this.Header.UseCWMPVersion) {
headerParts.push(
`<cwmp:UseCWMPVersion soap-env:mustUnderstand="${this.Header.UseCWMPVersion.MustUnderstand}">${this.Header.UseCWMPVersion._}</cwmp:UseCWMPVersion>`
)
}
// noMoreRequests est un header CPE→ACS uniquement (TR-069 §3.7.1.4) — ne pas l'émettre côté ACS
return `<soap-env:Envelope
xmlns:soap-enc="${this.$?.SoapEnc}"
xmlns:soap-env="${this.$?.SoapEnv}"
xmlns:xsd="${this.$?.Xsd}"
xmlns:xsi="${this.$?.Xsi}"
xmlns:cwmp="${this.$?.Cwmp}">
<soap-env:Header>
${headerParts.join('\n ')}
</soap-env:Header>
<soap-env:Body>
${
this.Body.InformResponse?.getXml() ||
this.Body.GetParameterValues?.getXml() ||
this.Body.GetParameterNames?.getXml() ||
this.Body.GetParameterAttributes?.getXml() ||
this.Body.SetParameterValues?.getXml() ||
this.Body.SetParameterAttributes?.getXml() ||
this.Body.ScheduleInform?.getXml() ||
this.Body.Download?.getXml() ||
this.Body.Upload?.getXml() ||
this.Body.Reboot?.getXml() ||
this.Body.FactoryReset?.getXml() ||
this.Body.TransferCompleteResponse?.getXml() ||
''
}
</soap-env:Body>
</soap-env:Envelope>`
}
}
+205
View File
@@ -0,0 +1,205 @@
/**
* TR-069 / CWMP Protocol Types
*
* Réexporte les classes et types des fichiers cwmp-1-x copiés depuis cwmp-connector (papaye).
* Point d'entrée unique pour tous les types CWMP dans adam-acs.
*/
// ─── Classes & enums de base (CWMP 1.0) ──────────────────────────────────────
export {
Cwmp_1_0,
EventCode,
FaultCodeType,
// Structures parsées CPE → ACS
Inform,
InformResponse,
TransferComplete,
TransferCompleteResponse,
// Structures ACS → CPE (réponses)
GetParameterNamesResponse,
GetParameterValuesResponse,
GetParameterAttributesResponse,
SetParameterValuesResponse,
SetParameterAttributesResponse,
RebootResponse,
FactoryResetResponse,
DownloadResponse,
UploadResponse,
ScheduleInformResponse,
// Structures de building block
DeviceIdStruct,
EventStruct,
Event,
ParameterValueStruct,
ParameterValueList,
ParameterValueAttribute,
ValueObject,
ValueObjectAttribute,
ParameterInfoStruct,
ParameterInfoList,
ParameterAttributeStruct,
ParameterAttributeList,
SetParameterAttributesStruct,
SetParameterAttributesList,
AccessList,
FaultStruct,
FaultDetail,
Fault,
// Commandes ACS → CPE
GetParameterNames,
GetParameterValues,
GetParameterAttributes,
SetParameterValues,
SetParameterAttributes,
Download,
Upload,
Reboot,
FactoryReset,
ScheduleInform,
// Types primitifs
type CommandKeyType,
type ObjectNameType,
} from '#protocol/cwmp-1-0'
// ─── CWMP 1.1 ─────────────────────────────────────────────────────────────────
export { Cwmp_1_1, EventCode_1_1, FaultCodeType_1_1 } from '#protocol/cwmp-1-1'
// ─── CWMP 1.2 ─────────────────────────────────────────────────────────────────
export { Cwmp_1_2, EventCode_1_2 } from '#protocol/cwmp-1-2'
// ─── CWMP 1.3 ─────────────────────────────────────────────────────────────────
export { Cwmp_1_3, SessionTimeout } from '#protocol/cwmp-1-3'
// ─── CWMP 1.4 ─────────────────────────────────────────────────────────────────
export { Cwmp_1_4, EventCode_1_4, SupportedCWMPVersions, UseCWMPVersion } from '#protocol/cwmp-1-4'
// ─── Types locaux complémentaires ─────────────────────────────────────────────
import type { Cwmp_1_0 } from '#protocol/cwmp-1-0'
import type { Cwmp_1_1 } from '#protocol/cwmp-1-1'
import type { Cwmp_1_2 } from '#protocol/cwmp-1-2'
import type { Cwmp_1_3 } from '#protocol/cwmp-1-3'
import type { Cwmp_1_4 } from '#protocol/cwmp-1-4'
import type {
Inform,
TransferComplete,
GetParameterNamesResponse,
GetParameterValuesResponse,
GetParameterAttributesResponse,
SetParameterValuesResponse,
Fault as SoapFaultBody,
} from '#protocol/cwmp-1-0'
/** Union de tous les corps CWMP selon la version du protocole */
export type CwmpBody = Cwmp_1_0 | Cwmp_1_1 | Cwmp_1_2 | Cwmp_1_3 | Cwmp_1_4
/** Union discriminée des messages CWMP parsés depuis le XML CPE */
export type CwmpMessage =
| { type: 'Inform'; data: Inform }
| { type: 'TransferComplete'; data: TransferComplete }
| { type: 'GetParameterNamesResponse'; data: GetParameterNamesResponse }
| { type: 'GetParameterValuesResponse'; data: GetParameterValuesResponse }
| { type: 'GetParameterAttributesResponse'; data: GetParameterAttributesResponse }
| { type: 'SetParameterValuesResponse'; data: SetParameterValuesResponse }
| { type: 'SetParameterAttributesResponse'; data: Record<string, unknown> }
| { type: 'RebootResponse'; data: Record<string, unknown> }
| { type: 'FactoryResetResponse'; data: Record<string, unknown> }
| { type: 'DownloadResponse'; data: Record<string, unknown> }
| { type: 'UploadResponse'; data: Record<string, unknown> }
| { type: 'ScheduleInformResponse'; data: Record<string, unknown> }
| { type: 'Fault'; data: SoapFaultBody }
/** Header SOAP simplifié (extrait par le parser) */
export interface SoapHeader {
id?: string
holdRequests?: boolean
noMoreRequests?: boolean
}
/** Enveloppe SOAP telle que retournée par parseSoapXml() */
export interface SoapEnvelope {
header: SoapHeader
body: Record<string, unknown>
cwmpVersion: string
}
// ─── Types des payloads de tâches CWMP ────────────────────────────────────────
export type CWMPTaskType =
| 'get_parameter_names'
| 'get_parameter_attributes'
| 'get_parameter_values'
| 'set_parameter_values'
| 'set_parameter_attributes'
| 'schedule_inform'
| 'download'
| 'upload'
| 'factory_reset'
| 'reboot'
export interface SetParameterValuesTaskPayload {
parameterValueList: Array<{
Name: string
Value: { _: string | number | boolean; $?: { Type: string } }
}>
parameterKey?: string
}
export interface SetParameterAttributesTaskPayload {
name: string
notificationChange: boolean
notification: number
accessListChange: boolean
accessList: string[]
}
export interface ScheduleInformTaskPayload {
delayInSeconds: number
commandKey?: string
}
export interface DownloadTaskPayload {
commandKey?: string
fileType?: string
url: string
username?: string
password?: string
fileSize?: number
targetFileName?: string
delaySeconds?: number
successUrl?: string
failureUrl?: string
}
export interface UploadTaskPayload {
commandKey?: string
fileType?: string
url: string
username?: string
password?: string
delaySeconds?: number
}
export interface RebootTaskPayload {
commandKey?: string
}
export type CWMPTaskPayload =
| string // get_parameter_names
| string[] // get_parameter_values / get_parameter_attributes
| SetParameterValuesTaskPayload
| SetParameterAttributesTaskPayload[]
| ScheduleInformTaskPayload
| DownloadTaskPayload
| UploadTaskPayload
| RebootTaskPayload
| Record<string, never> // factory_reset
/** Données de résultat retournées dans TaskResult.data selon le type de tâche */
export type CWMPTaskResultData =
| GetParameterNamesResponse
| GetParameterValuesResponse
| GetParameterAttributesResponse
| SetParameterValuesResponse
| SoapFaultBody
| Record<string, unknown>
+11 -5
View File
@@ -1,7 +1,7 @@
import CommandModel from '#models/command'
import { CommandRecord, CommandStatus, CommandType } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import { Priority } from '../domain/commands/command.js'
import { type CommandRecord, CommandStatus, type CommandType } from '#domain/commands/command'
import { ModeType } from '#domain/shared/mode'
import { Priority } from '#domain/commands/command'
export default class CommandService {
async getCommands() {
@@ -32,7 +32,10 @@ export default class CommandService {
})
}
async getICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
async getICommandsForDeviceWithStatus(
deviceIdentifier: string,
status: CommandStatus
): Promise<CommandRecord[]> {
const collection = CommandModel.getCollection()
return collection
.find({ deviceIdentifier, mode: ModeType.I, status })
@@ -40,7 +43,10 @@ export default class CommandService {
.toArray() as unknown as Promise<CommandRecord[]>
}
async getNICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
async getNICommandsForDeviceWithStatus(
deviceIdentifier: string,
status: CommandStatus
): Promise<CommandRecord[]> {
const collection = CommandModel.getCollection()
return collection
.find({ deviceIdentifier, mode: ModeType.NI, status })
+33 -18
View File
@@ -1,12 +1,16 @@
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'
import FirmwareCatalogService from '#services/firmware_catalog_service'
import type {
Configuration,
ParameterCriteria,
ParameterValueType,
} from '#domain/configuration/configuration'
import type { Device } from '#domain/device/device'
import type { MigrationStep } from '#domain/firmware/migration_step'
import { isWithinSchedule } from '#utils/schedule_window'
import { matchGlob } from '#utils/glob_match'
import { computeContentHash, parseProvisioningCode } from '#utils/provisioning_code'
export interface ComplianceDeviceResult {
identifier: string
@@ -140,7 +144,10 @@ export default class ConfigurationEngine {
skipFirmware = true
} else {
// Get current device version (from properties or parameter)
const currentVersion = this.getDeviceVersion(device, selectedConfig.firmwareTarget.productClass)
const currentVersion = this.getDeviceVersion(
device,
selectedConfig.firmwareTarget.productClass
)
if (currentVersion && currentVersion !== selectedConfig.firmwareTarget.targetVersion) {
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
@@ -153,9 +160,7 @@ export default class ConfigurationEngine {
}
// Check if compliant (no diffs and no firmware needed)
const isCompliant =
parameterDiffs.length === 0 &&
firmwareMigrationPath.length === 0
const isCompliant = parameterDiffs.length === 0 && firmwareMigrationPath.length === 0
return {
configuration: selectedConfig,
@@ -369,17 +374,21 @@ export default class ConfigurationEngine {
// 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 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)
valuesMatch =
!Number.isNaN(numCurrent) && !Number.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)
@@ -404,7 +413,10 @@ export default class ConfigurationEngine {
* 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 {
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)
@@ -419,7 +431,10 @@ export default class ConfigurationEngine {
}
// Fallback: plain DeviceRecord objects (parameters + properties arrays)
const allEntries = [...((device as any).parameters ?? []), ...((device as any).properties ?? [])]
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
+22 -14
View File
@@ -7,10 +7,10 @@ import {
type DesiredParameter,
type InventoryPath,
PolicyType,
} from '../domain/configuration/configuration.js'
import type { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
import { isParameterCoveredByInventory, suggestInventoryPath } from '../utils/inventory_coverage.js'
import { matchGlob } from '../utils/glob_match.js'
} from '#domain/configuration/configuration'
import type { ConfigurationVersion } from '#domain/configuration/configuration_version'
import { isParameterCoveredByInventory, suggestInventoryPath } from '#utils/inventory_coverage'
import { matchGlob } from '#utils/glob_match'
/**
* Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically).
@@ -82,7 +82,9 @@ export default class ConfigurationService {
/**
* Create a new Configuration after validation.
*/
async create(data: CreateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
async create(
data: CreateConfigurationDto
): Promise<Configuration & { warning?: OverlapWarning }> {
this.validateIdentifier(data.identifier)
this.validateVersion(data.version)
this.validatePriority(data.priority)
@@ -186,7 +188,10 @@ export default class ConfigurationService {
* Creates a version snapshot of the current state before applying changes.
* Auto-increments the patch version on behavioral changes and resets device compliance.
*/
async update(identifier: string, data: UpdateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
async update(
identifier: string,
data: UpdateConfigurationDto
): Promise<Configuration & { warning?: OverlapWarning }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
@@ -243,7 +248,8 @@ export default class ConfigurationService {
if (data.tags !== undefined) updatePayload.tags = data.tags
if (data.criteria !== undefined) updatePayload.criteria = data.criteria
if (data.inventory !== undefined) updatePayload.inventory = data.inventory
if (data.desiredParameters !== undefined) updatePayload.desiredParameters = data.desiredParameters
if (data.desiredParameters !== undefined)
updatePayload.desiredParameters = data.desiredParameters
if (data.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget
if (data.schedule !== undefined) updatePayload.schedule = data.schedule
@@ -271,7 +277,13 @@ export default class ConfigurationService {
identifier: string,
page: number = 1,
limit: number = 25
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
): Promise<{
data: ConfigurationVersion[]
total: number
page: number
perPage: number
lastPage: number
}> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
@@ -298,11 +310,7 @@ export default class ConfigurationService {
/**
* Add and/or remove tags from a Configuration without modifying other fields.
*/
async updateTags(
identifier: string,
add?: string[],
remove?: string[]
): Promise<Configuration> {
async updateTags(identifier: string, add?: string[], remove?: string[]): Promise<Configuration> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
@@ -538,7 +546,7 @@ export default class ConfigurationService {
private sanitizeCriteria(criteria: ConfigurationCriteria): ConfigurationCriteria {
if (!criteria.parameterCriteria?.length) return criteria
const filtered = criteria.parameterCriteria.filter(
(pc) => pc.name != null && String(pc.name).trim() !== ''
(pc) => pc.name !== null && pc.name !== undefined && String(pc.name).trim() !== ''
)
return {
...criteria,
+18 -6
View File
@@ -1,5 +1,5 @@
import { Device } from '../../domain/device/device.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import { type Device } from '#domain/device/device'
import { type DeviceActionExecutor } from '#services/cwmp/device_action_executor'
import logger from '@adonisjs/core/services/logger'
/**
@@ -23,17 +23,29 @@ export async function executeAction(
device: Device,
actionType: ActionType,
parameters: any[],
callback: ((result: unknown) => Promise<string>) = () => Promise.resolve('')
callback: (result: unknown) => Promise<string> = () => Promise.resolve('')
): Promise<string> {
switch (actionType) {
case ActionType.SET_PARAMETER_VALUES:
return deviceAction.applySetParameterValues(device, parameters as any[], callback as (r: boolean) => Promise<string>)
return deviceAction.applySetParameterValues(
device,
parameters as any[],
callback as (r: boolean) => Promise<string>
)
case ActionType.SET_PARAMETER_ATTRIBUTES:
return deviceAction.applySetParameterAttributes(device, parameters as any[], callback as (r: boolean) => Promise<string>)
return deviceAction.applySetParameterAttributes(
device,
parameters as any[],
callback as (r: boolean) => Promise<string>
)
case ActionType.SCHEDULE_INFORM:
return deviceAction.applyScheduleInform(device, parameters[0] as any, callback as (r: boolean) => Promise<string>)
return deviceAction.applyScheduleInform(
device,
parameters[0] as any,
callback as (r: boolean) => Promise<string>
)
case ActionType.DOWNLOAD: {
const p = parameters[0]
@@ -1,11 +1,12 @@
import ConfigurationEngine from '../configuration_engine.js'
import type { EngineResult } from '../configuration_engine.js'
import type { DeviceStateResult, DeviceContext } from './main_state_machine.js'
import { PartialOperationalState } from './main_state_machine.js'
import ConfigurationEngine from '#services/configuration_engine'
import type { EngineResult } from '#services/configuration_engine'
import type { DeviceStateResult, DeviceContext } from '#services/cwmp/main_state_machine'
import { PartialOperationalState } from '#services/cwmp/main_state_machine'
import DeviceModel from '#models/device'
import type { GetParameterNamesResponse, GetParameterValuesResponse } from '#protocol/tr069_types'
import logger from '@adonisjs/core/services/logger'
import { ComplianceStatus } from '../../domain/configuration/compliance.js'
import { buildProvisioningCode } from '../../utils/provisioning_code.js'
import { ComplianceStatus } from '#domain/configuration/compliance'
import { buildProvisioningCode } from '#utils/provisioning_code'
/**
* ConfigurationEvaluationState
@@ -54,7 +55,8 @@ export class ConfigurationEvaluationState {
{
'compliance.status': ComplianceStatus.IN_PROGRESS,
'compliance.lastEvaluatedAt': new Date(),
'compliance.configurationIdentifier': this.engineResult.configuration?.identifier || null,
'compliance.configurationIdentifier':
this.engineResult.configuration?.identifier || null,
}
)
@@ -71,7 +73,10 @@ export class ConfigurationEvaluationState {
return this.processNextAction(context)
} catch (error: any) {
logger.error({ identifier: context.device.identifier, error: error.message }, '[ConfigEval] Evaluation failed')
logger.error(
{ identifier: context.device.identifier, error: error.message },
'[ConfigEval] Evaluation failed'
)
await DeviceModel.updateOne(
{ identifier: context.device.identifier },
@@ -82,11 +87,13 @@ export class ConfigurationEvaluationState {
}
).catch(() => {})
return { nextState: new PartialOperationalState(context.device, {
reason: 'configuration_evaluation_failed',
blockingState: 'configuration-evaluation',
error: error.message,
}) }
return {
nextState: new PartialOperationalState(context.device, {
reason: 'configuration_evaluation_failed',
blockingState: 'configuration-evaluation',
error: error.message,
}),
}
}
}
@@ -95,43 +102,66 @@ export class ConfigurationEvaluationState {
if (this.awaitingPhase === 'gpn_response') {
this.awaitingPhase = null
if (context.message?.success && context.message.data) {
const infoList = context.message.data?.ParameterList?.ParameterInfoStruct || []
const gpnResp = context.message.data as GetParameterNamesResponse
const infoList = gpnResp?.ParameterList?.ParameterInfoStruct ?? []
const leafNames: string[] = infoList
.map((info: any) => {
.map((info) => {
const n = info?.Name
return typeof n === 'string' ? n : (n && typeof n._ === 'string' ? n._ : null)
return typeof n === 'string'
? n
: n && typeof (n as { _?: string })._ === 'string'
? (n as { _: string })._
: null
})
.filter((name: any): name is string => !!name && !name.endsWith('.'))
.filter((name): name is string => !!name && !name.endsWith('.'))
if (leafNames.length > 0) {
this.pendingValuesQueue.push(...leafNames)
}
logger.debug({ identifier: context.device.identifier, leafCount: leafNames.length }, '[ConfigEval] Inventory GPN response processed inline')
logger.debug(
{ identifier: context.device.identifier, leafCount: leafNames.length },
'[ConfigEval] Inventory GPN response processed inline'
)
} else if (context.message && !context.message.success) {
// GPN failed during inventory — record failure
context.device.recordGpnFailure()
logger.debug({ identifier: context.device.identifier }, '[ConfigEval] Inventory GPN failed — skipping remaining inventory')
logger.debug(
{ identifier: context.device.identifier },
'[ConfigEval] Inventory GPN failed — skipping remaining inventory'
)
this.inventoryQueue = []
}
} else if (this.awaitingPhase === 'gpv_response') {
this.awaitingPhase = null
if (context.message?.success && context.message.data) {
const list = context.message.data?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
const normalized = list.map((pv: any) => ({
const gpvResp = context.message.data as GetParameterValuesResponse
const list = gpvResp?.ParameterList?.ParameterValueStruct ?? []
if (list.length > 0) {
const normalized = list.map((pv) => ({
...pv,
Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name,
Name:
typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || pv?.Name,
}))
context.device.updateFromValues(normalized)
logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device')
logger.debug(
{ identifier: context.device.identifier, count: normalized.length },
'[ConfigEval] Inventory GPV values saved to device'
)
}
}
}
// 1. Inventory phase 1: GetParameterNames per subtree path
// Skip if device does not support GPN or circuit breaker is active
if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported' && context.device.isGpnAllowed()) {
if (
this.inventoryQueue.length > 0 &&
context.device.gpnStatus !== 'unsupported' &&
context.device.isGpnAllowed()
) {
const path = this.inventoryQueue.shift()!
logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames')
logger.debug(
{ identifier: context.device.identifier, path },
'[ConfigEval] Inventory GetParameterNames'
)
const response = await context.services.deviceAction.getParameterNames(
context.device,
path,
@@ -141,14 +171,20 @@ export class ConfigurationEvaluationState {
return { response }
} else if (this.inventoryQueue.length > 0) {
// GPN unsupported or circuit breaker active — skip inventory
logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported or circuit breaker active)')
logger.debug(
{ identifier: context.device.identifier, skipped: this.inventoryQueue.length },
'[ConfigEval] Skipping inventory (GPN unsupported or circuit breaker active)'
)
this.inventoryQueue = []
}
// 2. Inventory phase 2: GetParameterValues for discovered leaf names
if (this.pendingValuesQueue.length > 0) {
const leaves = this.pendingValuesQueue.splice(0)
logger.debug({ identifier: context.device.identifier, count: leaves.length }, '[ConfigEval] Inventory GetParameterValues')
logger.debug(
{ identifier: context.device.identifier, count: leaves.length },
'[ConfigEval] Inventory GetParameterValues'
)
const response = await context.services.deviceAction.getParameterValues(
context.device,
leaves,
@@ -161,7 +197,10 @@ export class ConfigurationEvaluationState {
// 3. All parameter diffs in one SetParameterValues RPC
if (this.paramDiffs.length > 0) {
const diffs = this.paramDiffs.splice(0)
logger.debug({ identifier: context.device.identifier, count: diffs.length }, '[ConfigEval] Applying parameter diffs')
logger.debug(
{ identifier: context.device.identifier, count: diffs.length },
'[ConfigEval] Applying parameter diffs'
)
const params = diffs.map((d) => ({ parameterName: d.name, value: d.value, type: d.type }))
const response = await context.services.deviceAction.applySetParameterValues(
context.device,
@@ -174,10 +213,19 @@ export class ConfigurationEvaluationState {
// 4. Firmware upgrade (if needed and within schedule)
if (this.engineResult!.firmwareMigrationPath.length > 0 && !this.engineResult!.skipFirmware) {
logger.debug(
{ identifier: context.device.identifier, steps: this.engineResult!.firmwareMigrationPath.length },
{
identifier: context.device.identifier,
steps: this.engineResult!.firmwareMigrationPath.length,
},
'[ConfigEval] Transitioning to firmware upgrade'
)
return { nextState: new FirmwareUpgradeState(this.engineResult!.firmwareMigrationPath, 0, this.engineResult!) }
return {
nextState: new FirmwareUpgradeState(
this.engineResult!.firmwareMigrationPath,
0,
this.engineResult!
),
}
}
// 5. All done
@@ -211,7 +259,11 @@ export class FirmwareUpgradeState {
}
logger.debug(
{ identifier: context.device.identifier, step: this.currentStepIndex + 1, total: this.migrationSteps.length },
{
identifier: context.device.identifier,
step: this.currentStepIndex + 1,
total: this.migrationSteps.length,
},
'[FirmwareUpgrade] Sending Download RPC'
)
@@ -230,7 +282,10 @@ export class FirmwareUpgradeState {
return { response }
} catch (error: any) {
logger.error({ identifier: context.device.identifier, error: error.message }, '[FirmwareUpgrade] Download RPC failed')
logger.error(
{ identifier: context.device.identifier, error: error.message },
'[FirmwareUpgrade] Download RPC failed'
)
await DeviceModel.updateOne(
{ identifier: context.device.identifier },
@@ -276,7 +331,8 @@ export class ComplianceUpdateState {
}
if (this.engineResult.configuration) {
updateData['compliance.configurationIdentifier'] = this.engineResult.configuration.identifier
updateData['compliance.configurationIdentifier'] =
this.engineResult.configuration.identifier
}
// Always build and write provisioning code to device (functional for "once", informative for "maintain")
@@ -289,18 +345,32 @@ export class ComplianceUpdateState {
const currentPCValue = currentPC?.value ?? ''
if (currentPCValue === provCode) {
logger.debug({ identifier: context.device.identifier, provCode }, '[ComplianceUpdate] Provisioning code already matches — skipping SPV')
logger.debug(
{ identifier: context.device.identifier, provCode },
'[ComplianceUpdate] Provisioning code already matches — skipping SPV'
)
updateData.provisioningCode = provCode
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(
() => {}
)
return { nextState: new ExecutingCommandsState(true) }
}
updateData.provisioningCode = provCode
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(
() => {}
)
context.device.tmpPC = provCode
logger.debug({ identifier: context.device.identifier, provCode, policy: this.engineResult.configuration.policy }, '[ComplianceUpdate] Writing provisioning code')
logger.debug(
{
identifier: context.device.identifier,
provCode,
policy: this.engineResult.configuration.policy,
},
'[ComplianceUpdate] Writing provisioning code'
)
const response = await context.services.deviceAction.insertProvisioningCode(
context.device,
@@ -309,8 +379,13 @@ export class ComplianceUpdateState {
return { response }
}
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
logger.debug({ identifier: context.device.identifier }, '[ComplianceUpdate] Compliance finalized (no configuration)')
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(
() => {}
)
logger.debug(
{ identifier: context.device.identifier },
'[ComplianceUpdate] Compliance finalized (no configuration)'
)
return { nextState: new ExecutingCommandsState(true) }
}
+100 -55
View File
@@ -1,18 +1,27 @@
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 DeviceModel from '../../models/device.js'
import { type Device } from '#domain/device/device'
import { DeviceParameter } from '#domain/device/device_parameter'
import { ParameterType } from '#domain/shared/types'
import { type CWMPTaskQueue, ResultStatus, type TaskResult } from '#services/cwmp/task_queue'
import DeviceModel from '#models/device'
import logger from '@adonisjs/core/services/logger'
import type {
GetParameterValuesResponse,
GetParameterAttributesResponse,
GetParameterNamesResponse,
ParameterValueStruct,
ParameterAttributeStruct,
ParameterInfoStruct,
} from '#protocol/tr069_types'
// Parameter types (simplified from legacy provisioning domain)
interface SetParameterValue {
// ─── Action parameter interfaces (exported pour usage externe) ─────────────────
export interface SetParameterValue {
parameterName: string
value: string | number | boolean
type?: string
}
interface SetParameterAttribute {
export interface SetParameterAttribute {
name: string
notificationChange: boolean
notification: number
@@ -20,12 +29,12 @@ interface SetParameterAttribute {
accessList: string[]
}
interface SetScheduleInform {
export interface SetScheduleInform {
delayInSeconds: number
commandKey?: string
}
interface DownloadParameter {
export interface DownloadParameter {
commandKey: string
fileType: string
url: string
@@ -38,7 +47,7 @@ interface DownloadParameter {
failureUrl?: string
}
interface UploadParameter {
export interface UploadParameter {
commandKey: string
fileType: string
url: string
@@ -47,11 +56,11 @@ interface UploadParameter {
delayInSeconds?: number
}
interface FactoryResetParameter {
export interface FactoryResetParameter {
commandKey?: string
}
interface RebootParameter {
export interface RebootParameter {
commandKey?: string
}
@@ -94,9 +103,9 @@ export class DeviceActionExecutor {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
const resp = result.data as GetParameterValuesResponse
const list: ParameterValueStruct[] = resp?.ParameterList?.ParameterValueStruct ?? []
if (list.length > 0) {
device.updateFromValues(list)
await this.persistDevice(device)
}
@@ -107,7 +116,12 @@ export class DeviceActionExecutor {
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback).xml
return this.taskQueue.addTask(
device.deviceId,
'get_parameter_values',
parameterPaths,
wrappedCallback
).xml
}
async getParameterAttributes(
@@ -118,13 +132,18 @@ export class DeviceActionExecutor {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterAttributeStruct
if (Array.isArray(list) && list.length > 0) {
const resp = result.data as GetParameterAttributesResponse
const list: ParameterAttributeStruct[] =
resp?.ParameterList?.ParameterAttributeStruct ?? []
if (list.length > 0) {
for (const attr of list) {
const name: string | undefined = attr?.Name
const name: string | undefined =
typeof attr?.Name === 'string'
? attr.Name
: (attr?.Name as { _?: string } | undefined)?._
if (!name) continue
const notification: number | undefined = typeof attr?.Notification === 'number' ? attr.Notification : undefined
const notification: number | undefined =
typeof attr?.Notification === 'number' ? attr.Notification : undefined
const accessList: string[] | undefined = attr?.AccessList?.string
const param = device.getParameter(name)
@@ -146,7 +165,12 @@ export class DeviceActionExecutor {
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback).xml
return this.taskQueue.addTask(
device.deviceId,
'get_parameter_attributes',
parameterList,
wrappedCallback
).xml
}
async getParameterNames(
@@ -157,7 +181,9 @@ export class DeviceActionExecutor {
const wrappedCallback = async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
try {
device.organizeParameters(result.data as any, new Date())
const resp = result.data as GetParameterNamesResponse
const list: ParameterInfoStruct[] = resp?.ParameterList?.ParameterInfoStruct ?? []
device.organizeParameters(list, new Date())
device.setGpnStatus('supported')
device.recordGpnSuccess()
device.lastGpnSentDate = new Date()
@@ -167,29 +193,41 @@ export class DeviceActionExecutor {
}
} else {
// Inspect fault code to distinguish "unsupported" from "transient error"
const faultData = result.data as {
FaultCode?: number | string
faultCode?: number | string
Detail?: { Fault?: { FaultCode?: number | string } }
detail?: { Fault?: { FaultCode?: number | string } }
}
const faultCode =
result.data?.FaultCode ??
result.data?.faultCode ??
result.data?.Detail?.Fault?.FaultCode ??
result.data?.detail?.Fault?.FaultCode
const code = typeof faultCode === 'string' ? parseInt(faultCode, 10) : faultCode
faultData?.FaultCode ??
faultData?.faultCode ??
faultData?.Detail?.Fault?.FaultCode ??
faultData?.detail?.Fault?.FaultCode
const code = typeof faultCode === 'string' ? Number.parseInt(faultCode, 10) : faultCode
const GPN_UNSUPPORTED_CODES = [9000, 9001, 9003, 9004, 9005]
const GPN_TRANSIENT_CODES = [9002]
if (GPN_UNSUPPORTED_CODES.includes(code)) {
if (code !== undefined && GPN_UNSUPPORTED_CODES.includes(code)) {
device.setGpnStatus('unsupported')
device.recordGpnFailure()
await this.persistDevice(device)
} else if (GPN_TRANSIENT_CODES.includes(code)) {
// Transient error: log warning, do not mark unsupported, allow retry this session
logger.warn({ code, deviceId: device.deviceId?.serialNumber }, 'GPN transient error (9002) — will not mark unsupported')
} else if (code !== undefined && GPN_TRANSIENT_CODES.includes(code)) {
logger.warn(
{ code, deviceId: device.deviceId?.serialNumber },
'GPN transient error (9002) — will not mark unsupported'
)
}
// Network timeout or other codes: do not mark unsupported, skip GPN for this session only
}
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback).xml
return this.taskQueue.addTask(
device.deviceId,
'get_parameter_names',
parameterName,
wrappedCallback
).xml
}
async applySetParameterValues(
@@ -199,12 +237,15 @@ export class DeviceActionExecutor {
): Promise<string> {
const existingParameters = device.getParameters()
const inferType = (val: any): string => {
const inferType = (val: string | number | boolean): string => {
const t = typeof val
switch (t) {
case 'boolean': return 'xsd:boolean'
case 'number': return Number.isInteger(val) ? 'xsd:int' : 'xsd:string'
default: return 'xsd:string'
case 'boolean':
return 'xsd:boolean'
case 'number':
return Number.isInteger(val) ? 'xsd:int' : 'xsd:string'
default:
return 'xsd:string'
}
}
@@ -218,18 +259,17 @@ export class DeviceActionExecutor {
const pvStructs = parameters.map((p) => {
const existing = existingParameters.find((ep) => ep.name === p.parameterName)
// Priority: 1) explicit type from config, 2) known device type, 3) inferred from JS value
let xsdType: string
if (p.type && cwmpTypeMap[p.type]) {
xsdType = cwmpTypeMap[p.type]
} else if (existing?.type && existing.type !== ParameterType.UNKNOWN) {
xsdType = existing.type // already in xsd: format (e.g. 'xsd:string')
xsdType = existing.type
} else {
xsdType = inferType(p.value)
}
return {
Name: p.parameterName,
Value: { _: p.value, $: { Type: xsdType } },
Value: { _: p.value as string, $: { Type: xsdType } },
}
})
@@ -241,7 +281,7 @@ export class DeviceActionExecutor {
{ parameterValueList: pvStructs, parameterKey },
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
device.updateFromValues(pvStructs as any)
device.updateFromValues(pvStructs as ParameterValueStruct[])
await this.persistDevice(device)
return await callback(true)
}
@@ -260,7 +300,7 @@ export class DeviceActionExecutor {
notificationChange: p.notificationChange ?? false,
notification: p.notification ?? 0,
accessListChange: p.accessListChange ?? false,
accessList: (p.accessList as any)?.string || [],
accessList: (p.accessList as unknown as { string?: string[] })?.string || p.accessList || [],
}))
return this.taskQueue.addTask(
@@ -281,7 +321,8 @@ export class DeviceActionExecutor {
parameter: SetScheduleInform,
callback: (result: boolean) => Promise<string>
): Promise<string> {
const commandKey = parameter.commandKey || `ScheduleInform_${device.deviceId.serialNumber}_${Date.now()}`
const commandKey =
parameter.commandKey || `ScheduleInform_${device.deviceId.serialNumber}_${Date.now()}`
return this.taskQueue.addTask(
device.deviceId,
'schedule_inform',
@@ -300,7 +341,7 @@ export class DeviceActionExecutor {
return this.taskQueue.addTask(
_device.deviceId,
'factory_reset',
{},
{} as Record<string, never>,
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
@@ -377,9 +418,8 @@ export class DeviceActionExecutor {
const allParams = device.getParameters()
const allProps = device.getProperties()
const knownPC = [...allParams, ...allProps].find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name))
const knownPC = [...allParams, ...allProps].find((p) => /(^|\.)ProvisioningCode$/.test(p?.name))
// Use the detected data model stored on the device; fall back to scanning known params
let provisioningPath: string
if (knownPC) {
provisioningPath = knownPC.name
@@ -388,13 +428,18 @@ export class DeviceActionExecutor {
} else if (device.dataModel === 'tr-098') {
provisioningPath = 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
} else {
// Legacy fallback: scan stored params
const hasDeviceTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('Device.'))
provisioningPath = hasDeviceTree ? 'Device.DeviceInfo.ProvisioningCode' : 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
const hasDeviceTree = [...allParams, ...allProps].some(
(p) => typeof p?.name === 'string' && p.name.startsWith('Device.')
)
provisioningPath = hasDeviceTree
? 'Device.DeviceInfo.ProvisioningCode'
: 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
}
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
const pvStructs = [{ Name: provisioningPath, Value: { _: device.tmpPC, $: { Type: 'xsd:string' } } }]
const pvStructs = [
{ Name: provisioningPath, Value: { _: device.tmpPC as string, $: { Type: 'xsd:string' } } },
]
return this.taskQueue.addTask(
device.deviceId,
@@ -402,10 +447,10 @@ export class DeviceActionExecutor {
{ parameterValueList: pvStructs, parameterKey },
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
// ProvisioningCode must always be stored as a writable parameter.
// If it was previously classified as a read-only property (wrong), remove it first.
device.removeProperty(provisioningPath)
device.setParameter(new DeviceParameter(provisioningPath, device.tmpPC, ParameterType.STRING))
device.setParameter(
new DeviceParameter(provisioningPath, device.tmpPC, ParameterType.STRING)
)
await this.persistDevice(device)
device.tmpPC = ''
return await callback(true)
@@ -1,7 +1,12 @@
import { Device } from '../../domain/device/device.js'
import { CWMPTaskQueue } from './task_queue.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import { ExecutingCommandsState, DeviceContext, DeviceStateResult } from './main_state_machine.js'
import { type Device } from '#domain/device/device'
import { type CWMPTaskQueue } from '#services/cwmp/task_queue'
import { DeviceActionExecutor } from '#services/cwmp/device_action_executor'
import {
ExecutingCommandsState,
type DeviceContext,
type DeviceStateResult,
} from '#services/cwmp/main_state_machine'
import type { CWMPTaskResultData } from '#protocol/tr069_types'
import logger from '@adonisjs/core/services/logger'
/**
@@ -26,11 +31,13 @@ export class ImmediateCommandStateMachine {
}
async start(): Promise<string> {
logger.info(`Starting immediate-command state machine for device ${this.context.device.deviceId?.serialNumber}`)
logger.info(
`Starting immediate-command state machine for device ${this.context.device.deviceId?.serialNumber}`
)
return this.execute()
}
async handleResponse(success: boolean, message: any): Promise<string> {
async handleResponse(success: boolean, message: CWMPTaskResultData): Promise<string> {
this.context.message = { success, data: message }
return this.execute()
}
@@ -40,7 +47,11 @@ export class ImmediateCommandStateMachine {
}
isCompleted(): boolean {
return this.currentState.name === 'completed' || this.currentState.name === 'operational' || this.currentState.name === 'partial_operational'
return (
this.currentState.name === 'completed' ||
this.currentState.name === 'operational' ||
this.currentState.name === 'partial_operational'
)
}
private async execute(): Promise<string> {
+289 -94
View File
@@ -1,21 +1,34 @@
import { Device } from '../../domain/device/device.js'
import { CWMPTaskQueue } from './task_queue.js'
import { DeviceActionExecutor } from './device_action_executor.js'
import { getDeviceHashIdentifier } from '../../utils/device_utils.js'
import { CommandStatus, CommandType } from '../../domain/commands/command.js'
import DeviceModel from '../../models/device.js'
import CommandModel from '../../models/command.js'
import { ModeType } from '../../domain/shared/mode.js'
import { type Device } from '#domain/device/device'
import { type CWMPTaskQueue } from '#services/cwmp/task_queue'
import { DeviceActionExecutor } from '#services/cwmp/device_action_executor'
import { getDeviceHashIdentifier } from '#utils/device_utils'
import { CommandStatus, CommandType } from '#domain/commands/command'
import DeviceModel from '#models/device'
import CommandModel from '#models/command'
import { ModeType } from '#domain/shared/mode'
import logger from '@adonisjs/core/services/logger'
import { ConfigurationEvaluationState } from './configuration_evaluation_state.js'
import { reclassifyDeviceFromCatalog } from '../parameter_writability.js'
import { ConfigurationEvaluationState } from '#services/cwmp/configuration_evaluation_state'
import { reclassifyDeviceFromCatalog } from '#services/parameter_writability'
import type {
CWMPTaskResultData,
GetParameterNamesResponse,
GetParameterValuesResponse,
ParameterValueStruct,
} from '#protocol/tr069_types'
import type {
SetParameterAttribute,
DownloadParameter,
UploadParameter,
RebootParameter,
} from '#services/cwmp/device_action_executor'
import { type ObjectId } from 'mongodb'
// ─── Interfaces ────────────────────────────────────────────────────────────────
export interface DeviceContext {
device: Device
session: string
message?: { success: boolean; data: any }
message?: { success: boolean; data: CWMPTaskResultData }
taskQueue: CWMPTaskQueue
events?: string[]
services: {
@@ -54,13 +67,15 @@ class IdleState extends DeviceState {
if (device.gpnStatus === 'unknown') {
// Never classified → all tmpParams are unclassified
for (const pv of tmpParams) {
const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null
const name =
typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || null
if (name) unclassified.push(name)
}
} else {
// Already classified before → only params NOT in _parameters AND NOT in _properties are new
for (const pv of tmpParams) {
const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null
const name =
typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || null
if (!name) continue
if (!device.getParameter(name) && !device.getProperty(name)) {
unclassified.push(name)
@@ -77,19 +92,30 @@ class IdleState extends DeviceState {
return { nextState: new CriticalReadsState(), response: '' }
}
// Extract unique parent paths from the UNCLASSIFIED params only
// Extract unique parent paths from the UNCLASSIFIED params only.
// Skip root-level paths (e.g. "Device." or "InternetGatewayDevice.") that would
// produce a huge GPN response — root-level params like Device.RootDataModelVersion
// will be discovered when deeper subtrees are explored or classified via catalog.
const parentPaths = new Set<string>()
for (const name of unclassified) {
const lastDot = name.lastIndexOf('.')
if (lastDot > 0) {
parentPaths.add(name.substring(0, lastDot + 1))
if (lastDot <= 0) continue
const parentPath = name.substring(0, lastDot + 1)
// Only add paths with at least 2 segments (e.g. "Device.DeviceInfo." not "Device.")
const segments = parentPath.replace(/\.$/, '').split('.')
if (segments.length >= 2) {
parentPaths.add(parentPath)
}
}
// Case 1: GPN unsupported or circuit breaker open → classify unclassified via catalog
if (device.gpnStatus === 'unsupported' || !device.isGpnAllowed()) {
logger.info(
{ gpnStatus: device.gpnStatus, unclassifiedCount: unclassified.length, deviceId: device.identifier },
{
gpnStatus: device.gpnStatus,
unclassifiedCount: unclassified.length,
deviceId: device.identifier,
},
'[IdleState] GPN unavailable — classifying unclassified params via catalog'
)
reclassifyDeviceFromCatalog(device)
@@ -100,7 +126,12 @@ class IdleState extends DeviceState {
// Case 3: GPN unknown → send GPN to test support + classify
const paths = Array.from(parentPaths).sort()
logger.info(
{ paths, unclassifiedCount: unclassified.length, gpnStatus: device.gpnStatus, deviceId: device.identifier },
{
paths,
unclassifiedCount: unclassified.length,
gpnStatus: device.gpnStatus,
deviceId: device.identifier,
},
'[IdleState] Classifying unclassified params via GPN'
)
return { nextState: new DiscoveringParametersState(paths), response: '' }
@@ -159,7 +190,11 @@ class DiscoveringParametersState extends DeviceState {
updatedAt: new Date(),
})
logger.info(
{ hashId, gpnStatus: context.device.gpnStatus, circuitBreaker: context.device.gpnCircuitBreaker },
{
hashId,
gpnStatus: context.device.gpnStatus,
circuitBreaker: context.device.gpnCircuitBreaker,
},
'[DiscoveringParameters] Persisted GPN failure + catalog classification to DB'
)
} catch (e) {
@@ -172,14 +207,22 @@ class DiscoveringParametersState extends DeviceState {
// GPN success — process the response
if (context.message?.success && context.message.data) {
try {
context.device.organizeParameters(context.message.data, new Date())
const gpnResp = context.message.data as GetParameterNamesResponse
const infoList = gpnResp?.ParameterList?.ParameterInfoStruct ?? []
context.device.organizeParameters(infoList, new Date())
context.device.setGpnStatus('supported')
logger.info(
{ deviceId: context.device.identifier, paramCount: context.device.getParameters().length },
{
deviceId: context.device.identifier,
paramCount: context.device.getParameters().length,
},
'[DiscoveringParameters] GPN response processed'
)
} catch (e: any) {
logger.error({ error: e?.message }, '[DiscoveringParameters] Error processing GPN response')
} catch (e: unknown) {
logger.error(
{ error: (e as Error)?.message },
'[DiscoveringParameters] Error processing GPN response'
)
}
}
@@ -194,7 +237,9 @@ class DiscoveringParametersState extends DeviceState {
private async processNext(context: DeviceContext): Promise<string> {
if (this.parameterQueue.length === 0) return ''
const current = this.parameterQueue.shift()!
return context.services.deviceAction.getParameterNames(context.device, current, () => Promise.resolve(''))
return context.services.deviceAction.getParameterNames(context.device, current, () =>
Promise.resolve('')
)
}
}
@@ -219,7 +264,14 @@ class CriticalReadsState extends DeviceState {
`${root}.DeviceInfo.HardwareVersion`,
`${root}.DeviceInfo.SoftwareVersion`,
`${root}.DeviceInfo.ModelName`,
`${root}.DeviceInfo.ProvisioningCode`,
`${root}.DeviceInfo.UpTime`,
`${root}.ManagementServer.URL`,
`${root}.ManagementServer.ConnectionRequestURL`,
`${root}.ManagementServer.ConnectionRequestUsername`,
`${root}.ManagementServer.ConnectionRequestPassword`,
`${root}.ManagementServer.PeriodicInformInterval`,
`${root}.ManagementServer.PeriodicInformEnable`,
]
const response = await context.services.deviceAction.getParameterValues(
@@ -227,9 +279,9 @@ class CriticalReadsState extends DeviceState {
criticalPaths,
async (result) => {
if (result.status === 'success') {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
const resp = result.data as GetParameterValuesResponse
const list = resp?.ParameterList?.ParameterValueStruct ?? []
if (list.length > 0) {
context.device.setTmpParamValues(list)
}
}
@@ -242,20 +294,27 @@ class CriticalReadsState extends DeviceState {
// After GPV response: process values from the response, then reclassify
if (context.message?.success && context.message.data) {
try {
const resp = context.message.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
const resp = context.message.data as GetParameterValuesResponse
const list = resp?.ParameterList?.ParameterValueStruct ?? []
if (list.length > 0) {
// Normalize Name fields: XMLParser may return {_: "path", "$xsi:type": "..."} when Name has attributes
const normalized = list.map((pv: any) => ({
const normalized = list.map((pv) => ({
...pv,
Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name,
Name:
typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || pv?.Name,
}))
context.device.setTmpParamValues(normalized)
context.device.updateFromValues(normalized)
logger.info({ count: normalized.length, names: normalized.map((p: any) => p.Name) }, '[CriticalReads] GPV response values processed')
logger.info(
{ count: normalized.length, names: normalized.map((p) => p.Name) },
'[CriticalReads] GPV response values processed'
)
}
} catch (e: any) {
logger.error({ error: e?.message }, '[CriticalReads] Error processing GPV response values')
} catch (e: unknown) {
logger.error(
{ error: (e as Error)?.message },
'[CriticalReads] Error processing GPV response values'
)
}
}
@@ -267,9 +326,9 @@ class CriticalReadsState extends DeviceState {
export class ExecutingCommandsState extends DeviceState {
name = 'executing-commands'
private commandsQueue: any[] = []
private commandsQueue: Record<string, unknown>[] = []
private started = false
private currentCommandRecord: any | null = null
private currentCommandRecord: Record<string, unknown> | null = null
private includeNI: boolean
constructor(includeNI: boolean = true) {
@@ -287,15 +346,33 @@ export class ExecutingCommandsState extends DeviceState {
})
const collection = CommandModel.getCollection()
const iWaiting = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.WAITING }).sort({ priority: 1 }).toArray()
const iPending = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray()
const iWaiting = await collection
.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.WAITING })
.sort({ priority: 1 })
.toArray()
const iPending = await collection
.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.PENDING })
.sort({ priority: 1 })
.toArray()
const niPending = this.includeNI
? await collection.find({ deviceIdentifier: hashId, mode: ModeType.NI, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray()
? await collection
.find({ deviceIdentifier: hashId, mode: ModeType.NI, status: CommandStatus.PENDING })
.sort({ priority: 1 })
.toArray()
: []
this.commandsQueue = [...iWaiting, ...iPending, ...niPending]
this.started = true
logger.debug({ hashId, iWaiting: iWaiting.length, iPending: iPending.length, niPending: niPending.length, includeNI: this.includeNI }, '[ExecutingCommands] Loaded commands queue')
logger.debug(
{
hashId,
iWaiting: iWaiting.length,
iPending: iPending.length,
niPending: niPending.length,
includeNI: this.includeNI,
},
'[ExecutingCommands] Loaded commands queue'
)
if (this.commandsQueue.length === 0) {
if (this.includeNI) {
@@ -318,10 +395,17 @@ export class ExecutingCommandsState extends DeviceState {
// Optimistic update for SPV
if (this.currentCommandRecord.action === CommandType.SET_PARAMETER_VALUES) {
const params = Array.isArray(this.currentCommandRecord.parameters) ? this.currentCommandRecord.parameters : []
const pvStructs = params.map((p: any) => ({
Name: p?.name,
Value: { _: p?.value, $: { Type: 'xsd:string' } },
const rawList = Array.isArray(this.currentCommandRecord.parameters)
? (this.currentCommandRecord.parameters as Array<{ name?: string; value?: unknown }>)
: []
const pvStructs: ParameterValueStruct[] = rawList.map((p) => ({
'@class': '.ParameterValueStruct',
'Name': p?.name ?? '',
'Value': {
'@class': '.ValueObject',
'_': String(p?.value ?? ''),
'$': { '@class': '.ValueObjectAttribute', 'Type': 'xsd:string' },
},
}))
if (pvStructs.length > 0) {
context.device.updateFromValues(pvStructs)
@@ -333,10 +417,10 @@ export class ExecutingCommandsState extends DeviceState {
}
try {
await CommandModel.updateCommand(this.currentCommandRecord._id, {
status: this.currentCommandRecord.status,
executedAt: now,
completedAt: this.currentCommandRecord.completedAt,
await CommandModel.updateCommand(this.currentCommandRecord._id as ObjectId, {
status: this.currentCommandRecord.status as CommandStatus | undefined,
executedAt: this.currentCommandRecord.executedAt as Date | undefined,
completedAt: this.currentCommandRecord.completedAt as Date | undefined,
updatedAt: now,
})
} catch (err) {
@@ -368,7 +452,11 @@ export class ExecutingCommandsState extends DeviceState {
commandRecord.status = CommandStatus.SENT
commandRecord.executedAt = now
try {
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.SENT, executedAt: now, updatedAt: now })
await CommandModel.updateCommand(commandRecord._id as ObjectId, {
status: CommandStatus.SENT,
executedAt: now,
updatedAt: now,
})
} catch (err) {
logger.error(`Failed to mark command as SENT: ${err}`)
}
@@ -379,64 +467,145 @@ export class ExecutingCommandsState extends DeviceState {
} catch {}
const action = commandRecord.action as CommandType
const params = commandRecord.parameters
// params is typed as unknown from MongoDB — we cast to a flexible record for access
const params = commandRecord.parameters as
| Record<string, unknown>
| string
| unknown[]
| undefined
// Map CommandType to CWMP request
switch (action) {
case CommandType.GET_PARAMETER_VALUES: {
const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || []
return context.services.deviceAction.getParameterValues(context.device, names, () => Promise.resolve(''))
const p = params as Record<string, unknown> | string[] | undefined
const names: string[] = Array.isArray(p)
? (p as string[])
: ((p as Record<string, unknown>)?.names as string[]) ||
((p as Record<string, unknown>)?.paths as string[]) ||
((p as Record<string, unknown>)?.parameterNames as string[]) ||
[]
return context.services.deviceAction.getParameterValues(context.device, names, () =>
Promise.resolve('')
)
}
case CommandType.GET_PARAMETER_NAMES: {
const name: string = typeof params === 'string' ? params : (params?.name || params?.path || params?.parameterPath || '')
return context.services.deviceAction.getParameterNames(context.device, name, () => Promise.resolve(''))
const p = params as Record<string, unknown> | string | undefined
const name: string =
typeof p === 'string'
? p
: ((p as Record<string, unknown>)?.name as string) ||
((p as Record<string, unknown>)?.path as string) ||
((p as Record<string, unknown>)?.parameterPath as string) ||
''
return context.services.deviceAction.getParameterNames(context.device, name, () =>
Promise.resolve('')
)
}
case CommandType.GET_PARAMETER_ATTRIBUTES: {
const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || []
return context.services.deviceAction.getParameterAttributes(context.device, names, () => Promise.resolve(''))
const p = params as Record<string, unknown> | string[] | undefined
const names: string[] = Array.isArray(p)
? (p as string[])
: ((p as Record<string, unknown>)?.names as string[]) ||
((p as Record<string, unknown>)?.paths as string[]) ||
((p as Record<string, unknown>)?.parameterNames as string[]) ||
[]
return context.services.deviceAction.getParameterAttributes(context.device, names, () =>
Promise.resolve('')
)
}
case CommandType.SET_PARAMETER_VALUES: {
const rawParams = Array.isArray(params)
? params
: params?.parameterValues || params?.parameters || []
const spvParams = rawParams.map((p: any) => ({
parameterName: p.parameterName ?? p.name,
value: p.value,
type: p.type,
const p = params as
| Record<string, unknown>
| Array<{ parameterName?: string; name?: string; value: unknown; type?: string }>
| undefined
const rawParams: Array<{
parameterName?: string
name?: string
value: unknown
type?: string
}> = Array.isArray(p)
? p
: ((p as Record<string, unknown>)?.parameterValues as typeof rawParams) ||
((p as Record<string, unknown>)?.parameters as typeof rawParams) ||
[]
const spvParams = rawParams.map((pv) => ({
parameterName: pv.parameterName ?? pv.name ?? '',
value: pv.value as string | number | boolean,
type: pv.type,
}))
return context.services.deviceAction.applySetParameterValues(context.device, spvParams, () => Promise.resolve(''))
return context.services.deviceAction.applySetParameterValues(
context.device,
spvParams,
() => Promise.resolve('')
)
}
case CommandType.SET_PARAMETER_ATTRIBUTES: {
const spaParams = Array.isArray(params) ? params : params?.attributes || params?.parameterAttributes || []
return context.services.deviceAction.applySetParameterAttributes(context.device, spaParams, () => Promise.resolve(''))
const p = params as Record<string, unknown> | unknown[] | undefined
const spaParams = Array.isArray(p)
? p
: ((p as Record<string, unknown>)?.attributes as unknown[]) ||
((p as Record<string, unknown>)?.parameterAttributes as unknown[]) ||
[]
return context.services.deviceAction.applySetParameterAttributes(
context.device,
spaParams as SetParameterAttribute[],
() => Promise.resolve('')
)
}
case CommandType.DOWNLOAD: {
return context.services.deviceAction.applyDownload(context.device, params || {}, () => Promise.resolve(''))
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyDownload(
context.device,
p as unknown as DownloadParameter,
() => Promise.resolve('')
)
}
case CommandType.UPLOAD: {
return context.services.deviceAction.applyUpload(context.device, params || {}, () => Promise.resolve(''))
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyUpload(
context.device,
p as unknown as UploadParameter,
() => Promise.resolve('')
)
}
case CommandType.REBOOT: {
return context.services.deviceAction.applyReboot(context.device, params || {}, () => Promise.resolve(''))
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyReboot(context.device, p as RebootParameter, () =>
Promise.resolve('')
)
}
case CommandType.FACTORY_RESET: {
return context.services.deviceAction.applyFactoryReset(context.device, {}, () => Promise.resolve(''))
return context.services.deviceAction.applyFactoryReset(context.device, {}, () =>
Promise.resolve('')
)
}
case CommandType.SCHEDULE_INFORM: {
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyScheduleInform(
context.device,
{ delayInSeconds: params?.delayInSeconds || 300, commandKey: params?.commandKey },
{
delayInSeconds: (p?.delayInSeconds as number) || 300,
commandKey: p?.commandKey as string | undefined,
},
() => Promise.resolve('')
)
}
case CommandType.REFRESH: {
// Refresh = GetParameterValues on all known leaf parameters that have non-empty values
const allNames = [
...context.device.getParameters().filter((p) => p.value != null && p.value !== '').map((p) => p.name),
...context.device.getProperties().filter((p) => p.value != null && p.value !== '').map((p) => p.name),
...context.device
.getParameters()
.filter((p) => p.value !== null && p.value !== undefined && p.value !== '')
.map((p) => p.name),
...context.device
.getProperties()
.filter((p) => p.value !== null && p.value !== undefined && p.value !== '')
.map((p) => p.name),
].filter((name) => !!name && !name.endsWith('.'))
if (allNames.length > 0) {
return context.services.deviceAction.getParameterValues(context.device, allNames, () => Promise.resolve(''))
return context.services.deviceAction.getParameterValues(context.device, allNames, () =>
Promise.resolve('')
)
}
return ''
}
@@ -444,7 +613,10 @@ export class ExecutingCommandsState extends DeviceState {
logger.error(`Unknown command type: ${action}`)
// Mark as failed
commandRecord.status = CommandStatus.FAILED
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.FAILED, updatedAt: new Date() })
await CommandModel.updateCommand(commandRecord._id as ObjectId, {
status: CommandStatus.FAILED,
updatedAt: new Date(),
})
this.currentCommandRecord = null
if (this.commandsQueue.length > 0) {
return this.processNextCommand(context)
@@ -486,7 +658,10 @@ export class OperationalState extends DeviceState {
logger.error(`Failed to persist device on completion: ${e}`)
}
logger.info({ deviceId: device.identifier, state: 'operational' }, '[StateMachine] Session completed successfully')
logger.info(
{ deviceId: device.identifier, state: 'operational' },
'[StateMachine] Session completed successfully'
)
return { isComplete: true, response: '' }
}
@@ -500,7 +675,10 @@ export class PartialOperationalState extends DeviceState {
name = 'partial_operational'
private failureContext: { reason: string; blockingState?: string; error?: any }
constructor(_device: Device, failureContext: { reason: string; blockingState?: string; error?: any }) {
constructor(
_device: Device,
failureContext: { reason: string; blockingState?: string; error?: any }
) {
super()
this.failureContext = failureContext
}
@@ -530,12 +708,15 @@ export class PartialOperationalState extends DeviceState {
logger.error(`Failed to persist device on partial completion: ${e}`)
}
logger.error({
deviceId: device.identifier,
reason: this.failureContext.reason,
blockingState: this.failureContext.blockingState,
state: 'partial_operational',
}, '[StateMachine] Session completed with degraded status')
logger.error(
{
deviceId: device.identifier,
reason: this.failureContext.reason,
blockingState: this.failureContext.blockingState,
state: 'partial_operational',
},
'[StateMachine] Session completed with degraded status'
)
return { isComplete: true, response: '' }
}
@@ -564,13 +745,15 @@ export class MainStateMachine {
return this.executeCurrentState()
}
async handleResponse(success: boolean, message: any): Promise<string> {
async handleResponse(success: boolean, message: CWMPTaskResultData): Promise<string> {
this.context.message = { success, data: message }
return this.executeCurrentState()
}
isCompleted(): boolean {
return this.currentState.name === 'operational' || this.currentState.name === 'partial_operational'
return (
this.currentState.name === 'operational' || this.currentState.name === 'partial_operational'
)
}
getCurrentStateName(): string {
@@ -583,11 +766,17 @@ export class MainStateMachine {
while (transitionCount++ < MAX_STATE_TRANSITIONS) {
try {
logger.debug({ state: this.currentState.name, iteration: transitionCount }, '[StateMachine] Executing state')
logger.debug(
{ state: this.currentState.name, iteration: transitionCount },
'[StateMachine] Executing state'
)
const result = await this.currentState.handle(this.context)
if (result.nextState) {
logger.debug({ from: this.currentState.name, to: result.nextState.name }, '[StateMachine] State transition')
logger.debug(
{ from: this.currentState.name, to: result.nextState.name },
'[StateMachine] State transition'
)
this.currentState = result.nextState
}
@@ -597,7 +786,10 @@ export class MainStateMachine {
}
if (result.response && result.response !== '') {
logger.debug({ state: this.currentState.name, responseLength: result.response.length }, '[StateMachine] Sending response to CPE')
logger.debug(
{ state: this.currentState.name, responseLength: result.response.length },
'[StateMachine] Sending response to CPE'
)
return result.response
}
@@ -618,11 +810,14 @@ export class MainStateMachine {
}
// Phase 9: Explicit error handling when max transitions exceeded
logger.error({
deviceId: this.context.device.identifier,
lastState: this.currentState.name,
sessionId: this.context.session,
}, `State machine stuck in ${this.currentState.name} after ${MAX_STATE_TRANSITIONS} transitions`)
logger.error(
{
deviceId: this.context.device.identifier,
lastState: this.currentState.name,
sessionId: this.context.session,
},
`State machine stuck in ${this.currentState.name} after ${MAX_STATE_TRANSITIONS} transitions`
)
this.currentState = new PartialOperationalState(this.context.device, {
reason: 'max_transitions_exceeded',
+158 -24
View File
@@ -1,14 +1,14 @@
import { EventEmitter } from 'node:events'
import type { Device } from '../../domain/device/device.js'
import type { DeviceId } from '../../domain/device/device_id.js'
import { CWMPTaskQueue, ResultStatus } from './task_queue.js'
import { MainStateMachine } from './main_state_machine.js'
import { ImmediateCommandStateMachine } from './immediate_command_state_machine.js'
import { getDeviceHashIdentifier } from '../../utils/device_utils.js'
import { resolveHighestPriorityEvent, normalizeEventCode } from '../../utils/cwmp-events.js'
import { CommandStatus } from '../../domain/commands/command.js'
import { ModeType } from '../../domain/shared/mode.js'
import CommandModel from '../../models/command.js'
import type { Device } from '#domain/device/device'
import type { DeviceId } from '#domain/device/device_id'
import { CWMPTaskQueue, ResultStatus } from '#services/cwmp/task_queue'
import { MainStateMachine } from '#services/cwmp/main_state_machine'
import { ImmediateCommandStateMachine } from '#services/cwmp/immediate_command_state_machine'
import { getDeviceHashIdentifier } from '#utils/device_utils'
import { resolveHighestPriorityEvent, normalizeEventCode } from '#utils/cwmp-events'
import { CommandStatus } from '#domain/commands/command'
import { ModeType } from '#domain/shared/mode'
import CommandModel from '#models/command'
import logger from '@adonisjs/core/services/logger'
export enum MachineStatus {
@@ -40,7 +40,8 @@ export class StateMachineManager extends EventEmitter {
private machines: Map<string, StateMachineInstance> = new Map()
private deviceSessions: Map<string, string> = new Map() // deviceKey -> sessionId
private sessionMachines: Map<string, string> = new Map() // sessionId -> machineId
private remoteToDevice: Map<string, string> = new Map() // remoteKey -> deviceKey
private remoteToDevice: Map<string, string> = new Map() // remoteKey -> deviceKeyJson
private deviceToRemotes: Map<string, Set<string>> = new Map() // deviceKeyJson -> Set<remoteKey>
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private static instance: StateMachineManager | null = null
@@ -128,7 +129,12 @@ export class StateMachineManager extends EventEmitter {
existingMachine.session = session
}
// Replace with MainStateMachine
const newMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
const newMachine = new MainStateMachine(
device,
session,
this.taskQueue,
normalizedEvents
)
;(existingMachine as any).stateMachine = newMachine
existingMachine.events = evts
await newMachine.start()
@@ -147,7 +153,11 @@ export class StateMachineManager extends EventEmitter {
// Inform cycle which means the previous session is dead. Complete the old
// machine and create a fresh one for the new session.
logger.warn(
{ oldSession: existingSessionId, newSession: session, state: existingMachine.currentState },
{
oldSession: existingSessionId,
newSession: session,
state: existingMachine.currentState,
},
'[StateMachineManager] Replacing stale MainStateMachine from dropped session'
)
this.completeMachine(existingMachine)
@@ -178,7 +188,11 @@ export class StateMachineManager extends EventEmitter {
} else if (primaryEvent === 'CONNECTION REQUEST') {
// CONNECTION REQUEST only if no BOOTSTRAP/BOOT present
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
} else if (primaryEvent === 'PERIODIC' || primaryEvent === 'VALUE CHANGE' || primaryEvent === 'SCHEDULED') {
} else if (
primaryEvent === 'PERIODIC' ||
primaryEvent === 'VALUE CHANGE' ||
primaryEvent === 'SCHEDULED'
) {
// Lightweight MainStateMachine
stateMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
} else if (primaryEvent === 'DIAGNOSTICS COMPLETE' || primaryEvent === 'TRANSFER COMPLETE') {
@@ -221,10 +235,31 @@ export class StateMachineManager extends EventEmitter {
}
/**
* Bind a remote endpoint to a device for session rebinding
* Bind a remote endpoint to a device for session rebinding.
* Maintains the reverse index deviceToRemotes for O(1) cleanup.
*/
bindRemoteToDevice(remoteKey: string, deviceId: DeviceId): void {
this.remoteToDevice.set(remoteKey, JSON.stringify(deviceId))
const deviceKeyJson = JSON.stringify(deviceId)
this.remoteToDevice.set(remoteKey, deviceKeyJson)
let remotes = this.deviceToRemotes.get(deviceKeyJson)
if (!remotes) {
remotes = new Set()
this.deviceToRemotes.set(deviceKeyJson, remotes)
}
remotes.add(remoteKey)
}
/**
* Remove all remoteToDevice entries for a device. Called on session completion/failure.
*/
private unbindRemotesForDevice(deviceKeyJson: string): void {
const remotes = this.deviceToRemotes.get(deviceKeyJson)
if (remotes) {
for (const rk of remotes) {
this.remoteToDevice.delete(rk)
}
this.deviceToRemotes.delete(deviceKeyJson)
}
}
/**
@@ -249,11 +284,98 @@ export class StateMachineManager extends EventEmitter {
}
/**
* Ensure session is bound when TCP connection changes
* Fallback lookup: returns the hash identifier for a RUNNING device whose
* last known remote IP matches the given IP (ignoring port).
* Used when a CPE opens a new TCP connection for an empty POST and the
* exact remoteKey (IP:port) is not in the map.
*
* Only considers devices with an active (RUNNING) state machine to avoid
* returning a stale or wrong device under NAT/CGNAT scenarios.
* Returns undefined if zero or multiple RUNNING devices match the IP.
*/
getHashIdForRemoteIp(remoteIp: string): string | undefined {
const candidates: string[] = []
for (const [key, deviceKeyJson] of this.remoteToDevice.entries()) {
// Strip port: for IPv4 "1.2.3.4:port" → "1.2.3.4"
// for IPv6 "[::1]:port" → "[::1]" (request.ip() returns without brackets)
const lastColon = key.lastIndexOf(':')
const keyIp = lastColon > 0 ? key.slice(0, lastColon) : key
// Normalize: strip brackets if present so "[::1]" matches "::1"
const normalizedKeyIp = keyIp.replace(/^\[|\]$/g, '')
if (normalizedKeyIp !== remoteIp) continue
// Only consider devices with a currently RUNNING session
const existingSession = this.deviceSessions.get(deviceKeyJson)
if (!existingSession) continue
const machineId = this.sessionMachines.get(existingSession)
if (!machineId) continue
const machine = this.machines.get(machineId)
if (!machine || machine.status !== MachineStatus.RUNNING) continue
if (!candidates.includes(deviceKeyJson)) {
candidates.push(deviceKeyJson)
}
}
// Ambiguous under NAT/CGNAT: refuse to guess
if (candidates.length !== 1) return undefined
try {
const deviceId = JSON.parse(candidates[0])
return getDeviceHashIdentifier({
manufacturer: deviceId.manufacturer ?? '',
oui: deviceId.oui,
productClass: deviceId.productClass,
serialNumber: deviceId.serialNumber,
})
} catch {
return undefined
}
}
/**
* Ensure session is bound when TCP connection changes.
*
* First tries an exact remoteKey match. If that fails (CPE opened a new TCP
* port for the empty POST — standard TR-069 behaviour), falls back to an
* IP-only match among RUNNING machines (single unambiguous candidate only).
* In both cases the new sessionId is registered in sessionMachines so that
* hasSession() returns true and continue() can run.
*/
ensureSessionForRemote(remoteKey: string, newSession: string): boolean {
const deviceKey = this.remoteToDevice.get(remoteKey)
if (!deviceKey) return false
// --- Exact match path ---
let deviceKey = this.remoteToDevice.get(remoteKey)
// --- IP-only fallback (CPE reconnected on a new TCP port) ---
if (!deviceKey) {
const lastColon = remoteKey.lastIndexOf(':')
const remoteIp =
lastColon > 0 ? remoteKey.slice(0, lastColon).replace(/^\[|\]$/g, '') : remoteKey
const candidates: string[] = []
for (const [key, dkJson] of this.remoteToDevice.entries()) {
const kLastColon = key.lastIndexOf(':')
const keyIp = (kLastColon > 0 ? key.slice(0, kLastColon) : key).replace(/^\[|\]$/g, '')
if (keyIp !== remoteIp) continue
const existSession = this.deviceSessions.get(dkJson)
if (!existSession) continue
const mid = this.sessionMachines.get(existSession)
if (!mid) continue
const m = this.machines.get(mid)
if (!m || m.status !== MachineStatus.RUNNING) continue
if (!candidates.includes(dkJson)) candidates.push(dkJson)
}
// Refuse to guess if ambiguous (NAT/CGNAT with multiple active devices)
if (candidates.length !== 1) return false
deviceKey = candidates[0]
// Register the new remoteKey so future lookups (exact or fallback) hit immediately
this.bindRemoteToDevice(remoteKey, JSON.parse(deviceKey))
}
const existingSession = this.deviceSessions.get(deviceKey)
if (!existingSession) return false
@@ -265,15 +387,17 @@ export class StateMachineManager extends EventEmitter {
if (!machine) return false
// Don't rebind to a completed/failed machine
if (machine.status !== MachineStatus.RUNNING) {
return false
}
if (machine.status !== MachineStatus.RUNNING) return false
if (existingSession !== newSession) {
this.sessionMachines.delete(existingSession)
this.sessionMachines.set(newSession, machineId)
this.deviceSessions.set(deviceKey, newSession)
machine.session = newSession
logger.debug(
{ oldSession: existingSession, newSession, deviceKey },
'[StateMachineManager] Session rebound via IP fallback (CPE new TCP port)'
)
}
return true
@@ -375,7 +499,11 @@ export class StateMachineManager extends EventEmitter {
/**
* Handle a CWMP response message
*/
async handleCWMPResponse(session: string, success: boolean, message: any): Promise<string> {
async handleCWMPResponse(
session: string,
success: boolean,
message: import('./../../protocol/tr069_types.js').CWMPTaskResultData
): Promise<string> {
const machineId = this.sessionMachines.get(session)
if (!machineId) return ''
@@ -435,7 +563,11 @@ export class StateMachineManager extends EventEmitter {
/**
* Enqueue an immediate task for a device (e.g., after Inform)
*/
async enqueueImmediate(device: Device, type: string, payload: any): Promise<void> {
async enqueueImmediate(
device: Device,
type: import('./../../protocol/tr069_types.js').CWMPTaskType,
payload: import('./../../protocol/tr069_types.js').CWMPTaskPayload
): Promise<void> {
this.taskQueue.addCWMPTask(device.deviceId, type, payload)
}
@@ -445,6 +577,7 @@ export class StateMachineManager extends EventEmitter {
const deviceKey = JSON.stringify(machine.deviceId)
this.deviceSessions.delete(deviceKey)
this.sessionMachines.delete(machine.session)
this.unbindRemotesForDevice(deviceKey)
// Drain any leftover tasks so they don't bleed into the next session
this.taskQueue.clearPendingTasksForDevice(machine.deviceId)
}
@@ -455,6 +588,7 @@ export class StateMachineManager extends EventEmitter {
const deviceKey = JSON.stringify(machine.deviceId)
this.deviceSessions.delete(deviceKey)
this.sessionMachines.delete(machine.session)
this.unbindRemotesForDevice(deviceKey)
logger.error(`State machine failed: ${error}`)
}
+60 -54
View File
@@ -1,11 +1,22 @@
import { DeviceId } from '../../domain/device/device_id.js'
import { type DeviceId } from '#domain/device/device_id'
import { EventEmitter } from 'node:events'
import { CWMPMessageBuilder } from '../../protocol/common/cwmp_message_builder.js'
import { CWMPMessageBuilder } from '#protocol/common/cwmp_message_builder'
import logger from '@adonisjs/core/services/logger'
import type {
CWMPTaskType,
CWMPTaskPayload,
CWMPTaskResultData,
SetParameterValuesTaskPayload,
SetParameterAttributesTaskPayload,
ScheduleInformTaskPayload,
DownloadTaskPayload,
UploadTaskPayload,
RebootTaskPayload,
} from '#protocol/tr069_types'
export class TaskResult {
status!: ResultStatus
data: any
data: CWMPTaskResultData = {} as CWMPTaskResultData
timestamp!: Date
}
@@ -17,12 +28,12 @@ export enum ResultStatus {
export interface CWMPTask {
id: string
deviceId: DeviceId
type: string
payload: any
type: CWMPTaskType
payload: CWMPTaskPayload
status: TaskStatus
createdAt: Date
completedAt?: Date
result?: any
result?: CWMPTaskResultData
error?: string
}
@@ -38,48 +49,49 @@ export enum TaskStatus {
* Maps tasks to CWMP message builders and manages callbacks
*/
export class CWMPTaskQueue extends EventEmitter {
static task_map: Record<string, (props: any) => string> = {
get_parameter_names: (parameterPath: string) => {
return CWMPMessageBuilder.getParameterNames(undefined, parameterPath, false)
static task_map: Record<CWMPTaskType, (props: CWMPTaskPayload) => string> = {
get_parameter_names: (parameterPath) => {
const path = parameterPath as string
const segments = path.replace(/\.$/, '').split('.')
const nextLevel = segments.length <= 1
return CWMPMessageBuilder.getParameterNames(undefined, path, nextLevel)
},
get_parameter_attributes: (parameterNames: string[]) => {
return CWMPMessageBuilder.getParameterAttributes(undefined, parameterNames)
get_parameter_attributes: (parameterNames) => {
return CWMPMessageBuilder.getParameterAttributes(undefined, parameterNames as string[])
},
get_parameter_values: (parameterNames: string[]) => {
return CWMPMessageBuilder.getParameterValues(undefined, parameterNames)
get_parameter_values: (parameterNames) => {
return CWMPMessageBuilder.getParameterValues(undefined, parameterNames as string[])
},
set_parameter_values: (props: { parameterValueList: any[]; parameterKey?: string }) => {
const params = (props.parameterValueList || []).map((p: any) => ({
name: p.Name,
value: String(p.Value?._ ?? ''),
type: p.Value?.$?.Type || 'xsd:string',
set_parameter_values: (props) => {
const p = props as SetParameterValuesTaskPayload
const params = (p.parameterValueList || []).map((pv) => ({
name: pv.Name,
value: String(pv.Value?._ ?? ''),
type: pv.Value?.$?.Type || 'xsd:string',
}))
return CWMPMessageBuilder.setParameterValues(undefined, params)
},
set_parameter_attributes: (attrs: any[]) => {
const mapped = attrs.map((a: any) => ({
name: a.name || a.Name,
notificationChange: !!(a.notificationChange ?? a.NotificationChange),
notification: a.notification ?? a.Notification ?? 0,
accessListChange: !!(a.accessListChange ?? a.AccessListChange),
accessList: a.accessList ?? a.AccessList ?? [],
}))
return CWMPMessageBuilder.setParameterAttributes(undefined, mapped)
set_parameter_attributes: (attrs) => {
return CWMPMessageBuilder.setParameterAttributes(
undefined,
attrs as SetParameterAttributesTaskPayload[]
)
},
schedule_inform: (props: { delayInSeconds: number; commandKey?: string }) => {
return CWMPMessageBuilder.scheduleInform(undefined, props.delayInSeconds || 300, props.commandKey)
schedule_inform: (props) => {
const p = props as ScheduleInformTaskPayload
return CWMPMessageBuilder.scheduleInform(undefined, p.delayInSeconds || 300, p.commandKey)
},
download: (props: any) => {
return CWMPMessageBuilder.download(undefined, props)
download: (props) => {
return CWMPMessageBuilder.download(undefined, props as DownloadTaskPayload)
},
upload: (props: any) => {
return CWMPMessageBuilder.upload(undefined, props)
upload: (props) => {
return CWMPMessageBuilder.upload(undefined, props as UploadTaskPayload)
},
factory_reset: () => {
return CWMPMessageBuilder.factoryReset(undefined)
},
reboot: (props: any) => {
return CWMPMessageBuilder.reboot(undefined, props?.commandKey)
reboot: (props) => {
return CWMPMessageBuilder.reboot(undefined, (props as RebootTaskPayload)?.commandKey)
},
}
@@ -95,8 +107,8 @@ export class CWMPTaskQueue extends EventEmitter {
*/
addTask(
deviceId: DeviceId,
taskType: string,
props: any,
taskType: CWMPTaskType,
props: CWMPTaskPayload,
callback: (result: TaskResult) => Promise<string>
): { xml: string; taskId: string } {
const cwmpTask = this.addCWMPTask(deviceId, taskType, props)
@@ -110,10 +122,8 @@ export class CWMPTaskQueue extends EventEmitter {
cwmpTask.status = TaskStatus.SENT
}
// Track the most recent task created for this device so callers can retrieve its ID
this.lastCreatedTaskIdByDevice.set(JSON.stringify(deviceId), cwmpTask.id)
// Generate CWMP envelope
const builder = CWMPTaskQueue.task_map[taskType]
if (typeof builder !== 'function') {
logger.warn(`No CWMP builder found for task '${taskType}'`)
@@ -123,7 +133,7 @@ export class CWMPTaskQueue extends EventEmitter {
}
/**
* Registers a callback for a task already in the queue (e.g. added via addCWMPTask)
* Registers a callback for a task already in the queue
*/
registerCallback(taskId: string, callback: (result: TaskResult) => Promise<string>): void {
this.taskCallbacks.set(taskId, callback)
@@ -132,7 +142,7 @@ export class CWMPTaskQueue extends EventEmitter {
/**
* Adds a CWMP task (internal)
*/
addCWMPTask(deviceId: DeviceId, type: string, payload: any): CWMPTask {
addCWMPTask(deviceId: DeviceId, type: CWMPTaskType, payload: CWMPTaskPayload): CWMPTask {
const taskId = this.generateTaskId(deviceId, type)
const task: CWMPTask = {
id: taskId,
@@ -158,7 +168,9 @@ export class CWMPTaskQueue extends EventEmitter {
/**
* Retrieve and mark a pending task as SENT for a device
*/
getTaskForDevice(deviceId: DeviceId): { id: string; task: string; props: any } | undefined {
getTaskForDevice(
deviceId: DeviceId
): { id: string; task: CWMPTaskType; props: CWMPTaskPayload } | undefined {
const deviceKey = JSON.stringify(deviceId)
const queue = this.deviceQueues.get(deviceKey)
if (!queue || queue.length === 0) return undefined
@@ -167,7 +179,6 @@ export class CWMPTaskQueue extends EventEmitter {
const cwmpTask = this.tasks.get(taskId)
if (!cwmpTask) return undefined
// Dequeue
queue.shift()
if (cwmpTask.status === TaskStatus.PENDING) {
@@ -177,7 +188,7 @@ export class CWMPTaskQueue extends EventEmitter {
return { id: taskId, task: cwmpTask.type, props: cwmpTask.payload }
}
completeTask(taskId: string, result: any): void {
completeTask(taskId: string, result: CWMPTaskResultData): void {
const task = this.tasks.get(taskId)
if (!task) return
@@ -201,7 +212,11 @@ export class CWMPTaskQueue extends EventEmitter {
this.emit('taskFailed', task)
}
async executeTaskCallback(taskId: string, status: ResultStatus, data: any): Promise<string> {
async executeTaskCallback(
taskId: string,
status: ResultStatus,
data: CWMPTaskResultData
): Promise<string> {
const callback = this.taskCallbacks.get(taskId)
if (callback) {
const result = new TaskResult()
@@ -222,11 +237,6 @@ export class CWMPTaskQueue extends EventEmitter {
return ''
}
/**
* Returns the ID of the most recently created task for a device (via addTask).
* Used by the state machine manager to correlate responses with in-flight tasks
* that were created directly by state handlers (not served from the external queue).
*/
getLastCreatedTaskId(deviceId: DeviceId): string | undefined {
return this.lastCreatedTaskIdByDevice.get(JSON.stringify(deviceId))
}
@@ -234,10 +244,6 @@ export class CWMPTaskQueue extends EventEmitter {
clearPendingTasksForDevice(deviceId: DeviceId): void {
const deviceKey = JSON.stringify(deviceId)
const queue = this.deviceQueues.get(deviceKey) || []
// Clear ALL tasks (PENDING and SENT) — after session completion, no task
// from the previous session should bleed into the next one. Tasks that were
// already SENT (created by state-machine states via addTask()) are ghost
// entries that would otherwise be served to the next session.
for (const taskId of queue) {
this.tasks.delete(taskId)
this.taskCallbacks.delete(taskId)
+71 -51
View File
@@ -1,8 +1,8 @@
import DeviceModel from '#models/device'
import CommandModel from '#models/command'
import { type DeviceRecord } from '../domain/device/device_record.js'
import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import { type DeviceRecord } from '#domain/device/device_record'
import { CommandStatus, CommandType, Priority } from '#domain/commands/command'
import { ModeType } from '#domain/shared/mode'
import logger from '@adonisjs/core/services/logger'
export interface PaginationData {
@@ -159,61 +159,81 @@ export default class DeviceService {
}
private async sendWakeOnWlan(connectionRequestURL: URL, device: DeviceRecord): Promise<boolean> {
try {
// Use ConnectionRequest credentials from device properties/parameters
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
const allParams = [...(device.properties || []), ...(device.parameters || [])]
// Use ConnectionRequest credentials from device properties/parameters
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const connReqUsername = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestUsername')
)
const connReqPassword = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestPassword')
)
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
const connReqUsername = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestUsername')
)
const connReqPassword = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestPassword')
)
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
// Real CPEs use ConnectionRequestUsername/Password
const username =
mgmtUsername?.value ||
connReqUsername?.value ||
`${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
// Real CPEs use ConnectionRequestUsername/Password
const username =
mgmtUsername?.value ||
connReqUsername?.value ||
`${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
const headers = { Authorization: `Basic ${basicAuth}` }
logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request')
// Build list of URLs to try:
// 1. Original ConnectionRequestURL (may be IPv6)
// 2. Fallback: same path but with lastRemoteIp (IPv4 public IP from last Inform)
const urlsToTry: URL[] = [connectionRequestURL]
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
const response = await fetch(connectionRequestURL.toString(), {
method: 'GET',
headers: {
Authorization: `Basic ${basicAuth}`,
},
})
if (!response.ok) {
logger.error(
{
status: response.status,
statusText: response.statusText,
url: connectionRequestURL.toString(),
},
'Connection Request failed with non-OK status'
const lastRemoteIp = (device as any).lastRemoteIp as string | undefined
if (lastRemoteIp && lastRemoteIp !== connectionRequestURL.hostname.replace(/^\[|\]$/g, '')) {
try {
const fallbackUrl = new URL(connectionRequestURL.toString())
// IPv6 hostnames need brackets in URL
fallbackUrl.hostname = lastRemoteIp.includes(':') ? `[${lastRemoteIp}]` : lastRemoteIp
urlsToTry.push(fallbackUrl)
logger.info(
{ original: connectionRequestURL.toString(), fallback: fallbackUrl.toString() },
'[ConnectionRequest] IPv4 fallback URL prepared'
)
return false
} catch {
// ignore invalid fallback URL
}
logger.info(
{ status: response.status, url: connectionRequestURL.toString() },
'Connection Request successful'
)
return true
} catch (error: any) {
logger.error(
{ error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() },
'Connection Request network error'
)
return false
}
for (const url of urlsToTry) {
try {
logger.info({ url: url.toString(), username }, 'Sending Connection Request')
const response = await fetch(url.toString(), { method: 'GET', headers })
if (!response.ok) {
logger.error(
{ status: response.status, statusText: response.statusText, url: url.toString() },
'Connection Request failed with non-OK status'
)
continue // try next URL
}
logger.info(
{ status: response.status, url: url.toString() },
'Connection Request successful'
)
return true
} catch (error: any) {
logger.warn(
{ error: error?.message, code: error?.cause?.code, url: url.toString() },
'[ConnectionRequest] URL failed, trying next if available'
)
// try next URL
}
}
logger.error(
{ urls: urlsToTry.map((u) => u.toString()) },
'Connection Request failed on all URLs'
)
return false
}
calculatePagination(offset: number, limit: number, total: number): PaginationData {
+8 -3
View File
@@ -1,4 +1,4 @@
import { Db } from 'mongodb'
import { type Db } from 'mongodb'
import { getMongoDb } from '#providers/mongodb_provider'
/**
@@ -72,7 +72,10 @@ export async function verifyCwmpExchangesIndexes(db?: Db): Promise<IndexVerifica
* Runs index verification at startup and logs warnings for missing indexes.
* Intended to be called after MongoDB provider has booted.
*/
export async function verifyIndexesAtStartup(db: Db, log: { warn: (msg: string) => void; info: (msg: string) => void }): Promise<void> {
export async function verifyIndexesAtStartup(
db: Db,
log: { warn: (msg: string) => void; info: (msg: string) => void }
): Promise<void> {
const result = await verifyCwmpExchangesIndexes(db)
if (result.allPresent) {
@@ -82,6 +85,8 @@ export async function verifyIndexesAtStartup(db: Db, log: { warn: (msg: string)
const missing = result.indexes.filter((idx) => !idx.exists)
for (const idx of missing) {
log.warn(`[ExchangeIndexCheck] Missing index on cwmp_exchanges: ${idx.name} (${idx.description})`)
log.warn(
`[ExchangeIndexCheck] Missing index on cwmp_exchanges: ${idx.name} (${idx.description})`
)
}
}
+2 -8
View File
@@ -53,10 +53,7 @@ export class ExchangeMetricsService {
this.maxSamples = maxSamples
}
static getInstance(
flushIntervalMs?: number,
maxSamples?: number
): ExchangeMetricsService {
static getInstance(flushIntervalMs?: number, maxSamples?: number): ExchangeMetricsService {
if (!ExchangeMetricsService.instance) {
ExchangeMetricsService.instance = new ExchangeMetricsService(flushIntervalMs, maxSamples)
}
@@ -161,10 +158,7 @@ export class ExchangeMetricsService {
Object.keys(metrics.apiResponseTimes).length > 0
if (hasData) {
logger.info(
{ metrics },
'[ExchangeMetrics] Aggregated performance metrics'
)
logger.info({ metrics }, '[ExchangeMetrics] Aggregated performance metrics')
}
// Reset samples
-1
View File
@@ -161,7 +161,6 @@ export class ExchangeStoreService {
* Internal persist implementation (called within the per-device queue).
*/
private async doPersistExchange(options: PersistOptions): Promise<CwmpExchange | null> {
try {
let rawXml = options.rawXml || ''
+8 -5
View File
@@ -1,9 +1,9 @@
import FirmwareEntryModel from '#models/firmware_entry'
import MigrationRouteModel from '#models/migration_route'
import { FirmwareEntry, MigrationRoute } from '../domain/firmware/firmware_catalog.js'
import { MigrationStep } from '../domain/firmware/migration_step.js'
import { matchGlob } from '../utils/glob_match.js'
import { BadRequestException } from '../exceptions/index.js'
import { type FirmwareEntry, type MigrationRoute } from '#domain/firmware/firmware_catalog'
import { type MigrationStep } from '#domain/firmware/migration_step'
import { matchGlob } from '#utils/glob_match'
import { BadRequestException } from '#exceptions/index'
export interface CreateFirmwareEntryDto {
identifier: string
@@ -125,7 +125,10 @@ export default class FirmwareCatalogService {
return MigrationRouteModel.find({}) as unknown as Promise<MigrationRoute[]>
}
async updateEntry(identifier: string, data: Partial<CreateFirmwareEntryDto>): Promise<FirmwareEntry> {
async updateEntry(
identifier: string,
data: Partial<CreateFirmwareEntryDto>
): Promise<FirmwareEntry> {
const collection = FirmwareEntryModel.getCollection()
const result = await collection.findOneAndUpdate(
{ identifier },
+31 -15
View File
@@ -1,8 +1,8 @@
import ConfigurationService from './configuration_service.js'
import FirmwareCatalogService from './firmware_catalog_service.js'
import type { CreateConfigurationDto } from './configuration_service.js'
import type { CreateFirmwareEntryDto } from './firmware_catalog_service.js'
import { Db } from 'mongodb'
import ConfigurationService from '#services/configuration_service'
import FirmwareCatalogService from '#services/firmware_catalog_service'
import type { CreateConfigurationDto } from '#services/configuration_service'
import type { CreateFirmwareEntryDto } from '#services/firmware_catalog_service'
import { type Db } from 'mongodb'
interface MigrationReport {
discoveryMigrated: number
@@ -23,7 +23,7 @@ export default class MigrationService {
constructor(
private db: Db,
private configService = new ConfigurationService(),
private firmwareCatalogService = new FirmwareCatalogService(),
private firmwareCatalogService = new FirmwareCatalogService()
) {}
/**
@@ -78,7 +78,9 @@ export default class MigrationService {
await this.extractFirmwareEntry(action, legacy)
report.firmwareEntriesCreated++
} catch (error) {
report.errors.push(`Firmware extraction for ${legacy.identifier}: ${(error as Error).message}`)
report.errors.push(
`Firmware extraction for ${legacy.identifier}: ${(error as Error).message}`
)
}
}
}
@@ -180,8 +182,16 @@ export default class MigrationService {
/**
* Build desired parameters from legacy Set Parameter actions
*/
private buildDesiredParametersFromLegacy(legacy: any): Array<{ name: string; value: string | number | boolean; type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime' }> {
const params: Array<{ name: string; value: string | number | boolean; type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime' }> = []
private buildDesiredParametersFromLegacy(legacy: any): Array<{
name: string
value: string | number | boolean
type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
}> {
const params: Array<{
name: string
value: string | number | boolean
type: 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
}> = []
if (!legacy.actions) return params
@@ -190,7 +200,12 @@ export default class MigrationService {
params.push({
name: action.parameter,
value: action.value,
type: typeof action.value === 'boolean' ? 'boolean' : typeof action.value === 'number' ? 'int' : 'string',
type:
typeof action.value === 'boolean'
? 'boolean'
: typeof action.value === 'number'
? 'int'
: 'string',
})
}
}
@@ -275,11 +290,12 @@ export default class MigrationService {
}
private buildLegacyProvisioningFromConfig(config: any, originalId: string): any {
const actions = config.desiredParameters?.map((param: any) => ({
type: 'SET_PARAMETER',
parameter: param.name,
value: param.value,
})) || []
const actions =
config.desiredParameters?.map((param: any) => ({
type: 'SET_PARAMETER',
parameter: param.name,
value: param.value,
})) || []
return {
identifier: originalId,
+19 -9
View File
@@ -1,18 +1,20 @@
import { createRequire } from 'node:module'
import type { DeviceSnapshot } from '../domain/device/device.js'
import type { Device } from '../domain/device/device.js'
import { DeviceParameter } from '../domain/device/device_parameter.js'
import type { DeviceSnapshot } from '#domain/device/device'
import type { Device } from '#domain/device/device'
import { DeviceParameter } from '#domain/device/device_parameter'
const require = createRequire(import.meta.url)
const tr181Catalog: Array<{ path: string; writable: boolean; deviceClass: string; type?: string }> =
require('../../config/tr181-catalog.json')
const tr181Catalog: Array<{
path: string
writable: boolean
deviceClass: string
type?: string
}> = require('../../config/tr181-catalog.json')
// Index chargé une fois au démarrage — O(1) lookup
const catalogIndex = new Map(tr181Catalog.map((e) => [e.path, e.writable]))
const catalogTypeIndex = new Map(tr181Catalog.map((e) => [e.path, e.type || 'xsd:string']))
const catalogWritableIndex = new Set(
tr181Catalog.filter((e) => e.writable).map((e) => e.path)
)
const catalogWritableIndex = new Set(tr181Catalog.filter((e) => e.writable).map((e) => e.path))
/** Remplace les segments numériques par {i} pour matcher les entrées du catalogue */
export function normalizePath(path: string): string {
@@ -69,7 +71,15 @@ export function reclassifyDeviceFromCatalog(device: Device): number {
const snap = prop.toSnapshot()
const catalogType = catalogTypeIndex.get(normalized) || snap.type
device.setParameter(
new DeviceParameter(snap.name, snap.value, catalogType as any, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
new DeviceParameter(
snap.name,
snap.value,
catalogType as any,
snap.createdAt,
snap.updatedAt,
snap.notification,
snap.accessList
)
)
;(device as any)._properties.delete(prop.name)
reclassified++
+1 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { ReferencesConfig } from './types.js'
import type { ReferencesConfig } from '#services/references/types'
const CONFIG_PATH = join(process.cwd(), 'config', 'references.json')
@@ -1,12 +1,11 @@
import type { DeviceRecord } from '../../../domain/device/device_record.js'
import type { DeviceRecord } from '#domain/device/device_record'
import type {
ReferenceProvider,
ResolvedReference,
InformContext,
ProviderConfig,
} from '../types.js'
import { ReferenceEngine } from '../reference_engine.js'
} from '#services/references/types'
import { ReferenceEngine } from '#services/references/reference_engine'
/**
* Wassup Provider — resolves customer identity via the Wassup protocol.
@@ -33,13 +32,22 @@ export class WassupProvider implements ReferenceProvider {
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']
this.requestedProperties = config.options?.requestedProperties || [
'sai',
'spr',
'clo',
'puid',
'utz',
]
}
async resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
const resolveValue = ReferenceEngine.extractResolveValue(device, this.resolveBy, context)
if (!resolveValue) {
console.debug({ provider: this.name, resolveBy: this.resolveBy }, '[Wassup] Cannot resolve: no value')
console.debug(
{ provider: this.name, resolveBy: this.resolveBy },
'[Wassup] Cannot resolve: no value'
)
return null
}
@@ -53,7 +61,7 @@ export class WassupProvider implements ReferenceProvider {
const response = await fetch(wassupUrl, {
method: 'GET',
headers: {
'X_WASSUP_USER_IP': resolveValue,
X_WASSUP_USER_IP: resolveValue,
},
signal: controller.signal,
})
@@ -99,13 +107,13 @@ export class WassupProvider implements ReferenceProvider {
// Map X_WASSUP_* keys to short names
const properties: Record<string, string> = {}
const keyMapping: Record<string, string> = {
'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',
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)) {
@@ -114,7 +122,14 @@ export class WassupProvider implements ReferenceProvider {
}
}
console.info({ provider: this.name, resolveBy: `${this.resolveBy}:${resolveValue}`, keys: Object.keys(properties) }, '[Wassup] Resolved')
console.info(
{
provider: this.name,
resolveBy: `${this.resolveBy}:${resolveValue}`,
keys: Object.keys(properties),
},
'[Wassup] Resolved'
)
return {
provider: this.name,
@@ -125,7 +140,10 @@ export class WassupProvider implements ReferenceProvider {
}
} catch (error: any) {
const isTimeout = error.name === 'AbortError'
console.error({ provider: this.name, error: error.message, isTimeout }, '[Wassup] Resolution failed')
console.error(
{ provider: this.name, error: error.message, isTimeout },
'[Wassup] Resolution failed'
)
return {
provider: this.name,
+34 -15
View File
@@ -1,13 +1,13 @@
import type { DeviceRecord } from '../../domain/device/device_record.js'
import type { DeviceRecord } from '#domain/device/device_record'
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'
} from '#services/references/types'
import { TRIGGER_EVENT_MAP } from '#services/references/types'
import { loadReferencesConfig } from '#services/references/config_loader'
import { WassupProvider } from '#services/references/providers/wassup_provider'
export class ReferenceEngine {
private providers: ReferenceProvider[] = []
@@ -27,7 +27,10 @@ export class ReferenceEngine {
const provider = this.createProvider(providerConfig)
if (provider) {
this.providers.push(provider)
console.info({ name: providerConfig.name, type: providerConfig.type }, '[References] Provider registered')
console.info(
{ name: providerConfig.name, type: providerConfig.type },
'[References] Provider registered'
)
}
}
}
@@ -41,13 +44,19 @@ export class ReferenceEngine {
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')
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')
console.error(
{ name: config.name, type: config.type },
'[References] Unknown provider type'
)
return null
}
}
@@ -60,9 +69,7 @@ export class ReferenceEngine {
const applicable = this.getApplicableProviders(context.events)
if (applicable.length === 0) return []
const results = await Promise.allSettled(
applicable.map((p) => p.resolve(device, context))
)
const results = await Promise.allSettled(applicable.map((p) => p.resolve(device, context)))
const resolved: ResolvedReference[] = []
for (const result of results) {
@@ -79,7 +86,11 @@ export class ReferenceEngine {
/**
* Resolve a single provider by name.
*/
async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
async resolveOne(
providerName: string,
device: DeviceRecord,
context: InformContext
): Promise<ResolvedReference | null> {
const provider = this.providers.find((p) => p.name === providerName)
if (!provider) {
console.warn({ providerName }, '[References] Provider not found')
@@ -112,7 +123,11 @@ export class ReferenceEngine {
/**
* Extracts the resolve value from a device based on the resolveBy config.
*/
static extractResolveValue(device: DeviceRecord, resolveBy: string, context: InformContext): string | null {
static extractResolveValue(
device: DeviceRecord,
resolveBy: string,
context: InformContext
): string | null {
switch (resolveBy) {
case 'ip':
return context.remoteIp || null
@@ -123,14 +138,14 @@ export class ReferenceEngine {
case 'mac': {
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const macParam = allParams.find((p) => p.name?.includes('MACAddress'))
return macParam?.value as string || null
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 (param?.value as string) || null
}
return null
}
@@ -154,3 +169,7 @@ export function getReferenceEngine(): ReferenceEngine {
}
return instance
}
export function resetReferenceEngine(): void {
instance = null
}
+1 -1
View File
@@ -5,7 +5,7 @@
* from N independent providers (Wassup, CRM, NOC, etc.)
*/
import type { DeviceRecord } from '../../domain/device/device_record.js'
import type { DeviceRecord } from '#domain/device/device_record'
// ─── Provider Interface ────────────────────────────────────────────────────────
+9 -7
View File
@@ -12,13 +12,15 @@ export function initTelemetry() {
// OpenTelemetry setup is optional - only initialize when available
import('@opentelemetry/sdk-node')
.then(({ NodeSDK }) => {
return import('@opentelemetry/auto-instrumentations-node').then(({ getNodeAutoInstrumentations }) => {
const sdk = new NodeSDK({
serviceName: env.get('OTEL_SERVICE_NAME', 'papaye-v2'),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
})
return import('@opentelemetry/auto-instrumentations-node').then(
({ getNodeAutoInstrumentations }) => {
const sdk = new NodeSDK({
serviceName: env.get('OTEL_SERVICE_NAME', 'papaye-v2'),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
}
)
})
.catch(() => {
// OpenTelemetry not available, skip
+9 -2
View File
@@ -98,12 +98,19 @@ export class Tr069TraceService {
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)
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> {
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`)
+20 -10
View File
@@ -31,16 +31,26 @@ export function normalizeEventCode(raw: string): string {
// Numeric-only → expand to standard label
switch (trimmed) {
case '0': return 'BOOTSTRAP'
case '1': return 'BOOT'
case '2': return 'PERIODIC'
case '3': return 'SCHEDULED'
case '4': return 'VALUE CHANGE'
case '5': return 'KICKED'
case '6': return 'CONNECTION REQUEST'
case '7': return 'TRANSFER COMPLETE'
case '8': return 'DIAGNOSTICS COMPLETE'
default: return trimmed
case '0':
return 'BOOTSTRAP'
case '1':
return 'BOOT'
case '2':
return 'PERIODIC'
case '3':
return 'SCHEDULED'
case '4':
return 'VALUE CHANGE'
case '5':
return 'KICKED'
case '6':
return 'CONNECTION REQUEST'
case '7':
return 'TRANSFER COMPLETE'
case '8':
return 'DIAGNOSTICS COMPLETE'
default:
return trimmed
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { InventoryPath } from '../domain/configuration/configuration.js'
import type { InventoryPath } from '#domain/configuration/configuration'
/**
* Checks whether a desired parameter is covered by at least one inventory path.
+7 -7
View File
@@ -11,17 +11,17 @@ export function computeNextPeriodicInform(
): Date | null {
if (!lastPeriodicInformAt) return null
const intervalSeconds = typeof periodicInformInterval === 'string'
? parseInt(periodicInformInterval, 10)
: periodicInformInterval
const intervalSeconds =
typeof periodicInformInterval === 'string'
? Number.parseInt(periodicInformInterval, 10)
: periodicInformInterval
if (!intervalSeconds || intervalSeconds <= 0) return null
const lastDate = lastPeriodicInformAt instanceof Date
? lastPeriodicInformAt
: new Date(lastPeriodicInformAt)
const lastDate =
lastPeriodicInformAt instanceof Date ? lastPeriodicInformAt : new Date(lastPeriodicInformAt)
if (isNaN(lastDate.getTime())) return null
if (Number.isNaN(lastDate.getTime())) return null
return new Date(lastDate.getTime() + intervalSeconds * 1000)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import type { Configuration } from '../domain/configuration/configuration.js'
import type { Configuration } from '#domain/configuration/configuration'
/**
* Computes a 12-character hex content hash of a Configuration's desired state.
+2 -5
View File
@@ -1,4 +1,4 @@
import type { ScheduleWindow } from '../domain/configuration/configuration.js'
import type { ScheduleWindow } from '#domain/configuration/configuration'
/**
* Checks if the current time (or provided time) falls within the schedule window.
@@ -41,10 +41,7 @@ export function isWithinSchedule(schedule: ScheduleWindow | undefined, now?: Dat
/**
* Gets the local hour and day-of-week for a given Date in a specific IANA timezone.
*/
function getLocalTimeParts(
date: Date,
timezone: string
): { hour: number; dayOfWeek: number } {
function getLocalTimeParts(date: Date, timezone: string): { hour: number; dayOfWeek: number } {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
+13 -3
View File
@@ -37,9 +37,15 @@ const scheduleWindowSchema = vine.object({
export const createConfigurationValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/),
identifier: vine
.string()
.trim()
.regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/),
name: vine.string().trim().minLength(1),
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/),
version: vine
.string()
.trim()
.regex(/^\d+\.\d+\.\d+$/),
priority: vine.number().positive(),
policy: vine.enum(['once', 'maintain']),
enabled: vine.boolean().optional(),
@@ -55,7 +61,11 @@ export const createConfigurationValidator = vine.compile(
export const updateConfigurationValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).optional(),
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/).optional(),
version: vine
.string()
.trim()
.regex(/^\d+\.\d+\.\d+$/)
.optional(),
priority: vine.number().positive().optional(),
policy: vine.enum(['once', 'maintain']).optional(),
enabled: vine.boolean().optional(),
+8 -2
View File
@@ -2,7 +2,10 @@ import vine from '@vinejs/vine'
export const createFirmwareEntryValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
identifier: vine
.string()
.trim()
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
productClass: vine.string().trim().minLength(1),
version: vine.string().trim().minLength(1),
downloadUrl: vine.string().trim().url(),
@@ -25,7 +28,10 @@ export const updateFirmwareEntryValidator = vine.compile(
export const createMigrationRouteValidator = vine.compile(
vine.object({
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
identifier: vine
.string()
.trim()
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
productClass: vine.string().trim().minLength(1),
sourceVersionPattern: vine.string().trim().minLength(1),
targetVersion: vine.string().trim().minLength(1),
+8
View File
@@ -13,6 +13,14 @@ export const appUrl = env.get('APP_URL')
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Trust the X-Forwarded-* headers sent by a reverse proxy (e.g. Nginx).
* Required so that AdonisJS sees the real client IP and the correct
* protocol (https) when running behind an SSL-terminating proxy.
* Set to 1 to trust only the immediately-preceding proxy.
*/
trustProxy: app.inProduction,
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
+15
View File
@@ -73,6 +73,21 @@ const bodyParserConfig = defineConfig({
*/
types: ['multipart/form-data'],
},
/**
* Config for raw/text body parsing.
* CWMP/TR-069 uses text/xml. A GetParameterNamesResponse can easily
* exceed the default 56kb limit on devices with many parameters.
*/
raw: {
limit: '4mb',
types: [
'text/xml',
'application/xml',
'application/soap+xml',
'text/plain',
],
},
})
export default bodyParserConfig
+1 -1
View File
@@ -3,7 +3,7 @@
{
"name": "wassup",
"type": "wassup",
"enabled": true,
"enabled": false,
"url": "http://wassup-mock:9999",
"timeout": 3000,
"trigger": ["BOOT", "BOOTSTRAP"],
+12 -39
View File
@@ -1,13 +1,16 @@
import { Db, Collection, ObjectId, Filter, UpdateFilter, Sort } from 'mongodb'
import {
type Db,
type Collection,
ObjectId,
type Filter,
type UpdateFilter,
type Sort,
} from 'mongodb'
import { getMongoDb } from '#providers/mongodb_provider'
export abstract class BaseModel {
static collectionName: string
_id?: ObjectId
createdAt?: Date
updatedAt?: Date
static getDb(): Db {
return getMongoDb()
}
@@ -16,7 +19,10 @@ export abstract class BaseModel {
return this.getDb().collection(this.collectionName)
}
static async find(this: { collectionName: string; getDb(): Db }, filter: Filter<any> = {}): Promise<any[]> {
static async find(
this: { collectionName: string; getDb(): Db },
filter: Filter<any> = {}
): Promise<any[]> {
const collection = this.getDb().collection(this.collectionName)
return collection.find(filter).toArray()
}
@@ -100,37 +106,4 @@ export abstract class BaseModel {
return collection
}
async save(): Promise<void> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
this.updatedAt = new Date()
if (!this.createdAt) {
this.createdAt = new Date()
}
const { _id, ...data } = this as any
if (_id) {
await collection.updateOne({ _id }, { $set: data })
} else {
const result = await collection.insertOne(data)
this._id = result.insertedId
}
}
async delete(): Promise<boolean> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
if (!this._id) return false
const result = await collection.deleteOne({ _id: this._id })
return result.deletedCount === 1
}
async refresh(): Promise<void> {
const constructor = this.constructor as typeof BaseModel
const collection = constructor.getDb().collection(constructor.collectionName)
if (!this._id) return
const doc = await collection.findOne({ _id: this._id })
if (doc) {
Object.assign(this, doc)
}
}
}
@@ -1,21 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,31 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateDevicesCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateCommandsCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateDiscoveryConfigsCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateProvisioningConfigsCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateConfigurationsCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateConfigurationVersionsCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateFirmwareEntriesCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateMigrationRoutesCollection extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class AddComplianceToDevices extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class DropLegacyCollections extends MongoMigration {
async up(): Promise<void> {
@@ -1,4 +1,4 @@
import { MongoMigration } from '../mongo_migrator.js'
import { MongoMigration } from '#database/mongo_migrator'
export default class CreateCwmpExchangesCollection extends MongoMigration {
async up(): Promise<void> {
+46
View File
@@ -0,0 +1,46 @@
services:
app:
image: 192.168.1.39:3000/stude12/adam-acs:latest
restart: unless-stopped
ports:
- "8080:8080"
environment:
NODE_ENV: production
PORT: 8080
HOST: 0.0.0.0
MONGODB_URI: mongodb://admin:admin_password@databases-mongo-1:27017/adam-acs?authSource=admin
MONGODB_DB_NAME: adam-acs
APP_URL: https://adam-acs.stude12-ugreen.ddns.net
SESSION_DRIVER: cookie
LOG_LEVEL: debug
WEBUI_USERNAME: Admin
# Secrets injectés via .env
APP_KEY: ${APP_KEY}
WEBUI_PASSWORD: ${WEBUI_PASSWORD}
API_KEY: ${API_KEY}
volumes:
- app_tr069_data:/app/data
- app_tr069_logs:/app/logs/tr069
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/api/v1/checklife').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
networks:
- databases_default
- default
volumes:
app_tr069_data:
app_tr069_logs:
networks:
databases_default:
external: true
default:
enable_ipv6: true
ipam:
config:
- subnet: "172.28.0.0/16"
- subnet: "fd00:ad4a::/64"
+8
View File
@@ -55,3 +55,11 @@ volumes:
mongo_data:
app_tr069_data:
app_tr069_logs:
networks:
default:
enable_ipv6: true
ipam:
config:
- subnet: "172.28.0.0/16"
- subnet: "fd00:ad4a::/64"
+18 -1
View File
@@ -1,2 +1,19 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()
export default [
...configApp(),
{
// Legacy CWMP protocol version files (cwmp-1-0.ts ... cwmp-1-4.ts, cwmp-events.ts):
// filenames and type names mirror the TR-069 spec version numbers (e.g. Cwmp_1_0,
// EventCode_1_1). Renaming would be a large, risky refactor across the protocol
// layer for no functional benefit, so these naming rules are relaxed here.
files: [
'app/protocol/cwmp-1-*.ts',
'app/utils/cwmp-events.ts',
],
rules: {
'@unicorn/filename-case': 'off',
'@typescript-eslint/naming-convention': 'off',
},
},
]
+3 -1
View File
@@ -23,7 +23,9 @@ createInertiaApp({
if (!page) {
throw new Error(`Page not found: ${name}`)
}
page.default.layout = page.default.layout || DefaultLayout
if (page.default.layout === undefined) {
page.default.layout = DefaultLayout
}
return page
},
setup({ el, App, props, plugin }) {
+7 -6
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatTimestamp, truncateMethod, needsTruncation, formatTraceStartTime } from '../helpers/sequence_viewer_helpers'
import { fetchWithCsrf } from '../helpers/fetch_with_csrf'
const { t } = useI18n()
@@ -68,7 +69,7 @@ function connect() {
eventSource.close()
}
const url = `/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges/stream`
const url = `/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges/stream`
eventSource = new EventSource(url)
eventSource.onopen = () => {
@@ -107,7 +108,7 @@ function disconnect() {
async function fetchInitialExchanges() {
try {
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&latest=true`
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&latest=true`
)
if (response.ok) {
const data = await response.json()
@@ -137,7 +138,7 @@ async function loadOlderMessages() {
// Get the timestamp of the oldest currently loaded message
const oldestTimestamp = messages.value[0].timestamp
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&before=${encodeURIComponent(oldestTimestamp)}`
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&before=${encodeURIComponent(oldestTimestamp)}`
)
if (response.ok) {
@@ -270,8 +271,8 @@ async function confirmClear() {
isClearing.value = true
clearError.value = ''
try {
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`,
const response = await fetchWithCsrf(
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`,
{ method: 'DELETE' }
)
if (response.status === 204 || response.ok) {
+1 -1
View File
@@ -16,7 +16,7 @@ const sectionColors: Record<SectionKey, string> = {
commands: 'orange',
device: 'green',
firmware: 'purple',
trace: 'orange',
trace: 'pink',
}
</script>
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more