没有公网 IP?
客户端主动向外建立 WSS 长连接。NAT、防火墙、动态 IP 之后的内网机器,也能被稳定访问。
出站连接即可内网穿透 · WebSocket 隧道 · 协议 v2
dtns.http-proxy 是一套基于 WebSocket 的轻量内网穿透系统。公网侧按 Host 精确路由,内网侧只需要一份 config.js;HTTP、WebSocket、SSE 原样透传,不改变你的应用。
01 — 概览
大多数内网服务并不缺功能,缺的是一个稳定、可寻址的公网入口。dtns.http-proxy 用一条常驻 WebSocket 把内网节点连到公网边缘:请求按域名分发,响应沿原路返回——不需要公网 IP,也不需要动路由器。
客户端主动向外建立 WSS 长连接。NAT、防火墙、动态 IP 之后的内网机器,也能被稳定访问。
出站连接即可一个客户端可以注册多条隧道。Host 命中哪个域名,请求就进入哪个本地端口,互不打扰。
一条连接 · 多条隧道WebSocket 升级、SSE 事件流、长连接下载都在隧道内原样传递,不会被缓冲成一次性响应。
HTTP · WS · SSE02 — 架构
公网入口通常只做两件事:终止 TLS,把请求交给隧道代理。真正的路由发生在服务端按 Host 查表,转发发生在内网客户端按配置查表。
HTTPS 请求
nginx · TLS 终止
:18386 HTTP · :18385 WSS
主动外连 · 常驻内网
任意 HTTP 应用
404 No tunnel registered for domain: <host>,而不是把请求泄露到别处。
接入层终止 TLS,把 HTTP 请求交给 :18386 代理。
代理读取 Host,在已注册隧道中定位 clientId 与目标隧道。
请求被编码为 DTNS 二进制帧,经 WSS 长连接送达内网。
客户端按 config.js 转发到本地端口,响应沿原路返回。
WebSocket 升级、双向帧与 SSE 事件流都走同一条通道。
03 — 能力
很多穿透工具能打开一个网页,却在 WebSocket 或事件流上退化成轮询。dtns.http-proxy 把这些通道当作一等公民。
每条隧道绑定一个域名。请求按 Host 精确匹配,多服务共用一个客户端也不会串线。
升级握手同样走隧道;101 之后 text / binary 帧双向透传,帧类型用标志位无损保留。
text/event-stream 增量透传。大响应按块下发,通过信用窗口控制节奏,不必整包缓冲。
4 字节魔数 + 固定头 + 纯二进制负载,没有 Base64 膨胀;v2 用双字节状态码修复 4xx/5xx。
浏览器侧与隧道侧各自设置高低水位(256 KB / 64 KB),慢消费者不会把内存吃满。
30 秒心跳、线性退避重连、健康校验失败后自动重连并全量重新注册隧道。
04 — 协议
DTNS 协议不追求通用,只解决一件事:让 HTTP 与 WebSocket 在一条长连接里高效、无损地通过。
固定头 13 字节;LEN 只描述负载长度。请求体与响应体直接以 Buffer 传输,不做文本化。
Set-Cookie 不会被逗号拼坏。IS_BINARY 标志区分 WebSocket 的 text 与 binary 帧,不再靠 UTF-8 猜测。v1 的状态码只占 1 字节,401 会被截成 145,所有 4xx/5xx 接口在浏览器里都会变成非法状态。v2 改成 2 字节大端;遇到旧服务端时,客户端只把超范围响应切到 JSON 帧发送。
05 — 上手
公网侧启动服务端,内网侧写一份 config.js 并启动客户端;域名解析与入口 TLS 交给 nginx。
cd server
npm install
node index.js
一台有公网入口的服务器、一个指向它的域名,以及 nginx(或同类入口)把 80/443 转发到隧道服务端。
:18386:18385,路径 /tunnelmodule.exports = {
http: { port: 18386, host: '0.0.0.0', domainBase: '.html.dtns.top' },
websocket: { port: 18385, path: '/tunnel' },
protocol: { preferred: 'binary', binaryEnabled: true, version: 1 },
clients: { heartbeatTimeout: 60000, maxConnections: 1000 },
streaming: { enabled: false, chunkSize: 262144, windowSize: 1048576 },
limits: { maxBodySize: 32 * 1024 * 1024 }
};
cd client
npm install
node index.js
读取 config.js,用唯一的 client.id 连接服务端,并把每条启用的隧道注册上去。
module.exports = {
server: { wsServerUrl: 'wss://res2.dtns.top', wsPath: '/tunnel' },
client: {
id: process.env.CLIENT_ID || 'my-node-1',
heartbeat: { interval: 30000 },
reconnect: { enabled: true, interval: 5000, maxAttempts: 100000000 }
},
protocol: { preferred: 'binary', binaryEnabled: true, version: 1 },
tunnels: [{
id: 'web',
domain: 'app.html.dtns.top',
local: { host: '127.0.0.1', port: 3000, protocol: 'http' },
enabled: true,
description: '主 Web 应用'
}]
};
curl -I http://app.html.dtns.top/
# HTTP/2 200
# server: nginx
# content-type: text/html
如果返回 404 No tunnel registered for domain,说明域名没有注册到任何客户端;检查 client.id、tunnels[].domain 与客户端日志。
test/demo.jscd test
npm install
node demo.js
# ws://127.0.0.1:30100/ → 为它配置一条隧道即可验证全双工
06 — 配置
客户端决定「暴露什么」,服务端决定「如何转发」。修改配置后重启对应进程即可生效。
client/config.js| 配置项 | 示例 / 默认 | 说明 |
|---|---|---|
server.wsServerUrl | wss://res2.dtns.top | 服务端 WebSocket 地址 |
server.wsPath | /tunnel | 握手路径,需与服务端一致 |
client.id | process.env.CLIENT_ID | 客户端唯一标识,支持环境变量覆盖 |
client.heartbeat.interval | 30000 | 心跳间隔(毫秒) |
client.reconnect.interval | 5000 | 重连基数,线性退避,上限 60 秒 |
client.performance.maxConcurrentRequests | 100 | 并发请求上限 |
client.performance.requestTimeout | 30000 | 请求超时(毫秒) |
client.performance.maxPayloadSize | 100 * 1024 * 1024 | WebSocket 单帧上限(100 MB) |
protocol.preferred | binary | binary / json / auto |
tunnels[].domain | app.html.dtns.top | 公网域名,Host 精确匹配 |
tunnels[].local | { host, port, protocol } | 本地目标服务 |
tunnels[].rewriteHost | false | 是否把 Host 改写为上游地址 |
tunnels[].rewriteOrigin | false | 是否把 Origin 改写为上游地址 |
tunnels[].rewriteLocation | false | 把指向上游的绝对 Location 改写回公网地址 |
tunnels[].rewriteCookieDomain | false | 把 Set-Cookie 的 Domain 改写为公网域名 |
tunnels[].auth | { enabled, username, password } | 访问上游时附加 Basic Auth |
server/config.js| 配置项 | 示例 / 默认 | 说明 |
|---|---|---|
http.port / http.host | 18386 / 0.0.0.0 | HTTP 代理监听地址 |
http.domainBase | .html.dtns.top | 文档中的域名基础部分 |
websocket.port / path | 18385 / /tunnel | 隧道长连接入口 |
clients.heartbeatTimeout | 60000 | 心跳超时(毫秒) |
clients.maxConnections | 1000 | 客户端连接上限 |
streaming.enabled | false | 流式通道总开关 |
streaming.chunkSize | 262144 | 单块 256 KB |
streaming.windowSize | 1048576 | 每流信用窗口 1 MB |
streaming.idleTimeoutMs | 60000 | 空闲超时(非总时长) |
limits.maxBodySize | 33554432 | 请求体上限 32 MB,超出返回 413 |
limits.maxBodySizeByDomain | {} | 按域名覆盖请求体上限 |
改配置前先备份:cp config.js config.js.bak-$(date +%Y%m%d-%H%M%S)。重启客户端后,新隧道才会注册。
07 — 发布
这个页面没有独立的托管平台:它由项目自己的客户端注册,由项目自己的服务端按 Host 路由。下面只展示本站的发布链路和多隧道配置示意。
dtns-proxy.html.dtns.top127.0.0.1:3016:18386 HTTP · :18385 WSS页面是静态的,服务是零依赖的;隧道只负责把请求送进来。
当前实例的公网入口只配置了 HTTP。生产环境应在 nginx 侧为隧道域名配置证书,并强制跳转 HTTPS。
同一个客户端可以同时暴露多个本地服务。请求按 Host 精确匹配,互不串线。
app.html.dtns.top127.0.0.1:3000ws.html.dtns.top127.0.0.1:10310console.html.dtns.top127.0.0.1:7681多隧道路由示意(非本实例真实配置)
出于安全考虑,本站不公开其他服务的域名与端口。GET /api/tunnels 只返回隧道数量。
08 — 结构
客户端只负责「连上去、查表、转发」,服务端只负责「接住、查表、送回去」。协议层被单独抽出来,两端共用同一套编解码逻辑。
dtns.http-proxy/
├── client/ # 内网侧
│ ├── index.js # 连接 / 注册 / 心跳 / 重连
│ ├── tunnel-manager.js # 隧道表与请求分发
│ ├── http-proxy.js # 本地转发 / WS / SSE / 流式
│ ├── protocol.js # DTNS 二进制编解码
│ ├── config.js # 客户端配置
│ └── daemon.js # 可选的健康守护
├── server/ # 公网侧
│ ├── index.js # 启动入口
│ ├── websocket-server.js # 连接与隧道注册
│ ├── proxy-server.js # 域名路由 / 升级 / 背压
│ ├── protocol.js # 与服务端一致的协议实现
│ └── config.js # 服务端配置
├── test/ # WebSocket 回显测试
└── website/ # 本站(静态页面 + 零依赖服务)
现有部署用 forever 托管客户端;也可以交给 systemd、supervisor 或容器编排。
客户端会打印隧道注册、转发失败与心跳统计;服务端按请求 ID 记录路由与状态码。
只暴露需要暴露的服务,本地服务尽量绑定 127.0.0.1;入口层负责 TLS 与访问控制,请求体上限兜底大包。
你现在看到的页面,正是通过本项目发布的:dtns-proxy.html.dtns.top → 内网 127.0.0.1:3016,配置写在 client/config.js 里。
09 — 问答
404 No tunnel registered for domain?说明请求已经到了隧道服务端,但 Host 没有匹配到任何已注册隧道。检查 client/config.js 里的域名拼写、客户端是否在线,以及客户端日志中的「隧道注册成功」。
这是 v1 协议的单字节状态码限制:401 & 0xff = 145。使用 v2 协议即可无损传输;客户端连接旧服务端时会自动把超范围响应切到 JSON 帧。
先确认本地服务本身能完成升级,再检查路径、子协议与 Origin。若本地框架校验 Host / Origin,可以在对应隧道上开启 rewriteHost / rewriteOrigin。
服务端默认请求体上限是 32 MB。可以在 limits.maxBodySize 调整全局值,或用 maxBodySizeByDomain 为特定域名放宽。
不需要。客户端内置心跳与线性退避重连(上限 60 秒);健康校验发现隧道未注册时,会主动重连并全量重新注册。
建议在入口层(nginx basic auth、IP 白名单、OIDC 等)做访问控制。隧道配置里的 auth 是访问上游时附加的 Basic Auth,不是公网鉴权。