Initial STUN NAT mapping console
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
data/
|
||||
node_modules/
|
||||
.git/
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
data/
|
||||
node_modules/
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -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"]
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -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://<VPS IPv4>: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 网络中进行,不能以本机回环结果代替。
|
||||
9
docker-compose.probe.yml
Normal file
9
docker-compose.probe.yml
Normal file
@@ -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
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -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
|
||||
11
package.json
Normal file
11
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
113
patches/natmap-health.patch
Normal file
113
patches/natmap-health.patch
Normal file
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
+#include <string.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
@@ -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) {
|
||||
14
probe-server.js
Normal file
14
probe-server.js
Normal file
@@ -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}`));
|
||||
12
public/app.css
Normal file
12
public/app.css
Normal 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
14
public/app.js
Normal 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) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[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
59
public/index.html
Normal 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>
|
||||
15
scripts/notify.js
Executable file
15
scripts/notify.js
Executable file
@@ -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);
|
||||
338
server.js
Normal file
338
server.js
Normal file
@@ -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 `<?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 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(/<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 };
|
||||
}
|
||||
|
||||
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)); });
|
||||
58
test/api.test.js
Normal file
58
test/api.test.js
Normal file
@@ -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'));
|
||||
});
|
||||
48
test/engine.test.js
Normal file
48
test/engine.test.js
Normal file
@@ -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');
|
||||
});
|
||||
21
test/probe.test.js
Normal file
21
test/probe.test.js
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user