Renew and recover UPnP router mappings

This commit is contained in:
hhqyb
2026-07-26 11:08:00 +08:00
parent 1a18532db0
commit 7a1f20bd3f
2 changed files with 92 additions and 16 deletions

View File

@@ -12,7 +12,7 @@
- TCP 使用同源端口保活连接和 STUN TCP 探测;UDP 持续保活并周期性探测。 - TCP 使用同源端口保活连接和 STUN TCP 探测;UDP 持续保活并周期性探测。
- Lucky 内置转发模式:TCP/UDP 流量转发到 `目标地址:目标端口`。 - Lucky 内置转发模式:TCP/UDP 流量转发到 `目标地址:目标端口`。
- bind 直转模式:只建立 NAT 映射;由路由器端口转发直接指向目标设备,可保留真实访问者 IP。 - bind 直转模式:只建立 NAT 映射;由路由器端口转发直接指向目标设备,可保留真实访问者 IP。
- NAT-PMP 与 UPnP IGD 自动上级路由映射。 - NAT-PMP 与 UPnP IGD 自动上级路由映射。UPnP 请求永久租约并每分钟校验;路由器仅支持有限租约时,系统会在到期前续租并在规则丢失后重建。
- Linux `iptables` 自动放行通道端口(每条规则可选)。 - Linux `iptables` 自动放行通道端口(每条规则可选)。
- 每条规则保存动态公网地址、端口、IP4P 地址、本地端口、运行状态和日志。 - 每条规则保存动态公网地址、端口、IP4P 地址、本地端口、运行状态和日志。
- 支持将状态文件用于 DDNS:`data/state/<规则 ID>.json`。 - 支持将状态文件用于 DDNS:`data/state/<规则 ID>.json`。

106
server.js
View File

@@ -18,6 +18,8 @@ const databasePath = path.join(dataDir, 'rules.json');
const stateDir = path.join(dataDir, 'state'); const stateDir = path.join(dataDir, 'state');
const logDir = path.join(dataDir, 'logs'); const logDir = path.join(dataDir, 'logs');
const runners = new Map(); 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'); 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 { 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) { log(level, message) {
const entry = { at: new Date().toISOString(), level, message: String(message).trim() }; const entry = { at: new Date().toISOString(), level, message: String(message).trim() };
this.logs.push(entry); if (this.logs.length > 300) this.logs.shift(); 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 `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${serviceType}">${Object.entries(fields).map(([key, val]) => `<${key}>${val}</${key}>`).join('')}</u:${action}></s:Body></s:Envelope>`; } function xmlEscape(value) { return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[character]); }
function soapEnvelope(action, fields, serviceType) { return `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${xmlEscape(serviceType)}">${Object.entries(fields).map(([key, val]) => `<${key}>${xmlEscape(val)}</${key}>`).join('')}</u:${action}></s:Body></s:Envelope>`; }
function xmlTag(body, name) { return body.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'i'))?.[1]?.trim() || ''; }
function localPrivateAddress() { function localPrivateAddress() {
for (const entries of Object.values(os.networkInterfaces())) for (const entry of entries || []) if (entry.family === 'IPv4' && !entry.internal) return entry.address; 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 地址'); throw new Error('未找到可用于 UPnP 的局域网 IPv4 地址');
} }
async function upnpMap(protocol, privatePort, description) { async function discoverUpnpService() {
const location = await new Promise((resolve, reject) => { 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); 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'); 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 device = await fetch(location).then((res) => res.text());
const service = device.match(/<service>\s*<serviceType>(urn:schemas-upnp-org:service:(?:WANIPConnection|WANPPPConnection):\d+)<\/serviceType>[\s\S]*?<controlURL>([^<]+)<\/controlURL>[\s\S]*?<\/service>/i); const service = device.match(/<service>\s*<serviceType>(urn:schemas-upnp-org:service:(?:WANIPConnection|WANPPPConnection):\d+)<\/serviceType>[\s\S]*?<controlURL>([^<]+)<\/controlURL>[\s\S]*?<\/service>/i);
if (!service) throw new Error('UPnP IGD 未提供 WANIP/WANPPP 控制服务'); 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]); return { controlUrl: new URL(service[2], location).toString(), serviceType: 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]) }); async function upnpRequest(service, action, fields) {
const externalAddress = query.ok ? (await query.text()).match(/<NewExternalIPAddress>([^<]+)<\/NewExternalIPAddress>/i)?.[1] : null; 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) });
return { controlUrl, externalAddress: externalAddress || null, externalPort: privatePort, lifetime: 3600 }; 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() { 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; const state = await readRuleState(runner.rule.id); runner.observeState(state); if (!runner.child || !state) continue;
runner.ensureFirewallRule(state.privatePort); runner.ensureFirewallRule(state.privatePort);
const fingerprint = `${state.privatePort}:${runner.rule.routerMapping}:${runner.rule.protocol}`; const fingerprint = `${state.privatePort}:${runner.rule.routerMapping}:${runner.rule.protocol}`;
if (runner.rule.routerMapping !== 'none' && runner.routerFingerprint !== fingerprint) try { const mappingChanged = runner.routerFingerprint !== fingerprint;
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}`); if (runner.rule.routerMapping !== 'none' && (mappingChanged || Date.now() >= runner.routerNextCheckAt)) try {
runner.routerFingerprint = fingerprint; const sameWan = !result.externalAddress || result.externalAddress === state.publicAddress; if (mappingChanged) await removePreviousUpnpMapping(runner, fingerprint);
runner.routerState = { type: runner.rule.routerMapping, externalAddress: result.externalAddress || null, externalPort: result.externalPort, matchesStunAddress: sameWan, verifiedAt: new Date().toISOString() }; const renewExisting = !mappingChanged && runner.routerState?.lifetime > 0;
runner.log('info', `${runner.rule.routerMapping.toUpperCase()} 映射成功,路由 WAN ${result.externalAddress || '未知'},外部端口 ${result.externalPort}${sameWan ? '' : ';与 STUN 地址不一致,存在上游 NAT'}`); 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);
} catch (error) { runner.log('error', `${runner.rule.routerMapping.toUpperCase()} 映射失败:${error.message}`); } 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); await runner.probe(state); await runner.notifyWebhook(state);
} }
} }