Renew and recover UPnP router mappings
This commit is contained in:
106
server.js
106
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 `<?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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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() {
|
||||
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(/<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 控制服务');
|
||||
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>([^<]+)<\/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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user