From 61bfb4e76247874f3d06009049a7be28318e9300 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Fri, 17 Jul 2026 16:14:38 +0200 Subject: [PATCH] fix: rebind CWMP session when CPE sends empty POST on new TCP port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TR-069 CPEs routinely open a new TCP connection (new port) for the empty POST that follows InformResponse. ensureSessionForRemote() only did an exact remoteKey match, so hasSession() returned false and the state machine was never continued — GPN discovery was never triggered. Add IP-only fallback in ensureSessionForRemote(): when the exact remoteKey is not found, scan RUNNING machines for a unique IP match, rebind the new remoteKey, and remap sessionMachines to the new sessionId. Ambiguous cases (NAT/CGNAT with multiple active devices on same IP) return false safely. --- app/services/cwmp/state_machine_manager.ts | 49 +++++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/app/services/cwmp/state_machine_manager.ts b/app/services/cwmp/state_machine_manager.ts index 6976622..6c21492 100644 --- a/app/services/cwmp/state_machine_manager.ts +++ b/app/services/cwmp/state_machine_manager.ts @@ -322,11 +322,46 @@ export class StateMachineManager extends EventEmitter { } /** - * Ensure session is bound when TCP connection changes + * Ensure session is bound when TCP connection changes. + * + * First tries an exact remoteKey match. If that fails (CPE opened a new TCP + * port for the empty POST — standard TR-069 behaviour), falls back to an + * IP-only match among RUNNING machines (single unambiguous candidate only). + * In both cases the new sessionId is registered in sessionMachines so that + * hasSession() returns true and continue() can run. */ ensureSessionForRemote(remoteKey: string, newSession: string): boolean { - const deviceKey = this.remoteToDevice.get(remoteKey) - if (!deviceKey) return false + // --- Exact match path --- + let deviceKey = this.remoteToDevice.get(remoteKey) + + // --- IP-only fallback (CPE reconnected on a new TCP port) --- + if (!deviceKey) { + const lastColon = remoteKey.lastIndexOf(':') + const remoteIp = lastColon > 0 ? remoteKey.slice(0, lastColon).replace(/^\[|\]$/g, '') : remoteKey + + const candidates: string[] = [] + for (const [key, dkJson] of this.remoteToDevice.entries()) { + const kLastColon = key.lastIndexOf(':') + const keyIp = (kLastColon > 0 ? key.slice(0, kLastColon) : key).replace(/^\[|\]$/g, '') + if (keyIp !== remoteIp) continue + + const existSession = this.deviceSessions.get(dkJson) + if (!existSession) continue + const mid = this.sessionMachines.get(existSession) + if (!mid) continue + const m = this.machines.get(mid) + if (!m || m.status !== MachineStatus.RUNNING) continue + + if (!candidates.includes(dkJson)) candidates.push(dkJson) + } + + // Refuse to guess if ambiguous (NAT/CGNAT with multiple active devices) + if (candidates.length !== 1) return false + deviceKey = candidates[0] + + // Register the new remoteKey so future lookups (exact or fallback) hit immediately + this.bindRemoteToDevice(remoteKey, JSON.parse(deviceKey)) + } const existingSession = this.deviceSessions.get(deviceKey) if (!existingSession) return false @@ -338,15 +373,17 @@ export class StateMachineManager extends EventEmitter { if (!machine) return false // Don't rebind to a completed/failed machine - if (machine.status !== MachineStatus.RUNNING) { - return false - } + if (machine.status !== MachineStatus.RUNNING) return false if (existingSession !== newSession) { this.sessionMachines.delete(existingSession) this.sessionMachines.set(newSession, machineId) this.deviceSessions.set(deviceKey, newSession) machine.session = newSession + logger.debug( + { oldSession: existingSession, newSession, deviceKey }, + '[StateMachineManager] Session rebound via IP fallback (CPE new TCP port)' + ) } return true