chore: initial commit — AdonisJS v7 TR-069 ACS
- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim) - AdonisJS v7 app with Inertia.js + Vue 3 - MongoDB integration - CWMP/TR-069 state machine engine - Configuration engine with compliance tracking - Branding: ADAM ACS (favicon, navbar, locales) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
d1c65dbf1e
164 files changed
+29127
No files matched your search
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* This file is automatically generated.
|
||||
* DO NOT EDIT manually
|
||||
*/
|
||||
|
||||
/// <reference path="./manifest.d.ts" />
|
||||
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
|
||||
import type UserTransformer from '#transformers/user_transformer'
|
||||
|
||||
export namespace Data {
|
||||
export type User = InferData<UserTransformer>
|
||||
export namespace User {
|
||||
export type Variants = InferVariants<UserTransformer>
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* This file is automatically generated.
|
||||
* DO NOT EDIT manually
|
||||
*/
|
||||
|
||||
/// <reference path="../../adonisrc.ts" />
|
||||
/// <reference path="../../config/auth.ts" />
|
||||
/// <reference path="../../config/hash.ts" />
|
||||
/// <reference path="../../config/logger.ts" />
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable prettier/prettier */
|
||||
import type { AdonisEndpoint } from '@tuyau/core/types'
|
||||
import type { Registry } from './schema.d.ts'
|
||||
import type { ApiDefinition } from './tree.d.ts'
|
||||
|
||||
const placeholder: any = {}
|
||||
|
||||
const routes = {
|
||||
'auth.new_account.store': {
|
||||
methods: ["POST"],
|
||||
pattern: '/api/v1/auth/signup',
|
||||
tokens: [{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}],
|
||||
types: placeholder as Registry['auth.new_account.store']['types'],
|
||||
},
|
||||
'auth.access_tokens.store': {
|
||||
methods: ["POST"],
|
||||
pattern: '/api/v1/auth/login',
|
||||
tokens: [{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}],
|
||||
types: placeholder as Registry['auth.access_tokens.store']['types'],
|
||||
},
|
||||
'profile.profile.show': {
|
||||
methods: ["GET","HEAD"],
|
||||
pattern: '/api/v1/account/profile',
|
||||
tokens: [{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}],
|
||||
types: placeholder as Registry['profile.profile.show']['types'],
|
||||
},
|
||||
'profile.access_tokens.destroy': {
|
||||
methods: ["POST"],
|
||||
pattern: '/api/v1/account/logout',
|
||||
tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],
|
||||
types: placeholder as Registry['profile.access_tokens.destroy']['types'],
|
||||
},
|
||||
} as const satisfies Record<string, AdonisEndpoint>
|
||||
|
||||
export { routes }
|
||||
|
||||
export const registry = {
|
||||
routes,
|
||||
$tree: {} as ApiDefinition,
|
||||
}
|
||||
|
||||
declare module '@tuyau/core/types' {
|
||||
export interface UserRegistry {
|
||||
routes: typeof routes
|
||||
$tree: ApiDefinition
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable prettier/prettier */
|
||||
/// <reference path="../manifest.d.ts" />
|
||||
|
||||
import type { ExtractBody, ExtractErrorResponse, ExtractQuery, ExtractQueryForGet, ExtractResponse } from '@tuyau/core/types'
|
||||
import type { InferInput, SimpleError } from '@vinejs/vine/types'
|
||||
|
||||
export type ParamValue = string | number | bigint | boolean
|
||||
|
||||
export interface Registry {
|
||||
'auth.new_account.store': {
|
||||
methods: ["POST"]
|
||||
pattern: '/api/v1/auth/signup'
|
||||
types: {
|
||||
body: ExtractBody<InferInput<(typeof import('#validators/user').signupValidator)>>
|
||||
paramsTuple: []
|
||||
params: {}
|
||||
query: ExtractQuery<InferInput<(typeof import('#validators/user').signupValidator)>>
|
||||
response: ExtractResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>>
|
||||
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
|
||||
}
|
||||
}
|
||||
'auth.access_tokens.store': {
|
||||
methods: ["POST"]
|
||||
pattern: '/api/v1/auth/login'
|
||||
types: {
|
||||
body: ExtractBody<InferInput<(typeof import('#validators/user').loginValidator)>>
|
||||
paramsTuple: []
|
||||
params: {}
|
||||
query: ExtractQuery<InferInput<(typeof import('#validators/user').loginValidator)>>
|
||||
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>>
|
||||
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
|
||||
}
|
||||
}
|
||||
'profile.profile.show': {
|
||||
methods: ["GET","HEAD"]
|
||||
pattern: '/api/v1/account/profile'
|
||||
types: {
|
||||
body: {}
|
||||
paramsTuple: []
|
||||
params: {}
|
||||
query: {}
|
||||
response: ExtractResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
|
||||
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
|
||||
}
|
||||
}
|
||||
'profile.access_tokens.destroy': {
|
||||
methods: ["POST"]
|
||||
pattern: '/api/v1/account/logout'
|
||||
types: {
|
||||
body: {}
|
||||
paramsTuple: []
|
||||
params: {}
|
||||
query: {}
|
||||
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
|
||||
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable prettier/prettier */
|
||||
import type { routes } from './index.ts'
|
||||
|
||||
export interface ApiDefinition {
|
||||
auth: {
|
||||
newAccount: {
|
||||
store: typeof routes['auth.new_account.store']
|
||||
}
|
||||
accessTokens: {
|
||||
store: typeof routes['auth.access_tokens.store']
|
||||
}
|
||||
}
|
||||
profile: {
|
||||
profile: {
|
||||
show: typeof routes['profile.profile.show']
|
||||
}
|
||||
accessTokens: {
|
||||
destroy: typeof routes['profile.access_tokens.destroy']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* This file is automatically generated.
|
||||
* DO NOT EDIT manually
|
||||
*/
|
||||
|
||||
export const controllers = {
|
||||
Commands: () => import('#controllers/commands_controller'),
|
||||
ConfigurationsV1: () => import('#controllers/configurations_v1_controller'),
|
||||
Cwmp: () => import('#controllers/cwmp_controller'),
|
||||
Devices: () => import('#controllers/devices_controller'),
|
||||
FirmwareCatalog: () => import('#controllers/firmware_catalog_controller'),
|
||||
Health: () => import('#controllers/health_controller'),
|
||||
Pages: () => import('#controllers/pages_controller'),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* This file is automatically generated.
|
||||
* DO NOT EDIT manually
|
||||
*/
|
||||
|
||||
export const events = {}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* This file is automatically generated.
|
||||
* DO NOT EDIT manually
|
||||
*/
|
||||
|
||||
export const listeners = {}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
import '@adonisjs/core/types/http'
|
||||
|
||||
type ParamValue = string | number | bigint | boolean
|
||||
|
||||
export type ScannedRoutes = {
|
||||
ALL: {
|
||||
'auth.new_account.store': { paramsTuple?: []; params?: {} }
|
||||
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
|
||||
'profile.profile.show': { paramsTuple?: []; params?: {} }
|
||||
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
GET: {
|
||||
'profile.profile.show': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
HEAD: {
|
||||
'profile.profile.show': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
POST: {
|
||||
'auth.new_account.store': { paramsTuple?: []; params?: {} }
|
||||
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
|
||||
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
|
||||
}
|
||||
}
|
||||
declare module '@adonisjs/core/types/http' {
|
||||
export interface RoutesList extends ScannedRoutes {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
build
|
||||
.git
|
||||
.idea
|
||||
.env
|
||||
@@ -0,0 +1,22 @@
|
||||
# http://editorconfig.org
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
insert_final_newline = unset
|
||||
|
||||
[**.min.js]
|
||||
indent_style = unset
|
||||
insert_final_newline = unset
|
||||
|
||||
[MakeFile]
|
||||
indent_style = space
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,31 @@
|
||||
# Node
|
||||
TZ=UTC
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=development
|
||||
|
||||
# App
|
||||
LOG_LEVEL=info
|
||||
APP_KEY=your-app-key-here-min-16-chars!!
|
||||
APP_URL=http://${HOST}:${PORT}
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=cookie
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://adam:adam@localhost:27017/adam-acs?authSource=admin
|
||||
MONGODB_DB_NAME=adam-acs
|
||||
|
||||
# Observability
|
||||
OTEL_EXPORTER_URI=http://localhost:4318
|
||||
OTEL_SAMPLER_MODE=parentbased_traceidratio
|
||||
OTEL_SAMPLER_RATIO=0.1
|
||||
OTEL_SERVICE_NAME=adam-acs
|
||||
|
||||
# Remote logging
|
||||
LOG_HOST=localhost
|
||||
LOG_PORT=514
|
||||
LOG_PROTOCOL=udp4
|
||||
|
||||
# Security
|
||||
HASH_KEY=0123456789abcdef0123456789abcdef
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Dependencies and AdonisJS build
|
||||
node_modules
|
||||
build
|
||||
tmp/*
|
||||
!tmp/.gitkeep
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
|
||||
# Frontend assets compiled code
|
||||
public/assets
|
||||
|
||||
# Build tools specific
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Editors specific
|
||||
.fleet
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Platform specific
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
.adonisjs
|
||||
node_modules
|
||||
build
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN node ace build
|
||||
|
||||
# Production stage
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
COPY --from=builder /app/build ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -u 1001 -S appuser -G appgroup
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "bin/server.js"]
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache build-base git && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN git clone https://github.com/genieacs/genieacs-sim.git . && \
|
||||
npm install
|
||||
|
||||
ENTRYPOINT ["./genieacs-sim"]
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JavaScript entrypoint for running ace commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
|
||||
| PROCESS.
|
||||
|
|
||||
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
||||
|
|
||||
| Since, we cannot run TypeScript source code using "node" binary, we need
|
||||
| a JavaScript entrypoint to run ace commands.
|
||||
|
|
||||
| This file registers the "ts-node/esm" hook with the Node.js module system
|
||||
| and then imports the "bin/console.ts" file.
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register hook to process TypeScript files using @poppinss/ts-exec
|
||||
*/
|
||||
import '@poppinss/ts-exec'
|
||||
|
||||
/**
|
||||
* Import ace console entrypoint
|
||||
*/
|
||||
await import('./bin/console.js')
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { indexEntities } from '@adonisjs/core'
|
||||
import { defineConfig } from '@adonisjs/core/app'
|
||||
import { generateRegistry } from '@tuyau/core/hooks'
|
||||
|
||||
export default defineConfig({
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Experimental flags
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following features will be enabled by default in the next major release
|
||||
| of AdonisJS. You can opt into them today to avoid any breaking changes
|
||||
| during upgrade.
|
||||
|
|
||||
*/
|
||||
experimental: {},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of ace commands to register from packages. The application commands
|
||||
| will be scanned automatically from the "./commands" directory.
|
||||
|
|
||||
*/
|
||||
commands: [
|
||||
() => import('@adonisjs/core/commands'),
|
||||
() => import('@adonisjs/session/commands'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Service providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of service providers to import and register when booting the
|
||||
| application
|
||||
|
|
||||
*/
|
||||
providers: [
|
||||
() => import('@adonisjs/core/providers/app_provider'),
|
||||
() => import('@adonisjs/core/providers/hash_provider'),
|
||||
{
|
||||
file: () => import('@adonisjs/core/providers/repl_provider'),
|
||||
environment: ['repl', 'test'],
|
||||
},
|
||||
() => import('@adonisjs/core/providers/vinejs_provider'),
|
||||
() => import('@adonisjs/core/providers/edge_provider'),
|
||||
() => import('@adonisjs/session/session_provider'),
|
||||
() => import('@adonisjs/shield/shield_provider'),
|
||||
() => import('@adonisjs/static/static_provider'),
|
||||
() => import('@adonisjs/cors/cors_provider'),
|
||||
() => import('@adonisjs/vite/vite_provider'),
|
||||
() => import('@adonisjs/inertia/inertia_provider'),
|
||||
() => import('#providers/mongodb_provider'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Preloads
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of modules to import before starting the application.
|
||||
|
|
||||
*/
|
||||
preloads: [
|
||||
() => import('#start/routes'),
|
||||
() => import('#start/kernel'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Tests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of test suites to organize tests by their type. Feel free to remove
|
||||
| and add additional suites.
|
||||
|
|
||||
*/
|
||||
tests: {
|
||||
suites: [
|
||||
{
|
||||
files: ['tests/unit/**/*.spec.{ts,js}'],
|
||||
name: 'unit',
|
||||
timeout: 2000,
|
||||
},
|
||||
{
|
||||
files: ['tests/functional/**/*.spec.{ts,js}'],
|
||||
name: 'functional',
|
||||
timeout: 30000,
|
||||
},
|
||||
{
|
||||
files: ['tests/integration/**/*.spec.{ts,js}'],
|
||||
name: 'integration',
|
||||
timeout: 60000,
|
||||
},
|
||||
{
|
||||
files: ['tests/property/**/*.spec.{ts,js}'],
|
||||
name: 'property',
|
||||
timeout: 30000,
|
||||
},
|
||||
],
|
||||
forceExit: false,
|
||||
},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metafiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A collection of files you want to copy to the build folder when creating
|
||||
| the production build.
|
||||
|
|
||||
*/
|
||||
metaFiles: [
|
||||
{ pattern: 'resources/views/**/*.edge', reloadServer: false },
|
||||
{ pattern: 'public/**', reloadServer: false },
|
||||
{ pattern: 'resources/xsd/**', reloadServer: false },
|
||||
],
|
||||
|
||||
hooks: {
|
||||
init: [
|
||||
indexEntities({ transformers: { enabled: true } }),
|
||||
generateRegistry(),
|
||||
],
|
||||
buildStarting: [() => import('@adonisjs/vite/build_hook')],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import CommandService from '#services/command_service'
|
||||
import { createCommandValidator } from '#validators/command'
|
||||
|
||||
export default class CommandsController {
|
||||
private commandService = new CommandService()
|
||||
|
||||
async index({ response }: HttpContext) {
|
||||
const commands = await this.commandService.getCommands()
|
||||
return response.json(commands)
|
||||
}
|
||||
|
||||
async show({ params, response }: HttpContext) {
|
||||
const commands = await this.commandService.getCommandsByDevice(params.id)
|
||||
return response.json(commands)
|
||||
}
|
||||
|
||||
async store({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(createCommandValidator)
|
||||
|
||||
const command = await this.commandService.createCommand({
|
||||
deviceIdentifier: payload.deviceIdentifier,
|
||||
action: payload.action as any,
|
||||
parameters: payload.parameters as Record<string, any>,
|
||||
mode: payload.mode as any,
|
||||
priority: payload.priority,
|
||||
})
|
||||
|
||||
return response.status(201).json(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import ConfigurationService, {
|
||||
type ConfigurationFilters,
|
||||
type CreateConfigurationDto,
|
||||
type UpdateConfigurationDto,
|
||||
} from '#services/configuration_service'
|
||||
import ConfigurationEngine from '#services/configuration_engine'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Configurations API Controller
|
||||
*
|
||||
* Handles REST endpoints for Configuration management:
|
||||
* - GET /api/v1/configurations (list with pagination and filters)
|
||||
* - GET /api/v1/configurations/:id (get single with compliance stats)
|
||||
* - POST /api/v1/configurations (create with validation and overlap detection)
|
||||
* - PUT /api/v1/configurations/:id (update with validation and overlap detection)
|
||||
* - DELETE /api/v1/configurations/:id (delete)
|
||||
* - PATCH /api/v1/configurations/:id/toggle (toggle enabled status)
|
||||
* - PATCH /api/v1/configurations/:id/tags (add/remove tags)
|
||||
* - GET /api/v1/configurations/:id/history (version history)
|
||||
* - POST /api/v1/configurations/:id/dry-run (dry-run evaluation)
|
||||
*/
|
||||
export default class ConfigurationsController {
|
||||
private configService: ConfigurationService
|
||||
private engine: ConfigurationEngine
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigurationService()
|
||||
this.engine = new ConfigurationEngine()
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/configurations
|
||||
* List configurations with pagination and filters
|
||||
*/
|
||||
async index({ request, response }: HttpContext) {
|
||||
try {
|
||||
const filters: ConfigurationFilters = {
|
||||
page: Number(request.input('page', 1)),
|
||||
perPage: Number(request.input('perPage', 20)),
|
||||
tag: request.input('tag'),
|
||||
enabled: request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
|
||||
productClass: request.input('productClass'),
|
||||
}
|
||||
|
||||
const result = await this.configService.findAll(filters)
|
||||
return response.ok(result)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[ConfigAPI] List configurations failed')
|
||||
return response.status(500).json({ message: 'Failed to list configurations' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/configurations/:id
|
||||
* Get single configuration with compliance statistics
|
||||
*/
|
||||
async show({ params, response }: HttpContext) {
|
||||
try {
|
||||
const config = await this.configService.findById(params.id)
|
||||
if (!config) {
|
||||
return response.status(404).json({ message: 'Configuration not found' })
|
||||
}
|
||||
|
||||
// TODO: Add compliance statistics from device compliance info
|
||||
return response.ok(config)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get configuration failed')
|
||||
return response.status(500).json({ message: 'Failed to retrieve configuration' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/configurations
|
||||
* Create new configuration with validation and overlap detection
|
||||
*/
|
||||
async store({ request, response }: HttpContext) {
|
||||
try {
|
||||
const data = request.only([
|
||||
'identifier',
|
||||
'name',
|
||||
'version',
|
||||
'priority',
|
||||
'policy',
|
||||
'enabled',
|
||||
'tags',
|
||||
'criteria',
|
||||
'inventory',
|
||||
'desiredParameters',
|
||||
'firmwareTarget',
|
||||
'schedule',
|
||||
]) 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')) {
|
||||
return response.status(409).json({
|
||||
message: 'Configuration overlaps with existing ones',
|
||||
overlaps: overlapResult.overlappingConfigurations,
|
||||
})
|
||||
}
|
||||
|
||||
// Create configuration
|
||||
const config = await this.configService.create(data)
|
||||
return response.status(201).json(config)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[ConfigAPI] Create configuration failed')
|
||||
if (error.field) {
|
||||
return response.status(422).json({ message: error.message, field: error.field })
|
||||
}
|
||||
return response.status(500).json({ message: 'Failed to create configuration' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/configurations/:id
|
||||
* Update configuration with validation and overlap detection
|
||||
*/
|
||||
async update({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const data = request.only([
|
||||
'name',
|
||||
'version',
|
||||
'priority',
|
||||
'policy',
|
||||
'enabled',
|
||||
'tags',
|
||||
'criteria',
|
||||
'inventory',
|
||||
'desiredParameters',
|
||||
'firmwareTarget',
|
||||
'schedule',
|
||||
]) as UpdateConfigurationDto
|
||||
|
||||
// Check for overlaps if priority changed
|
||||
if (data.priority) {
|
||||
const overlapResult = await this.configService.detectOverlap(
|
||||
data.criteria || {},
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
const config = await this.configService.update(params.id, data)
|
||||
if (!config) {
|
||||
return response.status(404).json({ message: 'Configuration not found' })
|
||||
}
|
||||
|
||||
return response.ok(config)
|
||||
} catch (error: any) {
|
||||
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 })
|
||||
}
|
||||
return response.status(500).json({ message: 'Failed to update configuration' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/configurations/:id
|
||||
* Delete configuration
|
||||
*/
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
try {
|
||||
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')
|
||||
return response.status(500).json({ message: 'Failed to delete configuration' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/configurations/:id/toggle
|
||||
* Toggle enabled status
|
||||
*/
|
||||
async toggleEnabled({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const enabled = request.input('enabled') as boolean
|
||||
const config = await this.configService.toggleEnabled(params.id, enabled)
|
||||
if (!config) {
|
||||
return response.status(404).json({ message: 'Configuration not found' })
|
||||
}
|
||||
return response.ok(config)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Toggle configuration failed')
|
||||
return response.status(500).json({ message: 'Failed to toggle configuration' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/configurations/:id/tags
|
||||
* Add or remove tags
|
||||
*/
|
||||
async manageTags({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const action = request.input('action') as 'add' | 'remove'
|
||||
const tag = request.input('tag') as string
|
||||
|
||||
if (!action || !['add', 'remove'].includes(action)) {
|
||||
return response.status(422).json({ message: 'Invalid action: must be "add" or "remove"' })
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
return response.status(422).json({ message: 'Tag is required' })
|
||||
}
|
||||
|
||||
const config = await this.configService.findById(params.id)
|
||||
if (!config) {
|
||||
return response.status(404).json({ message: 'Configuration not found' })
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
if (!config.tags.includes(tag)) {
|
||||
config.tags.push(tag)
|
||||
}
|
||||
} else {
|
||||
const index = config.tags.indexOf(tag)
|
||||
if (index > -1) {
|
||||
config.tags.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.configService.update(params.id, { tags: config.tags })
|
||||
return response.ok(updated)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Manage tags failed')
|
||||
return response.status(500).json({ message: 'Failed to manage tags' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/configurations/:id/history
|
||||
* Get configuration version history
|
||||
*/
|
||||
async history({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const page = Number(request.input('page', 1))
|
||||
const limit = Number(request.input('limit', 10))
|
||||
|
||||
const result = await this.configService.getHistory(params.id, page, limit)
|
||||
return response.ok(result)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get history failed')
|
||||
return response.status(500).json({ message: 'Failed to retrieve configuration history' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/configurations/:id/dry-run
|
||||
* Perform dry-run evaluation for a device
|
||||
*/
|
||||
async dryRun({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const deviceIdentifier = request.input('deviceIdentifier') as string
|
||||
|
||||
if (!deviceIdentifier) {
|
||||
return response.status(422).json({ message: 'deviceIdentifier is required' })
|
||||
}
|
||||
|
||||
const result = await this.engine.dryRun(params.id, deviceIdentifier)
|
||||
return response.ok(result)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Dry-run failed')
|
||||
if (error.message.includes('not found')) {
|
||||
return response.status(404).json({ message: error.message })
|
||||
}
|
||||
return response.status(500).json({ message: 'Dry-run failed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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 } 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 logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export default class CwmpController {
|
||||
private static managerInitialized = false
|
||||
|
||||
private getManager(): StateMachineManager {
|
||||
const manager = StateMachineManager.getInstance()
|
||||
if (!CwmpController.managerInitialized) {
|
||||
manager.startCleanupTimer(60000)
|
||||
CwmpController.managerInitialized = true
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cwmp10
|
||||
*
|
||||
* Main CWMP/TR-069 endpoint. The cwmp_middleware has already:
|
||||
* - Parsed the raw XML into a SOAP envelope
|
||||
* - Extracted the CWMP message type and data
|
||||
* - Validated content-type
|
||||
* - Attached everything to request.cwmp
|
||||
*/
|
||||
async handle({ request, response }: HttpContext) {
|
||||
const manager = this.getManager()
|
||||
const { sessionId, remoteKey, envelope, message, isEmpty } = request.cwmp
|
||||
|
||||
try {
|
||||
// Empty body = CPE polling, continue the state machine
|
||||
if (isEmpty) {
|
||||
manager.ensureSessionForRemote(remoteKey, sessionId)
|
||||
|
||||
if (manager.hasSession(sessionId)) {
|
||||
const result = await manager.continue(sessionId)
|
||||
if (result && result !== '') {
|
||||
return this.sendSoap(response, result)
|
||||
}
|
||||
}
|
||||
return response.status(204).send('')
|
||||
}
|
||||
|
||||
// Handle Inform
|
||||
if (message?.type === 'Inform') {
|
||||
const result = await this.handleInform(message.data, sessionId, envelope?.header?.id, remoteKey, manager)
|
||||
return this.sendSoap(response, result)
|
||||
}
|
||||
|
||||
// Handle TransferComplete
|
||||
if (message?.type === 'TransferComplete') {
|
||||
const resp = buildSoapEnvelope(undefined, '<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>')
|
||||
return this.sendSoap(response, resp)
|
||||
}
|
||||
|
||||
// Other CWMP responses - route to state machine
|
||||
manager.ensureSessionForRemote(remoteKey, sessionId)
|
||||
|
||||
if (message && manager.hasSession(sessionId)) {
|
||||
const { success, payload } = this.getMessageInfo(message)
|
||||
const result = await manager.handleCWMPResponse(sessionId, success, payload)
|
||||
if (result && result !== '') {
|
||||
return this.sendSoap(response, result)
|
||||
}
|
||||
}
|
||||
|
||||
return response.status(204).send('')
|
||||
} catch (error: any) {
|
||||
// Return SOAP Fault per TR-069 spec (always HTTP 200)
|
||||
logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request')
|
||||
const faultXml = this.buildSoapFault(error?.message || 'Internal error')
|
||||
return this.sendSoap(response, faultXml)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inform Handling ───────────────────────────────────────────────────────
|
||||
|
||||
private async handleInform(
|
||||
informData: any,
|
||||
session: string,
|
||||
cwmpId: string | undefined,
|
||||
remoteKey: string,
|
||||
manager: StateMachineManager
|
||||
): Promise<string> {
|
||||
logger.info({ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, 'CWMP handleInform called')
|
||||
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)
|
||||
}
|
||||
device.updateLastSeen()
|
||||
|
||||
// Process inform parameter values
|
||||
const paramValues = informData?.ParameterList?.ParameterValueStruct || []
|
||||
if (paramValues.length > 0) {
|
||||
device.updateFromValues(paramValues)
|
||||
}
|
||||
device.setTmpParamValues(paramValues)
|
||||
|
||||
// Persist device
|
||||
const hashId = getDeviceHashIdentifier({
|
||||
manufacturer: deviceId.manufacturer,
|
||||
oui: deviceId.oui,
|
||||
productClass: deviceId.productClass,
|
||||
serialNumber: deviceId.serialNumber,
|
||||
})
|
||||
await DeviceModel.createOrUpdate({
|
||||
identifier: hashId,
|
||||
deviceId,
|
||||
properties: device.getProperties().map((p) => p.toSnapshot()),
|
||||
parameters: device.getParameters().map((p) => p.toSnapshot()),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Extract events
|
||||
const events: string[] = Array.isArray(informData?.Event?.EventStruct)
|
||||
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter((c: any): c is string => !!c)
|
||||
: []
|
||||
|
||||
logger.info({ events }, 'CWMP Inform events extracted')
|
||||
|
||||
// Start state machine
|
||||
await manager.startStateMachine(device, session, { events })
|
||||
manager.bindRemoteToDevice(remoteKey, device.deviceId)
|
||||
|
||||
// After Inform for onboarding events, enqueue critical reads
|
||||
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
|
||||
if (!onlyConnReq) {
|
||||
try {
|
||||
const criticalPaths = [
|
||||
'InternetGatewayDevice.DeviceInfo.SpecVersion',
|
||||
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
|
||||
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
|
||||
'InternetGatewayDevice.ManagementServer.ConnectionRequestURL',
|
||||
]
|
||||
await manager.enqueueImmediate(device, 'get_parameter_values', criticalPaths)
|
||||
} catch (_e) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return CWMPMessageBuilder.informResponse(cwmpId)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private sendSoap(response: any, 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']
|
||||
return {
|
||||
success: !failTypes.includes(message.type),
|
||||
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)
|
||||
}
|
||||
|
||||
private async loadDevice(deviceId: DeviceId): Promise<Device | null> {
|
||||
try {
|
||||
const record = await DeviceModel.findByDeviceId(deviceId)
|
||||
if (!record) return null
|
||||
|
||||
const device = new Device(
|
||||
new DeviceId(record.deviceId.manufacturer, record.deviceId.oui, record.deviceId.productClass, record.deviceId.serialNumber),
|
||||
record.createdAt
|
||||
)
|
||||
|
||||
record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)))
|
||||
record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)))
|
||||
|
||||
return device
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 DeviceModel from '#models/device'
|
||||
|
||||
export default class DevicesController {
|
||||
private deviceService = new DeviceService()
|
||||
|
||||
async criteriaValues({ response }: HttpContext) {
|
||||
const values = await DeviceModel.distinctCriteriaValues()
|
||||
return response.json(values)
|
||||
}
|
||||
|
||||
async index({ request, response }: HttpContext) {
|
||||
const offset = Number(request.qs().offset) || 0
|
||||
const limit = Number(request.qs().limit) || 100
|
||||
|
||||
const { data, nbDocuments, pager } = await this.deviceService.getDevices(offset, limit)
|
||||
|
||||
response.header('x-total-count', nbDocuments.toString())
|
||||
response.header('link', this.generateLinkHeader(request, pager))
|
||||
return response.json(data)
|
||||
}
|
||||
|
||||
async show({ params, response }: HttpContext) {
|
||||
const device = await this.deviceService.getDeviceByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
return response.json(device)
|
||||
}
|
||||
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
const deleted = await this.deviceService.deleteByIdentifier(params.identifier)
|
||||
if (!deleted) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
return response.json({ message: 'Device deleted successfully' })
|
||||
}
|
||||
|
||||
async executeCommand({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const body = request.body() as {
|
||||
action: CommandType
|
||||
parameters?: Record<string, any>
|
||||
mode?: string
|
||||
priority?: number
|
||||
}
|
||||
await this.deviceService.executeCommand(params.identifier, body)
|
||||
return response.status(201).json({ message: 'Command created' })
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not found')) {
|
||||
return response.status(404).json({ message: error.message })
|
||||
}
|
||||
if (error.message?.includes('Duplicate')) {
|
||||
return response.status(409).json({ message: error.message })
|
||||
}
|
||||
return response.status(400).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
async refresh({ params, response }: HttpContext) {
|
||||
try {
|
||||
await this.deviceService.refreshDevice(params.identifier)
|
||||
return response.status(200).json({ message: 'Refresh command sent' })
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not found')) {
|
||||
return response.status(404).json({ message: error.message })
|
||||
}
|
||||
return response.status(500).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
private generateLinkHeader(request: any, pager: PaginationData): string {
|
||||
const baseUrl = `${request.protocol()}://${request.host()}${request.url().split('?')[0]}`
|
||||
const parts: string[] = []
|
||||
|
||||
if (pager.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"`)
|
||||
}
|
||||
if (pager.next) {
|
||||
parts.push(`<${baseUrl}?offset=${pager.next.offset}&limit=${pager.next.limit}>; rel="next"`)
|
||||
}
|
||||
if (pager.last) {
|
||||
parts.push(`<${baseUrl}?offset=${pager.last.offset}&limit=${pager.last.limit}>; rel="last"`)
|
||||
}
|
||||
|
||||
return parts.join(',')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import FirmwareCatalogService, {
|
||||
type CreateFirmwareEntryDto,
|
||||
type CreateMigrationRouteDto,
|
||||
} from '#services/firmware_catalog_service'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Firmware Catalog API Controller
|
||||
*
|
||||
* Handles REST endpoints for Firmware Catalog management:
|
||||
* - GET /api/v1/firmware-catalog (list entries grouped by productClass)
|
||||
* - POST /api/v1/firmware-catalog/entries (create firmware entry)
|
||||
* - DELETE /api/v1/firmware-catalog/entries/:id (delete firmware entry)
|
||||
* - POST /api/v1/firmware-catalog/routes (create migration route)
|
||||
* - PUT /api/v1/firmware-catalog/routes/:id (update migration route)
|
||||
* - DELETE /api/v1/firmware-catalog/routes/:id (delete migration route)
|
||||
*/
|
||||
export default class FirmwareCatalogController {
|
||||
private catalogService: FirmwareCatalogService
|
||||
|
||||
constructor() {
|
||||
this.catalogService = new FirmwareCatalogService()
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/firmware-catalog/entries
|
||||
* List firmware entries with optional search
|
||||
*/
|
||||
async listEntries({ request, response }: HttpContext) {
|
||||
try {
|
||||
const productClass = request.input('productClass')
|
||||
const entries = await this.catalogService.findEntries(productClass || undefined)
|
||||
return response.ok({ data: entries, total: entries.length })
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] List entries failed')
|
||||
return response.status(500).json({ message: 'Failed to list firmware entries' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/firmware-catalog/entries/:id
|
||||
* Get a single firmware entry by identifier
|
||||
*/
|
||||
async showEntry({ params, response }: HttpContext) {
|
||||
try {
|
||||
const entries = await this.catalogService.findEntries()
|
||||
const entry = entries.find((e) => e.identifier === params.id)
|
||||
if (!entry) return response.status(404).json({ message: 'Firmware entry not found' })
|
||||
return response.ok({ data: entry })
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] Show entry failed')
|
||||
return response.status(500).json({ message: 'Failed to load firmware entry' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/firmware-catalog/routes
|
||||
* List all migration routes
|
||||
*/
|
||||
async listRoutes({ response }: HttpContext) {
|
||||
try {
|
||||
const routes = await this.catalogService.findAllRoutes()
|
||||
return response.ok({ data: routes, total: routes.length })
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] List routes failed')
|
||||
return response.status(500).json({ message: 'Failed to list migration routes' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/firmware-catalog/entries/:id
|
||||
* Update a firmware entry
|
||||
*/
|
||||
async updateEntry({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const data = request.only([
|
||||
'productClass', 'version', 'downloadUrl', 'checksum', 'checksumType', 'fileSize',
|
||||
])
|
||||
const entry = await this.catalogService.updateEntry(params.id, data)
|
||||
return response.ok(entry)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] Update entry failed')
|
||||
return response.status(400).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/firmware-catalog
|
||||
* List all firmware entries, optionally filtered by productClass
|
||||
*/
|
||||
async index({ request, response }: HttpContext) {
|
||||
try {
|
||||
const productClass = request.input('productClass')
|
||||
const entries = await this.catalogService.findEntries(productClass)
|
||||
return response.ok({ entries })
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] List entries failed')
|
||||
return response.status(500).json({ message: 'Failed to list firmware entries' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/firmware-catalog/entries
|
||||
* Create new firmware entry
|
||||
*/
|
||||
async createEntry({ request, response }: HttpContext) {
|
||||
try {
|
||||
const data = request.only([
|
||||
'identifier',
|
||||
'productClass',
|
||||
'version',
|
||||
'downloadUrl',
|
||||
'checksum',
|
||||
'checksumType',
|
||||
'fileSize',
|
||||
]) as CreateFirmwareEntryDto
|
||||
|
||||
const entry = await this.catalogService.createEntry(data)
|
||||
return response.status(201).json(entry)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] Create entry failed')
|
||||
return response.status(400).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/firmware-catalog/entries/:id
|
||||
* Delete firmware entry
|
||||
*/
|
||||
async deleteEntry({ params, response }: HttpContext) {
|
||||
try {
|
||||
await this.catalogService.deleteEntry(params.id)
|
||||
return response.status(204).send(null)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[FirmwareAPI] Delete entry failed')
|
||||
return response.status(500).json({ message: 'Failed to delete firmware entry' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/firmware-catalog/routes
|
||||
* Create new migration route
|
||||
*/
|
||||
async createRoute({ request, response }: HttpContext) {
|
||||
try {
|
||||
const data = request.only([
|
||||
'identifier',
|
||||
'productClass',
|
||||
'sourceVersionPattern',
|
||||
'targetVersion',
|
||||
'downloadUrl',
|
||||
'priority',
|
||||
]) as CreateMigrationRouteDto
|
||||
|
||||
const route = await this.catalogService.createMigrationRoute(data)
|
||||
return response.status(201).json(route)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, '[FirmwareAPI] Create route failed')
|
||||
return response.status(400).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/firmware-catalog/routes/:id
|
||||
* Update migration route
|
||||
*/
|
||||
async updateRoute({ params: _params, request: _request, response }: HttpContext) {
|
||||
try {
|
||||
// TODO: Implement update method in FirmwareCatalogService
|
||||
// For now, return not implemented
|
||||
return response.status(501).json({ message: 'Not implemented' })
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: _params.id }, '[FirmwareAPI] Update route failed')
|
||||
return response.status(500).json({ message: 'Failed to update migration route' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/firmware-catalog/routes/:id
|
||||
* Delete migration route
|
||||
*/
|
||||
async deleteRoute({ params, response }: HttpContext) {
|
||||
try {
|
||||
await this.catalogService.deleteMigrationRoute(params.id)
|
||||
return response.status(204).send(null)
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message, id: params.id }, '[FirmwareAPI] Delete route failed')
|
||||
return response.status(500).json({ message: 'Failed to delete migration route' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { getMongoDb } from '#providers/mongodb_provider'
|
||||
|
||||
export default class HealthController {
|
||||
async checklife({ response }: HttpContext) {
|
||||
return response.json({ status: 'OK' })
|
||||
}
|
||||
|
||||
async checkservice({ response }: HttpContext) {
|
||||
try {
|
||||
const db = getMongoDb()
|
||||
await db.command({ ping: 1 })
|
||||
return response.json({ status: 'OK', version: '1.0.0' })
|
||||
} catch {
|
||||
return response.status(503).json({ status: 'KO', version: '1.0.0' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import DeviceService from '#services/device_service'
|
||||
import CommandService from '#services/command_service'
|
||||
|
||||
export default class PagesController {
|
||||
private deviceService = new DeviceService()
|
||||
private commandService = new CommandService()
|
||||
|
||||
async home({ inertia }: HttpContext) {
|
||||
const { data } = await this.deviceService.getDevices(0, 200)
|
||||
return (inertia as any).render('Home', { devices: data, message: '' })
|
||||
}
|
||||
|
||||
async deviceDetail({ params, inertia, response }: HttpContext) {
|
||||
const device = await this.deviceService.getDeviceByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
|
||||
// Get pending commands (max 5) + last processed command
|
||||
const allCommands = await this.commandService.getCommandsByDevice(params.identifier)
|
||||
const pending = allCommands
|
||||
.filter((c: any) => ['PENDING', 'WAITING', 'SENT'].includes(c.status))
|
||||
.slice(0, 5)
|
||||
const lastProcessed = allCommands
|
||||
.filter((c: any) => ['COMPLETED', 'FAILED'].includes(c.status))
|
||||
.sort((a: any, b: any) => new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime())
|
||||
[0]
|
||||
const commands = lastProcessed ? [...pending, lastProcessed] : pending
|
||||
|
||||
return (inertia as any).render('DeviceDetail', { device, commands })
|
||||
}
|
||||
|
||||
async commands({ inertia, request }: HttpContext) {
|
||||
const deviceFilter = request.qs().device || ''
|
||||
const statusFilter = request.qs().status || ''
|
||||
const actionFilter = request.qs().action || ''
|
||||
const modeFilter = request.qs().mode || ''
|
||||
|
||||
let commands: any[] = await this.commandService.getCommands()
|
||||
|
||||
if (deviceFilter) {
|
||||
commands = commands.filter((c: any) => c.deviceIdentifier === deviceFilter)
|
||||
}
|
||||
if (statusFilter) {
|
||||
commands = commands.filter((c: any) => c.status === statusFilter)
|
||||
}
|
||||
if (actionFilter) {
|
||||
commands = commands.filter((c: any) => c.action === actionFilter)
|
||||
}
|
||||
if (modeFilter) {
|
||||
commands = commands.filter((c: any) => c.mode === modeFilter)
|
||||
}
|
||||
|
||||
return (inertia as any).render('Commands', {
|
||||
commands,
|
||||
filters: { device: deviceFilter, status: statusFilter, action: actionFilter, mode: modeFilter },
|
||||
})
|
||||
}
|
||||
|
||||
async commandsFleet({ inertia, session }: HttpContext) {
|
||||
const devices = (session as any).get('fleetDevices', [])
|
||||
const commands = await this.commandService.getCommands()
|
||||
return (inertia as any).render('CommandsFleet', { devices, commands })
|
||||
}
|
||||
|
||||
async storeFleetDevices({ request, response, session }: HttpContext) {
|
||||
const { devices } = request.body()
|
||||
;(session as any).put('fleetDevices', devices || [])
|
||||
return response.redirect().toPath('/commands/fleet')
|
||||
}
|
||||
|
||||
async refreshDevice({ params, response }: HttpContext) {
|
||||
// Create a REFRESH command and send Connection Request to wake the CPE
|
||||
await this.deviceService.refreshDevice(params.identifier)
|
||||
return response.redirect().back()
|
||||
}
|
||||
|
||||
async getModel({ params, request }: HttpContext) {
|
||||
const { modelPath } = request.body()
|
||||
await this.deviceService.executeCommand(params.identifier, {
|
||||
action: 'GET_PARAMETER_NAMES' as any,
|
||||
parameters: { parameterPath: modelPath, nextLevel: true },
|
||||
mode: 'I',
|
||||
})
|
||||
}
|
||||
|
||||
async storeFleetCommand({ request, response, session }: HttpContext) {
|
||||
const { action, parameters, mode, priority, devices } = request.body()
|
||||
const deviceList = devices || session.get('fleetDevices', [])
|
||||
|
||||
for (const deviceIdentifier of deviceList) {
|
||||
await this.commandService.createCommand({
|
||||
deviceIdentifier,
|
||||
action,
|
||||
parameters: parameters || {},
|
||||
mode,
|
||||
priority,
|
||||
})
|
||||
}
|
||||
|
||||
return response.redirect().toPath('/commands/fleet')
|
||||
}
|
||||
|
||||
async configurationsList({ inertia }: HttpContext) {
|
||||
return (inertia as any).render('configurations/ConfigurationsList')
|
||||
}
|
||||
|
||||
async configurationCreate({ inertia }: HttpContext) {
|
||||
return (inertia as any).render('configurations/ConfigurationCreate')
|
||||
}
|
||||
|
||||
async configurationEdit({ params, inertia }: HttpContext) {
|
||||
return (inertia as any).render('configurations/ConfigurationEdit', { id: params.id })
|
||||
}
|
||||
|
||||
async configurationDetail({ params, inertia }: HttpContext) {
|
||||
return (inertia as any).render('configurations/ConfigurationDetail', { id: params.id })
|
||||
}
|
||||
|
||||
async firmwareCatalog({ inertia }: HttpContext) {
|
||||
return (inertia as any).render('firmware_catalog/FirmwareCatalog')
|
||||
}
|
||||
|
||||
async firmwareCatalogCreate({ inertia }: HttpContext) {
|
||||
return (inertia as any).render('firmware_catalog/FirmwareCatalogCreate')
|
||||
}
|
||||
|
||||
async firmwareCatalogEdit({ params, inertia }: HttpContext) {
|
||||
return (inertia as any).render('firmware_catalog/FirmwareCatalogEdit', { id: params.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { DeviceId } from '../device/device_id.js'
|
||||
import { ModeType } from '../shared/mode.js'
|
||||
import { ObjectId } from 'mongodb'
|
||||
|
||||
export type CommandId = string
|
||||
|
||||
export enum CommandType {
|
||||
SET_PARAMETER_VALUES = 'SET_PARAMETER_VALUES',
|
||||
GET_PARAMETER_VALUES = 'GET_PARAMETER_VALUES',
|
||||
GET_PARAMETER_NAMES = 'GET_PARAMETER_NAMES',
|
||||
SET_PARAMETER_ATTRIBUTES = 'SET_PARAMETER_ATTRIBUTES',
|
||||
GET_PARAMETER_ATTRIBUTES = 'GET_PARAMETER_ATTRIBUTES',
|
||||
DOWNLOAD = 'DOWNLOAD',
|
||||
UPLOAD = 'UPLOAD',
|
||||
REBOOT = 'REBOOT',
|
||||
FACTORY_RESET = 'FACTORY_RESET',
|
||||
SCHEDULE_INFORM = 'SCHEDULE_INFORM',
|
||||
REFRESH = 'REFRESH',
|
||||
}
|
||||
|
||||
export enum CommandStatus {
|
||||
PENDING = 'PENDING',
|
||||
SENT = 'SENT',
|
||||
WAITING = 'WAITING',
|
||||
FAILED = 'FAILED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
|
||||
export enum Priority {
|
||||
LOW = 0,
|
||||
NORMAL = 1,
|
||||
HIGH = 2,
|
||||
CRITICAL = 3,
|
||||
}
|
||||
|
||||
export type CommandParameters = Record<string, any>
|
||||
|
||||
export interface CommandResult {
|
||||
success: boolean
|
||||
data?: any
|
||||
message?: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface CommandRecord {
|
||||
_id?: ObjectId
|
||||
deviceIdentifier: string
|
||||
mode: ModeType
|
||||
action: CommandType
|
||||
parameters: Record<string, any>
|
||||
status: CommandStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
executedAt?: Date
|
||||
completedAt?: Date
|
||||
priority: number
|
||||
}
|
||||
|
||||
export class Command {
|
||||
private _status: CommandStatus
|
||||
private _executedAt: Date | null = null
|
||||
private _result: CommandResult | null = null
|
||||
private _error: string | null = null
|
||||
|
||||
constructor(
|
||||
public readonly id: CommandId,
|
||||
public readonly deviceId: DeviceId,
|
||||
public readonly type: CommandType,
|
||||
public readonly parameters: CommandParameters = {},
|
||||
public readonly priority: Priority = Priority.NORMAL,
|
||||
public readonly createdAt: Date = new Date(),
|
||||
status: CommandStatus = CommandStatus.PENDING
|
||||
) {
|
||||
this._status = status
|
||||
}
|
||||
|
||||
public get status(): CommandStatus {
|
||||
return this._status
|
||||
}
|
||||
|
||||
public get executedAt(): Date | null {
|
||||
return this._executedAt
|
||||
}
|
||||
|
||||
public get result(): CommandResult | null {
|
||||
return this._result
|
||||
}
|
||||
|
||||
public get error(): string | null {
|
||||
return this._error
|
||||
}
|
||||
|
||||
public markAsSent(): void {
|
||||
if (this._status !== CommandStatus.PENDING) {
|
||||
throw new Error(`Cannot mark command as sent. Current status: ${this._status}`)
|
||||
}
|
||||
this._status = CommandStatus.SENT
|
||||
this._executedAt = new Date()
|
||||
}
|
||||
|
||||
public markAsWaiting(): void {
|
||||
if (this._status !== CommandStatus.SENT) {
|
||||
throw new Error(`Cannot mark command as waiting. Current status: ${this._status}`)
|
||||
}
|
||||
this._status = CommandStatus.WAITING
|
||||
}
|
||||
|
||||
public complete(result: CommandResult): void {
|
||||
if (![CommandStatus.SENT, CommandStatus.WAITING].includes(this._status)) {
|
||||
throw new Error(`Cannot complete command. Current status: ${this._status}`)
|
||||
}
|
||||
this._status = CommandStatus.COMPLETED
|
||||
this._result = result
|
||||
this._executedAt = new Date()
|
||||
}
|
||||
|
||||
public fail(error: string): void {
|
||||
if (this._status === CommandStatus.COMPLETED) {
|
||||
throw new Error('Cannot fail a successful command')
|
||||
}
|
||||
this._status = CommandStatus.FAILED
|
||||
this._error = error
|
||||
this._executedAt = new Date()
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if ([CommandStatus.COMPLETED, CommandStatus.FAILED].includes(this._status)) {
|
||||
throw new Error(`Cannot cancel command with status: ${this._status}`)
|
||||
}
|
||||
this._status = CommandStatus.CANCELLED
|
||||
}
|
||||
|
||||
public canExecute(): boolean {
|
||||
return this._status === CommandStatus.PENDING
|
||||
}
|
||||
|
||||
public isCompleted(): boolean {
|
||||
return [CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED].includes(this._status)
|
||||
}
|
||||
|
||||
public hasExpired(timeoutMinutes: number = 30): boolean {
|
||||
return (Date.now() - this.createdAt.getTime()) / (1000 * 60) > timeoutMinutes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum ComplianceStatus {
|
||||
COMPLIANT = 'compliant',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
export interface DeviceComplianceInfo {
|
||||
status: ComplianceStatus
|
||||
configurationIdentifier: string
|
||||
lastEvaluatedAt: Date
|
||||
lastError?: string
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export enum PolicyType {
|
||||
ONCE = 'once',
|
||||
MAINTAIN = 'maintain',
|
||||
}
|
||||
|
||||
export interface ParameterCriteria {
|
||||
name: string // TR-069 path, e.g. "InternetGatewayDevice.Time.Enable"
|
||||
value: string | number | boolean // expected value (compared as string)
|
||||
}
|
||||
|
||||
export interface ConfigurationCriteria {
|
||||
manufacturer?: string // exact or glob pattern
|
||||
oui?: string // exact or glob pattern
|
||||
productClass?: string // exact or glob pattern
|
||||
serialNumber?: string // exact or glob pattern
|
||||
parameterCriteria?: ParameterCriteria[] // match on known device parameter values
|
||||
}
|
||||
|
||||
export interface InventoryPath {
|
||||
path: string // TR-069 path, e.g. "InternetGatewayDevice.DeviceInfo." or full param name
|
||||
}
|
||||
|
||||
export interface DesiredParameter {
|
||||
name: string // Full TR-069 path
|
||||
value: string | number | boolean // Target value
|
||||
type: ParameterValueType // 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
|
||||
}
|
||||
|
||||
export type ParameterValueType = 'string' | 'int' | 'unsignedInt' | 'boolean' | 'dateTime'
|
||||
|
||||
export interface ScheduleWindow {
|
||||
daysOfWeek: number[] // 0=Sunday, 1=Monday, ..., 6=Saturday (at least one required)
|
||||
startHour: number // 0-23
|
||||
endHour: number // 0-23, must differ from startHour; if endHour < startHour, spans midnight
|
||||
timezone: string // IANA timezone, e.g. "Europe/Paris"
|
||||
}
|
||||
|
||||
export interface FirmwareTarget {
|
||||
productClass: string
|
||||
targetVersion: string
|
||||
}
|
||||
|
||||
export interface Configuration {
|
||||
identifier: string
|
||||
name: string
|
||||
version: string // semver, e.g. "1.0.0"
|
||||
priority: number // positive integer, higher wins
|
||||
policy: PolicyType
|
||||
enabled: boolean
|
||||
tags: string[]
|
||||
criteria: ConfigurationCriteria
|
||||
inventory: InventoryPath[]
|
||||
desiredParameters: DesiredParameter[]
|
||||
firmwareTarget?: FirmwareTarget
|
||||
schedule?: ScheduleWindow
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Configuration } from './configuration.js'
|
||||
|
||||
export interface ConfigurationVersion {
|
||||
configurationIdentifier: string
|
||||
versionNumber: number
|
||||
snapshot: Configuration // Full Configuration state at that point
|
||||
modifiedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { DeviceId } from './device_id.js'
|
||||
import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot } from './device_parameter.js'
|
||||
import { ParameterType } from '../shared/types.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export enum OnboardingStatus {
|
||||
PENDING = 'PENDING',
|
||||
DISCOVERING = 'DISCOVERING',
|
||||
PROVISIONING = 'PROVISIONING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export class Device {
|
||||
public tmpParamValues: any[] = []
|
||||
public tmpPC: any = null
|
||||
private _parameters: Map<string, DeviceParameter> = new Map()
|
||||
private _properties: Map<string, DeviceProperty> = new Map()
|
||||
private _session: any = null
|
||||
private _lastSeen: Date = new Date()
|
||||
private _onboardingStatus: OnboardingStatus = OnboardingStatus.PENDING
|
||||
|
||||
constructor(
|
||||
public readonly deviceId: DeviceId,
|
||||
public readonly createdAt: Date = new Date()
|
||||
) {}
|
||||
|
||||
public get session(): any {
|
||||
return this._session
|
||||
}
|
||||
|
||||
public get lastSeen(): Date {
|
||||
return this._lastSeen
|
||||
}
|
||||
|
||||
public get onboardingStatus(): OnboardingStatus {
|
||||
return this._onboardingStatus
|
||||
}
|
||||
|
||||
public get identifier(): string {
|
||||
const raw = `${this.deviceId.manufacturer}-${this.deviceId.oui}-${this.deviceId.productClass}-${this.deviceId.serialNumber}`
|
||||
return createHash('sha256').update(raw).digest('hex').substring(0, 24)
|
||||
}
|
||||
|
||||
public attachSession(session: any): void {
|
||||
this._session = session
|
||||
this._lastSeen = new Date()
|
||||
}
|
||||
|
||||
public detachSession(): void {
|
||||
if (this._session) {
|
||||
this._session = null
|
||||
}
|
||||
}
|
||||
|
||||
public isOnline(): boolean {
|
||||
return this._session !== null
|
||||
}
|
||||
|
||||
public updateOnboardingStatus(status: OnboardingStatus): void {
|
||||
this._onboardingStatus = status
|
||||
this._lastSeen = new Date()
|
||||
}
|
||||
|
||||
public setParameter(parameter: DeviceParameter): void {
|
||||
this._parameters.set(parameter.name, parameter)
|
||||
}
|
||||
|
||||
public getParameter(name: string): DeviceParameter | null {
|
||||
return this._parameters.get(name) || null
|
||||
}
|
||||
|
||||
public getParameters(): DeviceParameter[] {
|
||||
return Array.from(this._parameters.values())
|
||||
}
|
||||
|
||||
public updateParameter(name: string, value: any): void {
|
||||
const parameter = this._parameters.get(name)
|
||||
if (parameter) {
|
||||
parameter.updateValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
public setProperty(property: DeviceProperty): void {
|
||||
this._properties.set(property.name, property)
|
||||
}
|
||||
|
||||
public getProperty(name: string): DeviceProperty | null {
|
||||
return this._properties.get(name) || null
|
||||
}
|
||||
|
||||
public getProperties(): DeviceProperty[] {
|
||||
return Array.from(this._properties.values())
|
||||
}
|
||||
|
||||
public updateProperty(name: string, value: any): void {
|
||||
const property = this._properties.get(name)
|
||||
if (property) {
|
||||
property.updateValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
public getConnectionRequestURL(): URL | null {
|
||||
for (const property of this._properties.values()) {
|
||||
const url = property.getConnectionRequestURL()
|
||||
if (url) return url
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
public canExecuteCommands(): boolean {
|
||||
return this.isOnline() && this._onboardingStatus === OnboardingStatus.COMPLETED
|
||||
}
|
||||
|
||||
public setTmpParamValues(values: any[]): void {
|
||||
this.tmpParamValues = values
|
||||
}
|
||||
|
||||
public getTmpParamValues(): any[] {
|
||||
return this.tmpParamValues
|
||||
}
|
||||
|
||||
public setTmpPC(value: any): void {
|
||||
this.tmpPC = value
|
||||
}
|
||||
|
||||
public getTmpPC(): any {
|
||||
return this.tmpPC
|
||||
}
|
||||
|
||||
public clearTmpValues(): void {
|
||||
this.tmpParamValues = []
|
||||
this.tmpPC = null
|
||||
}
|
||||
|
||||
public organizeParameters(response: any, timestamp: Date): void {
|
||||
const infoList = response?.ParameterList?.ParameterInfoStruct || []
|
||||
for (const info of infoList) {
|
||||
const name = info?.Name
|
||||
if (!name) continue
|
||||
// Skip object paths (ending with dot) - only store leaf parameters
|
||||
if (name.endsWith('.')) continue
|
||||
const isWritable = !!info?.Writable
|
||||
|
||||
if (this._parameters.has(name) || this._properties.has(name)) {
|
||||
if (isWritable && this._properties.has(name)) {
|
||||
const prop = this._properties.get(name)!
|
||||
this._properties.delete(name)
|
||||
this.setParameter(new DeviceParameter(name, prop.value, prop.type))
|
||||
} else if (!isWritable && this._parameters.has(name)) {
|
||||
const param = this._parameters.get(name)!
|
||||
this._parameters.delete(name)
|
||||
this.setProperty(new DeviceProperty(name, param.value, param.type))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isWritable) {
|
||||
this.setParameter(new DeviceParameter(name, null, ParameterType.UNKNOWN))
|
||||
} else {
|
||||
this.setProperty(new DeviceProperty(name, null, ParameterType.UNKNOWN))
|
||||
}
|
||||
}
|
||||
this._lastSeen = timestamp
|
||||
}
|
||||
|
||||
public updateFromValues(paramValues: any[]): void {
|
||||
for (const pv of paramValues || []) {
|
||||
const name = 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 mappedType = this.mapType(typeStr)
|
||||
|
||||
if (!name) continue
|
||||
if (val === undefined && !hasTypeAttr) continue
|
||||
|
||||
if (this._parameters.has(name)) {
|
||||
const existing = this._parameters.get(name)!
|
||||
if (mappedType && existing.type === ParameterType.UNKNOWN) {
|
||||
const snap = existing.toSnapshot()
|
||||
this._parameters.set(
|
||||
name,
|
||||
new DeviceParameter(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
|
||||
)
|
||||
}
|
||||
this.updateParameter(name, val)
|
||||
} else if (this._properties.has(name)) {
|
||||
const existing = this._properties.get(name)!
|
||||
if (mappedType && existing.type === ParameterType.UNKNOWN) {
|
||||
const snap = existing.toSnapshot()
|
||||
this._properties.set(
|
||||
name,
|
||||
new DeviceProperty(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
|
||||
)
|
||||
}
|
||||
this.updateProperty(name, val)
|
||||
} else {
|
||||
this.setProperty(new DeviceProperty(name, val ?? null, mappedType || ParameterType.UNKNOWN))
|
||||
}
|
||||
}
|
||||
this._lastSeen = new Date()
|
||||
}
|
||||
|
||||
private mapType(typeStr: string | undefined): ParameterType | undefined {
|
||||
switch (typeStr) {
|
||||
case 'xsd:string':
|
||||
return ParameterType.STRING
|
||||
case 'xsd:int':
|
||||
return ParameterType.INT
|
||||
case 'xsd:unsignedInt':
|
||||
return ParameterType.UNSIGNED_INT
|
||||
case 'xsd:boolean':
|
||||
return ParameterType.BOOLEAN
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public toSnapshot(): DeviceSnapshot {
|
||||
return {
|
||||
deviceId: this.deviceId,
|
||||
identifier: this.identifier,
|
||||
createdAt: this.createdAt,
|
||||
lastSeen: this._lastSeen,
|
||||
onboardingStatus: this._onboardingStatus,
|
||||
isOnline: this.isOnline(),
|
||||
parameters: this.getParameters().map((p) => p.toSnapshot()),
|
||||
properties: this.getProperties().map((p) => p.toSnapshot()),
|
||||
}
|
||||
}
|
||||
|
||||
public updateLastSeen(): void {
|
||||
this._lastSeen = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceSnapshot {
|
||||
deviceId: DeviceId
|
||||
identifier: string
|
||||
createdAt: Date
|
||||
lastSeen: Date
|
||||
onboardingStatus: OnboardingStatus
|
||||
isOnline: boolean
|
||||
parameters: ParameterSnapshot[]
|
||||
properties: PropertySnapshot[]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export class DeviceId {
|
||||
constructor(
|
||||
public readonly manufacturer: string,
|
||||
public readonly oui: string,
|
||||
public readonly productClass: string,
|
||||
public readonly serialNumber: string
|
||||
) {
|
||||
this.validate()
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
if (!this.manufacturer?.trim()) {
|
||||
throw new Error('Manufacturer is required')
|
||||
}
|
||||
if (!this.serialNumber?.trim()) {
|
||||
throw new Error('Serial number is required')
|
||||
}
|
||||
}
|
||||
|
||||
public toIdentifier(): string {
|
||||
return `${this.manufacturer}-${this.oui}-${this.productClass}-${this.serialNumber}`
|
||||
}
|
||||
|
||||
public equals(other: DeviceId): boolean {
|
||||
return (
|
||||
this.manufacturer === other.manufacturer &&
|
||||
this.oui === other.oui &&
|
||||
this.productClass === other.productClass &&
|
||||
this.serialNumber === other.serialNumber
|
||||
)
|
||||
}
|
||||
|
||||
public static fromInform(informData: any): DeviceId {
|
||||
return new DeviceId(
|
||||
String(informData?.Manufacturer ?? ''),
|
||||
String(informData?.OUI ?? ''),
|
||||
String(informData?.ProductClass ?? ''),
|
||||
String(informData?.SerialNumber ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.toIdentifier()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ParameterType } from '../shared/types.js'
|
||||
|
||||
export interface ParameterSnapshot {
|
||||
name: string
|
||||
value: any
|
||||
type: ParameterType
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
notification?: number
|
||||
accessList?: string[]
|
||||
}
|
||||
|
||||
export interface PropertySnapshot {
|
||||
name: string
|
||||
value: any
|
||||
type: ParameterType
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
notification?: number
|
||||
accessList?: string[]
|
||||
}
|
||||
|
||||
export class DeviceParameter {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private _value: any,
|
||||
public type: ParameterType = ParameterType.UNKNOWN,
|
||||
public createdAt: Date = new Date(),
|
||||
public updatedAt: Date = new Date(),
|
||||
public notification?: number,
|
||||
public accessList?: string[]
|
||||
) {}
|
||||
|
||||
get value(): any {
|
||||
return this._value
|
||||
}
|
||||
|
||||
updateValue(value: any): void {
|
||||
this._value = value
|
||||
this.updatedAt = new Date()
|
||||
}
|
||||
|
||||
updateAttributes(notification?: number, accessList?: string[]): void {
|
||||
if (notification !== undefined) this.notification = notification
|
||||
if (accessList !== undefined) this.accessList = accessList
|
||||
this.updatedAt = new Date()
|
||||
}
|
||||
|
||||
toSnapshot(): ParameterSnapshot {
|
||||
return {
|
||||
name: this.name,
|
||||
value: this._value,
|
||||
type: this.type,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
notification: this.notification,
|
||||
accessList: this.accessList,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceProperty {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private _value: any,
|
||||
public type: ParameterType = ParameterType.UNKNOWN,
|
||||
public createdAt: Date = new Date(),
|
||||
public updatedAt: Date = new Date(),
|
||||
public notification?: number,
|
||||
public accessList?: string[]
|
||||
) {}
|
||||
|
||||
get value(): any {
|
||||
return this._value
|
||||
}
|
||||
|
||||
updateValue(value: any): void {
|
||||
this._value = value
|
||||
this.updatedAt = new Date()
|
||||
}
|
||||
|
||||
updateAttributes(notification?: number, accessList?: string[]): void {
|
||||
if (notification !== undefined) this.notification = notification
|
||||
if (accessList !== undefined) this.accessList = accessList
|
||||
this.updatedAt = new Date()
|
||||
}
|
||||
|
||||
getConnectionRequestURL(): URL | null {
|
||||
if (this.name.includes('ConnectionRequestURL') && this._value) {
|
||||
try {
|
||||
return new URL(this._value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
toSnapshot(): PropertySnapshot {
|
||||
return {
|
||||
name: this.name,
|
||||
value: this._value,
|
||||
type: this.type,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
notification: this.notification,
|
||||
accessList: this.accessList,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DeviceId } from './device_id.js'
|
||||
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
|
||||
import { DeviceComplianceInfo } from '../configuration/compliance.js'
|
||||
|
||||
export interface DeviceRecord {
|
||||
identifier?: string
|
||||
deviceId: DeviceId
|
||||
properties: PropertySnapshot[]
|
||||
parameters: ParameterSnapshot[]
|
||||
compliance?: DeviceComplianceInfo
|
||||
provisioningCode?: string
|
||||
updatedAt?: Date
|
||||
createdAt?: Date
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface FirmwareEntry {
|
||||
identifier: string
|
||||
productClass: string
|
||||
version: string
|
||||
downloadUrl: string
|
||||
checksum: string
|
||||
checksumType: 'md5' | 'sha256'
|
||||
fileSize: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface MigrationRoute {
|
||||
identifier: string
|
||||
productClass: string
|
||||
sourceVersionPattern: string
|
||||
targetVersion: string
|
||||
downloadUrl: string
|
||||
priority: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface FirmwareCatalog {
|
||||
entries: FirmwareEntry[]
|
||||
migrationRoutes: MigrationRoute[]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface MigrationStep {
|
||||
fromVersion: string
|
||||
toVersion: string
|
||||
downloadUrl: string
|
||||
fileSize: number
|
||||
checksum: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export enum ModeType {
|
||||
NI = 'NI',
|
||||
I = 'I',
|
||||
}
|
||||
|
||||
export function isModeType(value: any): value is ModeType {
|
||||
return value === ModeType.NI || value === ModeType.I
|
||||
}
|
||||
|
||||
export function parseMode(value?: string): ModeType | undefined {
|
||||
if (!value) return undefined
|
||||
const upper = value.toUpperCase()
|
||||
if (upper === ModeType.NI) return ModeType.NI
|
||||
if (upper === ModeType.I) return ModeType.I
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum ParameterType {
|
||||
STRING = 'xsd:string',
|
||||
UNSIGNED_INT = 'xsd:unsignedInt',
|
||||
INT = 'xsd:int',
|
||||
BOOLEAN = 'xsd:boolean',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
||||
|
||||
export default class HttpExceptionHandler extends ExceptionHandler {
|
||||
protected debug = !app.inProduction
|
||||
|
||||
async handle(error: unknown, ctx: HttpContext) {
|
||||
return super.handle(error, ctx)
|
||||
}
|
||||
|
||||
async report(error: unknown, ctx: HttpContext) {
|
||||
return super.report(error, ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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,19 @@
|
||||
import { Logger } from '@adonisjs/core/logger'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { type NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* The container bindings middleware binds classes to their request
|
||||
* specific value using the container resolver.
|
||||
*
|
||||
* - We bind "HttpContext" class to the "ctx" object
|
||||
* - And bind "Logger" class to the "ctx.logger" object
|
||||
*/
|
||||
export default class ContainerBindingsMiddleware {
|
||||
handle(ctx: HttpContext, next: NextFn) {
|
||||
ctx.containerResolver.bindValue(HttpContext, ctx)
|
||||
ctx.containerResolver.bindValue(Logger, ctx.logger)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
// @ts-ignore - xml-formatter types conflict with ESM interop
|
||||
import formatXml from 'xml-formatter'
|
||||
|
||||
/**
|
||||
* CWMP Logger Middleware
|
||||
*
|
||||
* Middleware dédié à l'observation des messages TR-069 XML.
|
||||
* Trace les échanges CPE↔ACS (requêtes entrantes et réponses sortantes)
|
||||
* avec le XML pretty-printed directement sur stdout pour lisibilité humaine.
|
||||
*
|
||||
* Doit être placé avant le cwmp_middleware dans la chaîne.
|
||||
*/
|
||||
export default class CwmpLoggerMiddleware {
|
||||
private prettyXml(xml: string): string {
|
||||
try {
|
||||
return (formatXml as any)(xml, { indentation: ' ', collapseContent: true, lineSeparator: '\n' })
|
||||
} catch {
|
||||
return xml
|
||||
}
|
||||
}
|
||||
|
||||
private log(direction: string, sessionId: string, xml: string) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const separator = '─'.repeat(80)
|
||||
process.stdout.write(
|
||||
`\n${separator}\n` +
|
||||
`[TR-069] ${direction} | ${timestamp} | ${sessionId}\n` +
|
||||
`${separator}\n` +
|
||||
`${xml}\n` +
|
||||
`${separator}\n\n`
|
||||
)
|
||||
}
|
||||
|
||||
async handle({ request, response }: HttpContext, next: NextFn) {
|
||||
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}`
|
||||
|
||||
// Log incoming TR-069 message
|
||||
if (rawBody && rawBody.trim() !== '') {
|
||||
this.log('CPE→ACS', sessionId, this.prettyXml(rawBody))
|
||||
}
|
||||
|
||||
// Intercept response to log outgoing XML
|
||||
const originalSend = response.send.bind(response)
|
||||
response.send = (body: any) => {
|
||||
if (typeof body === 'string' && body.includes('soap-env:Envelope')) {
|
||||
this.log('ACS→CPE', sessionId, this.prettyXml(body))
|
||||
}
|
||||
return originalSend(body)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
import { parseSoapXml, extractCwmpMessage } from '../protocol/soap.js'
|
||||
|
||||
/**
|
||||
* CWMP Middleware
|
||||
*
|
||||
* Handles the specifics of TR-069/CWMP requests:
|
||||
* - Parses raw XML body into a SOAP envelope
|
||||
* - Extracts the CWMP message from the envelope
|
||||
* - Attaches parsed data to the HTTP context for the controller
|
||||
* - Validates content-type (must be text/xml or empty for polling)
|
||||
*/
|
||||
export default class CwmpMiddleware {
|
||||
async handle({ request, response, logger }: HttpContext, next: NextFn) {
|
||||
const contentType = request.header('content-type') || ''
|
||||
const rawBody = request.raw() || ''
|
||||
|
||||
// Derive session identifiers from the TCP socket
|
||||
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}`
|
||||
const remoteKey = `${remoteIp}:${remotePort}`
|
||||
|
||||
// Attach CWMP context to the request
|
||||
request.cwmp = {
|
||||
sessionId,
|
||||
remoteKey,
|
||||
rawBody,
|
||||
envelope: null,
|
||||
message: null,
|
||||
isEmpty: !rawBody || rawBody.trim() === '',
|
||||
}
|
||||
|
||||
// Empty body = CPE polling for next instruction (valid in TR-069)
|
||||
if (request.cwmp.isEmpty) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Validate content type for non-empty requests
|
||||
const isXml = contentType.includes('text/xml') ||
|
||||
contentType.includes('application/xml') ||
|
||||
contentType.includes('application/soap+xml') ||
|
||||
contentType === '' // Some CPEs don't set content-type
|
||||
|
||||
if (!isXml && rawBody.trim() !== '') {
|
||||
logger.warn({ sessionId, contentType }, 'CWMP invalid content-type')
|
||||
response.status(415).send('Unsupported Media Type: expected text/xml')
|
||||
return
|
||||
}
|
||||
|
||||
// Parse SOAP XML
|
||||
try {
|
||||
const envelope = parseSoapXml(rawBody)
|
||||
request.cwmp.envelope = envelope
|
||||
|
||||
// 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')
|
||||
response.status(400).send('Malformed SOAP/XML')
|
||||
return
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the AdonisJS Request interface to include CWMP context.
|
||||
*/
|
||||
declare module '@adonisjs/core/http' {
|
||||
interface HttpRequest {
|
||||
cwmp: {
|
||||
sessionId: string
|
||||
remoteKey: string
|
||||
rawBody: string
|
||||
envelope: any | null
|
||||
message: { type: string; data: any } | null
|
||||
isEmpty: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
export default class InertiaMiddleware extends BaseInertiaMiddleware {
|
||||
async share(ctx: HttpContext) {
|
||||
return {
|
||||
errors: ctx.session?.flashMessages.get('errors'),
|
||||
}
|
||||
}
|
||||
|
||||
async handle(ctx: HttpContext, next: NextFn) {
|
||||
await this.init(ctx)
|
||||
await next()
|
||||
this.dispose(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseModel } from '#database/base_model'
|
||||
import { CommandRecord, CommandStatus } from '../domain/commands/command.js'
|
||||
import { ModeType } from '../domain/shared/mode.js'
|
||||
import { ObjectId } from 'mongodb'
|
||||
|
||||
export default class CommandModel extends BaseModel {
|
||||
static collectionName = 'commands'
|
||||
|
||||
static async findByDevice(deviceIdentifier: string): Promise<CommandRecord[]> {
|
||||
const collection = this.getCollection()
|
||||
return 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[]>
|
||||
}
|
||||
|
||||
static async createCommand(command: Omit<CommandRecord, '_id'>): Promise<CommandRecord> {
|
||||
const collection = this.getCollection()
|
||||
const doc = { ...command, createdAt: new Date(), updatedAt: new Date() }
|
||||
const result = await collection.insertOne(doc)
|
||||
return { ...doc, _id: result.insertedId } as CommandRecord
|
||||
}
|
||||
|
||||
static async updateCommand(id: ObjectId, update: Partial<CommandRecord>): Promise<void> {
|
||||
const collection = this.getCollection()
|
||||
await collection.updateOne({ _id: id }, { $set: { ...update, updatedAt: new Date() } })
|
||||
}
|
||||
|
||||
static async deleteById(id: string | ObjectId): Promise<boolean> {
|
||||
const collection = this.getCollection()
|
||||
const objectId = typeof id === 'string' ? new ObjectId(id) : id
|
||||
const result = await collection.deleteOne({ _id: objectId })
|
||||
return result.deletedCount === 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseModel } from '#database/base_model'
|
||||
import { Configuration } from '../domain/configuration/configuration.js'
|
||||
import { Filter, Sort } from 'mongodb'
|
||||
|
||||
export default class ConfigurationModel extends BaseModel {
|
||||
static collectionName = 'configurations'
|
||||
|
||||
static async findByIdentifier(identifier: string): Promise<Configuration | null> {
|
||||
return this.findOne({ identifier }) as Promise<Configuration | null>
|
||||
}
|
||||
|
||||
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[]>
|
||||
}
|
||||
|
||||
static async paginateConfigurations(
|
||||
page: number,
|
||||
perPage: number,
|
||||
filter: Filter<any> = {},
|
||||
sort: Sort = { priority: -1 }
|
||||
): Promise<{ data: Configuration[]; total: number; page: number; perPage: number; lastPage: number }> {
|
||||
return this.paginate(page, perPage, filter, sort)
|
||||
}
|
||||
|
||||
static async createIfNotExists(data: Partial<Configuration>): Promise<Configuration> {
|
||||
const existing = await this.findOne({ identifier: data.identifier })
|
||||
if (existing) {
|
||||
throw new Error(`Configuration with identifier '${data.identifier}' already exists`)
|
||||
}
|
||||
return this.create(data) as Promise<Configuration>
|
||||
}
|
||||
|
||||
static async deleteByIdentifier(identifier: string): Promise<boolean> {
|
||||
return this.deleteOne({ identifier })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseModel } from '#database/base_model'
|
||||
import { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
|
||||
import { Sort } from 'mongodb'
|
||||
|
||||
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 }> {
|
||||
return this.paginate(page, perPage, { configurationIdentifier }, { versionNumber: -1 })
|
||||
}
|
||||
|
||||
static async getLatestVersionNumber(configurationIdentifier: string): Promise<number> {
|
||||
const collection = this.getCollection()
|
||||
const latest = await collection
|
||||
.find({ configurationIdentifier })
|
||||
.sort({ versionNumber: -1 })
|
||||
.limit(1)
|
||||
.toArray()
|
||||
return latest.length > 0 ? (latest[0] as any).versionNumber : 0
|
||||
}
|
||||
|
||||
static async deleteByConfigurationIdentifier(configurationIdentifier: string): Promise<number> {
|
||||
return this.deleteMany({ configurationIdentifier })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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'
|
||||
|
||||
export default class DeviceModel extends BaseModel {
|
||||
static collectionName = 'devices'
|
||||
|
||||
static async findByIdentifier(identifier: string): Promise<DeviceRecord | null> {
|
||||
return this.findOne({ identifier })
|
||||
}
|
||||
|
||||
static async findByDeviceId(deviceId: DeviceId): Promise<DeviceRecord | null> {
|
||||
return this.findOne({
|
||||
'deviceId.manufacturer': deviceId.manufacturer,
|
||||
'deviceId.oui': deviceId.oui,
|
||||
'deviceId.productClass': deviceId.productClass,
|
||||
'deviceId.serialNumber': deviceId.serialNumber,
|
||||
})
|
||||
}
|
||||
|
||||
static async paginateDevices(
|
||||
offset: number,
|
||||
limit: number,
|
||||
filter: Filter<any> = {}
|
||||
): Promise<{ data: DeviceRecord[]; total: number }> {
|
||||
const collection = this.getCollection()
|
||||
const total = await collection.countDocuments(filter)
|
||||
const data = (await collection
|
||||
.find(filter, { projection: { _id: 0 } })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.sort({ updatedAt: -1 } as Sort)
|
||||
.toArray()) as unknown as DeviceRecord[]
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
static async createOrUpdate(device: Partial<DeviceRecord>): Promise<DeviceRecord> {
|
||||
const collection = this.getCollection()
|
||||
let filter: Filter<any> = {}
|
||||
|
||||
if (device.deviceId) {
|
||||
filter = { deviceId: device.deviceId }
|
||||
} else if (device.identifier) {
|
||||
filter = { identifier: device.identifier }
|
||||
} else {
|
||||
throw new Error('Either identifier or deviceId must be provided')
|
||||
}
|
||||
|
||||
const result = await collection.findOneAndUpdate(
|
||||
filter,
|
||||
{
|
||||
$set: { ...device, updatedAt: new Date() },
|
||||
$setOnInsert: { createdAt: new Date() },
|
||||
},
|
||||
{ upsert: true, returnDocument: 'after', projection: { _id: 0 } }
|
||||
)
|
||||
|
||||
return result as unknown as DeviceRecord
|
||||
}
|
||||
|
||||
static async updateCompliance(
|
||||
identifier: string,
|
||||
compliance: DeviceComplianceInfo
|
||||
): Promise<void> {
|
||||
await this.updateOne({ identifier }, { compliance })
|
||||
}
|
||||
|
||||
static async updateProvisioningCode(
|
||||
identifier: string,
|
||||
provisioningCode: string
|
||||
): Promise<void> {
|
||||
await this.updateOne({ identifier }, { provisioningCode })
|
||||
}
|
||||
|
||||
static async findByComplianceStatus(status: string): Promise<DeviceRecord[]> {
|
||||
const collection = this.getCollection()
|
||||
return collection
|
||||
.find({ 'compliance.status': status })
|
||||
.toArray() as unknown as Promise<DeviceRecord[]>
|
||||
}
|
||||
|
||||
static async findByConfigurationIdentifier(configurationIdentifier: string): Promise<DeviceRecord[]> {
|
||||
const collection = this.getCollection()
|
||||
return collection
|
||||
.find({ 'compliance.configurationIdentifier': configurationIdentifier })
|
||||
.toArray() as unknown as Promise<DeviceRecord[]>
|
||||
}
|
||||
|
||||
static async distinctCriteriaValues(): Promise<{
|
||||
manufacturer: string[]
|
||||
oui: string[]
|
||||
productClass: string[]
|
||||
serialNumber: string[]
|
||||
}> {
|
||||
const collection = this.getCollection()
|
||||
const [manufacturer, oui, productClass, serialNumber] = await Promise.all([
|
||||
collection.distinct('deviceId.manufacturer'),
|
||||
collection.distinct('deviceId.oui'),
|
||||
collection.distinct('deviceId.productClass'),
|
||||
collection.distinct('deviceId.serialNumber'),
|
||||
])
|
||||
return {
|
||||
manufacturer: (manufacturer as string[]).filter(Boolean).sort(),
|
||||
oui: (oui as string[]).filter(Boolean).sort(),
|
||||
productClass: (productClass as string[]).filter(Boolean).sort(),
|
||||
serialNumber: (serialNumber as string[]).filter(Boolean).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
static async countByComplianceStatus(
|
||||
configurationIdentifier: string
|
||||
): Promise<{ compliant: number; in_progress: number; failed: number }> {
|
||||
const collection = this.getCollection()
|
||||
const results = await collection
|
||||
.aggregate([
|
||||
{ $match: { 'compliance.configurationIdentifier': configurationIdentifier } },
|
||||
{ $group: { _id: '$compliance.status', count: { $sum: 1 } } },
|
||||
])
|
||||
.toArray()
|
||||
|
||||
const counts = { compliant: 0, in_progress: 0, failed: 0 }
|
||||
for (const r of results) {
|
||||
if (r._id === 'compliant') counts.compliant = r.count
|
||||
else if (r._id === 'in_progress') counts.in_progress = r.count
|
||||
else if (r._id === 'failed') counts.failed = r.count
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseModel } from '#database/base_model'
|
||||
import { FirmwareEntry } from '../domain/firmware/firmware_catalog.js'
|
||||
|
||||
export default class FirmwareEntryModel extends BaseModel {
|
||||
static collectionName = 'firmware_entries'
|
||||
|
||||
static async findByIdentifier(identifier: string): Promise<FirmwareEntry | null> {
|
||||
return this.findOne({ identifier }) as Promise<FirmwareEntry | null>
|
||||
}
|
||||
|
||||
static async findByProductClass(productClass: string): Promise<FirmwareEntry[]> {
|
||||
const collection = this.getCollection()
|
||||
return collection.find({ productClass }).toArray() as unknown as Promise<FirmwareEntry[]>
|
||||
}
|
||||
|
||||
static async findByProductClassAndVersion(
|
||||
productClass: string,
|
||||
version: string
|
||||
): Promise<FirmwareEntry | null> {
|
||||
return this.findOne({ productClass, version }) as Promise<FirmwareEntry | null>
|
||||
}
|
||||
|
||||
static async createIfNotExists(data: Partial<FirmwareEntry>): Promise<FirmwareEntry> {
|
||||
const existing = await this.findOne({ identifier: data.identifier })
|
||||
if (existing) {
|
||||
throw new Error(`Firmware entry with identifier '${data.identifier}' already exists`)
|
||||
}
|
||||
return this.create(data) as Promise<FirmwareEntry>
|
||||
}
|
||||
|
||||
static async deleteByIdentifier(identifier: string): Promise<boolean> {
|
||||
return this.deleteOne({ identifier })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseModel } from '#database/base_model'
|
||||
import { MigrationRoute } from '../domain/firmware/firmware_catalog.js'
|
||||
import { 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>
|
||||
}
|
||||
|
||||
static async findByProductClass(
|
||||
productClass: string,
|
||||
sort: Sort = { priority: -1 }
|
||||
): Promise<MigrationRoute[]> {
|
||||
const collection = this.getCollection()
|
||||
return collection.find({ productClass }).sort(sort).toArray() as unknown as Promise<MigrationRoute[]>
|
||||
}
|
||||
|
||||
static async createIfNotExists(data: Partial<MigrationRoute>): Promise<MigrationRoute> {
|
||||
const existing = await this.findOne({ identifier: data.identifier })
|
||||
if (existing) {
|
||||
throw new Error(`Migration route with identifier '${data.identifier}' already exists`)
|
||||
}
|
||||
return this.create(data) as Promise<MigrationRoute>
|
||||
}
|
||||
|
||||
static async deleteByIdentifier(identifier: string): Promise<boolean> {
|
||||
return this.deleteOne({ identifier })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { buildSoapEnvelope } from '../soap.js'
|
||||
|
||||
export class CWMPMessageBuilder {
|
||||
static informResponse(cwmpId?: string): string {
|
||||
const body = `<cwmp:InformResponse>
|
||||
<MaxEnvelopes>1</MaxEnvelopes>
|
||||
</cwmp:InformResponse>`
|
||||
return buildSoapEnvelope(cwmpId, body)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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(
|
||||
cwmpId: string | undefined,
|
||||
params: {
|
||||
commandKey?: string
|
||||
fileType?: string
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
fileSize?: number
|
||||
targetFileName?: string
|
||||
delaySeconds?: number
|
||||
successUrl?: string
|
||||
failureUrl?: 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)
|
||||
}
|
||||
|
||||
static upload(
|
||||
cwmpId: string | undefined,
|
||||
params: { commandKey?: string; fileType?: string; url: string; username?: string; password?: string; delaySeconds?: number }
|
||||
): 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)
|
||||
}
|
||||
|
||||
static reboot(cwmpId?: string, commandKey?: string): string {
|
||||
const body = `<cwmp:Reboot>
|
||||
<CommandKey>${commandKey || ''}</CommandKey>
|
||||
</cwmp:Reboot>`
|
||||
return buildSoapEnvelope(cwmpId, body)
|
||||
}
|
||||
|
||||
static factoryReset(cwmpId?: string): string {
|
||||
const body = `<cwmp:FactoryReset></cwmp:FactoryReset>`
|
||||
return buildSoapEnvelope(cwmpId, body)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
static setParameterAttributes(
|
||||
cwmpId: string | undefined,
|
||||
attributes: { name: string; notificationChange: boolean; notification: number; accessListChange: boolean; accessList: string[] }[]
|
||||
): 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)
|
||||
}
|
||||
|
||||
static emptyResponse(): string {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
export const NAMESPACES = {
|
||||
'soap-env': 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||
'soap-enc': 'http://schemas.xmlsoap.org/soap/encoding/',
|
||||
xsd: 'http://www.w3.org/2001/XMLSchema',
|
||||
xsi: 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
cwmp: 'urn:dslforum-org:cwmp-1-0',
|
||||
}
|
||||
|
||||
export const CWMP_VERSIONS: Record<string, string> = {
|
||||
'urn:dslforum-org:cwmp-1-0': '1.0',
|
||||
'urn:dslforum-org:cwmp-1-1': '1.1',
|
||||
'urn:dslforum-org:cwmp-1-2': '1.2',
|
||||
'urn:dslforum-org:cwmp-1-3': '1.3',
|
||||
'urn:dslforum-org:cwmp-1-4': '1.4',
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '$',
|
||||
textNodeName: '_',
|
||||
numberParseOptions: { leadingZeros: false, hex: false },
|
||||
isArray: (name) => {
|
||||
const arrayTags = ['ParameterValueStruct', 'ParameterInfoStruct', 'EventStruct', 'string']
|
||||
return arrayTags.includes(name)
|
||||
},
|
||||
})
|
||||
|
||||
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'] || {}
|
||||
|
||||
// Detect CWMP version from namespace
|
||||
let cwmpVersion = '1.0'
|
||||
for (const [, value] of Object.entries(envelope)) {
|
||||
if (typeof value === 'string' && value.startsWith('urn:dslforum-org:cwmp-')) {
|
||||
cwmpVersion = CWMP_VERSIONS[value] || '1.0'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const soapHeader: SoapHeader = {}
|
||||
const idNode = header['cwmp:ID'] || header['cwmp:Id']
|
||||
if (idNode) {
|
||||
soapHeader.id = typeof idNode === 'string' ? idNode : idNode._ || idNode
|
||||
}
|
||||
|
||||
return { header: soapHeader, body, cwmpVersion }
|
||||
}
|
||||
|
||||
export function extractCwmpMessage(body: any): CwmpMessage | null {
|
||||
for (const key of Object.keys(body)) {
|
||||
if (key.startsWith('cwmp:')) {
|
||||
const type = key.replace('cwmp:', '')
|
||||
return { type, data: body[key] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildSoapEnvelope(cwmpId: string | undefined, bodyContent: string): string {
|
||||
const id = cwmpId || Math.random().toString(36).substring(2, 10)
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap-env:Envelope
|
||||
xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
|
||||
<soap-env:Header>
|
||||
<cwmp:ID soap-env:mustUnderstand="1">${id}</cwmp:ID>
|
||||
</soap-env:Header>
|
||||
<soap-env:Body>
|
||||
${bodyContent}
|
||||
</soap-env:Body>
|
||||
</soap-env:Envelope>`
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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'
|
||||
|
||||
export default class CommandService {
|
||||
async getCommands() {
|
||||
const collection = CommandModel.getCollection()
|
||||
return collection.find({}).sort({ createdAt: -1 }).toArray()
|
||||
}
|
||||
|
||||
async getCommandsByDevice(deviceIdentifier: string) {
|
||||
return CommandModel.findByDevice(deviceIdentifier)
|
||||
}
|
||||
|
||||
async createCommand(data: {
|
||||
deviceIdentifier: string
|
||||
action: CommandType
|
||||
parameters: Record<string, any>
|
||||
mode?: ModeType
|
||||
priority?: number
|
||||
}): Promise<CommandRecord> {
|
||||
return CommandModel.createCommand({
|
||||
deviceIdentifier: data.deviceIdentifier,
|
||||
action: data.action,
|
||||
parameters: data.parameters,
|
||||
mode: data.mode || ModeType.NI,
|
||||
status: CommandStatus.PENDING,
|
||||
priority: data.priority ?? Priority.NORMAL,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
async getICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
|
||||
const collection = CommandModel.getCollection()
|
||||
return collection
|
||||
.find({ deviceIdentifier, mode: ModeType.I, status })
|
||||
.sort({ priority: 1 })
|
||||
.toArray() as unknown as Promise<CommandRecord[]>
|
||||
}
|
||||
|
||||
async getNICommandsForDeviceWithStatus(deviceIdentifier: string, status: CommandStatus): Promise<CommandRecord[]> {
|
||||
const collection = CommandModel.getCollection()
|
||||
return collection
|
||||
.find({ deviceIdentifier, mode: ModeType.NI, status })
|
||||
.sort({ priority: 1 })
|
||||
.toArray() as unknown as Promise<CommandRecord[]>
|
||||
}
|
||||
|
||||
async updateCommandStatus(id: any, update: Partial<CommandRecord>): Promise<void> {
|
||||
await CommandModel.updateCommand(id, update)
|
||||
}
|
||||
|
||||
async deleteCommand(id: string): Promise<boolean> {
|
||||
return CommandModel.deleteById(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Result of a single device configuration evaluation.
|
||||
* Contains all the actions to be executed by the state machine.
|
||||
*/
|
||||
export interface EngineResult {
|
||||
configuration: Configuration | null
|
||||
inventoryPaths: string[]
|
||||
parameterDiffs: ParameterDiff[]
|
||||
firmwareMigrationPath: MigrationStep[]
|
||||
isAlreadyCompliant: boolean
|
||||
skipFirmware: boolean // true if outside schedule window
|
||||
}
|
||||
|
||||
/**
|
||||
* A single parameter difference (desired vs current).
|
||||
*/
|
||||
export interface ParameterDiff {
|
||||
name: string
|
||||
currentValue: string | number | boolean | null
|
||||
targetValue: string | number | boolean
|
||||
type: ParameterValueType
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a dry-run evaluation.
|
||||
*/
|
||||
export interface DryRunResult {
|
||||
configurationId: string
|
||||
deviceIdentifier: string
|
||||
evaluation: EngineResult
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigurationEngine evaluates device compliance against configurations.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Find matching enabled configurations for a device
|
||||
* - Select the highest priority configuration
|
||||
* - Compute parameter differences (desired vs current)
|
||||
* - Handle policy "once" via ProvisioningCode comparison
|
||||
* - Resolve firmware migration paths
|
||||
* - Check schedule windows for firmware operations
|
||||
*/
|
||||
export default class ConfigurationEngine {
|
||||
private firmwareService: FirmwareCatalogService
|
||||
|
||||
constructor() {
|
||||
this.firmwareService = new FirmwareCatalogService()
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate device compliance against all matching configurations.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Load all enabled configurations
|
||||
* 2. Filter by criteria (glob matching against DeviceId)
|
||||
* 3. Select highest priority configuration
|
||||
* 4. Check if already compliant via ProvisioningCode (policy "once")
|
||||
* 5. Compute parameter differences
|
||||
* 6. Resolve firmware migration path
|
||||
* 7. Check schedule window for firmware operations
|
||||
*/
|
||||
async evaluate(device: Device): Promise<EngineResult> {
|
||||
// 1. Load all enabled configurations
|
||||
const allConfigs = await ConfigurationModel.find({ enabled: true })
|
||||
|
||||
// 2. Filter by criteria (glob matching against DeviceId)
|
||||
const matchingConfigs = allConfigs.filter((config) => this.matchesCriteria(device, config))
|
||||
|
||||
// 3. Select highest priority configuration
|
||||
let selectedConfig: Configuration | null = null
|
||||
if (matchingConfigs.length > 0) {
|
||||
selectedConfig = matchingConfigs.reduce((prev, curr) =>
|
||||
curr.priority > prev.priority ? curr : prev
|
||||
)
|
||||
}
|
||||
|
||||
// If no matching configuration, device is already compliant (nothing to do)
|
||||
if (!selectedConfig) {
|
||||
return {
|
||||
configuration: null,
|
||||
inventoryPaths: [],
|
||||
parameterDiffs: [],
|
||||
firmwareMigrationPath: [],
|
||||
isAlreadyCompliant: true,
|
||||
skipFirmware: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check if already compliant via ProvisioningCode (policy "once")
|
||||
if (!this.shouldApplyOnce(device, selectedConfig)) {
|
||||
return {
|
||||
configuration: selectedConfig,
|
||||
inventoryPaths: [],
|
||||
parameterDiffs: [],
|
||||
firmwareMigrationPath: [],
|
||||
isAlreadyCompliant: true,
|
||||
skipFirmware: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Compute parameter differences
|
||||
const parameterDiffs = await this.computeParameterDiffs(device, selectedConfig)
|
||||
|
||||
// 6. Resolve firmware migration path
|
||||
let firmwareMigrationPath: MigrationStep[] = []
|
||||
let skipFirmware = false
|
||||
|
||||
if (selectedConfig.firmwareTarget) {
|
||||
// Check schedule window for firmware operations
|
||||
if (!isWithinSchedule(selectedConfig.schedule)) {
|
||||
skipFirmware = true
|
||||
} else {
|
||||
// Get current device version (from properties or parameter)
|
||||
const currentVersion = this.getDeviceVersion(device, selectedConfig.firmwareTarget.productClass)
|
||||
|
||||
if (currentVersion && currentVersion !== selectedConfig.firmwareTarget.targetVersion) {
|
||||
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
|
||||
selectedConfig.firmwareTarget.productClass,
|
||||
currentVersion,
|
||||
selectedConfig.firmwareTarget.targetVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if compliant (no diffs and no firmware needed)
|
||||
const isCompliant =
|
||||
parameterDiffs.length === 0 &&
|
||||
firmwareMigrationPath.length === 0
|
||||
|
||||
return {
|
||||
configuration: selectedConfig,
|
||||
inventoryPaths: selectedConfig.inventory.map((inv) => inv.path),
|
||||
parameterDiffs,
|
||||
firmwareMigrationPath,
|
||||
isAlreadyCompliant: isCompliant,
|
||||
skipFirmware,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run evaluation for a specific configuration on a device.
|
||||
* Similar to evaluate() but can be called explicitly by the user.
|
||||
*/
|
||||
async dryRun(configId: string, deviceIdentifier: string): Promise<DryRunResult> {
|
||||
const configuration = await ConfigurationModel.findOne({ identifier: configId })
|
||||
if (!configuration) {
|
||||
throw new Error(`Configuration ${configId} not found`)
|
||||
}
|
||||
|
||||
const device = await DeviceModel.findOne({
|
||||
'deviceId.id': deviceIdentifier,
|
||||
})
|
||||
if (!device) {
|
||||
throw new Error(`Device ${deviceIdentifier} not found`)
|
||||
}
|
||||
|
||||
// Check if configuration matches device criteria
|
||||
if (!this.matchesCriteria(device, configuration)) {
|
||||
throw new Error(`Configuration ${configId} does not match device ${deviceIdentifier}`)
|
||||
}
|
||||
|
||||
const evaluation = await this.evaluateSpecific(device, configuration)
|
||||
|
||||
return {
|
||||
configurationId: configId,
|
||||
deviceIdentifier,
|
||||
evaluation,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a specific configuration against a device.
|
||||
* Used by dry-run and potentially other flows.
|
||||
*/
|
||||
private async evaluateSpecific(device: Device, config: Configuration): Promise<EngineResult> {
|
||||
// Check policy "once"
|
||||
if (!this.shouldApplyOnce(device, config)) {
|
||||
return {
|
||||
configuration: config,
|
||||
inventoryPaths: [],
|
||||
parameterDiffs: [],
|
||||
firmwareMigrationPath: [],
|
||||
isAlreadyCompliant: true,
|
||||
skipFirmware: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Compute parameter differences
|
||||
const parameterDiffs = await this.computeParameterDiffs(device, config)
|
||||
|
||||
// Resolve firmware migration path
|
||||
let firmwareMigrationPath: MigrationStep[] = []
|
||||
let skipFirmware = false
|
||||
|
||||
if (config.firmwareTarget) {
|
||||
if (!isWithinSchedule(config.schedule)) {
|
||||
skipFirmware = true
|
||||
} else {
|
||||
const currentVersion = this.getDeviceVersion(device, config.firmwareTarget.productClass)
|
||||
if (currentVersion && currentVersion !== config.firmwareTarget.targetVersion) {
|
||||
firmwareMigrationPath = await this.firmwareService.resolveUpgradePath(
|
||||
config.firmwareTarget.productClass,
|
||||
currentVersion,
|
||||
config.firmwareTarget.targetVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isCompliant = parameterDiffs.length === 0 && firmwareMigrationPath.length === 0
|
||||
|
||||
return {
|
||||
configuration: config,
|
||||
inventoryPaths: config.inventory.map((inv) => inv.path),
|
||||
parameterDiffs,
|
||||
firmwareMigrationPath,
|
||||
isAlreadyCompliant: isCompliant,
|
||||
skipFirmware,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device matches all criteria of a configuration.
|
||||
* Uses glob matching for each criterion.
|
||||
*/
|
||||
private matchesCriteria(device: Device, config: Configuration): boolean {
|
||||
const criteria = config.criteria
|
||||
|
||||
// manufacturer
|
||||
if (criteria.manufacturer) {
|
||||
const deviceValue = device.deviceId.manufacturer || ''
|
||||
if (!matchGlob(criteria.manufacturer, deviceValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// oui
|
||||
if (criteria.oui) {
|
||||
const deviceValue = device.deviceId.oui || ''
|
||||
if (!matchGlob(criteria.oui, deviceValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// productClass
|
||||
if (criteria.productClass) {
|
||||
const deviceValue = device.deviceId.productClass || ''
|
||||
if (!matchGlob(criteria.productClass, deviceValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serialNumber
|
||||
if (criteria.serialNumber) {
|
||||
const deviceValue = device.deviceId.serialNumber || ''
|
||||
if (!matchGlob(criteria.serialNumber, deviceValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parameterCriteria: all conditions must match a known device parameter value
|
||||
if (Array.isArray(criteria.parameterCriteria) && criteria.parameterCriteria.length > 0) {
|
||||
const allParams = [...(device as any).getParameters?.() ?? [], ...(device as any).getProperties?.() ?? []]
|
||||
for (const pc of criteria.parameterCriteria as ParameterCriteria[]) {
|
||||
const known = allParams.find((p: any) => p.name === pc.name)
|
||||
if (known === undefined || known.value === null || known.value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (String(known.value) !== String(pc.value)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if policy "once" should allow application of the configuration.
|
||||
*
|
||||
* - For policy "maintain": always apply
|
||||
* - For policy "once": apply if device doesn't have the provisioning code
|
||||
* (indicating content has changed or never applied)
|
||||
*/
|
||||
private shouldApplyOnce(device: Device, config: Configuration): boolean {
|
||||
if (config.policy === 'maintain') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (config.policy !== 'once') {
|
||||
return true
|
||||
}
|
||||
|
||||
// For "once" policy, check if device has matching provisioning code
|
||||
const deviceProvCode = (device as any).provisioningCode
|
||||
if (!deviceProvCode) {
|
||||
return true // No provisioning code yet, apply it
|
||||
}
|
||||
|
||||
// Parse the provisioning code
|
||||
const parsed = parseProvisioningCode(deviceProvCode)
|
||||
if (!parsed) {
|
||||
return true // Invalid format, re-apply
|
||||
}
|
||||
|
||||
// Check if identifiers match and content hasn't changed
|
||||
if (parsed.identifier !== config.identifier) {
|
||||
return true // Different configuration, apply
|
||||
}
|
||||
|
||||
const currentHash = computeContentHash(config)
|
||||
if (parsed.hash !== currentHash) {
|
||||
return true // Content changed, re-apply
|
||||
}
|
||||
|
||||
// Same configuration, same content hash => already applied
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute parameter differences between desired and current state.
|
||||
*/
|
||||
private async computeParameterDiffs(
|
||||
device: Device,
|
||||
config: Configuration
|
||||
): Promise<ParameterDiff[]> {
|
||||
const diffs: ParameterDiff[] = []
|
||||
|
||||
for (const desired of config.desiredParameters) {
|
||||
// Get current value from device
|
||||
const currentValue = this.getDeviceParameterValue(device, desired.name)
|
||||
|
||||
// Compare values
|
||||
if (currentValue !== desired.value) {
|
||||
diffs.push({
|
||||
name: desired.name,
|
||||
currentValue,
|
||||
targetValue: desired.value,
|
||||
type: desired.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of a parameter from the device.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
private getDeviceParameterValue(device: Device, paramName: string): string | number | boolean | null {
|
||||
const param = (device as any).getParameter?.(paramName)
|
||||
if (!param) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = param.value
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Type coercion based on stored value type
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device's current firmware version.
|
||||
* Looks in device properties or parameters (e.g., "Device.DeviceInfo.SoftwareVersion")
|
||||
*/
|
||||
private getDeviceVersion(device: Device, productClass: string): string | null {
|
||||
// Try common firmware version parameters
|
||||
const versionParams = [
|
||||
'Device.DeviceInfo.SoftwareVersion',
|
||||
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
|
||||
`Device.${productClass}.FirmwareVersion`,
|
||||
]
|
||||
|
||||
for (const paramName of versionParams) {
|
||||
const value = this.getDeviceParameterValue(device, paramName)
|
||||
if (value && typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import ConfigurationModel from '#models/configuration'
|
||||
import ConfigurationVersionModel from '#models/configuration_version'
|
||||
import {
|
||||
type Configuration,
|
||||
type ConfigurationCriteria,
|
||||
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'
|
||||
|
||||
/**
|
||||
* Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically).
|
||||
*/
|
||||
const ALLOWED_CRITERIA_FIELDS: ReadonlySet<string> = new Set([
|
||||
'manufacturer',
|
||||
'oui',
|
||||
'productClass',
|
||||
'serialNumber',
|
||||
'parameterCriteria',
|
||||
])
|
||||
|
||||
const IDENTIFIER_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
||||
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/
|
||||
|
||||
export interface ConfigurationFilters {
|
||||
page?: number
|
||||
perPage?: number
|
||||
tag?: string
|
||||
enabled?: boolean
|
||||
productClass?: string
|
||||
}
|
||||
|
||||
export interface CreateConfigurationDto {
|
||||
identifier: string
|
||||
name: string
|
||||
version: string
|
||||
priority: number
|
||||
policy: PolicyType | string
|
||||
enabled?: boolean
|
||||
tags?: string[]
|
||||
criteria: ConfigurationCriteria
|
||||
inventory: InventoryPath[]
|
||||
desiredParameters: DesiredParameter[]
|
||||
firmwareTarget?: { productClass: string; targetVersion: string }
|
||||
schedule?: {
|
||||
daysOfWeek: number[]
|
||||
startHour: number
|
||||
endHour: number
|
||||
timezone: string
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>>
|
||||
|
||||
export interface OverlapWarning {
|
||||
type: 'overlap_different_priority'
|
||||
overlappingConfigurations: Array<{ identifier: string; priority: number }>
|
||||
}
|
||||
|
||||
export type OverlapResult = OverlapWarning | null
|
||||
|
||||
export class ValidationError extends Error {
|
||||
public readonly field: string
|
||||
public readonly statusCode: number
|
||||
|
||||
constructor(message: string, field: string = '', statusCode: number = 422) {
|
||||
super(message)
|
||||
this.name = 'ValidationError'
|
||||
this.field = field
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
export default class ConfigurationService {
|
||||
/**
|
||||
* Create a new Configuration after validation.
|
||||
*/
|
||||
async create(data: CreateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
|
||||
this.validateIdentifier(data.identifier)
|
||||
this.validateVersion(data.version)
|
||||
this.validatePriority(data.priority)
|
||||
this.validatePolicy(data.policy)
|
||||
this.validateCriteria(data.criteria)
|
||||
this.validateParameterCriteriaDeadlock(data.criteria, data.desiredParameters)
|
||||
this.validateInventoryCoverage(data.desiredParameters, data.inventory)
|
||||
|
||||
// Detect overlap — throws on same-priority conflict, returns warning for different-priority
|
||||
const overlapResult = await this.detectOverlap(data.criteria, data.priority)
|
||||
|
||||
const configuration: Partial<Configuration> = {
|
||||
identifier: data.identifier,
|
||||
name: data.name,
|
||||
version: data.version,
|
||||
priority: data.priority,
|
||||
policy: data.policy as PolicyType,
|
||||
enabled: data.enabled ?? true,
|
||||
tags: data.tags ?? [],
|
||||
criteria: data.criteria,
|
||||
inventory: data.inventory,
|
||||
desiredParameters: data.desiredParameters,
|
||||
firmwareTarget: data.firmwareTarget,
|
||||
schedule: data.schedule,
|
||||
}
|
||||
|
||||
const created = await ConfigurationModel.createIfNotExists(configuration)
|
||||
|
||||
if (overlapResult) {
|
||||
return { ...created, warning: overlapResult }
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a paginated list of Configurations with optional filters.
|
||||
*/
|
||||
async findAll(filters: ConfigurationFilters = {}) {
|
||||
const page = filters.page ?? 1
|
||||
const perPage = filters.perPage ?? 25
|
||||
const mongoFilter: Record<string, any> = {}
|
||||
|
||||
if (filters.tag) {
|
||||
mongoFilter.tags = filters.tag
|
||||
}
|
||||
|
||||
if (filters.enabled !== undefined) {
|
||||
mongoFilter.enabled = filters.enabled
|
||||
}
|
||||
|
||||
if (filters.productClass) {
|
||||
mongoFilter['criteria.productClass'] = filters.productClass
|
||||
}
|
||||
|
||||
return ConfigurationModel.paginateConfigurations(page, perPage, mongoFilter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Configuration by its unique identifier.
|
||||
*/
|
||||
async findById(identifier: string): Promise<Configuration | null> {
|
||||
return ConfigurationModel.findByIdentifier(identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Configuration after validation.
|
||||
* Creates a version snapshot of the current state before applying changes.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
if (data.version !== undefined) {
|
||||
this.validateVersion(data.version)
|
||||
}
|
||||
if (data.priority !== undefined) {
|
||||
this.validatePriority(data.priority)
|
||||
}
|
||||
if (data.policy !== undefined) {
|
||||
this.validatePolicy(data.policy)
|
||||
}
|
||||
if (data.criteria !== undefined) {
|
||||
this.validateCriteria(data.criteria)
|
||||
}
|
||||
|
||||
// Validate inventory coverage with merged data
|
||||
const finalDesiredParameters = data.desiredParameters ?? existing.desiredParameters
|
||||
const finalInventory = data.inventory ?? existing.inventory
|
||||
const finalCriteria2 = data.criteria ?? existing.criteria
|
||||
this.validateParameterCriteriaDeadlock(finalCriteria2, finalDesiredParameters)
|
||||
this.validateInventoryCoverage(finalDesiredParameters, finalInventory)
|
||||
|
||||
// Detect overlap if criteria or priority changed
|
||||
let overlapResult: OverlapResult = null
|
||||
const finalCriteria = data.criteria ?? existing.criteria
|
||||
const finalPriority = data.priority ?? existing.priority
|
||||
if (data.criteria !== undefined || data.priority !== undefined) {
|
||||
overlapResult = await this.detectOverlap(finalCriteria, finalPriority, identifier)
|
||||
}
|
||||
|
||||
// Create a version snapshot BEFORE applying changes
|
||||
const latestVersionNumber = await ConfigurationVersionModel.getLatestVersionNumber(identifier)
|
||||
const versionSnapshot: Omit<ConfigurationVersion, '_id'> = {
|
||||
configurationIdentifier: identifier,
|
||||
versionNumber: latestVersionNumber + 1,
|
||||
snapshot: existing,
|
||||
modifiedAt: new Date(),
|
||||
}
|
||||
await ConfigurationVersionModel.create(versionSnapshot)
|
||||
|
||||
// Build update payload (only provided fields)
|
||||
const updatePayload: Record<string, any> = {}
|
||||
if (data.name !== undefined) updatePayload.name = data.name
|
||||
if (data.version !== undefined) updatePayload.version = data.version
|
||||
if (data.priority !== undefined) updatePayload.priority = data.priority
|
||||
if (data.policy !== undefined) updatePayload.policy = data.policy
|
||||
if (data.enabled !== undefined) updatePayload.enabled = data.enabled
|
||||
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.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget
|
||||
if (data.schedule !== undefined) updatePayload.schedule = data.schedule
|
||||
|
||||
await ConfigurationModel.updateOne({ identifier }, updatePayload)
|
||||
|
||||
const updated = await ConfigurationModel.findByIdentifier(identifier)
|
||||
|
||||
if (overlapResult) {
|
||||
return { ...updated!, warning: overlapResult }
|
||||
}
|
||||
|
||||
return updated!
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version history of a Configuration with pagination.
|
||||
* Returns versions in reverse chronological order.
|
||||
*/
|
||||
async getHistory(
|
||||
identifier: string,
|
||||
page: number = 1,
|
||||
limit: number = 25
|
||||
): 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)
|
||||
}
|
||||
|
||||
return ConfigurationVersionModel.paginateVersions(identifier, page, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the enabled flag of a Configuration without modifying other fields.
|
||||
*/
|
||||
async toggleEnabled(identifier: string, enabled: boolean): Promise<Configuration> {
|
||||
const existing = await ConfigurationModel.findByIdentifier(identifier)
|
||||
if (!existing) {
|
||||
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
|
||||
}
|
||||
|
||||
await ConfigurationModel.updateOne({ identifier }, { enabled })
|
||||
|
||||
const updated = await ConfigurationModel.findByIdentifier(identifier)
|
||||
return updated!
|
||||
}
|
||||
|
||||
/**
|
||||
* Add and/or remove tags from a Configuration without modifying other fields.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
let tags = [...existing.tags]
|
||||
|
||||
// Add tags (avoid duplicates)
|
||||
if (add && add.length > 0) {
|
||||
for (const tag of add) {
|
||||
if (!tags.includes(tag)) {
|
||||
tags.push(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove tags
|
||||
if (remove && remove.length > 0) {
|
||||
tags = tags.filter((tag) => !remove.includes(tag))
|
||||
}
|
||||
|
||||
await ConfigurationModel.updateOne({ identifier }, { tags })
|
||||
|
||||
const updated = await ConfigurationModel.findByIdentifier(identifier)
|
||||
return updated!
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Configuration and all its associated versions.
|
||||
*/
|
||||
async delete(identifier: string): Promise<boolean> {
|
||||
const deleted = await ConfigurationModel.deleteByIdentifier(identifier)
|
||||
if (deleted) {
|
||||
await ConfigurationVersionModel.deleteByConfigurationIdentifier(identifier)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
// ─── Overlap Detection ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect overlap between a Configuration's criteria and existing enabled Configurations.
|
||||
*
|
||||
* Two configurations overlap if their criteria could match the same device.
|
||||
* - If both define the same field, check if patterns could match overlapping values
|
||||
* - If one doesn't define a field, it matches all values → potential overlap
|
||||
*
|
||||
* @param criteria - The criteria to check for overlap
|
||||
* @param priority - The priority of the configuration being created/updated
|
||||
* @param excludeId - Optional identifier to exclude (for updates)
|
||||
* @returns OverlapWarning if different-priority overlap found, null if no overlap
|
||||
* @throws ValidationError with 409 if same-priority overlap found
|
||||
*/
|
||||
async detectOverlap(
|
||||
criteria: ConfigurationCriteria,
|
||||
priority: number,
|
||||
excludeId?: string
|
||||
): Promise<OverlapResult> {
|
||||
const allEnabled = await ConfigurationModel.findAllEnabled()
|
||||
|
||||
const samePriorityConflicts: Array<{ identifier: string; priority: number }> = []
|
||||
const differentPriorityOverlaps: Array<{ identifier: string; priority: number }> = []
|
||||
|
||||
for (const existing of allEnabled) {
|
||||
// Skip the configuration being updated
|
||||
if (excludeId && existing.identifier === excludeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.criteriaOverlap(criteria, existing.criteria)) {
|
||||
if (existing.priority === priority) {
|
||||
samePriorityConflicts.push({
|
||||
identifier: existing.identifier,
|
||||
priority: existing.priority,
|
||||
})
|
||||
} else {
|
||||
differentPriorityOverlaps.push({
|
||||
identifier: existing.identifier,
|
||||
priority: existing.priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same-priority overlap → reject with 409
|
||||
if (samePriorityConflicts.length > 0) {
|
||||
const identifiers = samePriorityConflicts.map((c) => c.identifier).join(', ')
|
||||
throw new ValidationError(
|
||||
`Priority conflict: configurations with the same priority ${priority} have overlapping criteria: ${identifiers}`,
|
||||
'priority',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
// Different-priority overlap → return warning
|
||||
if (differentPriorityOverlaps.length > 0) {
|
||||
return {
|
||||
type: 'overlap_different_priority',
|
||||
overlappingConfigurations: differentPriorityOverlaps,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two sets of criteria could potentially match the same device.
|
||||
*
|
||||
* Conservative approach:
|
||||
* - If one criteria doesn't define a field, it matches ALL values → potential overlap
|
||||
* - If both define the same field, check if patterns could overlap using glob matching
|
||||
*/
|
||||
private criteriaOverlap(
|
||||
criteriaA: ConfigurationCriteria,
|
||||
criteriaB: ConfigurationCriteria
|
||||
): boolean {
|
||||
const fields: Array<keyof ConfigurationCriteria> = [
|
||||
'manufacturer',
|
||||
'oui',
|
||||
'productClass',
|
||||
'serialNumber',
|
||||
]
|
||||
|
||||
for (const field of fields) {
|
||||
const patternA = criteriaA[field] as string | undefined
|
||||
const patternB = criteriaB[field] as string | undefined
|
||||
|
||||
// If either doesn't define the field, it matches all → potential overlap on this field
|
||||
if (!patternA || !patternB) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Both define the field — check if patterns could match overlapping values
|
||||
if (!this.patternsCouldOverlap(patternA, patternB)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// All fields are potentially overlapping
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if two glob patterns could match overlapping sets of values.
|
||||
*
|
||||
* Conservative approach: if one pattern matches the other (or vice versa),
|
||||
* they could overlap. Also, if both are identical, they definitely overlap.
|
||||
* For complex cases, we assume potential overlap (conservative).
|
||||
*/
|
||||
private patternsCouldOverlap(patternA: string, patternB: string): boolean {
|
||||
// Identical patterns always overlap
|
||||
if (patternA === patternB) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one pattern could match a value that the other also matches.
|
||||
// Simple heuristic: try matching each pattern against the other as a literal value.
|
||||
// If patternA matches patternB (as a value) or vice versa, they could overlap.
|
||||
if (matchGlob(patternA, patternB) || matchGlob(patternB, patternA)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If either contains wildcards, be conservative and check for prefix overlap
|
||||
const hasWildcardA = patternA.includes('*') || patternA.includes('?')
|
||||
const hasWildcardB = patternB.includes('*') || patternB.includes('?')
|
||||
|
||||
if (hasWildcardA || hasWildcardB) {
|
||||
// Extract the fixed prefix before the first wildcard
|
||||
const prefixA = patternA.split(/[*?]/)[0]
|
||||
const prefixB = patternB.split(/[*?]/)[0]
|
||||
|
||||
// If one prefix starts with the other, patterns could overlap
|
||||
if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// No overlap detected
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Validation Methods ──────────────────────────────────────────────────────
|
||||
|
||||
private validateIdentifier(identifier: string): void {
|
||||
if (!identifier || !IDENTIFIER_REGEX.test(identifier)) {
|
||||
throw new ValidationError(
|
||||
`Invalid identifier '${identifier}'. Must start with alphanumeric and contain only alphanumeric, hyphens, or underscores.`,
|
||||
'identifier'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateVersion(version: string): void {
|
||||
if (!version || !SEMVER_REGEX.test(version)) {
|
||||
throw new ValidationError(
|
||||
`Invalid version '${version}'. Must be a valid semver format (e.g. "1.0.0").`,
|
||||
'version'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validatePriority(priority: number): void {
|
||||
if (!Number.isInteger(priority) || priority <= 0) {
|
||||
throw new ValidationError(
|
||||
`Invalid priority '${priority}'. Must be a positive integer greater than 0.`,
|
||||
'priority'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validatePolicy(policy: string): void {
|
||||
if (policy !== PolicyType.ONCE && policy !== PolicyType.MAINTAIN) {
|
||||
throw new ValidationError(
|
||||
`Invalid policy '${policy}'. Must be "once" or "maintain".`,
|
||||
'policy'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateCriteria(criteria: ConfigurationCriteria): void {
|
||||
const fields = Object.keys(criteria)
|
||||
for (const field of fields) {
|
||||
if (!ALLOWED_CRITERIA_FIELDS.has(field)) {
|
||||
throw new ValidationError(
|
||||
`Invalid criteria field '${field}'. Only DeviceId fields are allowed: ${[...ALLOWED_CRITERIA_FIELDS].join(', ')}.`,
|
||||
'criteria'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn if a parameterCriteria path is also in desiredParameters — this creates a risk of
|
||||
* the config never matching (deadlock) if the device doesn't already have that parameter value.
|
||||
* We throw a ValidationError to inform the caller of this dangerous overlap.
|
||||
*/
|
||||
private validateParameterCriteriaDeadlock(
|
||||
criteria: ConfigurationCriteria,
|
||||
desiredParameters: DesiredParameter[]
|
||||
): void {
|
||||
const pcNames = (criteria.parameterCriteria || []).map((p) => p.name)
|
||||
const dpNames = new Set((desiredParameters || []).map((p) => p.name))
|
||||
const conflicts = pcNames.filter((name) => dpNames.has(name))
|
||||
if (conflicts.length > 0) {
|
||||
throw new ValidationError(
|
||||
`Deadlock risk: parameter(s) [${conflicts.join(', ')}] appear in both criteria.parameterCriteria and desiredParameters. ` +
|
||||
`A device that does not yet have these values will never match, and the config will never apply.`,
|
||||
'criteria.parameterCriteria'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateInventoryCoverage(
|
||||
desiredParameters: DesiredParameter[],
|
||||
inventory: InventoryPath[]
|
||||
): void {
|
||||
if (!desiredParameters || desiredParameters.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const uncoveredParams: Array<{ name: string; suggestedPath: string }> = []
|
||||
|
||||
for (const param of desiredParameters) {
|
||||
if (!isParameterCoveredByInventory(param.name, inventory)) {
|
||||
uncoveredParams.push({
|
||||
name: param.name,
|
||||
suggestedPath: suggestInventoryPath(param.name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (uncoveredParams.length > 0) {
|
||||
const details = uncoveredParams
|
||||
.map((p) => `"${p.name}" (suggest adding "${p.suggestedPath}" to inventory)`)
|
||||
.join(', ')
|
||||
throw new ValidationError(
|
||||
`Desired parameters not covered by inventory: ${details}`,
|
||||
'desiredParameters'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Device } from '../../domain/device/device.js'
|
||||
import { DeviceActionExecutor } from './device_action_executor.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Action types
|
||||
*/
|
||||
export enum ActionType {
|
||||
SET_PARAMETER_VALUES = 'SET_PARAMETER_VALUES',
|
||||
SET_PARAMETER_ATTRIBUTES = 'SET_PARAMETER_ATTRIBUTES',
|
||||
SCHEDULE_INFORM = 'SCHEDULE_INFORM',
|
||||
DOWNLOAD = 'DOWNLOAD',
|
||||
UPLOAD = 'UPLOAD',
|
||||
FACTORY_RESET = 'FACTORY_RESET',
|
||||
REBOOT = 'REBOOT',
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an action to the corresponding DeviceActionExecutor method
|
||||
*/
|
||||
export async function executeAction(
|
||||
deviceAction: DeviceActionExecutor,
|
||||
device: Device,
|
||||
actionType: ActionType,
|
||||
parameters: any[],
|
||||
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>)
|
||||
|
||||
case ActionType.SET_PARAMETER_ATTRIBUTES:
|
||||
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>)
|
||||
|
||||
case ActionType.DOWNLOAD: {
|
||||
const p = parameters[0]
|
||||
return deviceAction.applyDownload(device, p, callback as (r: boolean) => Promise<string>)
|
||||
}
|
||||
|
||||
case ActionType.UPLOAD: {
|
||||
const p = parameters[0]
|
||||
return deviceAction.applyUpload(device, p, callback as (r: boolean) => Promise<string>)
|
||||
}
|
||||
|
||||
case ActionType.FACTORY_RESET: {
|
||||
const p = parameters?.[0]
|
||||
return deviceAction.applyFactoryReset(device, p, callback as (r: boolean) => Promise<string>)
|
||||
}
|
||||
|
||||
case ActionType.REBOOT: {
|
||||
const p = parameters[0]
|
||||
return deviceAction.applyReboot(device, p, callback as (r: boolean) => Promise<string>)
|
||||
}
|
||||
|
||||
default:
|
||||
logger.error(`Unknown action type: ${actionType}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import ConfigurationEngine from '../configuration_engine.js'
|
||||
import type { EngineResult } from '../configuration_engine.js'
|
||||
import type { DeviceStateResult, DeviceContext } from './main_state_machine.js'
|
||||
import DeviceModel from '#models/device'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { ComplianceStatus } from '../../domain/configuration/compliance.js'
|
||||
import { buildProvisioningCode } from '../../utils/provisioning_code.js'
|
||||
|
||||
/**
|
||||
* ConfigurationEvaluationState
|
||||
*
|
||||
* Stateful state that drives the full configuration evaluation flow:
|
||||
* 1. On first handle(): calls ConfigurationEngine.evaluate(), sets compliance to "in_progress"
|
||||
* 2. Processes inventory reads one path at a time (GetParameterNames RPC)
|
||||
* 3. Processes all parameter diffs in one SetParameterValues RPC
|
||||
* 4. If firmware needed, transitions to FirmwareUpgradeState
|
||||
* 5. Otherwise, transitions to ComplianceUpdateState
|
||||
*/
|
||||
export class ConfigurationEvaluationState {
|
||||
name = 'configuration-evaluation'
|
||||
private engine: ConfigurationEngine
|
||||
private engineResult: EngineResult | null = null
|
||||
private inventoryQueue: string[] = []
|
||||
private pendingValuesQueue: string[] = []
|
||||
private paramDiffs: Array<{ name: string; value: string | number | boolean; type?: string }> = []
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.engine = new ConfigurationEngine()
|
||||
}
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
try {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true
|
||||
|
||||
this.engineResult = await this.engine.evaluate(context.device)
|
||||
logger.debug(
|
||||
{
|
||||
identifier: context.device.identifier,
|
||||
configId: this.engineResult.configuration?.identifier,
|
||||
inventoryPaths: this.engineResult.inventoryPaths.length,
|
||||
paramDiffs: this.engineResult.parameterDiffs.length,
|
||||
firmwareSteps: this.engineResult.firmwareMigrationPath.length,
|
||||
isAlreadyCompliant: this.engineResult.isAlreadyCompliant,
|
||||
},
|
||||
'[ConfigEval] Evaluation complete'
|
||||
)
|
||||
|
||||
await DeviceModel.updateOne(
|
||||
{ identifier: context.device.identifier },
|
||||
{
|
||||
'compliance.status': ComplianceStatus.IN_PROGRESS,
|
||||
'compliance.lastEvaluatedAt': new Date(),
|
||||
'compliance.configurationIdentifier': this.engineResult.configuration?.identifier || null,
|
||||
}
|
||||
)
|
||||
|
||||
// Always populate inventory queue (discovery happens regardless of compliance)
|
||||
this.inventoryQueue = [...this.engineResult.inventoryPaths]
|
||||
|
||||
if (!this.engineResult.isAlreadyCompliant) {
|
||||
this.paramDiffs = this.engineResult.parameterDiffs.map((d) => ({
|
||||
name: d.name,
|
||||
value: d.targetValue,
|
||||
type: d.type,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return this.processNextAction(context)
|
||||
} catch (error: any) {
|
||||
logger.error({ identifier: context.device.identifier, error: error.message }, '[ConfigEval] Evaluation failed')
|
||||
|
||||
await DeviceModel.updateOne(
|
||||
{ identifier: context.device.identifier },
|
||||
{
|
||||
'compliance.status': ComplianceStatus.FAILED,
|
||||
'compliance.lastError': error.message,
|
||||
'compliance.lastEvaluatedAt': new Date(),
|
||||
}
|
||||
).catch(() => {})
|
||||
|
||||
return { isComplete: true }
|
||||
}
|
||||
}
|
||||
|
||||
private async processNextAction(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
// 1. Inventory phase 1: GetParameterNames per subtree path
|
||||
if (this.inventoryQueue.length > 0) {
|
||||
const path = this.inventoryQueue.shift()!
|
||||
logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames')
|
||||
const response = await context.services.deviceAction.getParameterNames(
|
||||
context.device,
|
||||
path,
|
||||
async (result) => {
|
||||
// Collect leaf names into pendingValuesQueue for phase 2
|
||||
const infoList = result.data?.ParameterList?.ParameterInfoStruct || []
|
||||
const leafNames: string[] = infoList
|
||||
.map((info: any) => info?.Name)
|
||||
.filter((name: any): name is string => !!name && !name.endsWith('.'))
|
||||
if (leafNames.length > 0) {
|
||||
this.pendingValuesQueue.push(...leafNames)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
)
|
||||
return { response }
|
||||
}
|
||||
|
||||
// 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')
|
||||
const response = await context.services.deviceAction.getParameterValues(
|
||||
context.device,
|
||||
leaves,
|
||||
() => Promise.resolve('')
|
||||
)
|
||||
return { response }
|
||||
}
|
||||
|
||||
// 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')
|
||||
const params = diffs.map((d) => ({ parameterName: d.name, value: d.value, type: d.type }))
|
||||
const response = await context.services.deviceAction.applySetParameterValues(
|
||||
context.device,
|
||||
params,
|
||||
() => Promise.resolve('')
|
||||
)
|
||||
return { response }
|
||||
}
|
||||
|
||||
// 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 },
|
||||
'[ConfigEval] Transitioning to firmware upgrade'
|
||||
)
|
||||
return { nextState: new FirmwareUpgradeState(this.engineResult!.firmwareMigrationPath, 0, this.engineResult!) }
|
||||
}
|
||||
|
||||
// 5. All done
|
||||
return { nextState: new ComplianceUpdateState(this.engineResult!) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FirmwareUpgradeState
|
||||
*
|
||||
* Sends a Download RPC for the current migration step.
|
||||
* After the RPC, the session ends (device reboots and re-Informs for next step).
|
||||
*/
|
||||
export class FirmwareUpgradeState {
|
||||
name = 'firmware-upgrade'
|
||||
private migrationSteps: any[]
|
||||
private currentStepIndex: number
|
||||
private engineResult: EngineResult
|
||||
|
||||
constructor(migrationSteps: any[], currentStepIndex: number = 0, engineResult: EngineResult) {
|
||||
this.migrationSteps = migrationSteps
|
||||
this.currentStepIndex = currentStepIndex
|
||||
this.engineResult = engineResult
|
||||
}
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
const step = this.migrationSteps[this.currentStepIndex]
|
||||
|
||||
if (!step) {
|
||||
return { nextState: new ComplianceUpdateState(this.engineResult) }
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ identifier: context.device.identifier, step: this.currentStepIndex + 1, total: this.migrationSteps.length },
|
||||
'[FirmwareUpgrade] Sending Download RPC'
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await context.services.deviceAction.applyDownload(
|
||||
context.device,
|
||||
{
|
||||
commandKey: `fw-upgrade-step${this.currentStepIndex}-${Date.now()}`,
|
||||
fileType: '1 Firmware Upgrade Image',
|
||||
url: step.downloadUrl,
|
||||
fileSize: step.fileSize || 0,
|
||||
targetFileName: '',
|
||||
},
|
||||
async () => ''
|
||||
)
|
||||
|
||||
return { response }
|
||||
} catch (error: any) {
|
||||
logger.error({ identifier: context.device.identifier, error: error.message }, '[FirmwareUpgrade] Download RPC failed')
|
||||
|
||||
await DeviceModel.updateOne(
|
||||
{ identifier: context.device.identifier },
|
||||
{
|
||||
'compliance.status': ComplianceStatus.FAILED,
|
||||
'compliance.lastError': `Firmware upgrade failed: ${error.message}`,
|
||||
'compliance.lastEvaluatedAt': new Date(),
|
||||
}
|
||||
).catch(() => {})
|
||||
|
||||
return { isComplete: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ComplianceUpdateState
|
||||
*
|
||||
* Finalizes device compliance:
|
||||
* - Updates compliance status to "conforme" in DB
|
||||
* - For policy "once": writes ProvisioningCode to device
|
||||
* - Transitions to ExecutingCommandsState to process pending user commands
|
||||
*/
|
||||
export class ComplianceUpdateState {
|
||||
name = 'compliance-update'
|
||||
private engineResult: EngineResult
|
||||
private started = false
|
||||
|
||||
constructor(engineResult: EngineResult) {
|
||||
this.engineResult = engineResult
|
||||
}
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
const { ExecutingCommandsState } = await import('./main_state_machine.js')
|
||||
|
||||
if (!this.started) {
|
||||
this.started = true
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
'compliance.status': ComplianceStatus.COMPLIANT,
|
||||
'compliance.lastEvaluatedAt': new Date(),
|
||||
'compliance.lastError': null,
|
||||
}
|
||||
|
||||
if (this.engineResult.configuration) {
|
||||
updateData['compliance.configurationIdentifier'] = this.engineResult.configuration.identifier
|
||||
}
|
||||
|
||||
// Always build and write provisioning code to device (functional for "once", informative for "maintain")
|
||||
if (this.engineResult.configuration) {
|
||||
const provCode = buildProvisioningCode(this.engineResult.configuration)
|
||||
updateData.provisioningCode = provCode
|
||||
|
||||
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')
|
||||
|
||||
const response = await context.services.deviceAction.insertProvisioningCode(
|
||||
context.device,
|
||||
() => Promise.resolve('')
|
||||
)
|
||||
return { response }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
// Second call (after provCode write response): execute user commands
|
||||
return { nextState: new ExecutingCommandsState(true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Device } from '../../domain/device/device.js'
|
||||
import { ParameterType } from '../../domain/shared/types.js'
|
||||
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
|
||||
import DeviceModel from '../../models/device.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
// Parameter types (simplified from legacy provisioning domain)
|
||||
interface SetParameterValue {
|
||||
parameterName: string
|
||||
value: string | number | boolean
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface SetParameterAttribute {
|
||||
name: string
|
||||
notificationChange: boolean
|
||||
notification: number
|
||||
accessListChange: boolean
|
||||
accessList: string[]
|
||||
}
|
||||
|
||||
interface SetScheduleInform {
|
||||
delayInSeconds: number
|
||||
commandKey?: string
|
||||
}
|
||||
|
||||
interface DownloadParameter {
|
||||
commandKey: string
|
||||
fileType: string
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
fileSize?: number
|
||||
targetFileName?: string
|
||||
delaySeconds?: number
|
||||
successUrl?: string
|
||||
failureUrl?: string
|
||||
}
|
||||
|
||||
interface UploadParameter {
|
||||
commandKey: string
|
||||
fileType: string
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
delayInSeconds?: number
|
||||
}
|
||||
|
||||
interface FactoryResetParameter {
|
||||
commandKey?: string
|
||||
}
|
||||
|
||||
interface RebootParameter {
|
||||
commandKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes CWMP actions on devices via the TaskQueue
|
||||
* Each action wraps a callback that persists state on success
|
||||
*/
|
||||
export class DeviceActionExecutor {
|
||||
private taskQueue: CWMPTaskQueue
|
||||
|
||||
constructor(taskQueue: CWMPTaskQueue) {
|
||||
this.taskQueue = taskQueue
|
||||
}
|
||||
|
||||
private async persistDevice(device: Device): Promise<void> {
|
||||
try {
|
||||
const snapshot = device.toSnapshot()
|
||||
await DeviceModel.createOrUpdate({
|
||||
identifier: snapshot.identifier,
|
||||
deviceId: snapshot.deviceId,
|
||||
properties: snapshot.properties,
|
||||
parameters: snapshot.parameters,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`Failed to persist device: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getParameterValues(
|
||||
device: Device,
|
||||
parameterPaths: string[],
|
||||
callback: (result: TaskResult) => Promise<string>
|
||||
): Promise<string> {
|
||||
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) {
|
||||
device.updateFromValues(list)
|
||||
await this.persistDevice(device)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error updating device from parameter values: ${e}`)
|
||||
}
|
||||
}
|
||||
return await callback(result)
|
||||
}
|
||||
|
||||
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback)
|
||||
}
|
||||
|
||||
async getParameterAttributes(
|
||||
device: Device,
|
||||
parameterList: string[],
|
||||
callback: (result: TaskResult) => Promise<string>
|
||||
): Promise<string> {
|
||||
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) {
|
||||
for (const attr of list) {
|
||||
const name: string | undefined = attr?.Name
|
||||
if (!name) continue
|
||||
const notification: number | undefined = typeof attr?.Notification === 'number' ? attr.Notification : undefined
|
||||
const accessList: string[] | undefined = attr?.AccessList?.string
|
||||
|
||||
const param = device.getParameter(name)
|
||||
if (param) {
|
||||
param.updateAttributes?.(notification, accessList)
|
||||
continue
|
||||
}
|
||||
const prop = device.getProperty(name)
|
||||
if (prop) {
|
||||
prop.updateAttributes?.(notification, accessList)
|
||||
}
|
||||
}
|
||||
await this.persistDevice(device)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error updating device from parameter attributes: ${e}`)
|
||||
}
|
||||
}
|
||||
return await callback(result)
|
||||
}
|
||||
|
||||
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback)
|
||||
}
|
||||
|
||||
async getParameterNames(
|
||||
device: Device,
|
||||
parameterName: string,
|
||||
callback: (result: TaskResult) => Promise<string>
|
||||
): Promise<string> {
|
||||
const wrappedCallback = async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
try {
|
||||
device.organizeParameters(result.data as any, new Date())
|
||||
await this.persistDevice(device)
|
||||
} catch (e) {
|
||||
logger.error(`Error organizing parameters: ${e}`)
|
||||
}
|
||||
}
|
||||
return await callback(result)
|
||||
}
|
||||
|
||||
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback)
|
||||
}
|
||||
|
||||
async applySetParameterValues(
|
||||
device: Device,
|
||||
parameters: SetParameterValue[],
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
const existingParameters = device.getParameters()
|
||||
|
||||
const inferType = (val: any): 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'
|
||||
}
|
||||
}
|
||||
|
||||
const cwmpTypeMap: Record<string, string> = {
|
||||
string: 'xsd:string',
|
||||
unsignedInt: 'xsd:unsignedInt',
|
||||
int: 'xsd:int',
|
||||
boolean: 'xsd:boolean',
|
||||
dateTime: 'xsd:dateTime',
|
||||
}
|
||||
|
||||
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')
|
||||
} else {
|
||||
xsdType = inferType(p.value)
|
||||
}
|
||||
return {
|
||||
Name: p.parameterName,
|
||||
Value: { _: p.value, $: { Type: xsdType } },
|
||||
}
|
||||
})
|
||||
|
||||
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
|
||||
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'set_parameter_values',
|
||||
{ parameterValueList: pvStructs, parameterKey },
|
||||
async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
device.updateFromValues(pvStructs as any)
|
||||
await this.persistDevice(device)
|
||||
return await callback(true)
|
||||
}
|
||||
return await callback(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applySetParameterAttributes(
|
||||
device: Device,
|
||||
parameters: SetParameterAttribute[],
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
const attrs = parameters.map((p) => ({
|
||||
name: p.name,
|
||||
notificationChange: p.notificationChange ?? false,
|
||||
notification: p.notification ?? 0,
|
||||
accessListChange: p.accessListChange ?? false,
|
||||
accessList: (p.accessList as any)?.string || [],
|
||||
}))
|
||||
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'set_parameter_attributes',
|
||||
attrs,
|
||||
async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
return await callback(true)
|
||||
}
|
||||
return await callback(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applyScheduleInform(
|
||||
device: Device,
|
||||
parameter: SetScheduleInform,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
const commandKey = parameter.commandKey || `ScheduleInform_${device.deviceId.serialNumber}_${Date.now()}`
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'schedule_inform',
|
||||
{ delayInSeconds: parameter.delayInSeconds, commandKey },
|
||||
async (result: TaskResult) => {
|
||||
return await callback(result.status === ResultStatus.Success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applyFactoryReset(
|
||||
_device: Device,
|
||||
_parameter: FactoryResetParameter,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
return this.taskQueue.addTask(
|
||||
_device.deviceId,
|
||||
'factory_reset',
|
||||
{},
|
||||
async (result: TaskResult) => {
|
||||
return await callback(result.status === ResultStatus.Success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applyReboot(
|
||||
device: Device,
|
||||
parameter: RebootParameter,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'reboot',
|
||||
{ commandKey: parameter.commandKey || '' },
|
||||
async (result: TaskResult) => {
|
||||
return await callback(result.status === ResultStatus.Success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applyDownload(
|
||||
device: Device,
|
||||
parameter: DownloadParameter,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'download',
|
||||
{
|
||||
commandKey: parameter.commandKey,
|
||||
fileType: parameter.fileType,
|
||||
url: parameter.url,
|
||||
username: parameter.username,
|
||||
password: parameter.password,
|
||||
fileSize: parameter.fileSize,
|
||||
targetFileName: parameter.targetFileName,
|
||||
delaySeconds: parameter.delaySeconds,
|
||||
successUrl: parameter.successUrl,
|
||||
failureUrl: parameter.failureUrl,
|
||||
},
|
||||
async (result: TaskResult) => {
|
||||
return await callback(result.status === ResultStatus.Success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async applyUpload(
|
||||
device: Device,
|
||||
parameter: UploadParameter,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'upload',
|
||||
{
|
||||
commandKey: parameter.commandKey,
|
||||
fileType: parameter.fileType,
|
||||
url: parameter.url,
|
||||
username: parameter.username,
|
||||
password: parameter.password,
|
||||
delaySeconds: parameter.delayInSeconds,
|
||||
},
|
||||
async (result: TaskResult) => {
|
||||
return await callback(result.status === ResultStatus.Success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async insertProvisioningCode(
|
||||
device: Device,
|
||||
callback: (result: boolean) => Promise<string>
|
||||
): Promise<string> {
|
||||
const allParams = device.getParameters()
|
||||
const allProps = device.getProperties()
|
||||
|
||||
const knownPC = [...allParams, ...allProps].find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name))
|
||||
|
||||
const hasDeviceTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('Device.'))
|
||||
const hasIgdTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('InternetGatewayDevice.'))
|
||||
|
||||
const inferredPath = hasDeviceTree ? 'Device.DeviceInfo.ProvisioningCode' : 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
|
||||
const provisioningPath = knownPC?.name || (hasIgdTree && !hasDeviceTree ? 'InternetGatewayDevice.DeviceInfo.ProvisioningCode' : inferredPath)
|
||||
|
||||
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
|
||||
const pvStructs = [{ Name: provisioningPath, Value: { _: device.tmpPC, $: { Type: 'xsd:string' } } }]
|
||||
|
||||
return this.taskQueue.addTask(
|
||||
device.deviceId,
|
||||
'set_parameter_values',
|
||||
{ parameterValueList: pvStructs, parameterKey },
|
||||
async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
device.updateFromValues(pvStructs as any)
|
||||
await this.persistDevice(device)
|
||||
device.tmpPC = ''
|
||||
return await callback(true)
|
||||
}
|
||||
return await callback(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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 logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* State machine for "6 CONNECTION REQUEST" events.
|
||||
* Only executes pending Immediate (I) commands - does NOT include NI commands.
|
||||
*/
|
||||
export class ImmediateCommandStateMachine {
|
||||
private currentState: ExecutingCommandsState
|
||||
private context: DeviceContext
|
||||
|
||||
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
|
||||
// includeNI = false: only execute Immediate commands
|
||||
this.currentState = new ExecutingCommandsState(false)
|
||||
this.context = {
|
||||
device,
|
||||
session,
|
||||
taskQueue,
|
||||
services: {
|
||||
deviceAction: new DeviceActionExecutor(taskQueue),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
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> {
|
||||
this.context.message = { success, data: message }
|
||||
return this.execute()
|
||||
}
|
||||
|
||||
getCurrentStateName(): string {
|
||||
return this.currentState.name
|
||||
}
|
||||
|
||||
isCompleted(): boolean {
|
||||
return this.currentState.name === 'completed'
|
||||
}
|
||||
|
||||
private async execute(): Promise<string> {
|
||||
try {
|
||||
const result: DeviceStateResult = await this.currentState.handle(this.context)
|
||||
|
||||
if (result.isComplete) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (result.response && result.response !== '') {
|
||||
return result.response
|
||||
}
|
||||
|
||||
return ''
|
||||
} catch (error) {
|
||||
logger.error(`Immediate-command state machine error: ${error}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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 logger from '@adonisjs/core/services/logger'
|
||||
import { ConfigurationEvaluationState } from './configuration_evaluation_state.js'
|
||||
|
||||
// ─── Interfaces ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DeviceContext {
|
||||
device: Device
|
||||
session: string
|
||||
message?: { success: boolean; data: any }
|
||||
taskQueue: CWMPTaskQueue
|
||||
services: {
|
||||
deviceAction: DeviceActionExecutor
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceStateResult {
|
||||
nextState?: DeviceState
|
||||
response?: string
|
||||
isComplete?: boolean
|
||||
}
|
||||
|
||||
// ─── Abstract State ────────────────────────────────────────────────────────────
|
||||
|
||||
abstract class DeviceState {
|
||||
abstract name: string
|
||||
abstract handle(context: DeviceContext): Promise<DeviceStateResult>
|
||||
}
|
||||
|
||||
// ─── States ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class IdleState extends DeviceState {
|
||||
name = 'idle'
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
const parameterQueue = context.device.tmpParamValues.map((p: any) => p.Name).filter((n: any): n is string => !!n)
|
||||
if (parameterQueue.length === 0) {
|
||||
return { nextState: new ConfigurationEvaluationState(), response: '' }
|
||||
}
|
||||
return { nextState: new DiscoveringParametersState(parameterQueue), response: '' }
|
||||
}
|
||||
}
|
||||
|
||||
class DiscoveringParametersState extends DeviceState {
|
||||
name = 'discovering-parameters'
|
||||
private parameterQueue: string[]
|
||||
private started = false
|
||||
|
||||
constructor(parameterQueue: string[]) {
|
||||
super()
|
||||
this.parameterQueue = parameterQueue
|
||||
}
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
if (!this.started) {
|
||||
this.started = true
|
||||
if (this.parameterQueue.length === 0) {
|
||||
return { nextState: new ConfigurationEvaluationState(), response: '' }
|
||||
}
|
||||
return { response: await this.processNext(context) }
|
||||
}
|
||||
|
||||
// Process response and continue
|
||||
if (this.parameterQueue.length > 0) {
|
||||
return { response: await this.processNext(context) }
|
||||
}
|
||||
|
||||
return { nextState: new ConfigurationEvaluationState(), response: '' }
|
||||
}
|
||||
|
||||
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(''))
|
||||
}
|
||||
}
|
||||
|
||||
export class ExecutingCommandsState extends DeviceState {
|
||||
name = 'executing-commands'
|
||||
private commandsQueue: any[] = []
|
||||
private started = false
|
||||
private currentCommandRecord: any | null = null
|
||||
private includeNI: boolean
|
||||
|
||||
constructor(includeNI: boolean = true) {
|
||||
super()
|
||||
this.includeNI = includeNI
|
||||
}
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
if (!this.started) {
|
||||
const hashId = getDeviceHashIdentifier({
|
||||
manufacturer: context.device.deviceId.manufacturer,
|
||||
oui: context.device.deviceId.oui,
|
||||
productClass: context.device.deviceId.productClass,
|
||||
serialNumber: context.device.deviceId.serialNumber,
|
||||
})
|
||||
|
||||
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 niPending = this.includeNI
|
||||
? await collection.find({ deviceIdentifier: hashId, mode: ModeType.NI, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray()
|
||||
: []
|
||||
|
||||
this.commandsQueue = [...iWaiting, ...iPending, ...niPending]
|
||||
this.started = true
|
||||
|
||||
if (this.commandsQueue.length === 0) {
|
||||
if (this.includeNI) {
|
||||
return { nextState: new CompletedState(), isComplete: true, response: '' }
|
||||
}
|
||||
return { response: '' }
|
||||
}
|
||||
|
||||
return { response: await this.processNextCommand(context) }
|
||||
}
|
||||
|
||||
// Handle response for last command
|
||||
if (this.currentCommandRecord) {
|
||||
const now = new Date()
|
||||
if (context.message?.success) {
|
||||
this.currentCommandRecord.status = CommandStatus.COMPLETED
|
||||
this.currentCommandRecord.completedAt = now
|
||||
this.currentCommandRecord.executedAt = now
|
||||
|
||||
// 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' } },
|
||||
}))
|
||||
if (pvStructs.length > 0) {
|
||||
context.device.updateFromValues(pvStructs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.currentCommandRecord.status = CommandStatus.FAILED
|
||||
this.currentCommandRecord.executedAt = now
|
||||
}
|
||||
|
||||
try {
|
||||
await CommandModel.updateCommand(this.currentCommandRecord._id, {
|
||||
status: this.currentCommandRecord.status,
|
||||
executedAt: now,
|
||||
completedAt: this.currentCommandRecord.completedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update command: ${err}`)
|
||||
}
|
||||
|
||||
this.currentCommandRecord = null
|
||||
}
|
||||
|
||||
if (this.commandsQueue.length > 0) {
|
||||
return { response: await this.processNextCommand(context) }
|
||||
}
|
||||
|
||||
// All commands done
|
||||
if (this.includeNI) {
|
||||
return { nextState: new CompletedState(), isComplete: true, response: '' }
|
||||
}
|
||||
return { response: '' }
|
||||
}
|
||||
|
||||
private async processNextCommand(context: DeviceContext): Promise<string> {
|
||||
if (this.commandsQueue.length === 0) return ''
|
||||
|
||||
const commandRecord = this.commandsQueue.shift()!
|
||||
this.currentCommandRecord = commandRecord
|
||||
|
||||
// Mark as SENT
|
||||
const now = new Date()
|
||||
commandRecord.status = CommandStatus.SENT
|
||||
commandRecord.executedAt = now
|
||||
try {
|
||||
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.SENT, executedAt: now, updatedAt: now })
|
||||
} catch (err) {
|
||||
logger.error(`Failed to mark command as SENT: ${err}`)
|
||||
}
|
||||
|
||||
// Clear pending tasks for this device before issuing command
|
||||
try {
|
||||
context.taskQueue.clearPendingTasksForDevice(context.device.deviceId)
|
||||
} catch {}
|
||||
|
||||
const action = commandRecord.action as CommandType
|
||||
const params = commandRecord.parameters
|
||||
|
||||
// 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(''))
|
||||
}
|
||||
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(''))
|
||||
}
|
||||
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(''))
|
||||
}
|
||||
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,
|
||||
}))
|
||||
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(''))
|
||||
}
|
||||
case CommandType.DOWNLOAD: {
|
||||
return context.services.deviceAction.applyDownload(context.device, params || {}, () => Promise.resolve(''))
|
||||
}
|
||||
case CommandType.UPLOAD: {
|
||||
return context.services.deviceAction.applyUpload(context.device, params || {}, () => Promise.resolve(''))
|
||||
}
|
||||
case CommandType.REBOOT: {
|
||||
return context.services.deviceAction.applyReboot(context.device, params || {}, () => Promise.resolve(''))
|
||||
}
|
||||
case CommandType.FACTORY_RESET: {
|
||||
return context.services.deviceAction.applyFactoryReset(context.device, {}, () => Promise.resolve(''))
|
||||
}
|
||||
case CommandType.SCHEDULE_INFORM: {
|
||||
return context.services.deviceAction.applyScheduleInform(
|
||||
context.device,
|
||||
{ delayInSeconds: params?.delayInSeconds || 300, commandKey: params?.commandKey },
|
||||
() => 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),
|
||||
].filter((name) => !!name && !name.endsWith('.'))
|
||||
if (allNames.length > 0) {
|
||||
return context.services.deviceAction.getParameterValues(context.device, allNames, () => Promise.resolve(''))
|
||||
}
|
||||
return ''
|
||||
}
|
||||
default:
|
||||
logger.error(`Unknown command type: ${action}`)
|
||||
// Mark as failed
|
||||
commandRecord.status = CommandStatus.FAILED
|
||||
await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.FAILED, updatedAt: new Date() })
|
||||
this.currentCommandRecord = null
|
||||
if (this.commandsQueue.length > 0) {
|
||||
return this.processNextCommand(context)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CompletedState extends DeviceState {
|
||||
name = 'completed'
|
||||
|
||||
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
||||
// Persist device on completion
|
||||
try {
|
||||
const snapshot = context.device.toSnapshot()
|
||||
await DeviceModel.createOrUpdate({
|
||||
identifier: snapshot.identifier,
|
||||
deviceId: snapshot.deviceId,
|
||||
properties: snapshot.properties,
|
||||
parameters: snapshot.parameters,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`Failed to persist device on completion: ${e}`)
|
||||
}
|
||||
return { isComplete: true, response: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main State Machine ────────────────────────────────────────────────────────
|
||||
|
||||
export class MainStateMachine {
|
||||
private currentState: DeviceState
|
||||
private context: DeviceContext
|
||||
|
||||
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
|
||||
this.currentState = new IdleState()
|
||||
this.context = {
|
||||
device,
|
||||
session,
|
||||
taskQueue,
|
||||
services: {
|
||||
deviceAction: new DeviceActionExecutor(taskQueue),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
return this.executeCurrentState()
|
||||
}
|
||||
|
||||
async handleResponse(success: boolean, message: any): Promise<string> {
|
||||
this.context.message = { success, data: message }
|
||||
return this.executeCurrentState()
|
||||
}
|
||||
|
||||
isCompleted(): boolean {
|
||||
return this.currentState.name === 'completed'
|
||||
}
|
||||
|
||||
getCurrentStateName(): string {
|
||||
return this.currentState.name
|
||||
}
|
||||
|
||||
private async executeCurrentState(): Promise<string> {
|
||||
let safetyGuard = 0
|
||||
|
||||
while (safetyGuard++ < 15) {
|
||||
try {
|
||||
logger.debug({ state: this.currentState.name, iteration: safetyGuard }, '[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')
|
||||
this.currentState = result.nextState
|
||||
}
|
||||
|
||||
if (result.isComplete) {
|
||||
logger.info({ finalState: this.currentState.name }, '[StateMachine] Completed')
|
||||
return ''
|
||||
}
|
||||
|
||||
if (result.response && result.response !== '') {
|
||||
logger.debug({ state: this.currentState.name, responseLength: result.response.length }, '[StateMachine] Sending response to CPE')
|
||||
return result.response
|
||||
}
|
||||
|
||||
if (!result.nextState) {
|
||||
return ''
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in state ${this.currentState.name}: ${error}`)
|
||||
// On error, try to complete gracefully
|
||||
this.currentState = new CompletedState()
|
||||
await this.currentState.handle(this.context)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Main state machine safety guard reached')
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Device } from '../../domain/device/device.js'
|
||||
import { 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 { CommandStatus } from '../../domain/commands/command.js'
|
||||
import { ModeType } from '../../domain/shared/mode.js'
|
||||
import CommandModel from '../../models/command.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export enum MachineStatus {
|
||||
CREATED = 'created',
|
||||
RUNNING = 'running',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
export interface StateMachineInstance {
|
||||
id: string
|
||||
deviceId: DeviceId
|
||||
session: string
|
||||
stateMachine: MainStateMachine | ImmediateCommandStateMachine
|
||||
status: MachineStatus
|
||||
createdAt: Date
|
||||
completedAt?: Date
|
||||
currentState: string
|
||||
lastTaskId?: string
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Central state machine manager with TCP-socket-based session tracking,
|
||||
* device preemption, and CWMP task queue integration.
|
||||
*/
|
||||
export class StateMachineManager extends EventEmitter {
|
||||
private taskQueue: CWMPTaskQueue
|
||||
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 cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
private static instance: StateMachineManager | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.taskQueue = new CWMPTaskQueue()
|
||||
this.setupTaskQueueListeners()
|
||||
}
|
||||
|
||||
static getInstance(): StateMachineManager {
|
||||
if (!StateMachineManager.instance) {
|
||||
StateMachineManager.instance = new StateMachineManager()
|
||||
}
|
||||
return StateMachineManager.instance
|
||||
}
|
||||
|
||||
getTaskQueue(): CWMPTaskQueue {
|
||||
return this.taskQueue
|
||||
}
|
||||
|
||||
startCleanupTimer(intervalMs: number = 60000): void {
|
||||
if (this.cleanupInterval) return
|
||||
this.cleanupInterval = setInterval(() => this.cleanupCompletedMachines(), intervalMs)
|
||||
}
|
||||
|
||||
stopCleanupTimer(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a state machine for a device session
|
||||
*/
|
||||
async startStateMachine(
|
||||
device: Device,
|
||||
session: string,
|
||||
opts?: { events?: string[] }
|
||||
): Promise<StateMachineInstance> {
|
||||
const deviceKey = JSON.stringify(device.deviceId)
|
||||
const evts = opts?.events || []
|
||||
const lastEvent = this.normalizeEvent(evts.length > 0 ? evts[evts.length - 1] : '')
|
||||
|
||||
// Check for NI commands that force MainStateMachine
|
||||
let forceMainForNI = false
|
||||
try {
|
||||
const hashId = getDeviceHashIdentifier({
|
||||
manufacturer: device.deviceId.manufacturer,
|
||||
oui: device.deviceId.oui,
|
||||
productClass: device.deviceId.productClass,
|
||||
serialNumber: device.deviceId.serialNumber,
|
||||
})
|
||||
const collection = CommandModel.getCollection()
|
||||
const niCount = await collection.countDocuments({
|
||||
deviceIdentifier: hashId,
|
||||
mode: ModeType.NI,
|
||||
status: { $in: [CommandStatus.PENDING, CommandStatus.WAITING] },
|
||||
})
|
||||
forceMainForNI = niCount > 0
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to check NI commands: ${e}`)
|
||||
}
|
||||
|
||||
// Handle preemption of existing machine
|
||||
const existingSessionId = this.deviceSessions.get(deviceKey)
|
||||
if (existingSessionId) {
|
||||
const existingMachineId = this.sessionMachines.get(existingSessionId)
|
||||
if (existingMachineId) {
|
||||
const existingMachine = this.machines.get(existingMachineId)
|
||||
if (existingMachine && existingMachine.status === MachineStatus.RUNNING) {
|
||||
const isImmediate = existingMachine.stateMachine instanceof ImmediateCommandStateMachine
|
||||
const onboardingEvent = ['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent)
|
||||
const shouldPreempt = isImmediate && (forceMainForNI || onboardingEvent)
|
||||
|
||||
if (shouldPreempt) {
|
||||
// Rebind session if changed
|
||||
if (existingSessionId !== session) {
|
||||
this.sessionMachines.delete(existingSessionId)
|
||||
this.sessionMachines.set(session, existingMachineId)
|
||||
this.deviceSessions.set(deviceKey, session)
|
||||
existingMachine.session = session
|
||||
}
|
||||
// Replace with MainStateMachine
|
||||
const newMachine = new MainStateMachine(device, session, this.taskQueue)
|
||||
;(existingMachine as any).stateMachine = newMachine
|
||||
existingMachine.events = evts
|
||||
await newMachine.start()
|
||||
existingMachine.currentState = newMachine.getCurrentStateName()
|
||||
if (newMachine.isCompleted()) this.completeMachine(existingMachine)
|
||||
return existingMachine
|
||||
}
|
||||
|
||||
// Rebind session to existing machine
|
||||
if (existingSessionId !== session) {
|
||||
this.sessionMachines.delete(existingSessionId)
|
||||
this.sessionMachines.set(session, existingMachineId)
|
||||
this.deviceSessions.set(deviceKey, session)
|
||||
existingMachine.session = session
|
||||
}
|
||||
return existingMachine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new machine
|
||||
const machineId = `${deviceKey}_${session}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
let stateMachine: MainStateMachine | ImmediateCommandStateMachine
|
||||
if (forceMainForNI) {
|
||||
stateMachine = new MainStateMachine(device, session, this.taskQueue)
|
||||
} else if (lastEvent === '6 CONNECTION REQUEST') {
|
||||
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
|
||||
} else if (['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent) || !lastEvent) {
|
||||
stateMachine = new MainStateMachine(device, session, this.taskQueue)
|
||||
} else {
|
||||
// Unknown event - use MainStateMachine as default
|
||||
stateMachine = new MainStateMachine(device, session, this.taskQueue)
|
||||
}
|
||||
|
||||
const machineInstance: StateMachineInstance = {
|
||||
id: machineId,
|
||||
deviceId: device.deviceId,
|
||||
session,
|
||||
stateMachine,
|
||||
status: MachineStatus.CREATED,
|
||||
createdAt: new Date(),
|
||||
currentState: stateMachine.getCurrentStateName(),
|
||||
events: evts,
|
||||
}
|
||||
|
||||
this.machines.set(machineId, machineInstance)
|
||||
this.deviceSessions.set(deviceKey, session)
|
||||
this.sessionMachines.set(session, machineId)
|
||||
|
||||
// Start the machine
|
||||
try {
|
||||
machineInstance.status = MachineStatus.RUNNING
|
||||
await stateMachine.start()
|
||||
machineInstance.currentState = stateMachine.getCurrentStateName()
|
||||
|
||||
if (stateMachine.isCompleted()) {
|
||||
this.completeMachine(machineInstance)
|
||||
}
|
||||
} catch (error) {
|
||||
machineInstance.status = MachineStatus.FAILED
|
||||
machineInstance.completedAt = new Date()
|
||||
logger.error(`Failed to start state machine: ${error}`)
|
||||
}
|
||||
|
||||
return machineInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a remote endpoint to a device for session rebinding
|
||||
*/
|
||||
bindRemoteToDevice(remoteKey: string, deviceId: DeviceId): void {
|
||||
this.remoteToDevice.set(remoteKey, JSON.stringify(deviceId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure session is bound when TCP connection changes
|
||||
*/
|
||||
ensureSessionForRemote(remoteKey: string, newSession: string): boolean {
|
||||
const deviceKey = this.remoteToDevice.get(remoteKey)
|
||||
if (!deviceKey) return false
|
||||
|
||||
const existingSession = this.deviceSessions.get(deviceKey)
|
||||
if (!existingSession) return false
|
||||
|
||||
const machineId = this.sessionMachines.get(existingSession)
|
||||
if (!machineId) return false
|
||||
|
||||
const machine = this.machines.get(machineId)
|
||||
if (!machine) return false
|
||||
|
||||
if (existingSession !== newSession) {
|
||||
this.sessionMachines.delete(existingSession)
|
||||
this.sessionMachines.set(newSession, machineId)
|
||||
this.deviceSessions.set(deviceKey, newSession)
|
||||
machine.session = newSession
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue when CPE sends empty body
|
||||
*/
|
||||
async continue(session: string): Promise<string> {
|
||||
const machineId = this.sessionMachines.get(session)
|
||||
if (!machineId) return ''
|
||||
|
||||
const machineInstance = this.machines.get(machineId)
|
||||
if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return ''
|
||||
|
||||
try {
|
||||
// Serve queued tasks first
|
||||
const pendingTask = this.taskQueue.getTaskForDevice(machineInstance.deviceId)
|
||||
if (pendingTask) {
|
||||
const builder = CWMPTaskQueue.task_map[pendingTask.task]
|
||||
if (typeof builder === 'function') {
|
||||
const envelope = builder(pendingTask.props)
|
||||
if (pendingTask.id) machineInstance.lastTaskId = pendingTask.id
|
||||
return envelope
|
||||
}
|
||||
}
|
||||
|
||||
// If a task is in-flight, wait
|
||||
if (machineInstance.lastTaskId) return ''
|
||||
|
||||
// Let the state machine continue
|
||||
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
|
||||
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
|
||||
|
||||
if (machineInstance.stateMachine.isCompleted()) {
|
||||
this.completeMachine(machineInstance)
|
||||
return ''
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`Error continuing session ${session}: ${error}`)
|
||||
this.failMachine(machineInstance, String(error))
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a CWMP response message
|
||||
*/
|
||||
async handleCWMPResponse(session: string, success: boolean, message: any): Promise<string> {
|
||||
const machineId = this.sessionMachines.get(session)
|
||||
if (!machineId) return ''
|
||||
|
||||
const machineInstance = this.machines.get(machineId)
|
||||
if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return ''
|
||||
|
||||
try {
|
||||
const responseTaskId = machineInstance.lastTaskId
|
||||
const response = await machineInstance.stateMachine.handleResponse(success, message)
|
||||
|
||||
// Execute task callback
|
||||
if (responseTaskId) {
|
||||
if (success) {
|
||||
this.taskQueue.completeTask(responseTaskId, message)
|
||||
} else {
|
||||
this.taskQueue.failTask(responseTaskId, JSON.stringify(message))
|
||||
}
|
||||
try {
|
||||
await this.taskQueue.executeTaskCallback(
|
||||
responseTaskId,
|
||||
success ? ResultStatus.Success : ResultStatus.Error,
|
||||
message
|
||||
)
|
||||
} catch (cbErr) {
|
||||
logger.error(`Task callback error: ${cbErr}`)
|
||||
}
|
||||
if (machineInstance.lastTaskId === responseTaskId) {
|
||||
machineInstance.lastTaskId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
|
||||
|
||||
if (machineInstance.stateMachine.isCompleted()) {
|
||||
this.completeMachine(machineInstance)
|
||||
return ''
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`Error handling CWMP response: ${error}`)
|
||||
this.failMachine(machineInstance, String(error))
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
hasSession(session: string): boolean {
|
||||
return this.sessionMachines.has(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an immediate task for a device (e.g., after Inform)
|
||||
*/
|
||||
async enqueueImmediate(device: Device, type: string, payload: any): Promise<void> {
|
||||
this.taskQueue.addCWMPTask(device.deviceId, type, payload)
|
||||
}
|
||||
|
||||
private completeMachine(machine: StateMachineInstance): void {
|
||||
machine.status = MachineStatus.COMPLETED
|
||||
machine.completedAt = new Date()
|
||||
const deviceKey = JSON.stringify(machine.deviceId)
|
||||
this.deviceSessions.delete(deviceKey)
|
||||
this.sessionMachines.delete(machine.session)
|
||||
}
|
||||
|
||||
private failMachine(machine: StateMachineInstance, error: string): void {
|
||||
machine.status = MachineStatus.FAILED
|
||||
machine.completedAt = new Date()
|
||||
const deviceKey = JSON.stringify(machine.deviceId)
|
||||
this.deviceSessions.delete(deviceKey)
|
||||
this.sessionMachines.delete(machine.session)
|
||||
logger.error(`State machine failed: ${error}`)
|
||||
}
|
||||
|
||||
private normalizeEvent(evt: string): string {
|
||||
return (evt || '').toString().trim().replace(/\s+/g, ' ').toUpperCase()
|
||||
}
|
||||
|
||||
cleanupCompletedMachines(olderThanHours: number = 1): void {
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000)
|
||||
for (const [id, machine] of this.machines.entries()) {
|
||||
if ((machine.status === MachineStatus.COMPLETED || machine.status === MachineStatus.FAILED) &&
|
||||
machine.completedAt && machine.completedAt < cutoff) {
|
||||
this.machines.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.stopCleanupTimer()
|
||||
this.cleanupCompletedMachines(0)
|
||||
}
|
||||
|
||||
private setupTaskQueueListeners(): void {
|
||||
this.taskQueue.on('taskSent', (task: any) => {
|
||||
// Find the machine for this device and set lastTaskId
|
||||
const deviceKey = JSON.stringify(task.deviceId)
|
||||
const sessionId = this.deviceSessions.get(deviceKey)
|
||||
if (sessionId) {
|
||||
const machineId = this.sessionMachines.get(sessionId)
|
||||
if (machineId) {
|
||||
const machine = this.machines.get(machineId)
|
||||
if (machine) machine.lastTaskId = task.id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { DeviceId } from '../../domain/device/device_id.js'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { CWMPMessageBuilder } from '../../protocol/common/cwmp_message_builder.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export class TaskResult {
|
||||
status!: ResultStatus
|
||||
data: any
|
||||
timestamp!: Date
|
||||
}
|
||||
|
||||
export enum ResultStatus {
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export interface CWMPTask {
|
||||
id: string
|
||||
deviceId: DeviceId
|
||||
type: string
|
||||
payload: any
|
||||
status: TaskStatus
|
||||
createdAt: Date
|
||||
completedAt?: Date
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
PENDING = 'pending',
|
||||
SENT = 'sent',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* CWMP Task Queue with event-based lifecycle
|
||||
* 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, true)
|
||||
},
|
||||
get_parameter_attributes: (parameterNames: string[]) => {
|
||||
return CWMPMessageBuilder.getParameterAttributes(undefined, parameterNames)
|
||||
},
|
||||
get_parameter_values: (parameterNames: string[]) => {
|
||||
return CWMPMessageBuilder.getParameterValues(undefined, parameterNames)
|
||||
},
|
||||
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',
|
||||
}))
|
||||
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)
|
||||
},
|
||||
schedule_inform: (props: { delayInSeconds: number; commandKey?: string }) => {
|
||||
return CWMPMessageBuilder.scheduleInform(undefined, props.delayInSeconds || 300, props.commandKey)
|
||||
},
|
||||
download: (props: any) => {
|
||||
return CWMPMessageBuilder.download(undefined, props)
|
||||
},
|
||||
upload: (props: any) => {
|
||||
return CWMPMessageBuilder.upload(undefined, props)
|
||||
},
|
||||
factory_reset: () => {
|
||||
return CWMPMessageBuilder.factoryReset(undefined)
|
||||
},
|
||||
reboot: (props: any) => {
|
||||
return CWMPMessageBuilder.reboot(undefined, props?.commandKey)
|
||||
},
|
||||
}
|
||||
|
||||
private tasks: Map<string, CWMPTask> = new Map()
|
||||
private deviceQueues: Map<string, string[]> = new Map()
|
||||
private taskCallbacks: Map<string, (result: TaskResult) => Promise<string>> = new Map()
|
||||
|
||||
/**
|
||||
* Adds a task with callback (legacy-compatible API used by DeviceActionExecutor)
|
||||
* Returns the CWMP XML envelope to send to the device
|
||||
*/
|
||||
addTask(
|
||||
deviceId: DeviceId,
|
||||
taskType: string,
|
||||
props: any,
|
||||
callback: (result: TaskResult) => Promise<string>
|
||||
): string {
|
||||
const cwmpTask = this.addCWMPTask(deviceId, taskType, props)
|
||||
|
||||
if (callback) {
|
||||
this.taskCallbacks.set(cwmpTask.id, callback)
|
||||
}
|
||||
|
||||
// Mark as SENT immediately so the manager correlates the response
|
||||
if (cwmpTask.status === TaskStatus.PENDING) {
|
||||
cwmpTask.status = TaskStatus.SENT
|
||||
this.emit('taskSent', cwmpTask)
|
||||
}
|
||||
|
||||
// Generate CWMP envelope
|
||||
const builder = CWMPTaskQueue.task_map[taskType]
|
||||
if (typeof builder !== 'function') {
|
||||
logger.warn(`No CWMP builder found for task '${taskType}'`)
|
||||
return ''
|
||||
}
|
||||
return builder(props)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback for a task already in the queue (e.g. added via addCWMPTask)
|
||||
*/
|
||||
registerCallback(taskId: string, callback: (result: TaskResult) => Promise<string>): void {
|
||||
this.taskCallbacks.set(taskId, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a CWMP task (internal)
|
||||
*/
|
||||
addCWMPTask(deviceId: DeviceId, type: string, payload: any): CWMPTask {
|
||||
const taskId = this.generateTaskId(deviceId, type)
|
||||
const task: CWMPTask = {
|
||||
id: taskId,
|
||||
deviceId,
|
||||
type,
|
||||
payload,
|
||||
status: TaskStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
this.tasks.set(taskId, task)
|
||||
|
||||
const deviceKey = JSON.stringify(deviceId)
|
||||
if (!this.deviceQueues.has(deviceKey)) {
|
||||
this.deviceQueues.set(deviceKey, [])
|
||||
}
|
||||
this.deviceQueues.get(deviceKey)!.push(taskId)
|
||||
|
||||
this.emit('taskAdded', task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and mark a pending task as SENT for a device
|
||||
*/
|
||||
getTaskForDevice(deviceId: DeviceId): { id: string; task: string; props: any } | undefined {
|
||||
const deviceKey = JSON.stringify(deviceId)
|
||||
const queue = this.deviceQueues.get(deviceKey)
|
||||
if (!queue || queue.length === 0) return undefined
|
||||
|
||||
const taskId = queue[0]
|
||||
const cwmpTask = this.tasks.get(taskId)
|
||||
if (!cwmpTask) return undefined
|
||||
|
||||
// Dequeue
|
||||
queue.shift()
|
||||
|
||||
if (cwmpTask.status === TaskStatus.PENDING) {
|
||||
cwmpTask.status = TaskStatus.SENT
|
||||
this.emit('taskSent', cwmpTask)
|
||||
}
|
||||
|
||||
return { id: taskId, task: cwmpTask.type, props: cwmpTask.payload }
|
||||
}
|
||||
|
||||
completeTask(taskId: string, result: any): void {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task) return
|
||||
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.result = result
|
||||
task.completedAt = new Date()
|
||||
|
||||
this.removeTaskFromQueue(task.deviceId, taskId)
|
||||
this.emit('taskCompleted', task)
|
||||
}
|
||||
|
||||
failTask(taskId: string, error: string): void {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task) return
|
||||
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = error
|
||||
task.completedAt = new Date()
|
||||
|
||||
this.removeTaskFromQueue(task.deviceId, taskId)
|
||||
this.emit('taskFailed', task)
|
||||
}
|
||||
|
||||
async executeTaskCallback(taskId: string, status: ResultStatus, data: any): Promise<string> {
|
||||
const callback = this.taskCallbacks.get(taskId)
|
||||
if (callback) {
|
||||
const result = new TaskResult()
|
||||
result.status = status
|
||||
result.data = data
|
||||
result.timestamp = new Date()
|
||||
|
||||
try {
|
||||
const response = await callback(result)
|
||||
this.taskCallbacks.delete(taskId)
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`Error executing task callback: ${error}`)
|
||||
this.taskCallbacks.delete(taskId)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
clearPendingTasksForDevice(deviceId: DeviceId): void {
|
||||
const deviceKey = JSON.stringify(deviceId)
|
||||
const queue = this.deviceQueues.get(deviceKey) || []
|
||||
const keep: string[] = []
|
||||
for (const taskId of queue) {
|
||||
const t = this.tasks.get(taskId)
|
||||
if (t && t.status === TaskStatus.SENT) {
|
||||
keep.push(taskId)
|
||||
continue
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
this.taskCallbacks.delete(taskId)
|
||||
}
|
||||
this.deviceQueues.set(deviceKey, keep)
|
||||
}
|
||||
|
||||
private generateTaskId(deviceId: DeviceId, type: string): string {
|
||||
return `${JSON.stringify(deviceId)}_${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
private removeTaskFromQueue(deviceId: DeviceId, taskId: string): void {
|
||||
const deviceKey = JSON.stringify(deviceId)
|
||||
const queue = this.deviceQueues.get(deviceKey)
|
||||
if (queue) {
|
||||
const index = queue.indexOf(taskId)
|
||||
if (index > -1) queue.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import DeviceModel from '#models/device'
|
||||
import CommandModel from '#models/command'
|
||||
import { DeviceRecord } from '../domain/device/device_record.js'
|
||||
import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js'
|
||||
import { ModeType } from '../domain/shared/mode.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export interface PaginationData {
|
||||
first?: { offset: number; limit: number }
|
||||
previous?: { offset: number; limit: number }
|
||||
next?: { offset: number; limit: number }
|
||||
last?: { offset: number; limit: number }
|
||||
}
|
||||
|
||||
export default class DeviceService {
|
||||
async getDevices(offset: number = 0, limit: number = 100) {
|
||||
const { data, total } = await DeviceModel.paginateDevices(offset, limit)
|
||||
const pager = this.calculatePagination(offset, limit, total)
|
||||
return { data, nbDocuments: total, pager }
|
||||
}
|
||||
|
||||
async getDeviceByIdentifier(identifier: string): Promise<DeviceRecord | null> {
|
||||
return DeviceModel.findByIdentifier(identifier)
|
||||
}
|
||||
|
||||
async deleteByIdentifier(identifier: string): Promise<boolean> {
|
||||
return DeviceModel.deleteOne({ identifier })
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command on a device. Mode I triggers a Connection Request.
|
||||
*/
|
||||
async executeCommand(identifier: string, commandPayload: {
|
||||
action: CommandType
|
||||
parameters?: Record<string, any>
|
||||
mode?: string
|
||||
priority?: number
|
||||
}): Promise<void> {
|
||||
const device = await DeviceModel.findByIdentifier(identifier)
|
||||
if (!device) {
|
||||
throw new Error(`Device not found: ${identifier}`)
|
||||
}
|
||||
|
||||
const mode = (commandPayload.mode || 'NI').toUpperCase() === 'I' ? ModeType.I : ModeType.NI
|
||||
const priority = commandPayload.priority ?? Priority.NORMAL
|
||||
|
||||
// Check for duplicate pending command with same mode + priority + action
|
||||
const collection = CommandModel.getCollection()
|
||||
const existingDup = await collection.findOne({
|
||||
deviceIdentifier: identifier,
|
||||
mode,
|
||||
status: CommandStatus.PENDING,
|
||||
action: commandPayload.action,
|
||||
priority,
|
||||
})
|
||||
if (existingDup) {
|
||||
throw new Error(`Duplicate pending command: ${commandPayload.action} in mode ${mode}`)
|
||||
}
|
||||
|
||||
// Create the command
|
||||
await CommandModel.createCommand({
|
||||
deviceIdentifier: identifier,
|
||||
action: commandPayload.action,
|
||||
parameters: commandPayload.parameters || {},
|
||||
mode,
|
||||
status: CommandStatus.PENDING,
|
||||
priority,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// For mode I, trigger Connection Request to wake the device
|
||||
if (mode === ModeType.I) {
|
||||
await this.applyImmediateCommandForDevice(device, identifier)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a device by sending a Connection Request for REFRESH command
|
||||
*/
|
||||
async refreshDevice(identifier: string): Promise<void> {
|
||||
const device = await DeviceModel.findByIdentifier(identifier)
|
||||
if (!device) {
|
||||
throw new Error(`Device not found: ${identifier}`)
|
||||
}
|
||||
|
||||
await CommandModel.createCommand({
|
||||
deviceIdentifier: identifier,
|
||||
action: CommandType.REFRESH,
|
||||
parameters: {},
|
||||
mode: ModeType.I,
|
||||
status: CommandStatus.PENDING,
|
||||
priority: Priority.NORMAL,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await this.applyImmediateCommandForDevice(device, identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Connection Request to CPE to wake it up for immediate command execution
|
||||
*/
|
||||
private async applyImmediateCommandForDevice(device: DeviceRecord, identifier: string): Promise<void> {
|
||||
const connectionRequestParam = (device.properties || []).find((p: any) =>
|
||||
p.name?.includes('ConnectionRequestURL')
|
||||
)
|
||||
|
||||
if (!connectionRequestParam || !connectionRequestParam.value) {
|
||||
logger.error({ identifier, availableProps: (device.properties || []).map((p: any) => p.name) }, 'ConnectionRequestURL not found in device properties')
|
||||
return
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(connectionRequestParam.value as string)
|
||||
} catch {
|
||||
logger.error(`Invalid ConnectionRequestURL: ${connectionRequestParam.value}`)
|
||||
return
|
||||
}
|
||||
|
||||
const wakeSuccess = await this.sendWakeOnWlan(url, device)
|
||||
|
||||
// Mark pending I commands as WAITING (or FAILED)
|
||||
const collection = CommandModel.getCollection()
|
||||
const pendingCmds = await collection.find({
|
||||
deviceIdentifier: identifier,
|
||||
mode: ModeType.I,
|
||||
status: CommandStatus.PENDING,
|
||||
}).toArray()
|
||||
|
||||
for (const cmd of pendingCmds) {
|
||||
await collection.updateOne(
|
||||
{ _id: cmd._id },
|
||||
{ $set: { status: wakeSuccess ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 || [])]
|
||||
|
||||
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 ?? ''
|
||||
|
||||
logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request')
|
||||
|
||||
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')
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
calculatePagination(offset: number, limit: number, total: number): PaginationData {
|
||||
const pagination: PaginationData = { first: { offset: 0, limit } }
|
||||
|
||||
if (total >= limit) {
|
||||
pagination.last = { offset: total - limit, limit }
|
||||
} else {
|
||||
pagination.last = { offset: 0, limit }
|
||||
}
|
||||
|
||||
if (offset > 0) {
|
||||
pagination.previous = { offset: Math.max(0, offset - limit), limit }
|
||||
}
|
||||
|
||||
if (offset + limit < total) {
|
||||
pagination.next = { offset: offset + limit, limit }
|
||||
}
|
||||
|
||||
return pagination
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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'
|
||||
|
||||
export interface CreateFirmwareEntryDto {
|
||||
identifier: string
|
||||
productClass: string
|
||||
version: string
|
||||
downloadUrl: string
|
||||
checksum: string
|
||||
checksumType: 'md5' | 'sha256'
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export interface CreateMigrationRouteDto {
|
||||
identifier: string
|
||||
productClass: string
|
||||
sourceVersionPattern: string
|
||||
targetVersion: string
|
||||
downloadUrl: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export default class FirmwareCatalogService {
|
||||
/**
|
||||
* Validate that a string is a valid URL.
|
||||
*/
|
||||
private validateUrl(url: string, fieldName: string): void {
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
throw new BadRequestException(`${fieldName} must be a valid URL`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a number is a positive integer (> 0).
|
||||
*/
|
||||
private validatePositiveInteger(value: number, fieldName: string): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is non-empty.
|
||||
*/
|
||||
private validateNonEmptyString(value: string, fieldName: string): void {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate checksumType is 'md5' or 'sha256'.
|
||||
*/
|
||||
private validateChecksumType(value: string, fieldName: string): void {
|
||||
if (value !== 'md5' && value !== 'sha256') {
|
||||
throw new BadRequestException(`${fieldName} must be 'md5' or 'sha256'`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new firmware entry after validation.
|
||||
*/
|
||||
async createEntry(data: CreateFirmwareEntryDto): Promise<FirmwareEntry> {
|
||||
this.validateNonEmptyString(data.identifier, 'identifier')
|
||||
this.validateUrl(data.downloadUrl, 'downloadUrl')
|
||||
this.validatePositiveInteger(data.fileSize, 'fileSize')
|
||||
this.validateChecksumType(data.checksumType, 'checksumType')
|
||||
|
||||
return FirmwareEntryModel.createIfNotExists({
|
||||
identifier: data.identifier,
|
||||
productClass: data.productClass,
|
||||
version: data.version,
|
||||
downloadUrl: data.downloadUrl,
|
||||
checksum: data.checksum,
|
||||
checksumType: data.checksumType,
|
||||
fileSize: data.fileSize,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find firmware entries, optionally filtered by productClass.
|
||||
*/
|
||||
async findEntries(productClass?: string): Promise<FirmwareEntry[]> {
|
||||
if (productClass) {
|
||||
return FirmwareEntryModel.findByProductClass(productClass)
|
||||
}
|
||||
return FirmwareEntryModel.find({}) as unknown as Promise<FirmwareEntry[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a firmware entry by identifier.
|
||||
*/
|
||||
async deleteEntry(identifier: string): Promise<void> {
|
||||
const deleted = await FirmwareEntryModel.deleteByIdentifier(identifier)
|
||||
if (!deleted) {
|
||||
throw new BadRequestException(`Firmware entry '${identifier}' not found`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new migration route after validation.
|
||||
*/
|
||||
async createMigrationRoute(data: CreateMigrationRouteDto): Promise<MigrationRoute> {
|
||||
this.validateNonEmptyString(data.identifier, 'identifier')
|
||||
this.validateUrl(data.downloadUrl, 'downloadUrl')
|
||||
this.validatePositiveInteger(data.priority, 'priority')
|
||||
|
||||
return MigrationRouteModel.createIfNotExists({
|
||||
identifier: data.identifier,
|
||||
productClass: data.productClass,
|
||||
sourceVersionPattern: data.sourceVersionPattern,
|
||||
targetVersion: data.targetVersion,
|
||||
downloadUrl: data.downloadUrl,
|
||||
priority: data.priority,
|
||||
})
|
||||
}
|
||||
|
||||
async findAllRoutes(): Promise<MigrationRoute[]> {
|
||||
return MigrationRouteModel.find({}) as unknown as Promise<MigrationRoute[]>
|
||||
}
|
||||
|
||||
async updateEntry(identifier: string, data: Partial<CreateFirmwareEntryDto>): Promise<FirmwareEntry> {
|
||||
const collection = FirmwareEntryModel.getCollection()
|
||||
const result = await collection.findOneAndUpdate(
|
||||
{ identifier },
|
||||
{ $set: data },
|
||||
{ returnDocument: 'after' }
|
||||
)
|
||||
if (!result) throw new BadRequestException(`Firmware entry '${identifier}' not found`)
|
||||
return result as unknown as FirmwareEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Find migration routes for a productClass, sorted by priority descending.
|
||||
*/
|
||||
async findMigrationRoutes(productClass: string): Promise<MigrationRoute[]> {
|
||||
return MigrationRouteModel.findByProductClass(productClass, { priority: -1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a migration route by identifier.
|
||||
*/
|
||||
async deleteMigrationRoute(identifier: string): Promise<void> {
|
||||
const deleted = await MigrationRouteModel.deleteByIdentifier(identifier)
|
||||
if (!deleted) {
|
||||
throw new BadRequestException(`Migration route '${identifier}' not found`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the firmware upgrade path from currentVersion to targetVersion.
|
||||
* Uses a greedy best-first algorithm based on route priority with cycle detection.
|
||||
*
|
||||
* @returns MigrationStep[] ordered from first to last, or [] if no path exists
|
||||
*/
|
||||
async resolveUpgradePath(
|
||||
productClass: string,
|
||||
currentVersion: string,
|
||||
targetVersion: string
|
||||
): Promise<MigrationStep[]> {
|
||||
// Trivial case: already at target version
|
||||
if (currentVersion === targetVersion) return []
|
||||
|
||||
// Load all routes for this productClass
|
||||
const routes = await MigrationRouteModel.findByProductClass(productClass)
|
||||
|
||||
const path: MigrationStep[] = []
|
||||
const visited = new Set<string>()
|
||||
let version = currentVersion
|
||||
|
||||
visited.add(version)
|
||||
|
||||
while (version !== targetVersion) {
|
||||
// Find all applicable routes from the current version
|
||||
const applicableRoutes = routes
|
||||
.filter((r) => matchGlob(r.sourceVersionPattern, version))
|
||||
.sort((a, b) => b.priority - a.priority) // Highest priority first
|
||||
|
||||
// Take the best route (highest priority)
|
||||
const bestRoute = applicableRoutes[0]
|
||||
if (!bestRoute) {
|
||||
// No route found → no path exists
|
||||
return []
|
||||
}
|
||||
|
||||
// Cycle detection
|
||||
if (visited.has(bestRoute.targetVersion)) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Add step to path
|
||||
path.push({
|
||||
fromVersion: version,
|
||||
toVersion: bestRoute.targetVersion,
|
||||
downloadUrl: bestRoute.downloadUrl,
|
||||
fileSize: 0,
|
||||
checksum: '',
|
||||
})
|
||||
|
||||
// Advance
|
||||
visited.add(bestRoute.targetVersion)
|
||||
version = bestRoute.targetVersion
|
||||
}
|
||||
|
||||
// Enrich each step with fileSize and checksum from firmware_entries
|
||||
for (const step of path) {
|
||||
const entry = await FirmwareEntryModel.findByProductClassAndVersion(
|
||||
productClass,
|
||||
step.toVersion
|
||||
)
|
||||
if (entry) {
|
||||
step.fileSize = entry.fileSize
|
||||
step.checksum = entry.checksum
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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'
|
||||
|
||||
interface MigrationReport {
|
||||
discoveryMigrated: number
|
||||
discoverySkipped: number
|
||||
provisioningMigrated: number
|
||||
provisioningSkipped: number
|
||||
firmwareEntriesCreated: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
interface RollbackReport {
|
||||
discoveryRolledBack: number
|
||||
provisioningRolledBack: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export default class MigrationService {
|
||||
constructor(
|
||||
private db: Db,
|
||||
private configService = new ConfigurationService(),
|
||||
private firmwareCatalogService = new FirmwareCatalogService(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Migrate all legacy Discovery and Provisioning configs to unified Configuration format
|
||||
*/
|
||||
async migrateAll(): Promise<MigrationReport> {
|
||||
const report: MigrationReport = {
|
||||
discoveryMigrated: 0,
|
||||
discoverySkipped: 0,
|
||||
provisioningMigrated: 0,
|
||||
provisioningSkipped: 0,
|
||||
firmwareEntriesCreated: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
// Migrate Discovery configs (inventory + maintain policy)
|
||||
const discoveryCollection = this.db.collection('discovery_configs')
|
||||
const discoveryConfigs = await discoveryCollection.find({}).toArray()
|
||||
|
||||
for (const legacy of discoveryConfigs) {
|
||||
try {
|
||||
const migrated = await this.migrateDiscoveryConfig(legacy)
|
||||
if (migrated) {
|
||||
report.discoveryMigrated++
|
||||
} else {
|
||||
report.discoverySkipped++
|
||||
}
|
||||
} catch (error) {
|
||||
report.discoverySkipped++
|
||||
report.errors.push(`Discovery ${legacy.identifier}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate Provisioning configs (desired parameters + once/maintain + firmware)
|
||||
const provisioningCollection = this.db.collection('provisioning_configs')
|
||||
const provisioningConfigs = await provisioningCollection.find({}).toArray()
|
||||
|
||||
for (const legacy of provisioningConfigs) {
|
||||
try {
|
||||
const migrated = await this.migrateProvisioningConfig(legacy)
|
||||
if (migrated) {
|
||||
report.provisioningMigrated++
|
||||
} else {
|
||||
report.provisioningSkipped++
|
||||
}
|
||||
|
||||
// Extract firmware Downloads into FirmwareCatalog
|
||||
if (legacy.downloadActions && legacy.downloadActions.length > 0) {
|
||||
for (const action of legacy.downloadActions) {
|
||||
try {
|
||||
await this.extractFirmwareEntry(action, legacy)
|
||||
report.firmwareEntriesCreated++
|
||||
} catch (error) {
|
||||
report.errors.push(`Firmware extraction for ${legacy.identifier}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.provisioningSkipped++
|
||||
report.errors.push(`Provisioning ${legacy.identifier}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.errors.push(`Migration error: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single Discovery config to Configuration
|
||||
* Discovery = inventory paths + "maintain" policy
|
||||
*/
|
||||
private async migrateDiscoveryConfig(legacy: any): Promise<boolean> {
|
||||
const criteria = this.buildCriteriaFromLegacy(legacy)
|
||||
|
||||
const dto: CreateConfigurationDto = {
|
||||
identifier: `legacy-disc-${legacy.identifier}`,
|
||||
name: `[Migrated] ${legacy.name || legacy.identifier}`,
|
||||
version: '1.0.0',
|
||||
priority: 100,
|
||||
policy: 'maintain',
|
||||
enabled: true,
|
||||
tags: ['legacy', 'discovery'],
|
||||
criteria,
|
||||
inventory: (legacy.modelPaths || []).map((p: string) => ({ path: p })),
|
||||
desiredParameters: [],
|
||||
}
|
||||
|
||||
// Check if already exists (skip if yes)
|
||||
const existing = await this.configService.findById(dto.identifier)
|
||||
if (existing) {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.configService.create(dto)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single Provisioning config to Configuration
|
||||
* Provisioning = desired parameters + (once OR maintain) policy + firmware migration
|
||||
*/
|
||||
private async migrateProvisioningConfig(legacy: any): Promise<boolean> {
|
||||
const criteria = this.buildCriteriaFromLegacy(legacy)
|
||||
const policy = legacy.downloadActions?.length > 0 ? 'once' : 'maintain'
|
||||
const desiredParameters = this.buildDesiredParametersFromLegacy(legacy)
|
||||
|
||||
// Build inventory paths from desired parameters (auto-suggest)
|
||||
const inventoryPaths = new Set<string>()
|
||||
for (const param of desiredParameters) {
|
||||
const parts = param.name.split('.')
|
||||
parts.pop()
|
||||
inventoryPaths.add(parts.join('.') + '.')
|
||||
}
|
||||
|
||||
const dto: CreateConfigurationDto = {
|
||||
identifier: `legacy-prov-${legacy.identifier}`,
|
||||
name: `[Migrated] ${legacy.name || legacy.identifier}`,
|
||||
version: '1.0.0',
|
||||
priority: 100,
|
||||
policy,
|
||||
enabled: true,
|
||||
tags: ['legacy', 'provisioning'],
|
||||
criteria,
|
||||
inventory: [...inventoryPaths].map((p) => ({ path: p })),
|
||||
desiredParameters,
|
||||
}
|
||||
|
||||
const existing = await this.configService.findById(dto.identifier)
|
||||
if (existing) {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.configService.create(dto)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Build criteria object from legacy config device filters
|
||||
*/
|
||||
private buildCriteriaFromLegacy(legacy: any): Record<string, string> {
|
||||
const criteria: Record<string, string> = {}
|
||||
|
||||
if (legacy.manufacturer) criteria.manufacturer = legacy.manufacturer
|
||||
if (legacy.oui) criteria.oui = legacy.oui
|
||||
if (legacy.productClass) criteria.productClass = legacy.productClass
|
||||
if (legacy.serialNumber) criteria.serialNumber = legacy.serialNumber
|
||||
|
||||
return criteria
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' }> = []
|
||||
|
||||
if (!legacy.actions) return params
|
||||
|
||||
for (const action of legacy.actions) {
|
||||
if (action.type === 'SET_PARAMETER' && action.parameter && action.value !== undefined) {
|
||||
params.push({
|
||||
name: action.parameter,
|
||||
value: action.value,
|
||||
type: typeof action.value === 'boolean' ? 'boolean' : typeof action.value === 'number' ? 'int' : 'string',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract firmware Download action into FirmwareCatalog entry
|
||||
*/
|
||||
private async extractFirmwareEntry(action: any, legacy: any): Promise<void> {
|
||||
const entry: CreateFirmwareEntryDto = {
|
||||
identifier: `legacy-fw-${legacy.productClass || 'device'}-${action.version || '1.0.0'}`,
|
||||
productClass: legacy.productClass || 'Device',
|
||||
version: action.version || '1.0.0',
|
||||
downloadUrl: action.url || action.firmwareUrl || '',
|
||||
checksum: action.checksum || '',
|
||||
checksumType: 'md5',
|
||||
fileSize: action.fileSize || 0,
|
||||
}
|
||||
|
||||
try {
|
||||
await this.firmwareCatalogService.createEntry(entry)
|
||||
} catch {
|
||||
// Entry may already exist — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback migration: reconstruct legacy collections from prefixed configurations
|
||||
*/
|
||||
async rollback(): Promise<RollbackReport> {
|
||||
const report: RollbackReport = {
|
||||
discoveryRolledBack: 0,
|
||||
provisioningRolledBack: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.configService.findAll({ perPage: 1000 })
|
||||
const allConfigs = result.data
|
||||
|
||||
const discoveryCollection = this.db.collection('discovery_configs')
|
||||
const provisioningCollection = this.db.collection('provisioning_configs')
|
||||
|
||||
for (const config of allConfigs) {
|
||||
try {
|
||||
if (config.identifier.startsWith('legacy-disc-')) {
|
||||
const originalId = config.identifier.replace('legacy-disc-', '')
|
||||
const legacy = this.buildLegacyDiscoveryFromConfig(config, originalId)
|
||||
await discoveryCollection.insertOne(legacy)
|
||||
report.discoveryRolledBack++
|
||||
} else if (config.identifier.startsWith('legacy-prov-')) {
|
||||
const originalId = config.identifier.replace('legacy-prov-', '')
|
||||
const legacy = this.buildLegacyProvisioningFromConfig(config, originalId)
|
||||
await provisioningCollection.insertOne(legacy)
|
||||
report.provisioningRolledBack++
|
||||
}
|
||||
} catch (error) {
|
||||
report.errors.push(`Rollback ${config.identifier}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.errors.push(`Rollback error: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
private buildLegacyDiscoveryFromConfig(config: any, originalId: string): any {
|
||||
return {
|
||||
identifier: originalId,
|
||||
name: config.name.replace('[Migrated] ', ''),
|
||||
manufacturer: config.criteria?.manufacturer,
|
||||
oui: config.criteria?.oui,
|
||||
productClass: config.criteria?.productClass,
|
||||
serialNumber: config.criteria?.serialNumber,
|
||||
modelPaths: (config.inventory || []).map((inv: any) => inv.path),
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
private buildLegacyProvisioningFromConfig(config: any, originalId: string): any {
|
||||
const actions = config.desiredParameters?.map((param: any) => ({
|
||||
type: 'SET_PARAMETER',
|
||||
parameter: param.name,
|
||||
value: param.value,
|
||||
})) || []
|
||||
|
||||
return {
|
||||
identifier: originalId,
|
||||
name: config.name.replace('[Migrated] ', ''),
|
||||
manufacturer: config.criteria?.manufacturer,
|
||||
oui: config.criteria?.oui,
|
||||
productClass: config.criteria?.productClass,
|
||||
serialNumber: config.criteria?.serialNumber,
|
||||
actions,
|
||||
downloadActions: [],
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import env from '#start/env'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initTelemetry() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const exporterUri = env.get('OTEL_EXPORTER_URI')
|
||||
if (!exporterUri) return
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// OpenTelemetry not available, skip
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@adonisjs/inertia/types'
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Generates a deterministic hex identifier for a device (URL-safe, 24 chars)
|
||||
*/
|
||||
export function getDeviceHashIdentifier(device: {
|
||||
oui: string
|
||||
productClass: string
|
||||
serialNumber: string
|
||||
manufacturer?: string
|
||||
}): string {
|
||||
const manufacturer = device.manufacturer ?? ''
|
||||
const canonical = `${manufacturer}-${device.oui}-${device.productClass}-${device.serialNumber}`
|
||||
return createHash('sha256').update(canonical).digest('hex').substring(0, 24)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Simple glob matching for Configuration criteria.
|
||||
* Supports:
|
||||
* - `*` matches any sequence of characters (including empty)
|
||||
* - `?` matches exactly one character
|
||||
* Matching is case-sensitive.
|
||||
*/
|
||||
export function matchGlob(pattern: string, value: string): boolean {
|
||||
const regex = new RegExp(
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.') +
|
||||
'$'
|
||||
)
|
||||
return regex.test(value)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InventoryPath } from '../domain/configuration/configuration.js'
|
||||
|
||||
/**
|
||||
* Checks whether a desired parameter is covered by at least one inventory path.
|
||||
* An inventory path ending with "." covers all parameters starting with that prefix (subtree match).
|
||||
* An inventory path without trailing "." requires an exact match.
|
||||
*/
|
||||
export function isParameterCoveredByInventory(
|
||||
paramName: string,
|
||||
inventoryPaths: InventoryPath[]
|
||||
): boolean {
|
||||
return inventoryPaths.some((inv) => {
|
||||
const path = inv.path
|
||||
if (!path.endsWith('.')) {
|
||||
return paramName === path
|
||||
}
|
||||
// Subtree match: param starts with the inventory path
|
||||
return paramName.startsWith(path)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests the inventory path to add to cover a given parameter.
|
||||
* Removes the leaf name and adds a trailing dot.
|
||||
* Example: "InternetGatewayDevice.ManagementServer.PeriodicInformInterval"
|
||||
* → "InternetGatewayDevice.ManagementServer."
|
||||
*/
|
||||
export function suggestInventoryPath(paramName: string): string {
|
||||
const parts = paramName.split('.')
|
||||
parts.pop() // Remove leaf name
|
||||
return parts.join('.') + '.'
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Configuration } from '../domain/configuration/configuration.js'
|
||||
|
||||
/**
|
||||
* Computes a 12-character hex content hash of a Configuration's desired state.
|
||||
* The hash is based on the sorted desiredParameters and firmwareTarget.
|
||||
*/
|
||||
export function computeContentHash(config: Configuration): string {
|
||||
const payload = JSON.stringify({
|
||||
desiredParameters: config.desiredParameters
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
firmwareTarget: config.firmwareTarget || null,
|
||||
})
|
||||
return createHash('sha256').update(payload).digest('hex').substring(0, 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ProvisioningCode string for a Configuration.
|
||||
* Format: `<identifier>(v<version>)-<hash12>`
|
||||
* Example: `BM632w-standard(v1.0.1)-a1b2c3d4e5f6`
|
||||
*/
|
||||
export function buildProvisioningCode(config: Configuration): string {
|
||||
const hash = computeContentHash(config)
|
||||
return `${config.identifier}(v${config.version})-${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an existing ProvisioningCode string.
|
||||
* Returns null if the format is invalid.
|
||||
*/
|
||||
export function parseProvisioningCode(
|
||||
code: string
|
||||
): { identifier: string; version: string; hash: string } | null {
|
||||
const match = code.match(/^(.+)\(v(.+)\)-([a-f0-9]{12})$/)
|
||||
if (!match) return null
|
||||
return { identifier: match[1], version: match[2], hash: match[3] }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ScheduleWindow } from '../domain/configuration/configuration.js'
|
||||
|
||||
/**
|
||||
* Checks if the current time (or provided time) falls within the schedule window.
|
||||
* Returns true if no schedule is defined (no restriction).
|
||||
*
|
||||
* Handles:
|
||||
* - IANA timezone conversion (uses the timezone to get local hour and day)
|
||||
* - Midnight-spanning windows (startHour > endHour means spans midnight)
|
||||
* - Day-of-week filtering (daysOfWeek array, 0=Sunday)
|
||||
*/
|
||||
export function isWithinSchedule(schedule: ScheduleWindow | undefined, now?: Date): boolean {
|
||||
if (!schedule) {
|
||||
return true
|
||||
}
|
||||
|
||||
const currentTime = now ?? new Date()
|
||||
|
||||
// Get the local hour and day in the schedule's timezone
|
||||
const localParts = getLocalTimeParts(currentTime, schedule.timezone)
|
||||
const localHour = localParts.hour
|
||||
const localDay = localParts.dayOfWeek // 0=Sunday
|
||||
|
||||
// Check day-of-week filter
|
||||
if (schedule.daysOfWeek.length > 0 && !schedule.daysOfWeek.includes(localDay)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time window
|
||||
const { startHour, endHour } = schedule
|
||||
|
||||
if (startHour <= endHour) {
|
||||
// Normal window (e.g., 2-6 means 02:00 to 06:00)
|
||||
return localHour >= startHour && localHour < endHour
|
||||
} else {
|
||||
// Midnight-spanning window (e.g., 22-6 means 22:00 to 06:00 next day)
|
||||
return localHour >= startHour || localHour < endHour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
weekday: 'short',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(date)
|
||||
|
||||
let hour = 0
|
||||
let dayOfWeek = 0
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'hour') {
|
||||
// Intl may return "24" for midnight in some locales, normalize to 0
|
||||
hour = Number.parseInt(part.value, 10) % 24
|
||||
}
|
||||
if (part.type === 'weekday') {
|
||||
dayOfWeek = weekdayToNumber(part.value)
|
||||
}
|
||||
}
|
||||
|
||||
return { hour, dayOfWeek }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a short weekday name (from Intl) to a number (0=Sunday).
|
||||
*/
|
||||
function weekdayToNumber(weekday: string): number {
|
||||
const map: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
}
|
||||
return map[weekday] ?? 0
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createCommandValidator = vine.compile(
|
||||
vine.object({
|
||||
deviceIdentifier: vine.string().trim(),
|
||||
action: vine.enum([
|
||||
'SET_PARAMETER_VALUES',
|
||||
'GET_PARAMETER_VALUES',
|
||||
'GET_PARAMETER_NAMES',
|
||||
'SET_PARAMETER_ATTRIBUTES',
|
||||
'GET_PARAMETER_ATTRIBUTES',
|
||||
'DOWNLOAD',
|
||||
'UPLOAD',
|
||||
'REBOOT',
|
||||
'FACTORY_RESET',
|
||||
'SCHEDULE_INFORM',
|
||||
'REFRESH',
|
||||
]),
|
||||
parameters: vine.object({}).allowUnknownProperties(),
|
||||
mode: vine.enum(['NI', 'I']).optional(),
|
||||
priority: vine.number().min(0).max(3).optional(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const parameterCriteriaItemSchema = vine.object({
|
||||
name: vine.string().trim().minLength(1),
|
||||
value: vine.any(),
|
||||
})
|
||||
|
||||
const criteriaSchema = vine.object({
|
||||
manufacturer: vine.string().optional(),
|
||||
oui: vine.string().optional(),
|
||||
productClass: vine.string().optional(),
|
||||
serialNumber: vine.string().optional(),
|
||||
parameterCriteria: vine.array(parameterCriteriaItemSchema).optional(),
|
||||
})
|
||||
|
||||
const inventoryPathSchema = vine.object({
|
||||
path: vine.string().trim().minLength(1),
|
||||
})
|
||||
|
||||
const desiredParameterSchema = vine.object({
|
||||
name: vine.string().trim().minLength(1),
|
||||
value: vine.any(),
|
||||
type: vine.enum(['string', 'int', 'unsignedInt', 'boolean', 'dateTime']),
|
||||
})
|
||||
|
||||
const firmwareTargetSchema = vine.object({
|
||||
productClass: vine.string().trim().minLength(1),
|
||||
targetVersion: vine.string().trim().minLength(1),
|
||||
})
|
||||
|
||||
const scheduleWindowSchema = vine.object({
|
||||
daysOfWeek: vine.array(vine.number().min(0).max(6)).minLength(1),
|
||||
startHour: vine.number().min(0).max(23),
|
||||
endHour: vine.number().min(0).max(23),
|
||||
timezone: vine.string().trim().minLength(1),
|
||||
})
|
||||
|
||||
export const createConfigurationValidator = vine.compile(
|
||||
vine.object({
|
||||
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
|
||||
name: vine.string().trim().minLength(1),
|
||||
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/),
|
||||
priority: vine.number().positive(),
|
||||
policy: vine.enum(['once', 'maintain']),
|
||||
enabled: vine.boolean().optional(),
|
||||
tags: vine.array(vine.string().trim()).optional(),
|
||||
criteria: criteriaSchema.optional(),
|
||||
inventory: vine.array(inventoryPathSchema).optional(),
|
||||
desiredParameters: vine.array(desiredParameterSchema).optional(),
|
||||
firmwareTarget: firmwareTargetSchema.optional(),
|
||||
schedule: scheduleWindowSchema.optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const updateConfigurationValidator = vine.compile(
|
||||
vine.object({
|
||||
name: vine.string().trim().minLength(1).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(),
|
||||
tags: vine.array(vine.string().trim()).optional(),
|
||||
criteria: criteriaSchema.optional(),
|
||||
inventory: vine.array(inventoryPathSchema).optional(),
|
||||
desiredParameters: vine.array(desiredParameterSchema).optional(),
|
||||
firmwareTarget: firmwareTargetSchema.optional(),
|
||||
schedule: scheduleWindowSchema.optional(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createDeviceValidator = vine.compile(
|
||||
vine.object({
|
||||
deviceId: vine.object({
|
||||
manufacturer: vine.string().trim().minLength(1),
|
||||
oui: vine.string().trim(),
|
||||
productClass: vine.string().trim(),
|
||||
serialNumber: vine.string().trim().minLength(1),
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createFirmwareEntryValidator = vine.compile(
|
||||
vine.object({
|
||||
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(),
|
||||
checksum: vine.string().trim().minLength(1),
|
||||
checksumType: vine.enum(['md5', 'sha256']),
|
||||
fileSize: vine.number().min(0),
|
||||
})
|
||||
)
|
||||
|
||||
export const updateFirmwareEntryValidator = vine.compile(
|
||||
vine.object({
|
||||
productClass: vine.string().trim().minLength(1).optional(),
|
||||
version: vine.string().trim().minLength(1).optional(),
|
||||
downloadUrl: vine.string().trim().url().optional(),
|
||||
checksum: vine.string().trim().minLength(1).optional(),
|
||||
checksumType: vine.enum(['md5', 'sha256']).optional(),
|
||||
fileSize: vine.number().min(0).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const createMigrationRouteValidator = vine.compile(
|
||||
vine.object({
|
||||
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),
|
||||
downloadUrl: vine.string().trim().url(),
|
||||
priority: vine.number().min(0),
|
||||
})
|
||||
)
|
||||
|
||||
export const updateMigrationRouteValidator = vine.compile(
|
||||
vine.object({
|
||||
productClass: vine.string().trim().minLength(1).optional(),
|
||||
sourceVersionPattern: vine.string().trim().minLength(1).optional(),
|
||||
targetVersion: vine.string().trim().minLength(1).optional(),
|
||||
downloadUrl: vine.string().trim().url().optional(),
|
||||
priority: vine.number().min(0).optional(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ace entry point
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "console.ts" file is the entrypoint for booting the AdonisJS
|
||||
| command-line framework and executing commands.
|
||||
|
|
||||
| Commands do not boot the application, unless the currently running command
|
||||
| has "options.startApp" flag set to true.
|
||||
|
|
||||
*/
|
||||
|
||||
await import('reflect-metadata')
|
||||
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.ace()
|
||||
.handle(process.argv.splice(2))
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP server entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
|
||||
| server. Either you can run this file directly or use the "serve"
|
||||
| command to run this file and monitor file changes
|
||||
|
|
||||
*/
|
||||
|
||||
await import('reflect-metadata')
|
||||
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.httpServer()
|
||||
.start()
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test runner entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "test.ts" file is the entrypoint for running tests using Japa.
|
||||
|
|
||||
| Either you can run this file directly or use the "test"
|
||||
| command to run this file and monitor file changes.
|
||||
|
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
import { configure, processCLIArgs, run } from '@japa/runner'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.testRunner()
|
||||
.configure(async (app) => {
|
||||
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
|
||||
|
||||
processCLIArgs(process.argv.splice(2))
|
||||
configure({
|
||||
...app.rcFile.tests,
|
||||
...config,
|
||||
...{
|
||||
setup: runnerHooks.setup,
|
||||
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
|
||||
},
|
||||
})
|
||||
})
|
||||
.run(() => run())
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* The app URL can be used in various places where you want to create absolute
|
||||
* URLs to your application. For example, when sending emails, images should
|
||||
* use absolute URLs.
|
||||
*/
|
||||
export const appUrl = env.get('APP_URL')
|
||||
|
||||
/**
|
||||
* The configuration settings used by the HTTP server
|
||||
*/
|
||||
export const http = defineConfig({
|
||||
/**
|
||||
* Generate a unique request id for each incoming request.
|
||||
* Useful to correlate logs and debug a request flow.
|
||||
*/
|
||||
generateRequestId: true,
|
||||
|
||||
/**
|
||||
* Allow HTTP method spoofing via the "_method" form/query parameter.
|
||||
* This lets HTML forms target PUT/PATCH/DELETE routes while still
|
||||
* submitting with POST.
|
||||
*/
|
||||
allowMethodSpoofing: false,
|
||||
|
||||
/**
|
||||
* Enabling async local storage will let you access HTTP context
|
||||
* from anywhere inside your application.
|
||||
*/
|
||||
useAsyncLocalStorage: false,
|
||||
|
||||
/**
|
||||
* Redirect configuration controls the behavior of
|
||||
* response.redirect().back() and query string forwarding.
|
||||
*/
|
||||
redirect: {
|
||||
/**
|
||||
* When enabled, all redirects automatically carry over the current
|
||||
* request's query string parameters to the redirect destination.
|
||||
* Use withQs(false) to opt out for a specific redirect.
|
||||
*/
|
||||
forwardQueryString: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Manage cookies configuration. The settings for the session id cookie are
|
||||
* defined inside the "config/session.ts" file.
|
||||
*/
|
||||
cookie: {
|
||||
/**
|
||||
* Restrict the cookie to a specific domain.
|
||||
* Keep empty to use the current host.
|
||||
*/
|
||||
domain: '',
|
||||
|
||||
/**
|
||||
* Restrict the cookie to a URL path. '/' means all routes.
|
||||
*/
|
||||
path: '/',
|
||||
|
||||
/**
|
||||
* Default lifetime for cookies managed by the HTTP layer.
|
||||
*/
|
||||
maxAge: '2h',
|
||||
|
||||
/**
|
||||
* Prevent JavaScript access to the cookie in the browser.
|
||||
*/
|
||||
httpOnly: true,
|
||||
|
||||
/**
|
||||
* Send cookies only over HTTPS in production.
|
||||
*/
|
||||
secure: app.inProduction,
|
||||
|
||||
/**
|
||||
* Cross-site policy for cookie sending.
|
||||
*/
|
||||
sameSite: 'lax',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineConfig } from '@adonisjs/core/bodyparser'
|
||||
|
||||
const bodyParserConfig = defineConfig({
|
||||
/**
|
||||
* Parse request bodies for these HTTP methods.
|
||||
* Keep this aligned with methods that receive payloads in your routes.
|
||||
*/
|
||||
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
/**
|
||||
* Config for the "application/x-www-form-urlencoded"
|
||||
* content-type parser.
|
||||
*/
|
||||
form: {
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Content types handled by the form parser.
|
||||
*/
|
||||
types: ['application/x-www-form-urlencoded'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the JSON parser.
|
||||
*/
|
||||
json: {
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Content types handled by the JSON parser.
|
||||
*/
|
||||
types: [
|
||||
'application/json',
|
||||
'application/json-patch+json',
|
||||
'application/vnd.api+json',
|
||||
'application/csp-report',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the "multipart/form-data" content-type parser.
|
||||
* File uploads are handled by the multipart parser.
|
||||
*/
|
||||
multipart: {
|
||||
/**
|
||||
* Automatically process uploaded files into the system tmp directory.
|
||||
*/
|
||||
autoProcess: true,
|
||||
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Routes where multipart processing is handled manually.
|
||||
*/
|
||||
processManually: [],
|
||||
|
||||
/**
|
||||
* Maximum accepted payload size for multipart requests.
|
||||
*/
|
||||
limit: '20mb',
|
||||
|
||||
/**
|
||||
* Content types handled by the multipart parser.
|
||||
*/
|
||||
types: ['multipart/form-data'],
|
||||
},
|
||||
})
|
||||
|
||||
export default bodyParserConfig
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from '@adonisjs/cors'
|
||||
|
||||
/**
|
||||
* Configuration options to tweak the CORS policy. The following
|
||||
* options are documented on the official documentation website.
|
||||
*
|
||||
* https://docs.adonisjs.com/guides/security/cors
|
||||
*/
|
||||
const corsConfig = defineConfig({
|
||||
enabled: true,
|
||||
origin: [],
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
|
||||
headers: true,
|
||||
exposeHeaders: [],
|
||||
credentials: true,
|
||||
maxAge: 90,
|
||||
})
|
||||
|
||||
export default corsConfig
|
||||
@@ -0,0 +1,8 @@
|
||||
import env from '#start/env'
|
||||
|
||||
const databaseConfig = {
|
||||
uri: env.get('MONGODB_URI'),
|
||||
dbName: env.get('MONGODB_DB_NAME', 'adam-acs'),
|
||||
}
|
||||
|
||||
export default databaseConfig
|
||||
@@ -0,0 +1,34 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, drivers } from '@adonisjs/core/encryption'
|
||||
|
||||
const encryptionConfig = defineConfig({
|
||||
/**
|
||||
* Default encryption driver used by the application.
|
||||
*/
|
||||
default: 'gcm',
|
||||
|
||||
list: {
|
||||
gcm: drivers.aes256gcm({
|
||||
/**
|
||||
* Keys used for encryption/decryption.
|
||||
* First key encrypts, all keys are tried for decryption.
|
||||
*/
|
||||
keys: [env.get('APP_KEY')],
|
||||
|
||||
/**
|
||||
* Stable identifier for this driver.
|
||||
*/
|
||||
id: 'gcm',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default encryptionConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of encryptors you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineConfig, drivers } from '@adonisjs/core/hash'
|
||||
|
||||
/**
|
||||
* Hashing configuration.
|
||||
*
|
||||
* This starter uses Node.js scrypt under the hood.
|
||||
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
|
||||
*/
|
||||
const hashConfig = defineConfig({
|
||||
/**
|
||||
* Default hasher used by the application.
|
||||
*/
|
||||
default: 'scrypt',
|
||||
|
||||
list: {
|
||||
/**
|
||||
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
|
||||
*/
|
||||
scrypt: drivers.scrypt({
|
||||
/**
|
||||
* Work factor (Node alias: N / cost).
|
||||
* Higher values increase security and CPU+memory usage.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Start with 16384.
|
||||
* - Increase gradually (for example 32768) and benchmark login/signup latency.
|
||||
* - Keep values practical for your slowest production machine.
|
||||
*
|
||||
* Node constraint: value must be a power of two greater than 1.
|
||||
*/
|
||||
cost: 16384,
|
||||
|
||||
/**
|
||||
* Block size (Node alias: r / blockSize).
|
||||
* Increases memory and CPU linearly.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep 8 unless you have a measured reason to change it.
|
||||
* - Raise only with benchmark data, because memory usage grows quickly.
|
||||
*/
|
||||
blockSize: 8,
|
||||
|
||||
/**
|
||||
* Parallelization (Node alias: p / parallelization).
|
||||
* Controls how many independent computations are performed.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep 1 for most applications.
|
||||
* - Increase only after load testing if your infrastructure benefits from it.
|
||||
*/
|
||||
parallelization: 1,
|
||||
|
||||
/**
|
||||
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
|
||||
* Hashing throws if the estimated memory usage is above this limit.
|
||||
* Node documents the check as approximately: 128 * N * r > maxmem.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep this aligned with your cost/blockSize choices.
|
||||
* - Increase carefully on memory-constrained environments.
|
||||
*/
|
||||
maxMemory: 33554432,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default hashConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of hashers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface HashersList extends InferHashers<typeof hashConfig> {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from '@adonisjs/inertia'
|
||||
|
||||
export default defineConfig({
|
||||
rootView: 'inertia_layout',
|
||||
ssr: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, targets } from '@adonisjs/core/logger'
|
||||
|
||||
const loggerConfig = defineConfig({
|
||||
default: 'app',
|
||||
|
||||
/**
|
||||
* The loggers object can be used to define multiple loggers.
|
||||
* By default, we configure only one logger (named "app").
|
||||
*/
|
||||
loggers: {
|
||||
app: {
|
||||
enabled: true,
|
||||
name: env.get('APP_NAME'),
|
||||
level: env.get('LOG_LEVEL'),
|
||||
transport: {
|
||||
targets: targets()
|
||||
.pushIf(!app.inProduction, targets.pretty())
|
||||
.pushIf(app.inProduction, targets.file({ destination: 1 }))
|
||||
.toArray(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default loggerConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of loggers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, stores } from '@adonisjs/session'
|
||||
|
||||
const sessionConfig = defineConfig({
|
||||
enabled: true,
|
||||
cookieName: 'adonis-session',
|
||||
|
||||
/**
|
||||
* When set to true, the session id cookie will be deleted
|
||||
* once the user closes the browser.
|
||||
*/
|
||||
clearWithBrowser: false,
|
||||
|
||||
/**
|
||||
* Define how long to keep the session data alive without
|
||||
* any activity.
|
||||
*/
|
||||
age: '2h',
|
||||
|
||||
/**
|
||||
* Configuration for session cookie and the
|
||||
* cookie store
|
||||
*/
|
||||
cookie: {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: app.inProduction,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
|
||||
/**
|
||||
* The store to use. Make sure to validate the environment
|
||||
* variable in order to infer the store name without any
|
||||
* errors.
|
||||
*/
|
||||
store: env.get('SESSION_DRIVER'),
|
||||
|
||||
/**
|
||||
* List of configured stores. Refer documentation to see
|
||||
* list of available stores and their config.
|
||||
*/
|
||||
stores: {
|
||||
cookie: stores.cookie(),
|
||||
},
|
||||
})
|
||||
|
||||
export default sessionConfig
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineConfig } from '@adonisjs/shield'
|
||||
|
||||
const shieldConfig = defineConfig({
|
||||
/**
|
||||
* Configure CSP policies for your app. Refer documentation
|
||||
* to learn more
|
||||
*/
|
||||
csp: {
|
||||
enabled: false,
|
||||
directives: {},
|
||||
reportOnly: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Configure CSRF protection options. Refer documentation
|
||||
* to learn more
|
||||
*/
|
||||
csrf: {
|
||||
enabled: true,
|
||||
exceptRoutes: (ctx) => {
|
||||
const url = ctx.request.url()
|
||||
return url === '/cwmp10' || url.startsWith('/api/v1/')
|
||||
},
|
||||
enableXsrfCookie: true,
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Control how your website should be embedded inside
|
||||
* iFrames
|
||||
*/
|
||||
xFrame: {
|
||||
enabled: true,
|
||||
action: 'DENY',
|
||||
},
|
||||
|
||||
/**
|
||||
* Force browser to always use HTTPS
|
||||
*/
|
||||
hsts: {
|
||||
enabled: true,
|
||||
maxAge: '180 days',
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable browsers from sniffing the content type of a
|
||||
* response and always rely on the "content-type" header.
|
||||
*/
|
||||
contentTypeSniffing: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
export default shieldConfig
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from '@adonisjs/static'
|
||||
|
||||
/**
|
||||
* Configuration options to tweak the static files middleware.
|
||||
* The complete set of options are documented on the
|
||||
* official documentation website.
|
||||
*
|
||||
* https://docs.adonisjs.com/guides/static-assets
|
||||
*/
|
||||
const staticServerConfig = defineConfig({
|
||||
enabled: true,
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
dotFiles: 'ignore',
|
||||
})
|
||||
|
||||
export default staticServerConfig
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from '@adonisjs/vite'
|
||||
|
||||
const viteBackendConfig = defineConfig({
|
||||
/**
|
||||
* The output of vite will be written inside this
|
||||
* directory. The path should be relative from
|
||||
* the application root.
|
||||
*/
|
||||
buildDirectory: 'public/assets',
|
||||
|
||||
/**
|
||||
* The path to the manifest file generated by the
|
||||
* "vite build" command.
|
||||
*/
|
||||
manifestFile: 'public/assets/.vite/manifest.json',
|
||||
|
||||
/**
|
||||
* Feel free to change the value of the "assetsUrl" to
|
||||
* point to a CDN in production.
|
||||
*/
|
||||
assetsUrl: '/assets',
|
||||
|
||||
scriptAttributes: {
|
||||
defer: true,
|
||||
},
|
||||
})
|
||||
|
||||
export default viteBackendConfig
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Db, Collection, ObjectId, Filter, UpdateFilter, 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()
|
||||
}
|
||||
|
||||
static getCollection(this: { collectionName: string; getDb(): Db }): Collection {
|
||||
return this.getDb().collection(this.collectionName)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
static async findById(this: { collectionName: string; getDb(): Db }, id: string | ObjectId): Promise<any | null> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const objectId = typeof id === 'string' ? new ObjectId(id) : id
|
||||
return collection.findOne({ _id: objectId })
|
||||
}
|
||||
|
||||
static async findOne(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<any | null> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
return collection.findOne(filter)
|
||||
}
|
||||
|
||||
static async create(this: { collectionName: string; getDb(): Db }, data: Partial<any>): Promise<any> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const now = new Date()
|
||||
const doc = { ...data, createdAt: now, updatedAt: now }
|
||||
const result = await collection.insertOne(doc)
|
||||
return { ...doc, _id: result.insertedId }
|
||||
}
|
||||
|
||||
static async createMany(this: { collectionName: string; getDb(): Db }, data: Partial<any>[]): Promise<any[]> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const now = new Date()
|
||||
const docs = data.map((d) => ({ ...d, createdAt: now, updatedAt: now }))
|
||||
const result = await collection.insertMany(docs)
|
||||
return docs.map((doc, i) => ({ ...doc, _id: result.insertedIds[i] }))
|
||||
}
|
||||
|
||||
static async updateOne(
|
||||
this: { collectionName: string; getDb(): Db },
|
||||
filter: Filter<any>,
|
||||
update: UpdateFilter<any> | Partial<any>
|
||||
): Promise<any> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const updateDoc = '$set' in update ? update : { $set: { ...update, updatedAt: new Date() } }
|
||||
return collection.updateOne(filter, updateDoc)
|
||||
}
|
||||
|
||||
static async updateMany(
|
||||
this: { collectionName: string; getDb(): Db },
|
||||
filter: Filter<any>,
|
||||
update: UpdateFilter<any> | Partial<any>
|
||||
): Promise<any> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const updateDoc = '$set' in update ? update : { $set: { ...update, updatedAt: new Date() } }
|
||||
return collection.updateMany(filter, updateDoc)
|
||||
}
|
||||
|
||||
static async deleteOne(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<boolean> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const result = await collection.deleteOne(filter)
|
||||
return result.deletedCount === 1
|
||||
}
|
||||
|
||||
static async deleteMany(this: { collectionName: string; getDb(): Db }, filter: Filter<any>): Promise<number> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const result = await collection.deleteMany(filter)
|
||||
return result.deletedCount
|
||||
}
|
||||
|
||||
static async paginate(
|
||||
this: { collectionName: string; getDb(): Db },
|
||||
page: number,
|
||||
perPage: number,
|
||||
filter: Filter<any> = {},
|
||||
sort: Sort = {}
|
||||
): Promise<{ data: any[]; total: number; page: number; perPage: number; lastPage: number }> {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
const total = await collection.countDocuments(filter)
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
||||
const skip = (page - 1) * perPage
|
||||
const data = await collection.find(filter).sort(sort).skip(skip).limit(perPage).toArray()
|
||||
return { data, total, page, perPage, lastPage }
|
||||
}
|
||||
|
||||
static query(this: { collectionName: string; getDb(): Db }) {
|
||||
const collection = this.getDb().collection(this.collectionName)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateDevicesCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('devices')
|
||||
|
||||
await collection.createIndex(
|
||||
{
|
||||
'deviceId.manufacturer': 1,
|
||||
'deviceId.oui': 1,
|
||||
'deviceId.productClass': 1,
|
||||
'deviceId.serialNumber': 1,
|
||||
},
|
||||
{ unique: true, name: 'idx_deviceId_compound' }
|
||||
)
|
||||
|
||||
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
|
||||
await collection.createIndex({ 'deviceId.manufacturer': 1 }, { name: 'idx_manufacturer' })
|
||||
await collection.createIndex({ 'deviceId.oui': 1 }, { name: 'idx_oui' })
|
||||
await collection.createIndex({ 'deviceId.productClass': 1 }, { name: 'idx_productClass' })
|
||||
await collection.createIndex({ 'deviceId.serialNumber': 1 }, { name: 'idx_serialNumber' })
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('devices').catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateCommandsCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('commands')
|
||||
|
||||
await collection.createIndex(
|
||||
{ deviceIdentifier: 1 },
|
||||
{ background: true, name: 'idx_deviceIdentifier' }
|
||||
)
|
||||
await collection.createIndex(
|
||||
{ status: 1, priority: -1 },
|
||||
{ background: true, name: 'idx_status_priority' }
|
||||
)
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('commands').catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateDiscoveryConfigsCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('discovery_configs')
|
||||
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('discovery_configs').catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateProvisioningConfigsCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('provisioning_configs')
|
||||
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('provisioning_configs').catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateConfigurationsCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('configurations')
|
||||
await collection.createIndex({ identifier: 1 }, { unique: true, name: 'idx_identifier' })
|
||||
await collection.createIndex({ enabled: 1, priority: -1 }, { name: 'idx_enabled_priority' })
|
||||
await collection.createIndex({ tags: 1 }, { name: 'idx_tags' })
|
||||
await collection.createIndex({ 'criteria.productClass': 1 }, { name: 'idx_criteria_product_class' })
|
||||
await collection.createIndex(
|
||||
{ 'criteria.manufacturer': 1, 'criteria.productClass': 1 },
|
||||
{ name: 'idx_criteria_manufacturer_product_class' }
|
||||
)
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('configurations').catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MongoMigration } from '../mongo_migrator.js'
|
||||
|
||||
export default class CreateConfigurationVersionsCollection extends MongoMigration {
|
||||
async up(): Promise<void> {
|
||||
const collection = this.db.collection('configuration_versions')
|
||||
await collection.createIndex(
|
||||
{ configurationIdentifier: 1, versionNumber: -1 },
|
||||
{ name: 'idx_configuration_identifier_version_number' }
|
||||
)
|
||||
await collection.createIndex(
|
||||
{ configurationIdentifier: 1 },
|
||||
{ name: 'idx_configuration_identifier' }
|
||||
)
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
await this.db.dropCollection('configuration_versions').catch(() => {})
|
||||
}
|
||||
}
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user