Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e75132d5 | ||
|
|
576b068cf4 | ||
|
|
f9e56c78b1 | ||
|
|
4177f1adbd | ||
|
|
9cceb139b7 | ||
|
|
ab45275a89 | ||
|
|
b643077e8d | ||
|
|
f3eb8e1650 | ||
|
|
8969355688 | ||
|
|
7d603da0ca | ||
|
|
c3bc2902f0 | ||
|
|
19caa1a06f | ||
|
|
26ffcbea6b | ||
|
|
cce14d7039 | ||
|
|
61bfb4e762 | ||
|
|
7835f99657 | ||
|
|
d44d896556 | ||
|
|
3bb85d9031 | ||
|
|
1fc9715888 | ||
|
|
b12141792f | ||
|
|
7932b43840 | ||
|
|
5ea25d462e | ||
|
|
ebe088a179 | ||
|
|
b00c0c94a8 | ||
|
|
b22d3e9b56 | ||
|
|
8cddf98093 | ||
|
|
63ee6f94f1 | ||
|
|
e645157970 | ||
|
|
7ac4e018a2 | ||
|
|
645e845b95 | ||
|
|
167422be0f | ||
|
|
5ce1db42ff | ||
|
|
83e561fc19 | ||
|
|
e98bc5f0a3 | ||
|
|
ba10591150 | ||
|
|
c1eb606d01 | ||
|
|
420d31176c | ||
|
|
b812349ba9 | ||
|
|
1f87eae0b2 | ||
|
|
2f3d89b3d6 | ||
|
|
790f0b9410 | ||
|
|
12e1267a51 | ||
|
|
55a35c9e26 | ||
|
|
1a00ceb4c4 | ||
|
|
d3bd4df934 | ||
|
|
943ec2683a | ||
|
|
0a32eeabfe | ||
|
|
d8e4a9ec01 | ||
|
|
c493d31e79 | ||
|
|
43976194cb | ||
|
|
9f230ffcc9 | ||
|
|
228d382cbf | ||
|
|
3693d0c0a5 | ||
|
|
991173aa05 |
No files matched your search
@@ -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
@@ -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: '/'
|
||||
|
||||
Vendored
+5
@@ -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']
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Vendored
+7
@@ -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?: {} }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
"
|
||||
@@ -26,3 +26,7 @@ yarn-error.log
|
||||
# Platform specific
|
||||
.DS_Store
|
||||
coverage/
|
||||
|
||||
# SSH keys
|
||||
gitea_runner_key
|
||||
gitea_runner_key.pub
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('')
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { ParameterType } from '../shared/types.js'
|
||||
import { ParameterType } from '#domain/shared/types'
|
||||
|
||||
export interface ParameterSnapshot {
|
||||
name: string
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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/>'
|
||||
}
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"name": "wassup",
|
||||
"type": "wassup",
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"url": "http://wassup-mock:9999",
|
||||
"timeout": 3000,
|
||||
"trigger": ["BOOT", "BOOTSTRAP"],
|
||||
|
||||
+12
-39
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user