commit e38ef6ef05b05a6a8090b575b3c81f479561eb23 Author: hhqyb Date: Thu Jul 16 15:07:12 2026 +0800 Initial STUN NAT mapping console diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f8b60d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +bin/ +data/ +node_modules/ +.git/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd950c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fe8def --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM alpine:3.21 AS natmap-build +RUN apk add --no-cache build-base git linux-headers +WORKDIR /build +COPY patches/natmap-health.patch /tmp/natmap-health.patch +ARG NATMAP_REF=1853fc21aff07a1d5fdfb31c7e90af34fddf0296 +RUN git clone --recursive https://github.com/heiher/natmap.git . \ + && git checkout "$NATMAP_REF" \ + && git submodule update --init --recursive \ + && patch -p1 < /tmp/natmap-health.patch \ + && make CFLAGS='-Wno-error' + +FROM node:24-alpine +RUN apk add --no-cache iptables libstdc++ +WORKDIR /app +COPY --from=natmap-build /build/bin/natmap /usr/local/bin/natmap +COPY package.json server.js probe-server.js ./ +COPY scripts ./scripts +COPY public ./public +RUN chmod 0755 /app/scripts/notify.js \ + && mkdir -p /data \ + && chown -R node:node /app /data +USER node +ENV STUNMAP_DATA_DIR=/data \ + NATMAP_BIN=/usr/local/bin/natmap \ + NODE_ENV=production \ + PORT=16888 +EXPOSE 16888 +EXPOSE 16900 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..03c8042 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# STUNMap Console + +独立的 NAT1 STUN 内网穿透管理系统,按 Lucky 的 STUN 模块工作方式实现:在一台设备上维持 TCP 或 UDP 的 NAT 映射,获得动态公网端口,再将流量转发到指定内网服务,或交由路由器直转。 + +它不是双节点 P2P 打洞系统,也不提供中继服务。映射是否可用取决于运营商和每一层 NAT 是否为 NAT1(全锥形)。公网端口不可指定,变化周期也无法保证。 + +## 功能 + +- Web 管理页面、HTTP Basic Auth 和规则 REST API。 +- 多条 IPv4 TCP / UDP 穿透规则。 +- 通道监听端口支持固定值或 `0` 随机分配。 +- TCP 使用同源端口保活连接和 STUN TCP 探测;UDP 持续保活并周期性探测。 +- Lucky 内置转发模式:TCP/UDP 流量转发到 `目标地址:目标端口`。 +- bind 直转模式:只建立 NAT 映射;由路由器端口转发直接指向目标设备,可保留真实访问者 IP。 +- NAT-PMP 与 UPnP IGD 自动上级路由映射。 +- Linux `iptables` 自动放行通道端口(每条规则可选)。 +- 每条规则保存动态公网地址、端口、IP4P 地址、本地端口、运行状态和日志。 +- 支持将状态文件用于 DDNS:`data/state/<规则 ID>.json`。 +- 默认每 10 秒进行一次 STUN/保活心跳;连续缺失心跳会自动重建映射。 +- 可选独立公网探针:TCP 验证通道握手,UDP 验证映射引擎健康回显;两者均不依赖目标服务是否启动。 +- 映射成功或公网端点变化后,可调用指定 GET 或 POST Webhook;保活心跳不会重复触发。 + +映射核心使用 [NATMap](https://github.com/heiher/natmap) 的 MIT 许可实现,管理服务、规则存储、路由映射适配和界面为本项目独立编写。 + +## 部署 + +此类程序必须运行在需要暴露服务的主路由或内网设备上,Docker 必须使用宿主机网络;容器 NAT 网络会破坏端口映射。 + +`network_mode: host` 面向 Linux 主机/路由器。Docker Desktop(macOS/Windows)会将 host 网络指向其 Linux VM,管理端口和 NAT 映射均不等同于桌面宿主机;可用于构建验证,但不能替代在目标 Linux 路由设备上的部署。 + +```sh +export STUNMAP_ADMIN_USER=admin +export STUNMAP_ADMIN_PASSWORD='use-a-long-random-password' +docker compose up -d --build +``` + +访问 `http://设备地址:16888`,使用上面的账号密码登录。 + +首次建议创建一条 TCP 内置转发规则: + +| 字段 | 示例 | +| --- | --- | +| 穿透类型 | `IPv4 TCP` | +| 通道监听端口 | `0`(随机)或未被占用的高端口 | +| STUN 服务器 | `turn.cloudflare.com:3478` | +| TCP 保活服务器 | `www.cloudflare.com:80` | +| 转发模式 | `Lucky 内置转发` | +| 目标地址 / 端口 | `192.168.1.20` / `443` | + +运行后页面会显示 `公网映射 IP:端口`。该端口可能变化,应通过状态文件或后续 DDNS 集成同步域名记录。 + +## 公网可达性探针 + +STUN 成功证明映射已建立,但不能单独证明外部入站数据包当前仍可到达。需要严格验证时,将探针部署在与家庭网络不同的公网 VPS: + +```sh +export STUNMAP_PROBE_TOKEN='use-a-long-random-token' +docker compose -f docker-compose.probe.yml up -d --build +``` + +在穿透规则的“定制模式”填写: + +```text +公网探针地址: http://:16900/probe +公网探针令牌: 与 STUNMAP_PROBE_TOKEN 相同 +公网探针间隔: 10 +``` + +探针不会访问目标内网服务。TCP 只验证公网端口的三次握手;UDP 发送 `SMHP:` 健康报文,映射引擎在转发前原样回显。因此即使 `targetHost:targetPort` 尚未启动,健康结果仍可正确反映 NAT 通道状态。探针连续两次失败时,规则会立即重建映射。 + +## 映射 Webhook + +在规则“定制模式”设置 Webhook 地址和方法。首次获取映射、或 STUN 地址/端口变化时会触发一次;每次正常心跳不会重复调用。Webhook 地址和 POST JSON 模板均可使用变量,写法支持 `{{STUN_PUBLIC_IP}}`、`${STUN_PUBLIC_IP}` 或 `{STUN_PUBLIC_IP}`。 + +- `POST`:可填写 JSON 模板;为空时发送默认 JSON,包含 `event`、规则信息、STUN 映射、本地通道和路由器映射状态。示例:`{"ip":"{{STUN_PUBLIC_IP}}","port":{{STUN_PUBLIC_PORT}},"address":"{{STUN_PUBLIC_ADDR}}"}`。 +- `GET`:URL 中可直接使用变量,并自动补充 `event`、`rule_id`、`rule_name`、`protocol`、`public_address`、`public_port`、`private_address`、`private_port`、`ip4p` 查询参数;URL 中已明确提供的同名参数不会被覆盖。 + +变量列表:`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`。JSON 模板中字符串变量应放在双引号内;端口变量可直接作为数值填写。 + +## 环境要求 + +1. 光猫拨号时,光猫 DMZ 应指向主路由。 +2. 优先在主路由运行。若运行在局域网设备,可启用 NAT-PMP 或 UPnP,或手动将通道监听端口转发到该设备。 +3. 放行设备防火墙上的通道监听端口。 +4. UDP 规则不支持 bind 直转,必须使用内置转发。 +5. TCP bind 直转须在路由器上将通道监听端口转发到目标设备/端口。 + +## 本地开发与验证 + +无需安装 npm 依赖: + +```sh +STUNMAP_ADMIN_PASSWORD=dev-password npm test +STUNMAP_ADMIN_PASSWORD=dev-password NATMAP_BIN=/path/to/natmap npm start +``` + +测试覆盖认证、规则校验、创建和删除。真实 NAT 映射测试必须在 NAT1 网络中进行,不能以本机回环结果代替。 diff --git a/docker-compose.probe.yml b/docker-compose.probe.yml new file mode 100644 index 0000000..c912f71 --- /dev/null +++ b/docker-compose.probe.yml @@ -0,0 +1,9 @@ +services: + stunmap-probe: + build: . + command: ["node", "probe-server.js"] + network_mode: host + environment: + PROBE_PORT: "16900" + STUNMAP_PROBE_TOKEN: "${STUNMAP_PROBE_TOKEN:?set a long random probe token}" + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ebd6986 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + stunmap: + build: . + container_name: stunmap + network_mode: host + cap_add: + - NET_ADMIN + environment: + PORT: "16888" + STUNMAP_ADMIN_USER: "${STUNMAP_ADMIN_USER:-admin}" + STUNMAP_ADMIN_PASSWORD: "${STUNMAP_ADMIN_PASSWORD:?set a strong admin password}" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..46fe053 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "stunmap-console", + "version": "1.0.0", + "private": true, + "description": "Rule-driven NAT1 STUN port mapping console", + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/patches/natmap-health.patch b/patches/natmap-health.patch new file mode 100644 index 0000000..176c337 --- /dev/null +++ b/patches/natmap-health.patch @@ -0,0 +1,113 @@ +diff --git a/src/hev-exec.c b/src/hev-exec.c +index 5cabb76..6f81560 100644 +--- a/src/hev-exec.c ++++ b/src/hev-exec.c +@@ -7,6 +7,7 @@ + + #include + #include ++#include + #include + #include + #include +@@ -30,6 +30,11 @@ static char iaddr[INET6_ADDRSTRLEN]; + static char oport[32]; + static char iport[32]; + static char ip4p[32]; ++static unsigned int last_maddr[4]; ++static unsigned int last_baddr[4]; ++static unsigned short last_mport; ++static unsigned short last_bport; ++static int last_family; + + static void + signal_handler (int signum) +@@ -73,6 +78,12 @@ hev_exec_run (int family, unsigned int maddr[4], unsigned short mport, + path = hev_conf_path (); + signal (SIGCHLD, signal_handler); + ++ last_family = family; ++ memcpy (last_maddr, maddr, sizeof (last_maddr)); ++ memcpy (last_baddr, baddr, sizeof (last_baddr)); ++ last_mport = mport; ++ last_bport = bport; ++ + q = (unsigned char *)maddr; + p = (unsigned char *)&mport; + +@@ -113,3 +124,11 @@ hev_exec_run (int family, unsigned int maddr[4], unsigned short mport, + hev_exec_fork_exec (&call); + #endif + } ++ ++void ++hev_exec_ping (void) ++{ ++ if (last_family) ++ hev_exec_run (last_family, last_maddr, last_mport, last_baddr, ++ last_bport); ++} +diff --git a/src/hev-exec.h b/src/hev-exec.h +index dcbaee4..ed62b58 100644 +--- a/src/hev-exec.h ++++ b/src/hev-exec.h +@@ -36,4 +36,6 @@ void hev_exec_init (void *stack); + void hev_exec_run (int family, unsigned int maddr[4], unsigned short mport, + unsigned int baddr[4], unsigned short bport); + ++void hev_exec_ping (void); ++ + #endif /* __HEV_EXEC_H__ */ +diff --git a/src/hev-stun.c b/src/hev-stun.c +index 2916b72..de8cc3b 100644 +--- a/src/hev-stun.c ++++ b/src/hev-stun.c +@@ -273,10 +273,10 @@ stun_bind (int fd, int mode, unsigned int baddr[4], int bport) + handler (); + + exec = cmp_addr (family, maddr, mport, baddr, bport); +- if (exec) { ++ if (exec) + hev_conf_mport (ntohs (mport)); +- hev_exec_run (family, maddr, mport, baddr, bport); +- } ++ ++ hev_exec_run (family, maddr, mport, baddr, bport); + + return 0; + } +diff --git a/src/hev-tnsk.c b/src/hev-tnsk.c +index a191c70..a4b4d7b 100644 +--- a/src/hev-tnsk.c ++++ b/src/hev-tnsk.c +@@ -19,6 +19,7 @@ + #include "hev-misc.h" + #include "hev-sock.h" + #include "hev-stun.h" ++#include "hev-exec.h" + #include "hev-tfwd.h" + #include "hev-xnsk.h" + +@@ -67,6 +68,7 @@ tnsk_keep_alive (int fd, const char *http) + } else if (res <= 0) { + return; + } else { ++ hev_exec_ping (); + misscnt = 0; + } + } +diff --git a/src/hev-ufwd.c b/src/hev-ufwd.c +index 9c5a226..a1d0303 100644 +--- a/src/hev-ufwd.c ++++ b/src/hev-ufwd.c +@@ -222,6 +222,11 @@ server_task_entry (void *data) + break; + } + ++ if ((len >= 5) && !memcmp (buf, "SMHP:", 5)) { ++ sendto (sfd, buf, len, 0, pa, alen); ++ continue; ++ } ++ + s = session_find (pa, alen); + if (!s) { diff --git a/probe-server.js b/probe-server.js new file mode 100644 index 0000000..0e8d58d --- /dev/null +++ b/probe-server.js @@ -0,0 +1,14 @@ +import crypto from 'node:crypto'; +import dgram from 'node:dgram'; +import http from 'node:http'; +import net from 'node:net'; + +const port = Number(process.env.PROBE_PORT || 16900); +const token = process.env.STUNMAP_PROBE_TOKEN || ''; +if (!token) throw new Error('必须设置 STUNMAP_PROBE_TOKEN'); + +function sameToken(value) { const input = Buffer.from(String(value || '')); const expected = Buffer.from(token); return input.length === expected.length && crypto.timingSafeEqual(input, expected); } +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); }); } +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}`)); diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..8e0acc3 --- /dev/null +++ b/public/app.css @@ -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; } } diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f9e600e --- /dev/null +++ b/public/app.js @@ -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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[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]) => `
${escapeHtml(key)}
${escapeHtml(text(val))}
`).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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..64c6831 --- /dev/null +++ b/public/index.html @@ -0,0 +1,59 @@ + + + + + + STUNMap Console + + + +
+
STUNMap NAT1 动态端口穿透
+
加载中
+
+
+

穿透规则

公网端口由 NAT 运行状态决定,不能指定或固定。

+ +
+
+ +
+

添加穿透规则

+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + diff --git a/scripts/notify.js b/scripts/notify.js new file mode 100755 index 0000000..ab33c4a --- /dev/null +++ b/scripts/notify.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const [publicAddress, publicPort, ip4p, privatePort, protocol, privateAddress] = process.argv.slice(2); +const id = process.env.STUNMAP_RULE_ID; +const directory = process.env.STUNMAP_STATE_DIR; +if (!id || !directory || !publicAddress || !publicPort || !privatePort) process.exit(2); +const target = path.join(directory, `${id}.json`); +let previous = {}; +try { previous = JSON.parse(fs.readFileSync(target, 'utf8')); } catch {} +const now = new Date().toISOString(); const changed = previous.publicAddress !== publicAddress || previous.publicPort !== Number(publicPort) || previous.privatePort !== Number(privatePort); +const state = { updatedAt: now, lastVerifiedAt: now, mappingChangedAt: changed ? now : previous.mappingChangedAt, publicAddress, publicPort: Number(publicPort), ip4p, privatePort: Number(privatePort), protocol, privateAddress }; +const temp = `${target}.${process.pid}.tmp`; +fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, target); diff --git a/server.js b/server.js new file mode 100644 index 0000000..9c4509e --- /dev/null +++ b/server.js @@ -0,0 +1,338 @@ +import crypto from 'node:crypto'; +import dgram from 'node:dgram'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = process.env.STUNMAP_DATA_DIR || path.join(root, 'data'); +const natmapBin = process.env.NATMAP_BIN || 'natmap'; +const port = Number(process.env.PORT || 16888); +const adminUser = process.env.STUNMAP_ADMIN_USER || 'admin'; +const adminPassword = process.env.STUNMAP_ADMIN_PASSWORD || 'change-me-before-deploying'; +const databasePath = path.join(dataDir, 'rules.json'); +const stateDir = path.join(dataDir, 'state'); +const logDir = path.join(dataDir, 'logs'); +const runners = new Map(); + +if (process.env.NODE_ENV === 'production' && adminPassword === 'change-me-before-deploying') throw new Error('生产环境必须设置 STUNMAP_ADMIN_PASSWORD'); + +function json(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +function basicAuth(req, res) { + const value = req.headers.authorization || ''; + const encoded = value.startsWith('Basic ') ? value.slice(6) : ''; + const expected = Buffer.from(`${adminUser}:${adminPassword}`).toString('base64'); + const sameLength = encoded.length === expected.length; + const valid = sameLength && crypto.timingSafeEqual(Buffer.from(encoded), Buffer.from(expected)); + if (valid) return true; + res.writeHead(401, { 'www-authenticate': 'Basic realm="STUNMap Console"' }); + res.end('Authentication required'); + return false; +} + +async function ensureStorage() { + await fsp.mkdir(stateDir, { recursive: true }); + await fsp.mkdir(logDir, { recursive: true }); + try { await fsp.access(databasePath); } catch { await saveDatabase({ rules: [] }); } +} + +async function loadDatabase() { + try { return JSON.parse(await fsp.readFile(databasePath, 'utf8')); } catch { return { rules: [] }; } +} + +async function saveDatabase(database) { + const temp = `${databasePath}.tmp`; + await fsp.writeFile(temp, `${JSON.stringify(database, null, 2)}\n`, { mode: 0o600 }); + await fsp.rename(temp, databasePath); +} + +function value(input, key, fallback = '') { return input[key] === undefined ? fallback : input[key]; } + +const webhookTemplatePattern = /\{\{([A-Z][A-Z0-9_]*)\}\}|\$\{([A-Z][A-Z0-9_]*)\}|\{([A-Z][A-Z0-9_]*)\}/g; + +function webhookTemplateValues(rule, state = {}, routerState = null) { + const publicAddress = String(state.publicAddress || ''); const publicPort = String(state.publicPort || ''); + const privateAddress = String(state.privateAddress || ''); const privatePort = String(state.privatePort || ''); + const routerAddress = String(routerState?.externalAddress || ''); const routerPort = String(routerState?.externalPort || ''); + return { + STUN_PUBLIC_IP: publicAddress, STUN_PUBLIC_PORT: publicPort, STUN_PUBLIC_ADDR: publicAddress && publicPort ? `${publicAddress}:${publicPort}` : publicAddress, + STUN_PRIVATE_IP: privateAddress, STUN_PRIVATE_PORT: privatePort, STUN_PRIVATE_ADDR: privateAddress && privatePort ? `${privateAddress}:${privatePort}` : privateAddress, + STUN_IP4P: String(state.ip4p || ''), STUN_PROTOCOL: String(rule.protocol || ''), STUN_RULE_ID: String(rule.id || ''), STUN_RULE_NAME: String(rule.name || ''), + STUN_ROUTER_IP: routerAddress, STUN_ROUTER_PORT: routerPort + }; +} + +function renderWebhookTemplate(template, variables) { + return String(template || '').replace(webhookTemplatePattern, (match, mustache, dollar, brace) => { + const name = mustache || dollar || brace; + if (!Object.hasOwn(variables, name)) throw new Error(`未知 Webhook 变量:${name}`); + return variables[name]; + }); +} + +function isInsideJsonString(source, index) { + let quoted = false; let escaped = false; + for (let offset = 0; offset < index; offset++) { + const character = source[offset]; + if (escaped) { escaped = false; continue; } + if (character === '\\') { escaped = true; continue; } + if (character === '"') quoted = !quoted; + } + return quoted; +} + +function renderWebhookJsonTemplate(template, variables) { + const source = String(template || ''); + return source.replace(webhookTemplatePattern, (match, mustache, dollar, brace, offset) => { + const name = mustache || dollar || brace; + if (!Object.hasOwn(variables, name)) throw new Error(`未知 Webhook 变量:${name}`); + const replacement = variables[name]; + return isInsideJsonString(source, offset) ? JSON.stringify(replacement).slice(1, -1) : replacement; + }); +} + +function validateRule(input, existing = {}) { + const rule = { + id: existing.id || crypto.randomUUID(), + name: String(value(input, 'name', existing.name)).trim(), + enabled: Boolean(value(input, 'enabled', existing.enabled ?? true)), + protocol: String(value(input, 'protocol', existing.protocol || 'tcp')).toLowerCase(), + bindPort: Number(value(input, 'bindPort', existing.bindPort ?? 0)), + stunServer: String(value(input, 'stunServer', existing.stunServer || 'turn.cloudflare.com:3478')).trim(), + keepaliveServer: String(value(input, 'keepaliveServer', existing.keepaliveServer || 'www.cloudflare.com:80')).trim(), + interval: Number(value(input, 'interval', existing.interval ?? 10)), + checkEvery: Number(value(input, 'checkEvery', existing.checkEvery ?? 1)), + externalProbeUrl: String(value(input, 'externalProbeUrl', existing.externalProbeUrl || '')).trim(), + externalProbeToken: String(value(input, 'externalProbeToken', existing.externalProbeToken || '')).trim(), + probeInterval: Number(value(input, 'probeInterval', existing.probeInterval ?? 10)), + webhookUrl: String(value(input, 'webhookUrl', existing.webhookUrl || '')).trim(), + webhookMethod: String(value(input, 'webhookMethod', existing.webhookMethod || 'post')).toLowerCase(), + webhookBody: String(value(input, 'webhookBody', existing.webhookBody || '')).trim(), + mode: String(value(input, 'mode', existing.mode || 'forward')).toLowerCase(), + targetHost: String(value(input, 'targetHost', existing.targetHost || '')).trim(), + targetPort: Number(value(input, 'targetPort', existing.targetPort ?? 0)), + routerMapping: String(value(input, 'routerMapping', existing.routerMapping || 'none')).toLowerCase(), + firewallAuto: Boolean(value(input, 'firewallAuto', existing.firewallAuto ?? false)), + firewallNote: String(value(input, 'firewallNote', existing.firewallNote || '')).trim() + }; + if (!rule.name || rule.name.length > 80) throw new Error('规则名称必须是 1-80 个字符'); + if (!['tcp', 'udp'].includes(rule.protocol)) throw new Error('穿透类型只能是 tcp 或 udp'); + if (!Number.isInteger(rule.bindPort) || rule.bindPort < 0 || rule.bindPort > 65535) throw new Error('监听端口必须在 0-65535'); + if (!rule.stunServer.includes(':')) throw new Error('STUN 服务器必须是 host:port'); + if (rule.protocol === 'tcp' && !rule.keepaliveServer.includes(':')) throw new Error('TCP 规则需要保活服务器 host:port'); + if (!Number.isInteger(rule.interval) || rule.interval < 5 || rule.interval > 3600) throw new Error('保活间隔必须在 5-3600 秒'); + if (!Number.isInteger(rule.checkEvery) || rule.checkEvery < 1 || rule.checkEvery > 3600) throw new Error('STUN 检测周期必须在 1-3600'); + if (!Number.isInteger(rule.probeInterval) || rule.probeInterval < 5 || rule.probeInterval > 3600) throw new Error('外部探针间隔必须在 5-3600 秒'); + if (Boolean(rule.externalProbeUrl) !== Boolean(rule.externalProbeToken)) throw new Error('外部探针地址和令牌必须同时填写'); + if (rule.externalProbeUrl && !/^https?:\/\//.test(rule.externalProbeUrl)) throw new Error('外部探针地址必须是 http:// 或 https:// URL'); + const webhookTestVariables = webhookTemplateValues(rule, { publicAddress: '203.0.113.10', publicPort: 34567, privateAddress: '192.168.1.10', privatePort: 45678, ip4p: 'test-ip4p' }, { externalAddress: '203.0.113.10', externalPort: 34567 }); + if (rule.webhookUrl) { + try { const target = new URL(renderWebhookTemplate(rule.webhookUrl, webhookTestVariables)); if (!['http:', 'https:'].includes(target.protocol)) throw new Error('协议无效'); } catch (error) { throw new Error(`Webhook 地址无效:${error.message}`); } + } + if (!['get', 'post'].includes(rule.webhookMethod)) throw new Error('Webhook 方法只能是 get 或 post'); + if (rule.webhookBody && rule.webhookMethod === 'post') { + try { JSON.parse(renderWebhookJsonTemplate(rule.webhookBody, webhookTestVariables)); } catch (error) { throw new Error(`Webhook POST JSON 模板无效:${error.message}`); } + } + if (!['forward', 'bind'].includes(rule.mode)) throw new Error('模式只能是 forward 或 bind'); + if (rule.protocol === 'udp' && rule.mode === 'bind') throw new Error('UDP 穿透不支持 bind 直转模式,必须使用内置转发'); + if (rule.mode === 'forward' && (!rule.targetHost || !Number.isInteger(rule.targetPort) || rule.targetPort < 1 || rule.targetPort > 65535)) throw new Error('内置转发必须填写目标地址和目标端口'); + if (!['none', 'nat-pmp', 'upnp'].includes(rule.routerMapping)) throw new Error('路由映射类型无效'); + return rule; +} + +async function readRuleState(id) { + try { return JSON.parse(await fsp.readFile(path.join(stateDir, `${id}.json`), 'utf8')); } catch { return null; } +} + +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 心跳' }; } + 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(); + fs.appendFileSync(path.join(logDir, `${this.rule.id}.log`), `${entry.at} ${level.toUpperCase()} ${entry.message}\n`); + } + args() { + const r = this.rule; + const args = ['-s', r.stunServer, '-b', String(r.bindPort), '-k', String(r.interval), '-c', String(r.checkEvery), '-e', path.join(root, 'scripts', 'notify.js')]; + if (r.protocol === 'udp') args.push('-u'); else args.push('-h', r.keepaliveServer); + if (r.mode === 'forward') args.push('-t', r.targetHost, '-p', String(r.targetPort)); + return args; + } + async start() { + this.desired = true; + if (this.child) return; + await fsp.rm(path.join(stateDir, `${this.rule.id}.json`), { force: true }); + this.lastProbeAt = 0; this.probeFailures = 0; this.health = { local: 'waiting', external: this.rule.externalProbeUrl ? 'waiting' : 'not-configured', verifiedAt: null, detail: '正在建立 STUN 映射' }; + this.log('info', `启动规则:${this.args().join(' ')}`); + try { + this.child = spawn(natmapBin, this.args(), { env: { ...process.env, STUNMAP_RULE_ID: this.rule.id, STUNMAP_STATE_DIR: stateDir }, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { this.log('error', error.message); throw error; } + this.child.stdout.on('data', (chunk) => this.log('info', chunk)); + this.child.stderr.on('data', (chunk) => this.log('error', chunk)); + this.child.on('error', (error) => this.log('error', `无法启动 NATMap:${error.message}`)); + this.child.on('exit', (code, signal) => { this.log(code === 0 ? 'info' : 'error', `NATMap 已退出(code=${code}, signal=${signal || 'none'})`); this.child = null; this.restarting = false; if (this.desired) setTimeout(() => this.start().catch((error) => this.log('error', error.message)), 1000).unref(); }); + } + removeFirewallRule() { + if (!this.firewallPort) return; + const result = spawnSync('iptables', ['-D', 'INPUT', '-p', this.rule.protocol, '--dport', String(this.firewallPort), '-j', 'ACCEPT']); + this.log(result.status === 0 ? 'info' : 'error', result.status === 0 ? `已移除防火墙放行端口 ${this.firewallPort}` : `移除防火墙规则失败:${result.error?.message || result.stderr?.toString() || 'iptables 返回错误'}`); + this.firewallPort = null; + } + ensureFirewallRule(port) { + if (!this.rule.firewallAuto || this.firewallPort === port) return; + this.removeFirewallRule(); + if (process.platform !== 'linux') { this.log('error', '防火墙自动放行仅支持 Linux iptables'); return; } + const check = spawnSync('iptables', ['-C', 'INPUT', '-p', this.rule.protocol, '--dport', String(port), '-j', 'ACCEPT']); + if (check.status === 0) { this.log('info', `检测到已有防火墙放行端口 ${port}`); return; } + const add = spawnSync('iptables', ['-I', 'INPUT', '1', '-p', this.rule.protocol, '--dport', String(port), '-j', 'ACCEPT']); + if (add.status === 0) { this.firewallPort = port; this.log('info', `已自动放行防火墙端口 ${port}`); } else this.log('error', `防火墙自动放行失败:${add.error?.message || add.stderr?.toString() || 'iptables 返回错误'}`); + } + stop() { + this.desired = false; + this.removeFirewallRule(); + if (this.child) { this.log('info', '停止规则'); this.child.kill('SIGTERM'); } + } + restart(reason) { + if (this.restarting || !this.child) return; + this.restarting = true; this.log('error', `映射健康检查失败,重建通道:${reason}`); this.child.kill('SIGTERM'); + } + observeState(state) { + if (!state) return; + const endpoint = `${state.publicAddress}:${state.publicPort}`; + if (this.lastEndpoint && endpoint !== this.lastEndpoint) this.log('info', `公网映射已变化:${this.lastEndpoint} -> ${endpoint}`); + this.lastEndpoint = endpoint; + const verifiedAt = Date.parse(state.lastVerifiedAt || state.updatedAt || ''); const expected = this.rule.protocol === 'udp' ? this.rule.interval * this.rule.checkEvery : this.rule.interval; + const age = Number.isFinite(verifiedAt) ? Date.now() - verifiedAt : Infinity; + if (age <= expected * 2000 + 5000) this.health = { ...this.health, local: 'healthy', verifiedAt: state.lastVerifiedAt || state.updatedAt, detail: 'STUN/保活通道正常' }; + else { this.health = { ...this.health, local: 'unhealthy', verifiedAt: state.lastVerifiedAt || state.updatedAt, detail: `超过 ${Math.ceil(age / 1000)} 秒未收到映射心跳` }; this.restart(this.health.detail); } + } + async probe(state) { + if (!this.rule.externalProbeUrl || this.health.local !== 'healthy' || Date.now() - this.lastProbeAt < this.rule.probeInterval * 1000) return; + this.lastProbeAt = Date.now(); + try { + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); + const response = await fetch(this.rule.externalProbeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ address: state.publicAddress, port: state.publicPort, protocol: this.rule.protocol, token: this.rule.externalProbeToken }) }); clearTimeout(timer); + const result = await response.json(); if (!response.ok || !result.reachable) throw new Error(result.error || `HTTP ${response.status}`); + this.probeFailures = 0; this.health = { ...this.health, external: 'healthy', externalVerifiedAt: new Date().toISOString() }; this.log('info', `外部探针验证通过:${state.publicAddress}:${state.publicPort}`); + } catch (error) { + this.probeFailures++; this.health = { ...this.health, external: 'unhealthy', externalVerifiedAt: new Date().toISOString(), externalDetail: error.message }; this.log('error', `外部探针失败(${this.probeFailures}/2):${error.message}`); if (this.probeFailures >= 2) this.restart('连续两次外部通道探测失败'); + } + } + async notifyWebhook(state) { + if (!this.rule.webhookUrl) return; + const key = `${state.mappingChangedAt || state.updatedAt}:${state.publicAddress}:${state.publicPort}`; + if (this.lastWebhookKey === key || Date.now() - this.lastWebhookAttempt < 5000) return; + this.lastWebhookAttempt = Date.now(); + const payload = { event: 'mapping.updated', occurredAt: new Date().toISOString(), rule: { id: this.rule.id, name: this.rule.name, protocol: this.rule.protocol, mode: this.rule.mode }, mapping: { stunAddress: state.publicAddress, stunPort: state.publicPort, privateAddress: state.privateAddress, privatePort: state.privatePort, ip4p: state.ip4p }, routerMapping: this.routerState }; + const variables = webhookTemplateValues(this.rule, state, this.routerState); + try { + let url = renderWebhookTemplate(this.rule.webhookUrl, variables); const options = { method: this.rule.webhookMethod.toUpperCase(), headers: {} }; + if (this.rule.webhookMethod === 'get') { const target = new URL(url); for (const [keyName, value] of Object.entries({ event: payload.event, rule_id: payload.rule.id, rule_name: payload.rule.name, protocol: payload.rule.protocol, public_address: payload.mapping.stunAddress, public_port: String(payload.mapping.stunPort), private_address: payload.mapping.privateAddress, private_port: String(payload.mapping.privatePort), ip4p: payload.mapping.ip4p || '' })) if (!target.searchParams.has(keyName)) target.searchParams.append(keyName, value); url = target.toString(); } + else { options.headers['content-type'] = 'application/json'; options.body = this.rule.webhookBody ? renderWebhookJsonTemplate(this.rule.webhookBody, variables) : JSON.stringify(payload); } + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); options.signal = controller.signal; + const response = await fetch(url, options); clearTimeout(timer); if (!response.ok) throw new Error(`HTTP ${response.status}`); + this.lastWebhookKey = key; this.log('info', `映射 Webhook 调用成功:${this.rule.webhookMethod.toUpperCase()} ${this.rule.webhookUrl}`); + } catch (error) { this.log('error', `映射 Webhook 调用失败:${error.message}`); } + } + snapshot(state) { return { ...this.rule, running: Boolean(this.child), state, routerState: this.routerState, health: this.health, logs: this.logs.slice(-80) }; } +} + +function gatewayAddress() { + if (process.platform !== 'linux') throw new Error('NAT-PMP 自动网关发现目前仅支持 Linux'); + const rows = fs.readFileSync('/proc/net/route', 'utf8').trim().split('\n').slice(1); + const row = rows.map((line) => line.trim().split(/\s+/)).find((cells) => cells[1] === '00000000' && (Number.parseInt(cells[3], 16) & 2)); + if (!row) throw new Error('未找到默认网关'); + const value = Number.parseInt(row[2], 16); + return [value & 255, (value >> 8) & 255, (value >> 16) & 255, (value >> 24) & 255].join('.'); +} + +function natPmpMap(protocol, privatePort, lifetime = 3600) { + return new Promise((resolve, reject) => { + let gateway; + try { gateway = gatewayAddress(); } catch (error) { reject(error); return; } + const socket = dgram.createSocket('udp4'); const request = Buffer.alloc(12); request[1] = protocol === 'tcp' ? 2 : 1; request.writeUInt16BE(privatePort, 4); request.writeUInt16BE(privatePort, 6); request.writeUInt32BE(lifetime, 8); + const timer = setTimeout(() => { socket.close(); reject(new Error('NAT-PMP 请求超时')); }, 3000); + socket.on('error', (error) => { clearTimeout(timer); socket.close(); reject(error); }); + socket.on('message', (message) => { clearTimeout(timer); socket.close(); if (message.length < 16 || message[2] !== 0 || message[3] !== 0) { reject(new Error(`NAT-PMP 返回错误码 ${message.readUInt16BE(2)}`)); return; } resolve({ gateway, externalPort: message.readUInt16BE(10), lifetime: message.readUInt32BE(12) }); }); + socket.send(request, 5351, gateway); + }); +} + +function soapEnvelope(action, fields, serviceType) { return `${Object.entries(fields).map(([key, val]) => `<${key}>${val}`).join('')}`; } + +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) { + 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'); + }); + 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 }; +} + +async function reconcileRouterMappings() { + for (const runner of runners.values()) { + 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, `STUNMap ${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}`); } + await runner.probe(state); await runner.notifyWebhook(state); + } +} + +async function refreshRunners() { + const { rules } = await loadDatabase(); const ids = new Set(rules.map((rule) => rule.id)); + for (const [id, runner] of runners) if (!ids.has(id)) { runner.stop(); runners.delete(id); } + for (const rule of rules) { let runner = runners.get(rule.id); if (!runner) { runner = new RuleRunner(rule); runners.set(rule.id, runner); } else runner.rule = rule; runner.desired = rule.enabled; if (rule.enabled && !runner.child) await runner.start(); if (!rule.enabled) runner.stop(); } +} + +async function parseBody(req) { + const chunks = []; for await (const chunk of req) { chunks.push(chunk); if (Buffer.concat(chunks).length > 1024 * 1024) throw new Error('请求过大'); } + try { return JSON.parse(Buffer.concat(chunks).toString() || '{}'); } catch { throw new Error('JSON 格式无效'); } +} + +function staticFile(res, pathname) { + const target = pathname === '/' ? '/index.html' : pathname; const file = path.resolve(root, 'public', `.${target}`); if (!file.startsWith(path.join(root, 'public'))) return json(res, 403, { error: 'forbidden' }); + fs.readFile(file, (error, data) => { if (error) return json(res, 404, { error: 'not found' }); const contentType = file.endsWith('.css') ? 'text/css' : file.endsWith('.js') ? 'application/javascript' : 'text/html'; res.writeHead(200, { 'content-type': `${contentType}; charset=utf-8` }); res.end(data); }); +} + +const server = http.createServer(async (req, res) => { + if (!basicAuth(req, res)) return; + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const match = url.pathname.match(/^\/api\/rules\/([0-9a-f-]+)(?:\/(start|stop))?$/); + try { + if (req.method === 'GET' && url.pathname === '/api/rules') { const { rules } = await loadDatabase(); const output = await Promise.all(rules.map(async (rule) => { const runner = runners.get(rule.id); return runner ? runner.snapshot(await readRuleState(rule.id)) : { ...rule, running: false, state: await readRuleState(rule.id), logs: [] }; })); return json(res, 200, { rules: output }); } + if (req.method === 'POST' && url.pathname === '/api/rules') { const body = await parseBody(req); const database = await loadDatabase(); const rule = validateRule(body); if (database.rules.some((item) => item.name === rule.name)) throw new Error('规则名称已存在'); database.rules.push(rule); await saveDatabase(database); await refreshRunners(); return json(res, 201, { rule }); } + if (match && req.method === 'PUT' && !match[2]) { const body = await parseBody(req); const database = await loadDatabase(); const index = database.rules.findIndex((rule) => rule.id === match[1]); if (index < 0) return json(res, 404, { error: '规则不存在' }); database.rules[index] = validateRule(body, database.rules[index]); await saveDatabase(database); const runner = runners.get(match[1]); runner?.stop(); await refreshRunners(); return json(res, 200, { rule: database.rules[index] }); } + if (match && req.method === 'DELETE' && !match[2]) { const database = await loadDatabase(); database.rules = database.rules.filter((rule) => rule.id !== match[1]); await saveDatabase(database); const runner = runners.get(match[1]); runner?.stop(); runners.delete(match[1]); return json(res, 204, {}); } + if (match && req.method === 'POST' && match[2]) { const runner = runners.get(match[1]); if (!runner) return json(res, 404, { error: '规则不存在' }); if (match[2] === 'start') await runner.start(); else runner.stop(); return json(res, 200, runner.snapshot(await readRuleState(match[1]))); } + staticFile(res, url.pathname); + } catch (error) { json(res, 400, { error: error.message }); } +}); + +await ensureStorage(); await refreshRunners(); setInterval(reconcileRouterMappings, 1000).unref(); +server.listen(port, '0.0.0.0', () => console.log(`STUNMap Console listening on :${port}`)); +process.on('SIGTERM', () => { for (const runner of runners.values()) runner.stop(); server.close(() => process.exit(0)); }); diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..f1d9683 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'stunmap-test-')); +const port = 17991; +const auth = `Basic ${Buffer.from('admin:test-password').toString('base64')}`; +let app; + +async function request(url, options = {}) { + return fetch(`http://127.0.0.1:${port}${url}`, { ...options, headers: { authorization: auth, 'content-type': 'application/json', ...(options.headers || {}) } }); +} + +before(async () => { + app = spawn(process.execPath, ['server.js'], { env: { ...process.env, PORT: String(port), STUNMAP_DATA_DIR: directory, NATMAP_BIN: '/does/not/exist', STUNMAP_ADMIN_PASSWORD: 'test-password' } }); + await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('server did not start')), 3000); app.stdout.on('data', () => { clearTimeout(timer); resolve(); }); app.on('error', reject); }); +}); +after(async () => { app.kill('SIGTERM'); await fs.rm(directory, { recursive: true, force: true }); }); + +test('requires HTTP basic authentication', async () => { + const response = await fetch(`http://127.0.0.1:${port}/api/rules`); + assert.equal(response.status, 401); +}); + +test('creates, validates, and stops a Lucky-style STUN rule', async () => { + const body = { name: 'web', enabled: false, protocol: 'tcp', bindPort: 0, stunServer: 'turn.cloudflare.com:3478', keepaliveServer: 'www.cloudflare.com:80', interval: 30, checkEvery: 10, mode: 'forward', targetHost: '192.168.1.20', targetPort: 443, routerMapping: 'upnp', firewallNote: 'WAN allowed' }; + const created = await request('/api/rules', { method: 'POST', body: JSON.stringify(body) }); + assert.equal(created.status, 201); const rule = (await created.json()).rule; + assert.equal(rule.protocol, 'tcp'); assert.equal(rule.mode, 'forward'); assert.equal(rule.routerMapping, 'upnp'); + const list = await request('/api/rules'); const rules = (await list.json()).rules; + assert.equal(rules.length, 1); assert.equal(rules[0].running, false); + const invalid = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'bad', protocol: 'udp', mode: 'forward', targetPort: 0 }) }); + assert.equal(invalid.status, 400); + const udpBind = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'udp-bind', protocol: 'udp', mode: 'bind', targetHost: '', targetPort: 0 }) }); + assert.equal(udpBind.status, 400); + const incompleteProbe = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'incomplete-probe', externalProbeUrl: 'https://probe.example.com/probe' }) }); + assert.equal(incompleteProbe.status, 400); + const webhook = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'webhook-template', webhookUrl: 'https://hooks.example.com/{{STUN_PUBLIC_ADDR}}', webhookMethod: 'post', webhookBody: '{"ip":"{{STUN_PUBLIC_IP}}","port":{{STUN_PUBLIC_PORT}},"name":"{{STUN_RULE_NAME}}"}' }) }); + assert.equal(webhook.status, 201); const webhookRule = (await webhook.json()).rule; + assert.equal(webhookRule.webhookBody, '{"ip":"{{STUN_PUBLIC_IP}}","port":{{STUN_PUBLIC_PORT}},"name":"{{STUN_RULE_NAME}}"}'); + const invalidWebhookBody = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'invalid-webhook-template', webhookUrl: 'https://hooks.example.com', webhookMethod: 'post', webhookBody: '{"ip":"{{MISSING_VARIABLE}}"}' }) }); + assert.equal(invalidWebhookBody.status, 400); + const removed = await request(`/api/rules/${rule.id}`, { method: 'DELETE' }); + assert.equal(removed.status, 204); +}); + +test('keeps the console available when an enabled mapping engine cannot start', async () => { + const body = { name: 'engine-error', enabled: true, protocol: 'udp', bindPort: 0, stunServer: 'turn.cloudflare.com:3478', keepaliveServer: '', interval: 30, checkEvery: 10, mode: 'forward', targetHost: '192.168.1.20', targetPort: 51820, routerMapping: 'none' }; + const created = await request('/api/rules', { method: 'POST', body: JSON.stringify(body) }); + assert.equal(created.status, 201); + await new Promise((resolve) => setTimeout(resolve, 40)); + const rules = (await (await request('/api/rules')).json()).rules; + const rule = rules.find((item) => item.name === 'engine-error'); + assert.ok(rule.logs.some((entry) => entry.level === 'error')); +}); diff --git a/test/engine.test.js b/test/engine.test.js new file mode 100644 index 0000000..aeee009 --- /dev/null +++ b/test/engine.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import net from 'node:net'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { after, test } from 'node:test'; + +const engine = process.env.NATMAP_BIN; +const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'stunmap-engine-')); +const children = []; +const sockets = []; +after(async () => { for (const child of children) child.kill('SIGTERM'); for (const socket of sockets) socket.close(); await fs.rm(directory, { recursive: true, force: true }); }); + +function bind(socket) { return new Promise((resolve) => socket.bind(0, '127.0.0.1', () => resolve(socket.address().port))); } +function listen(server) { return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); } +function waitFor(predicate, timeout = 5000) { return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const timer = setInterval(async () => { try { const value = await predicate(); if (value) { clearInterval(timer); resolve(value); } else if (Date.now() >= deadline) { clearInterval(timer); reject(new Error('timed out waiting for mapping')); } } catch (error) { clearInterval(timer); reject(error); } }, 40); }); } + +test('NATMap UDP forward mode maps a STUN channel port to the local target', { skip: !engine }, async () => { + const stun = dgram.createSocket('udp4'); sockets.push(stun); const stunPort = await bind(stun); + stun.on('message', (message, remote) => { + if (message.length < 20 || message.readUInt16BE(0) !== 1) return; + const response = Buffer.alloc(32); response.writeUInt16BE(0x0101, 0); response.writeUInt16BE(12, 2); message.copy(response, 4, 4, 20); response.writeUInt16BE(0x0020, 20); response.writeUInt16BE(8, 22); response[25] = 1; response.writeUInt16BE(remote.port ^ 0x2112, 26); Buffer.from([0x7f ^ 0x21, 0x00 ^ 0x12, 0x00 ^ 0xa4, 0x01 ^ 0x42]).copy(response, 28); stun.send(response, remote.port, remote.address); + }); + const echo = dgram.createSocket('udp4'); sockets.push(echo); const echoPort = await bind(echo); echo.on('message', (message, remote) => echo.send(message, remote.port, remote.address)); + const stateDir = path.join(directory, 'state'); await fs.mkdir(stateDir); const child = spawn(engine, ['-u', '-s', `127.0.0.1:${stunPort}`, '-b', '0', '-k', '1', '-c', '1', '-t', '127.0.0.1', '-p', String(echoPort), '-e', path.resolve('scripts/notify.js')], { env: { ...process.env, STUNMAP_RULE_ID: 'udp', STUNMAP_STATE_DIR: stateDir } }); children.push(child); + let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); + const state = await waitFor(async () => { try { return JSON.parse(await fs.readFile(path.join(stateDir, 'udp.json'), 'utf8')); } catch { return null; } }); + const client = dgram.createSocket('udp4'); sockets.push(client); const response = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`UDP forwarding timed out: ${stderr}`)), 3000); client.on('message', (message) => { clearTimeout(timer); resolve(message.toString()); }); client.send(Buffer.from('stunmap-ok'), state.privatePort, '127.0.0.1'); }); + assert.equal(response, 'stunmap-ok'); + const initialHeartbeat = state.lastVerifiedAt; await new Promise((resolve) => setTimeout(resolve, 1200)); const refreshed = JSON.parse(await fs.readFile(path.join(stateDir, 'udp.json'), 'utf8')); + assert.notEqual(refreshed.lastVerifiedAt, initialHeartbeat, 'each successful STUN check must refresh mapping health'); + echo.close(); sockets.splice(sockets.indexOf(echo), 1); + const probe = dgram.createSocket('udp4'); sockets.push(probe); const probeResponse = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('UDP channel health response timed out')), 3000); probe.on('message', (message) => { clearTimeout(timer); resolve(message.toString()); }); probe.send(Buffer.from('SMHP:target-is-irrelevant'), refreshed.privatePort, '127.0.0.1'); }); + assert.equal(probeResponse, 'SMHP:target-is-irrelevant'); +}); + +test('NATMap TCP forward mode keeps a channel and proxies TCP traffic', { skip: !engine }, async () => { + const keepalive = net.createServer((socket) => socket.on('data', () => socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n'))); const keepalivePort = await listen(keepalive); + const stun = net.createServer((socket) => socket.once('data', (message) => { if (message.length < 20 || message.readUInt16BE(0) !== 1) return socket.destroy(); const remote = socket.address(); const response = Buffer.alloc(32); response.writeUInt16BE(0x0101, 0); response.writeUInt16BE(12, 2); message.copy(response, 4, 4, 20); response.writeUInt16BE(0x0020, 20); response.writeUInt16BE(8, 22); response[25] = 1; response.writeUInt16BE(remote.port ^ 0x2112, 26); Buffer.from([0x7f ^ 0x21, 0x00 ^ 0x12, 0x00 ^ 0xa4, 0x01 ^ 0x42]).copy(response, 28); socket.end(response); })); const stunPort = await listen(stun); + const echo = net.createServer((socket) => socket.pipe(socket)); const echoPort = await listen(echo); after(() => { keepalive.close(); stun.close(); echo.close(); }); + const stateDir = path.join(directory, 'tcp-state'); await fs.mkdir(stateDir); const child = spawn(engine, ['-s', `127.0.0.1:${stunPort}`, '-h', `127.0.0.1:${keepalivePort}`, '-b', '0', '-k', '1', '-t', '127.0.0.1', '-p', String(echoPort), '-e', path.resolve('scripts/notify.js')], { env: { ...process.env, STUNMAP_RULE_ID: 'tcp', STUNMAP_STATE_DIR: stateDir } }); children.push(child); + let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); + const state = await waitFor(async () => { try { return JSON.parse(await fs.readFile(path.join(stateDir, 'tcp.json'), 'utf8')); } catch { return null; } }); + const reply = await new Promise((resolve, reject) => { const socket = net.connect(state.privatePort, '127.0.0.1'); const timer = setTimeout(() => { socket.destroy(); reject(new Error(`TCP forwarding timed out: ${stderr}`)); }, 3000); socket.on('connect', () => socket.write('stunmap-tcp')); socket.on('data', (chunk) => { clearTimeout(timer); socket.end(); resolve(chunk.toString()); }); socket.on('error', reject); }); + assert.equal(reply, 'stunmap-tcp'); +}); diff --git a/test/probe.test.js b/test/probe.test.js new file mode 100644 index 0000000..fb59fbf --- /dev/null +++ b/test/probe.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import net from 'node:net'; +import { after, before, test } from 'node:test'; +import { spawn } from 'node:child_process'; + +const port = 17992; const token = 'probe-test-token'; let probe; +function listen(server) { return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); } +async function request(body) { return fetch(`http://127.0.0.1:${port}/probe`, { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({ token, ...body }) }); } +before(async () => { probe=spawn(process.execPath,['probe-server.js'],{env:{...process.env,PROBE_PORT:String(port),STUNMAP_PROBE_TOKEN:token}}); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('probe did not start')),3000); probe.stdout.on('data',()=>{clearTimeout(timer);resolve();}); probe.on('error',reject);}); }); +after(() => probe.kill('SIGTERM')); + +test('public probe checks TCP channel handshake without application payload', async () => { + const server=net.createServer((socket)=>socket.destroy()); const targetPort=await listen(server); const response=await request({address:'127.0.0.1',port:targetPort,protocol:'tcp'}); assert.equal(response.status,200); assert.equal((await response.json()).reachable,true); server.close(); +}); +test('public probe checks UDP channel echo', async () => { + const server=dgram.createSocket('udp4'); const targetPort=await new Promise((resolve)=>server.bind(0,'127.0.0.1',()=>resolve(server.address().port))); server.on('message',(message,remote)=>server.send(message,remote.port,remote.address)); const response=await request({address:'127.0.0.1',port:targetPort,protocol:'udp'}); assert.equal(response.status,200); assert.equal((await response.json()).reachable,true); server.close(); +}); +test('public probe rejects invalid token', async () => { + const response=await fetch(`http://127.0.0.1:${port}/probe`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:'wrong',address:'127.0.0.1',port:1,protocol:'tcp'})}); assert.equal(response.status,403); +});