Rename project to STUN-NAT

This commit is contained in:
hhqyb
2026-07-16 15:55:42 +08:00
parent e38ef6ef05
commit 1a18532db0
7 changed files with 14 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
# STUNMap Console # STUN-NAT
独立的 NAT1 STUN 内网穿透管理系统,按 Lucky 的 STUN 模块工作方式实现:在一台设备上维持 TCP 或 UDP 的 NAT 映射,获得动态公网端口,再将流量转发到指定内网服务,或交由路由器直转。 独立的 STUN 内网穿透管理系统,按 Lucky 的 STUN 模块工作方式实现:在一台设备上维持 TCP 或 UDP 的 NAT 映射,获得动态公网端口,再将流量转发到指定内网服务,或交由路由器直转。
它不是双节点 P2P 打洞系统,也不提供中继服务。映射是否可用取决于运营商和每一层 NAT 是否为 NAT1(全锥形)。公网端口不可指定,变化周期也无法保证。 它不是双节点 P2P 打洞系统,也不提供中继服务。映射是否可用取决于运营商和每一层 NAT 是否为 NAT1(全锥形)。公网端口不可指定,变化周期也无法保证。

View File

@@ -1,6 +1,7 @@
services: services:
stunmap-probe: stun-nat-probe:
build: . build: .
container_name: stun-nat-probe
command: ["node", "probe-server.js"] command: ["node", "probe-server.js"]
network_mode: host network_mode: host
environment: environment:

View File

@@ -1,7 +1,7 @@
services: services:
stunmap: stun-nat:
build: . build: .
container_name: stunmap container_name: stun-nat
network_mode: host network_mode: host
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN

View File

