fix: Connection Request fallback to IPv4 when CPE has IPv6-only URL
- 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
This commit is contained in:
1 parent
c3bc2902f0
commit
7d603da0ca
3 files changed
+67
-52
No files matched your search
@@ -200,13 +200,14 @@ export default class CwmpController {
|
||||
|
||||
logger.info({ events }, 'CWMP Inform events extracted')
|
||||
|
||||
// Track last periodic inform timestamp
|
||||
// Track last periodic inform timestamp + last remote IP (used for Connection Request fallback)
|
||||
const normalizedEvents = events.map((e) => normalizeEventCode(e))
|
||||
const isPeriodic = normalizedEvents.some((e) => e === 'PERIODIC')
|
||||
const isBootstrap = normalizedEvents.some((e) => e === 'BOOTSTRAP')
|
||||
if (isPeriodic || isBootstrap) {
|
||||
await DeviceModel.updateOne({ identifier: hashId }, { $set: { lastPeriodicInformAt: new Date() } }).catch(() => {})
|
||||
}
|
||||
const cpeIpForStorage = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0]
|
||||
const dbUpdate: Record<string, any> = { lastRemoteIp: cpeIpForStorage }
|
||||
if (isPeriodic || isBootstrap) dbUpdate.lastPeriodicInformAt = new Date()
|
||||
await DeviceModel.updateOne({ identifier: hashId }, { $set: dbUpdate }).catch(() => {})
|
||||
|
||||
// Phase 12: Crash pattern detection
|
||||
const hasBoot = normalizedEvents.some((e) => e === 'BOOT')
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface DeviceRecord {
|
||||
provisioningCode?: string
|
||||
references?: DeviceReference[]
|
||||
lastPeriodicInformAt?: Date
|
||||
lastRemoteIp?: string
|
||||
updatedAt?: Date
|
||||
createdAt?: Date
|
||||
}
|
||||
@@ -159,61 +159,74 @@ export default class DeviceService {
|
||||
}
|
||||
|
||||
private async sendWakeOnWlan(connectionRequestURL: URL, device: DeviceRecord): Promise<boolean> {
|
||||
try {
|
||||
// Use ConnectionRequest credentials from device properties/parameters
|
||||
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
|
||||
const allParams = [...(device.properties || []), ...(device.parameters || [])]
|
||||
// Use ConnectionRequest credentials from device properties/parameters
|
||||
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
|
||||
const allParams = [...(device.properties || []), ...(device.parameters || [])]
|
||||
|
||||
const connReqUsername = allParams.find((p: any) =>
|
||||
p.name?.includes('ConnectionRequestUsername')
|
||||
)
|
||||
const connReqPassword = allParams.find((p: any) =>
|
||||
p.name?.includes('ConnectionRequestPassword')
|
||||
)
|
||||
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
|
||||
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
|
||||
const connReqUsername = allParams.find((p: any) => p.name?.includes('ConnectionRequestUsername'))
|
||||
const connReqPassword = allParams.find((p: any) => p.name?.includes('ConnectionRequestPassword'))
|
||||
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
|
||||
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
|
||||
|
||||
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
|
||||
// Real CPEs use ConnectionRequestUsername/Password
|
||||
const username =
|
||||
mgmtUsername?.value ||
|
||||
connReqUsername?.value ||
|
||||
`${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
|
||||
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
|
||||
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
|
||||
// Real CPEs use ConnectionRequestUsername/Password
|
||||
const username =
|
||||
mgmtUsername?.value ||
|
||||
connReqUsername?.value ||
|
||||
`${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
|
||||
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
|
||||
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
const headers = { Authorization: `Basic ${basicAuth}` }
|
||||
|
||||
logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request')
|
||||
// Build list of URLs to try:
|
||||
// 1. Original ConnectionRequestURL (may be IPv6)
|
||||
// 2. Fallback: same path but with lastRemoteIp (IPv4 public IP from last Inform)
|
||||
const urlsToTry: URL[] = [connectionRequestURL]
|
||||
|
||||
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
const response = await fetch(connectionRequestURL.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
{
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: connectionRequestURL.toString(),
|
||||
},
|
||||
'Connection Request failed with non-OK status'
|
||||
const lastRemoteIp = (device as any).lastRemoteIp as string | undefined
|
||||
if (lastRemoteIp && lastRemoteIp !== connectionRequestURL.hostname.replace(/^\[|\]$/g, '')) {
|
||||
try {
|
||||
const fallbackUrl = new URL(connectionRequestURL.toString())
|
||||
// IPv6 hostnames need brackets in URL
|
||||
fallbackUrl.hostname = lastRemoteIp.includes(':') ? `[${lastRemoteIp}]` : lastRemoteIp
|
||||
urlsToTry.push(fallbackUrl)
|
||||
logger.info(
|
||||
{ original: connectionRequestURL.toString(), fallback: fallbackUrl.toString() },
|
||||
'[ConnectionRequest] IPv4 fallback URL prepared'
|
||||
)
|
||||
return false
|
||||
} catch {
|
||||
// ignore invalid fallback URL
|
||||
}
|
||||
logger.info(
|
||||
{ status: response.status, url: connectionRequestURL.toString() },
|
||||
'Connection Request successful'
|
||||
)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
{ error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() },
|
||||
'Connection Request network error'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
for (const url of urlsToTry) {
|
||||
try {
|
||||
logger.info({ url: url.toString(), username }, 'Sending Connection Request')
|
||||
const response = await fetch(url.toString(), { method: 'GET', headers })
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
{ status: response.status, statusText: response.statusText, url: url.toString() },
|
||||
'Connection Request failed with non-OK status'
|
||||
)
|
||||
continue // try next URL
|
||||
}
|
||||
logger.info({ status: response.status, url: url.toString() }, 'Connection Request successful')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
{ error: error?.message, code: error?.cause?.code, url: url.toString() },
|
||||
'[ConnectionRequest] URL failed, trying next if available'
|
||||
)
|
||||
// try next URL
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ urls: urlsToTry.map((u) => u.toString()) },
|
||||
'Connection Request failed on all URLs'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
calculatePagination(offset: number, limit: number, total: number): PaginationData {
|
||||
|
||||
Reference in new issue
Block a user