From 7a1f20bd3f8fc8007e657ebc8da0711bb67f4c05 Mon Sep 17 00:00:00 2001 From: hhqyb Date: Sun, 26 Jul 2026 11:08:00 +0800 Subject: [PATCH] Renew and recover UPnP router mappings --- README.md | 2 +- server.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2f267a0..2cf108d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - TCP 使用同源端口保活连接和 STUN TCP 探测;UDP 持续保活并周期性探测。 - Lucky 内置转发模式:TCP/UDP 流量转发到 `目标地址:目标端口`。 - bind 直转模式:只建立 NAT 映射;由路由器端口转发直接指向目标设备,可保留真实访问者 IP。 -- NAT-PMP 与 UPnP IGD 自动上级路由映射。 +- NAT-PMP 与 UPnP IGD 自动上级路由映射。UPnP 请求永久租约并每分钟校验;路由器仅支持有限租约时,系统会在到期前续租并在规则丢失后重建。 - Linux `iptables` 自动放行通道端口(每条规则可选)。 - 每条规则保存动态公网地址、端口、IP4P 地址、本地端口、运行状态和日志。 - 支持将状态文件用于 DDNS:`data/state/<规则 ID>.json`。 diff --git a/server.js b/server.js index a9121df..39e9297 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,8 @@ const databasePath = path.join(dataDir, 'rules.json'); const stateDir = path.join(dataDir, 'state'); const logDir = path.join(dataDir, 'logs'); const runners = new Map(); +const routerRetryDelayMs = 30_000; +const upnpPermanentCheckMs = 60_000; if (process.env.NODE_ENV === 'production' && adminPassword === 'change-me-before-deploying') throw new Error('生产环境必须设置 STUNMAP_ADMIN_PASSWORD'); @@ -153,7 +155,7 @@ async function readRuleState(id) { } class RuleRunner { - constructor(rule) { this.rule = rule; this.child = null; this.desired = false; this.logs = []; this.restarting = false; this.routerFingerprint = ''; this.routerState = null; this.firewallPort = null; this.lastEndpoint = ''; this.lastWebhookKey = ''; this.lastWebhookAttempt = 0; this.lastProbeAt = 0; this.probeFailures = 0; this.health = { local: 'waiting', external: 'not-configured', verifiedAt: null, detail: '等待首个 STUN 心跳' }; } + constructor(rule) { this.rule = rule; this.child = null; this.desired = false; this.logs = []; this.restarting = false; this.routerFingerprint = ''; this.routerState = null; this.routerNextCheckAt = 0; this.firewallPort = null; this.lastEndpoint = ''; this.lastWebhookKey = ''; this.lastWebhookAttempt = 0; this.lastProbeAt = 0; this.probeFailures = 0; this.health = { local: 'waiting', external: 'not-configured', verifiedAt: null, detail: '等待首个 STUN 心跳' }; } log(level, message) { const entry = { at: new Date().toISOString(), level, message: String(message).trim() }; this.logs.push(entry); if (this.logs.length > 300) this.logs.shift(); @@ -266,14 +268,16 @@ function natPmpMap(protocol, privatePort, lifetime = 3600) { }); } -function soapEnvelope(action, fields, serviceType) { return `${Object.entries(fields).map(([key, val]) => `<${key}>${val}`).join('')}`; } +function xmlEscape(value) { return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); } +function soapEnvelope(action, fields, serviceType) { return `${Object.entries(fields).map(([key, val]) => `<${key}>${xmlEscape(val)}`).join('')}`; } +function xmlTag(body, name) { return body.match(new RegExp(`<${name}>([\\s\\S]*?)`, 'i'))?.[1]?.trim() || ''; } function localPrivateAddress() { for (const entries of Object.values(os.networkInterfaces())) for (const entry of entries || []) if (entry.family === 'IPv4' && !entry.internal) return entry.address; throw new Error('未找到可用于 UPnP 的局域网 IPv4 地址'); } -async function upnpMap(protocol, privatePort, description) { +async function discoverUpnpService() { const location = await new Promise((resolve, reject) => { const socket = dgram.createSocket('udp4'); const payload = Buffer.from('M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: 2\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n'); const timer = setTimeout(() => { socket.close(); reject(new Error('未发现 UPnP IGD')); }, 3000); socket.on('message', (message) => { const match = message.toString().match(/^location:\s*(.+)$/im); if (match) { clearTimeout(timer); socket.close(); resolve(match[1].trim()); } }); socket.on('error', reject); socket.send(payload, 1900, '239.255.255.250'); @@ -281,12 +285,75 @@ async function upnpMap(protocol, privatePort, description) { const device = await fetch(location).then((res) => res.text()); const service = device.match(/\s*(urn:schemas-upnp-org:service:(?:WANIPConnection|WANPPPConnection):\d+)<\/serviceType>[\s\S]*?([^<]+)<\/controlURL>[\s\S]*?<\/service>/i); if (!service) throw new Error('UPnP IGD 未提供 WANIP/WANPPP 控制服务'); - const controlUrl = new URL(service[2], location).toString(); const body = soapEnvelope('AddPortMapping', { NewRemoteHost: '', NewExternalPort: privatePort, NewProtocol: protocol.toUpperCase(), NewInternalPort: privatePort, NewInternalClient: localPrivateAddress(), NewEnabled: 1, NewPortMappingDescription: description, NewLeaseDuration: 3600 }, service[1]); - const response = await fetch(controlUrl, { method: 'POST', headers: { 'content-type': 'text/xml; charset="utf-8"', soapaction: `"${service[1]}#AddPortMapping"` }, body }); - if (!response.ok) throw new Error(`UPnP AddPortMapping 失败:HTTP ${response.status}`); - const query = await fetch(controlUrl, { method: 'POST', headers: { 'content-type': 'text/xml; charset="utf-8"', soapaction: `"${service[1]}#GetExternalIPAddress"` }, body: soapEnvelope('GetExternalIPAddress', {}, service[1]) }); - const externalAddress = query.ok ? (await query.text()).match(/([^<]+)<\/NewExternalIPAddress>/i)?.[1] : null; - return { controlUrl, externalAddress: externalAddress || null, externalPort: privatePort, lifetime: 3600 }; + return { controlUrl: new URL(service[2], location).toString(), serviceType: service[1] }; +} + +async function upnpRequest(service, action, fields) { + const response = await fetch(service.controlUrl, { method: 'POST', headers: { 'content-type': 'text/xml; charset="utf-8"', soapaction: `"${service.serviceType}#${action}"` }, body: soapEnvelope(action, fields, service.serviceType), signal: AbortSignal.timeout(5000) }); + const body = await response.text(); + if (response.ok) return body; + const code = xmlTag(body, 'errorCode'); const description = xmlTag(body, 'errorDescription'); + const detail = code ? `,UPnP 错误 ${code}${description ? ` (${description})` : ''}` : ''; + const error = new Error(`UPnP ${action} 失败:HTTP ${response.status}${detail}`); error.upnpCode = code; throw error; +} + +async function upnpGetMapping(service, protocol, externalPort) { + try { + const body = await upnpRequest(service, 'GetSpecificPortMappingEntry', { NewRemoteHost: '', NewExternalPort: externalPort, NewProtocol: protocol.toUpperCase() }); + return { internalClient: xmlTag(body, 'NewInternalClient'), internalPort: Number(xmlTag(body, 'NewInternalPort')), lifetime: Number(xmlTag(body, 'NewLeaseDuration')) || 0 }; + } catch (error) { + if (error.upnpCode === '714') return null; + throw error; + } +} + +async function upnpDeleteMapping(protocol, externalPort) { + const service = await discoverUpnpService(); + try { await upnpRequest(service, 'DeletePortMapping', { NewRemoteHost: '', NewExternalPort: externalPort, NewProtocol: protocol.toUpperCase() }); } catch (error) { if (error.upnpCode !== '714') throw error; } +} + +async function upnpExternalAddress(service) { + try { return xmlTag(await upnpRequest(service, 'GetExternalIPAddress', {}), 'NewExternalIPAddress') || null; } catch { return null; } +} + +async function upnpAddMapping(service, protocol, privatePort, internalClient, description) { + await upnpRequest(service, 'AddPortMapping', { NewRemoteHost: '', NewExternalPort: privatePort, NewProtocol: protocol.toUpperCase(), NewInternalPort: privatePort, NewInternalClient: internalClient, NewEnabled: 1, NewPortMappingDescription: description, NewLeaseDuration: 0 }); +} + +async function upnpMap(protocol, privatePort, description, renewExisting = false) { + const service = await discoverUpnpService(); const internalClient = localPrivateAddress(); + let existing = null; + try { existing = await upnpGetMapping(service, protocol, privatePort); } catch { /* Older IGDs may not implement GetSpecificPortMappingEntry. */ } + if (existing) { + if (existing.internalClient !== internalClient || existing.internalPort !== privatePort) throw new Error(`UPnP 端口 ${privatePort}/${protocol.toUpperCase()} 已映射到 ${existing.internalClient || '未知地址'}:${existing.internalPort || '未知端口'}`); + if (renewExisting && existing.lifetime) { + try { await upnpAddMapping(service, protocol, privatePort, internalClient, description); } catch { /* Existing rule remains usable; retry on the next scheduled verification. */ } + const refreshed = await upnpGetMapping(service, protocol, privatePort).catch(() => null); + if (refreshed?.internalClient === internalClient && refreshed.internalPort === privatePort) existing = refreshed; + } + return { externalAddress: await upnpExternalAddress(service), externalPort: privatePort, lifetime: existing.lifetime, reused: true }; + } + try { + await upnpAddMapping(service, protocol, privatePort, internalClient, description); + } catch (error) { + // Several IGDs return HTTP 500 / 718 when a matching rule survived a process restart. + const afterFailure = await upnpGetMapping(service, protocol, privatePort).catch(() => null); + if (!afterFailure || afterFailure.internalClient !== internalClient || afterFailure.internalPort !== privatePort) throw error; + return { externalAddress: await upnpExternalAddress(service), externalPort: privatePort, lifetime: afterFailure.lifetime, reused: true }; + } + return { externalAddress: await upnpExternalAddress(service), externalPort: privatePort, lifetime: 0, reused: false }; +} + +function routerCheckDelay(result) { + const lifetime = Number(result.lifetime) || 0; + if (!lifetime) return upnpPermanentCheckMs; + return Math.min(15 * 60_000, Math.max(60_000, Math.floor(lifetime * 500))); +} + +async function removePreviousUpnpMapping(runner, fingerprint) { + const previous = runner.routerState; + if (!previous || previous.type !== 'upnp' || runner.routerFingerprint === fingerprint || !previous.externalPort) return; + try { await upnpDeleteMapping(runner.rule.protocol, previous.externalPort); runner.log('info', `已移除旧 UPnP 映射端口 ${previous.externalPort}`); } catch (error) { runner.log('error', `移除旧 UPnP 映射端口 ${previous.externalPort} 失败:${error.message}`); } } async function reconcileRouterMappings() { @@ -294,12 +361,21 @@ async function reconcileRouterMappings() { const state = await readRuleState(runner.rule.id); runner.observeState(state); if (!runner.child || !state) continue; runner.ensureFirewallRule(state.privatePort); const fingerprint = `${state.privatePort}:${runner.rule.routerMapping}:${runner.rule.protocol}`; - if (runner.rule.routerMapping !== 'none' && runner.routerFingerprint !== fingerprint) try { - const result = runner.rule.routerMapping === 'nat-pmp' ? await natPmpMap(runner.rule.protocol, state.privatePort) : await upnpMap(runner.rule.protocol, state.privatePort, `STUN-NAT ${runner.rule.name}`); - runner.routerFingerprint = fingerprint; const sameWan = !result.externalAddress || result.externalAddress === state.publicAddress; - runner.routerState = { type: runner.rule.routerMapping, externalAddress: result.externalAddress || null, externalPort: result.externalPort, matchesStunAddress: sameWan, verifiedAt: new Date().toISOString() }; - runner.log('info', `${runner.rule.routerMapping.toUpperCase()} 映射成功,路由 WAN ${result.externalAddress || '未知'},外部端口 ${result.externalPort}${sameWan ? '' : ';与 STUN 地址不一致,存在上游 NAT'}`); - } catch (error) { runner.log('error', `${runner.rule.routerMapping.toUpperCase()} 映射失败:${error.message}`); } + const mappingChanged = runner.routerFingerprint !== fingerprint; + if (runner.rule.routerMapping !== 'none' && (mappingChanged || Date.now() >= runner.routerNextCheckAt)) try { + if (mappingChanged) await removePreviousUpnpMapping(runner, fingerprint); + const renewExisting = !mappingChanged && runner.routerState?.lifetime > 0; + const result = runner.rule.routerMapping === 'nat-pmp' ? await natPmpMap(runner.rule.protocol, state.privatePort) : await upnpMap(runner.rule.protocol, state.privatePort, `STUN-NAT ${runner.rule.name}`, renewExisting); + const sameWan = !result.externalAddress || result.externalAddress === state.publicAddress; + const firstMapping = !runner.routerState || mappingChanged; + runner.routerFingerprint = fingerprint; runner.routerNextCheckAt = Date.now() + routerCheckDelay(result); + runner.routerState = { type: runner.rule.routerMapping, externalAddress: result.externalAddress || null, externalPort: result.externalPort, matchesStunAddress: sameWan, lifetime: Number(result.lifetime) || 0, verifiedAt: new Date().toISOString(), lastError: null }; + if (firstMapping || !result.reused) runner.log('info', `${runner.rule.routerMapping.toUpperCase()} ${result.reused ? '映射已确认' : '映射成功'},路由 WAN ${result.externalAddress || '未知'},外部端口 ${result.externalPort}${sameWan ? '' : ';与 STUN 地址不一致,存在上游 NAT'}`); + } catch (error) { + runner.routerNextCheckAt = Date.now() + routerRetryDelayMs; + runner.routerState = { ...runner.routerState, type: runner.rule.routerMapping, lastError: error.message, verifiedAt: new Date().toISOString() }; + runner.log('error', `${runner.rule.routerMapping.toUpperCase()} 映射失败:${error.message}`); + } await runner.probe(state); await runner.notifyWebhook(state); } }