Initial STUN NAT mapping console

This commit is contained in:
hhqyb
2026-07-16 15:07:12 +08:00
commit e38ef6ef05
17 changed files with 858 additions and 0 deletions

12
public/app.css Normal file
View File

@@ -0,0 +1,12 @@
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f7fb; color:#18212f; }
* { box-sizing:border-box; }
body { margin:0; }
.topbar { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 max(24px, calc((100vw - 1180px) / 2)); background:#18212f; color:#f7fafc; font-size:14px; }
.topbar strong { font-size:18px; letter-spacing:0; color:#63d5b0; }.topbar span { margin-left:8px; color:#b7c1d1; }
main { max-width:1180px; margin:0 auto; padding:34px 24px 64px; }.toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:22px; }
h1 { font-size:24px; margin:0 0 5px; } h2 { font-size:17px; margin:0; } p { margin:0; color:#667388; font-size:14px; line-height:1.55; }
button { appearance:none; border:0; border-radius:5px; background:#07785e; color:white; font:inherit; padding:9px 13px; cursor:pointer; } button:hover { background:#05634d; } button.secondary { background:#e9edf3; color:#344054; } button.secondary:hover { background:#dce3ec; } button.danger { background:#fff0f0; color:#b42318; } button.danger:hover { background:#ffdcdc; }.actions { display:flex; gap:8px; align-items:center; }
.rules { display:grid; gap:14px; }.rule { background:#fff; border:1px solid #e1e7ef; border-radius:7px; padding:18px; box-shadow:0 1px 2px rgb(16 24 40 / 3%); }.rule > header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; }.meta { margin-top:3px; }.mapping { display:inline-flex; margin:16px 0 13px; padding:8px 11px; border-radius:4px; background:#eef9f5; color:#07654f; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:14px; }.mapping.offline { background:#fff7e8; color:#985d00; }.details { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px 20px; }.detail { min-width:0; }.detail-label { color:#7b8798; font-size:12px; margin-bottom:3px; }.detail-value { overflow-wrap:anywhere; font-size:14px; }details { margin-top:15px; border-top:1px solid #edf0f4; padding-top:12px; } summary { cursor:pointer; color:#445166; font-size:13px; }pre { margin:10px 0 0; max-height:180px; overflow:auto; padding:10px; background:#192330; color:#cbe3d9; border-radius:4px; font:12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space:pre-wrap; }.empty { border:1px dashed #c7d1df; border-radius:7px; padding:48px 24px; text-align:center; background:#fff; }.empty h2 { margin-bottom:8px; }
dialog { width:min(760px, calc(100vw - 32px)); max-height:calc(100vh - 32px); padding:0; border:0; border-radius:8px; box-shadow:0 22px 55px rgb(16 24 40 / 25%); }dialog::backdrop { background:rgb(22 31 44 / 42%); }form > header, form > footer { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid #e9edf2; }form > footer { border-top:1px solid #e9edf2; border-bottom:0; justify-content:flex-end; gap:8px; }.icon { background:transparent; color:#667388; padding:0 4px; font-size:28px; line-height:1; }.icon:hover { background:transparent; color:#18212f; }.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; padding:20px; overflow:auto; max-height:calc(100vh - 180px); }label { display:grid; gap:6px; font-size:13px; font-weight:600; color:#344054; }input, select { width:100%; border:1px solid #cfd7e3; border-radius:4px; padding:9px 10px; background:white; color:#18212f; font:inherit; font-size:14px; }small { color:#7b8798; font-size:12px; font-weight:400; line-height:1.4; }.wide { grid-column:1 / -1; }.checkbox { display:flex; align-items:center; gap:8px; }.checkbox input { width:auto; }.hidden { display:none; }
textarea { width:100%; min-height:132px; resize:vertical; border:1px solid #cfd7e3; border-radius:4px; padding:9px 10px; background:white; color:#18212f; font:14px ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width:720px) { .topbar { padding:0 16px; }.topbar span { display:none; }main { padding:24px 16px; }.toolbar { align-items:flex-start; }.rule > header { flex-direction:column; }.details,.form-grid { grid-template-columns:1fr; }.actions { width:100%; flex-wrap:wrap; } }

14
public/app.js Normal file
View File

@@ -0,0 +1,14 @@
const rulesEl = document.querySelector('#rules'); const emptyEl = document.querySelector('#empty'); const editor = document.querySelector('#editor'); const form = document.querySelector('#ruleForm'); const template = document.querySelector('#ruleTemplate'); const openLogs = new Set(); let rules = [];
async function api(url, options = {}) { const endpoint = new URL(url, `${location.protocol}//${location.host}`); const response = await fetch(endpoint, { headers:{ 'content-type':'application/json', ...(options.headers || {}) }, ...options }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `请求失败:${response.status}`); } return response.status === 204 ? null : response.json(); }
function setMode() { const udp = form.protocol.value === 'udp'; const bindOption = form.mode.querySelector('option[value="bind"]'); bindOption.disabled = udp; if (udp && form.mode.value === 'bind') form.mode.value = 'forward'; const bind = form.mode.value === 'bind'; const advanced = document.querySelector('#viewMode').value === 'advanced'; document.querySelectorAll('.target').forEach((el) => el.classList.toggle('hidden', bind)); document.querySelectorAll('.advanced').forEach((el) => el.classList.toggle('hidden', !advanced)); document.querySelector('.tcp-only').classList.toggle('hidden', udp || !advanced); document.querySelector('.webhook-body').classList.toggle('hidden', !advanced || form.webhookMethod.value !== 'post'); }
function text(value) { return value || '—'; }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[character]); }
function render() { rulesEl.replaceChildren(); emptyEl.hidden = rules.length > 0; const running = rules.filter((r) => r.running).length; document.querySelector('#summary').textContent = `${running}/${rules.length} 条规则运行中`; for (const rule of rules) { const node = template.content.cloneNode(true); node.querySelector('.name').textContent = rule.name; node.querySelector('.meta').textContent = `${rule.protocol.toUpperCase()} · ${rule.mode === 'forward' ? '内置转发' : '路由器直转'} · 本地通道 ${rule.bindPort || '随机'}`; const mapping = node.querySelector('.mapping'); if (rule.state?.publicAddress) mapping.textContent = `公网映射 ${rule.state.publicAddress}:${rule.state.publicPort}`; else { mapping.textContent = rule.running ? '正在建立 STUN 映射…' : '规则未运行'; mapping.classList.add('offline'); }
const router = rule.routerState; const routerValue = !router ? (rule.routerMapping === 'none' ? '不使用' : '等待路由器响应') : `${router.type.toUpperCase()} ${router.externalAddress || 'WAN 地址未知'}:${router.externalPort}${router.matchesStunAddress ? '' : '(与 STUN 地址不一致)'}`; const details = [['通道健康', rule.health?.local === 'healthy' ? '正常' : rule.health?.detail || '等待建立'], ['公网探针', rule.health?.external === 'not-configured' ? '未配置,公网可达性未知' : rule.health?.external === 'healthy' ? '可从公网访问' : rule.health?.externalDetail || '等待探测'], ['最近验证', rule.health?.verifiedAt ? new Date(rule.health.verifiedAt).toLocaleString() : '—'], ['STUN 观测', rule.state?.publicAddress ? `${rule.state.publicAddress}:${rule.state.publicPort}` : '—'], ['路由器映射', routerValue], ['目标服务', rule.mode === 'forward' ? `${rule.targetHost}:${rule.targetPort}` : '由路由器端口转发决定'], ['保活 / 检测', `${rule.interval}s / 每 ${rule.checkEvery} 次`], ['本地绑定', rule.state?.privateAddress ? `${rule.state.privateAddress}:${rule.state.privatePort}` : '等待建立'], ['IP4P', rule.state?.ip4p || '—']]; node.querySelector('.details').innerHTML = details.map(([key, val]) => `<div class="detail"><div class="detail-label">${escapeHtml(key)}</div><div class="detail-value">${escapeHtml(text(val))}</div></div>`).join(''); const logDetails = node.querySelector('details'); logDetails.open = openLogs.has(rule.id); logDetails.addEventListener('toggle', () => { if (logDetails.open) openLogs.add(rule.id); else openLogs.delete(rule.id); }); node.querySelector('.logs').textContent = (rule.logs || []).map((log) => `${log.at} ${log.level.toUpperCase()} ${log.message}`).join('\n') || '暂无日志';
const toggle = node.querySelector('.toggle'); toggle.textContent = rule.running ? '停止' : '启动'; toggle.classList.toggle('secondary', rule.running); toggle.addEventListener('click', async () => { await api(`/api/rules/${rule.id}/${rule.running ? 'stop' : 'start'}`, { method:'POST' }); await load(); }); node.querySelector('.edit').addEventListener('click', () => openEditor(rule)); node.querySelector('.delete').addEventListener('click', async () => { if (confirm(`删除规则“${rule.name}”?`)) { await api(`/api/rules/${rule.id}`, { method:'DELETE' }); await load(); } }); rulesEl.append(node); } }
async function load() { try { rules = (await api('/api/rules')).rules; render(); } catch (error) { document.querySelector('#summary').textContent = error.message; } }
function openEditor(rule = null) { form.reset(); form.id.value = rule?.id || ''; form.name.value = rule?.name || ''; document.querySelector('#viewMode').value = rule ? 'advanced' : 'simple'; form.protocol.value = rule?.protocol || 'tcp'; form.bindPort.value = rule?.bindPort ?? 0; form.stunServer.value = rule?.stunServer || 'turn.cloudflare.com:3478'; form.keepaliveServer.value = rule?.keepaliveServer || 'www.cloudflare.com:80'; form.interval.value = rule?.interval || 10; form.checkEvery.value = rule?.checkEvery || 1; form.externalProbeUrl.value = rule?.externalProbeUrl || ''; form.externalProbeToken.value = rule?.externalProbeToken || ''; form.probeInterval.value = rule?.probeInterval || 10; form.webhookUrl.value = rule?.webhookUrl || ''; form.webhookMethod.value = rule?.webhookMethod || 'post'; form.webhookBody.value = rule?.webhookBody || ''; form.mode.value = rule?.mode || 'forward'; form.targetHost.value = rule?.targetHost || ''; form.targetPort.value = rule?.targetPort || ''; form.routerMapping.value = rule?.routerMapping || 'none'; form.firewallAuto.checked = rule?.firewallAuto || false; form.firewallNote.value = rule?.firewallNote || ''; form.enabled.checked = rule?.enabled ?? true; document.querySelector('#dialogTitle').textContent = rule ? '编辑穿透规则' : '添加穿透规则'; setMode(); editor.showModal(); }
document.querySelector('#newRule').addEventListener('click', () => openEditor()); document.querySelector('#closeEditor').addEventListener('click', () => editor.close()); document.querySelector('#cancelEditor').addEventListener('click', () => editor.close()); form.protocol.addEventListener('change', setMode); form.mode.addEventListener('change', setMode); form.webhookMethod.addEventListener('change', setMode); document.querySelector('#viewMode').addEventListener('change', setMode);
form.addEventListener('submit', async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(form)); data.enabled = form.enabled.checked; data.firewallAuto = form.firewallAuto.checked; ['bindPort','interval','checkEvery','targetPort','probeInterval'].forEach((key) => { data[key] = Number(data[key] || 0); }); try { await api(form.id.value ? `/api/rules/${form.id.value}` : '/api/rules', { method: form.id.value ? 'PUT' : 'POST', body: JSON.stringify(data) }); editor.close(); await load(); } catch (error) { alert(error.message); } });
load(); setInterval(load, 5000);

59
public/index.html Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>STUNMap Console</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<header class="topbar">
<div><strong>STUNMap</strong><span> NAT1 动态端口穿透</span></div>
<div id="summary">加载中</div>
</header>
<main>
<section class="toolbar"><div><h1>穿透规则</h1><p>公网端口由 NAT 运行状态决定,不能指定或固定。</p></div><button id="newRule">添加规则</button></section>
<section id="empty" class="empty" hidden><h2>尚未创建穿透规则</h2><p>创建后,系统会在本机绑定通道端口,持续获取公网映射并按配置转发流量。</p></section>
<section id="rules" class="rules"></section>
</main>
<dialog id="editor">
<form id="ruleForm" method="dialog">
<header><h2 id="dialogTitle">添加穿透规则</h2><button type="button" class="icon" id="closeEditor" title="关闭">×</button></header>
<input type="hidden" name="id">
<div class="form-grid">
<label>规则名称<input name="name" required maxlength="80" placeholder="例如:家庭 Web 服务"></label>
<label>操作模式<select id="viewMode"><option value="simple">简易模式</option><option value="advanced">定制模式</option></select><small>简易模式采用推荐参数;定制模式显示全部选项。</small></label>
<label>穿透类型<select name="protocol"><option value="tcp">IPv4 TCP</option><option value="udp">IPv4 UDP</option></select></label>
<label>通道监听端口<input name="bindPort" type="number" min="0" max="65535" value="0"><small>填 0 时自动选择随机端口;局域网内应避免冲突。</small></label>
<label class="advanced">STUN 服务器<input name="stunServer" required value="turn.cloudflare.com:3478"></label>
<label class="tcp-only advanced">TCP 保活服务器<input name="keepaliveServer" value="www.cloudflare.com:80"><small>TCP 规则须保持此连接,以维持 NAT 映射。</small></label>
<label class="advanced">保活间隔(秒)<input name="interval" type="number" min="5" max="3600" value="10"></label>
<label class="advanced">UDP STUN 检测周期<input name="checkEvery" type="number" min="1" max="3600" value="1"><small>每 N 次保活后重新探测映射。</small></label>
<label>转发模式<select name="mode" id="mode"><option value="forward">Lucky 内置转发</option><option value="bind">路由器直转(bind)</option></select></label>
<label class="advanced">上级路由映射<select name="routerMapping"><option value="none">不使用</option><option value="nat-pmp">NAT-PMP</option><option value="upnp">UPnP IGD</option></select><small>Lucky 不在主路由时使用,路由器须启用对应协议。</small></label>
<label class="wide advanced">公网探针地址<input name="externalProbeUrl" placeholder="https://probe.example.com/probe"><small>部署在公网 VPS 的 STUNMap Probe。留空时仅使用本地 STUN/保活心跳。</small></label>
<label class="advanced">公网探针令牌<input name="externalProbeToken" type="password" autocomplete="new-password"></label>
<label class="advanced">公网探针间隔(秒)<input name="probeInterval" type="number" min="5" max="3600" value="10"></label>
<label class="wide advanced">映射 Webhook 地址<input name="webhookUrl" placeholder="https://example.com/hooks/stunmap"><small>首次映射成功或 IP/端口变化后调用。心跳不会重复触发。</small></label>
<label class="advanced">Webhook 方法<select name="webhookMethod"><option value="post">POST JSON</option><option value="get">GET 查询参数</option></select></label>
<label class="wide advanced webhook-body">Webhook POST JSON 模板<textarea name="webhookBody" spellcheck="false" placeholder='{"public_ip":"{{STUN_PUBLIC_IP}}","public_port":{{STUN_PUBLIC_PORT}},"public_addr":"{{STUN_PUBLIC_ADDR}}"}'></textarea><small>可用变量:STUN_PUBLIC_IP、STUN_PUBLIC_PORT、STUN_PUBLIC_ADDR、STUN_PRIVATE_IP、STUN_PRIVATE_PORT、STUN_PRIVATE_ADDR、STUN_IP4P、STUN_PROTOCOL、STUN_RULE_ID、STUN_RULE_NAME、STUN_ROUTER_IP、STUN_ROUTER_PORT。支持双花括号、美元花括号或单花括号写法。</small></label>
<label class="target">目标地址<input name="targetHost" placeholder="192.168.1.20 或 nas.lan"></label>
<label class="target">目标端口<input name="targetPort" type="number" min="1" max="65535" placeholder="443"></label>
<label class="checkbox wide advanced"><input name="firewallAuto" type="checkbox"> Linux 主路由上自动放行本规则的防火墙端口</label>
<label class="wide advanced">防火墙备注<input name="firewallNote" placeholder="例如:已放行 WAN TCP 端口;或记录路由器 DNAT 规则"></label>
<label class="checkbox wide"><input name="enabled" type="checkbox" checked> 保存后立即启用</label>
</div>
<footer><button type="button" class="secondary" id="cancelEditor">取消</button><button type="submit">保存规则</button></footer>
</form>
</dialog>
<template id="ruleTemplate">
<article class="rule">
<header><div><h2 class="name"></h2><p class="meta"></p></div><div class="actions"><button class="toggle"></button><button class="secondary edit">编辑</button><button class="danger delete">删除</button></div></header>
<div class="mapping"></div>
<div class="details"></div>
<details><summary>规则日志</summary><pre class="logs"></pre></details>
</article>
</template>
<script type="module" src="/app.js"></script>
</body>
</html>