- device_record.ts: add lastRemoteIp field to store the CPE public IP from each Inform (X-Forwarded-For or TCP remote IP) - cwmp_controller.ts: persist lastRemoteIp on every Inform alongside lastPeriodicInformAt (single operation) - device_service.ts: sendWakeOnWlan() now tries the original URL first (may be IPv6), then falls back to a rebuilt URL using lastRemoteIp (IPv4 public IP) if the ACS server has no IPv6 connectivity
252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
import DeviceModel from '#models/device'
|
|
import CommandModel from '#models/command'
|
|
import { type DeviceRecord } from '../domain/device/device_record.js'
|
|
import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js'
|
|
import { ModeType } from '../domain/shared/mode.js'
|
|
import 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)
|
|
await this.updatePendingCommandsStatus(identifier, wakeSuccess)
|
|
}
|
|
|
|
/**
|
|
* Update pending mode-I commands status based on Connection Request result
|
|
*/
|
|
private async updatePendingCommandsStatus(identifier: string, success: boolean): Promise<void> {
|
|
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: success ? CommandStatus.WAITING : CommandStatus.PENDING,
|
|
updatedAt: new Date(),
|
|
},
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private async sendWakeOnWlan(connectionRequestURL: URL, device: DeviceRecord): Promise<boolean> {
|
|
// 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 ?? ''
|
|
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
|
|
const headers = { Authorization: `Basic ${basicAuth}` }
|
|
|
|
// Build list of URLs to try:
|
|
// 1. Original ConnectionRequestURL (may be IPv6)
|
|
// 2. Fallback: same path but with lastRemoteIp (IPv4 public IP from last Inform)
|
|
const urlsToTry: URL[] = [connectionRequestURL]
|
|
|
|
const lastRemoteIp = (device as any).lastRemoteIp as string | undefined
|
|
if (lastRemoteIp && lastRemoteIp !== connectionRequestURL.hostname.replace(/^\[|\]$/g, '')) {
|
|
try {
|
|
const fallbackUrl = new URL(connectionRequestURL.toString())
|
|
// IPv6 hostnames need brackets in URL
|
|
fallbackUrl.hostname = lastRemoteIp.includes(':') ? `[${lastRemoteIp}]` : lastRemoteIp
|
|
urlsToTry.push(fallbackUrl)
|
|
logger.info(
|
|
{ original: connectionRequestURL.toString(), fallback: fallbackUrl.toString() },
|
|
'[ConnectionRequest] IPv4 fallback URL prepared'
|
|
)
|
|
} catch {
|
|
// ignore invalid fallback URL
|
|
}
|
|
}
|
|
|
|
for (const url of urlsToTry) {
|
|
try {
|
|
logger.info({ url: url.toString(), username }, 'Sending Connection Request')
|
|
const response = await fetch(url.toString(), { method: 'GET', headers })
|
|
|
|
if (!response.ok) {
|
|
logger.error(
|
|
{ status: response.status, statusText: response.statusText, url: url.toString() },
|
|
'Connection Request failed with non-OK status'
|
|
)
|
|
continue // try next URL
|
|
}
|
|
logger.info({ status: response.status, url: url.toString() }, 'Connection Request successful')
|
|
return true
|
|
} catch (error: any) {
|
|
logger.warn(
|
|
{ error: error?.message, code: error?.cause?.code, url: url.toString() },
|
|
'[ConnectionRequest] URL failed, trying next if available'
|
|
)
|
|
// try next URL
|
|
}
|
|
}
|
|
|
|
logger.error(
|
|
{ urls: urlsToTry.map((u) => u.toString()) },
|
|
'Connection Request failed on all URLs'
|
|
)
|
|
return false
|
|
}
|
|
|
|
calculatePagination(offset: number, limit: number, total: number): PaginationData {
|
|
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
|
|
}
|
|
}
|