@@ -1,8 +1,8 @@
{ {
"name": "stunmap-console", "name": "stun-nat",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Rule-driven NAT1 STUN port mapping console", "description": "Rule-driven STUN port mapping console",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",

View File

@@ -11,4 +11,4 @@ function sameToken(value) { const input = Buffer.from(String(value || '')); cons
function tcpProbe(address, probePort) { return new Promise((resolve) => { const socket = net.connect({ host: address, port: probePort }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 4000); socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.once('error', () => { clearTimeout(timer); resolve(false); }); }); } function tcpProbe(address, probePort) { return new Promise((resolve) => { const socket = net.connect({ host: address, port: probePort }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 4000); socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.once('error', () => { clearTimeout(timer); resolve(false); }); }); }
function udpProbe(address, probePort) { return new Promise((resolve) => { const socket = dgram.createSocket('udp4'); const payload = Buffer.from(`SMHP:${token}:${crypto.randomUUID()}`); const timer = setTimeout(() => { socket.close(); resolve(false); }, 4000); socket.on('message', (message) => { clearTimeout(timer); socket.close(); resolve(message.length === payload.length && crypto.timingSafeEqual(message, payload)); }); socket.on('error', () => { clearTimeout(timer); socket.close(); resolve(false); }); socket.send(payload, probePort, address); }); } function udpProbe(address, probePort) { return new Promise((resolve) => { const socket = dgram.createSocket('udp4'); const payload = Buffer.from(`SMHP:${token}:${crypto.randomUUID()}`); const timer = setTimeout(() => { socket.close(); resolve(false); }, 4000); socket.on('message', (message) => { clearTimeout(timer); socket.close(); resolve(message.length === payload.length && crypto.timingSafeEqual(message, payload)); }); socket.on('error', () => { clearTimeout(timer); socket.close(); resolve(false); }); socket.send(payload, probePort, address); }); }
async function body(req) { const chunks=[]; for await (const chunk of req) chunks.push(chunk); return JSON.parse(Buffer.concat(chunks).toString() || '{}'); } async function body(req) { const chunks=[]; for await (const chunk of req) chunks.push(chunk); return JSON.parse(Buffer.concat(chunks).toString() || '{}'); }
http.createServer(async (req, res) => { if (req.method !== 'POST' || req.url !== '/probe') { res.writeHead(404); return res.end(); } try { const input=await body(req); if (!sameToken(input.token)) { res.writeHead(403); return res.end(JSON.stringify({ error:'invalid token' })); } if (!['tcp','udp'].includes(input.protocol) || !Number.isInteger(input.port) || input.port < 1 || input.port > 65535 || !net.isIPv4(input.address)) throw new Error('invalid probe request'); const reachable = input.protocol === 'tcp' ? await tcpProbe(input.address, input.port) : await udpProbe(input.address, input.port); res.writeHead(200, { 'content-type':'application/json' }); res.end(JSON.stringify({ reachable, checkedAt:new Date().toISOString() })); } catch (error) { res.writeHead(400, { 'content-type':'application/json' }); res.end(JSON.stringify({ error:error.message })); } }).listen(port, '0.0.0.0', () => console.log(`STUNMap Probe listening on :${port}`)); http.createServer(async (req, res) => { if (req.method !== 'POST' || req.url !== '/probe') { res.writeHead(404); return res.end(); } try { const input=await body(req); if (!sameToken(input.token)) { res.writeHead(403); return res.end(JSON.stringify({ error:'invalid token' })); } if (!['tcp','udp'].includes(input.protocol) || !Number.isInteger(input.port) || input.port < 1 || input.port > 65535 || !net.isIPv4(input.address)) throw new Error('invalid probe request'); const reachable = input.protocol === 'tcp' ? await tcpProbe(input.address, input.port) : await udpProbe(input.address, input.port); res.writeHead(200, { 'content-type':'application/json' }); res.end(JSON.stringify({ reachable, checkedAt:new Date().toISOString() })); } catch (error) { res.writeHead(400, { 'content-type':'application/json' }); res.end(JSON.stringify({ error:error.message })); } }).listen(port, '0.0.0.0', () => console.log(`STUN-NAT Probe listening on :${port}`));

View File

@@ -3,12 +3,12 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>STUNMap Console</title> <title>STUN-NAT Console</title>
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div><strong>STUNMap</strong><span> NAT1 动态端口穿透</span></div> <div><strong>STUN-NAT</strong><span> 动态端口穿透</span></div>
<div id="summary">加载中</div> <div id="summary">加载中</div>
</header> </header>
<main> <main>

View File

@@ -33,7 +33,7 @@ function basicAuth(req, res) {
const sameLength = encoded.length === expected.length; const sameLength = encoded.length === expected.length;
const valid = sameLength && crypto.timingSafeEqual(Buffer.from(encoded), Buffer.from(expected)); const valid = sameLength && crypto.timingSafeEqual(Buffer.from(encoded), Buffer.from(expected));
if (valid) return true; if (valid) return true;
res.writeHead(401, { 'www-authenticate': 'Basic realm="STUNMap Console"' }); res.writeHead(401, { 'www-authenticate': 'Basic realm="STUN-NAT Console"' });
res.end('Authentication required'); res.end('Authentication required');
return false; return false;
} }
@@ -295,7 +295,7 @@ async function reconcileRouterMappings() {
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 { 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, `STUNMap ${runner.rule.name}`); 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.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.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'}`); runner.log('info', `${runner.rule.routerMapping.toUpperCase()} 映射成功,路由 WAN ${result.externalAddress || '未知'},外部端口 ${result.externalPort}${sameWan ? '' : ';与 STUN 地址不一致,存在上游 NAT'}`);
@@ -334,5 +334,5 @@ const server = http.createServer(async (req, res) => {
}); });
await ensureStorage(); await refreshRunners(); setInterval(reconcileRouterMappings, 1000).unref(); await ensureStorage(); await refreshRunners(); setInterval(reconcileRouterMappings, 1000).unref();
server.listen(port, '0.0.0.0', () => console.log(`STUNMap Console listening on :${port}`)); server.listen(port, '0.0.0.0', () => console.log(`STUN-NAT Console listening on :${port}`));
process.on('SIGTERM', () => { for (const runner of runners.values()) runner.stop(); server.close(() => process.exit(0)); }); process.on('SIGTERM', () => { for (const runner of runners.values()) runner.stop(); server.close(() => process.exit(0)); });