TEST-AGENT · 浏览器内 DTNS 客户端

一个页面,
就是一个 API。

这是首页的测试副本,也是一个跑在浏览器里的 DTNS 隧道客户端。它在当前标签页连接隧道服务端,注册一个 *.html.dtns.top 域名,把页面读取、滚动、高亮、点击等能力封装成 HTTP API,供 AI Agent 调用。

  • WSS浏览器内隧道
  • DTNS v2二进制协议
  • BearerToken 鉴权
tunnel://live WSS
已连接 心跳 30s 协议 v2
HOST 路由 200 OK · 18 ms
站点隧道
6条
透传流量类型
3类
固定帧头
13B
流式分块
256KB

TEST-AGENT · HTTP API

把页面功能,
变成可调用的 HTTP API。

test-agent.html 是首页的测试副本,也是一个运行在浏览器里的 DTNS 隧道客户端。它把自己注册成 *.html.dtns.top 的一个域名,把页面读取、滚动、高亮、点击等能力封装成 HTTP 接口,供 AI Agent 调用。

LIVE

页面必须保持打开:隧道由当前标签页维持,关闭后 API 立即离线。除状态与 OpenAPI 外,所有接口都要求 Authorization: Bearer <token>。

初始化中 DTNS —

正在加载协议与连接隧道…

API Base
等待注册…
隧道域名
—
访问令牌
••••
客户端 ID
—
协议 / 统计
DTNS v— · 0 请求 · 0 收 / 0 发

仅支持 *.html.dtns.top。修改后会断开并重新注册;当前入口为 HTTP,生产环境请在 nginx 启用 TLS。

调用测试
点击上方按钮,经由公网 API 调用本页。

Endpoints

BEARER AUTH

正在加载端点…

curl · 复制时使用真实 Token
等待隧道注册…
请求日志

等待请求…

协议帧0 收 / 0 发

等待帧…

01 — 概览

把「本地能跑」
变成「公网可用」。

大多数内网服务并不缺功能,缺的是一个稳定、可寻址的公网入口。dtns.http-proxy 用一条常驻 WebSocket 把内网节点连到公网边缘:请求按域名分发,响应沿原路返回——不需要公网 IP,也不需要动路由器。

没有公网 IP?

客户端主动向外建立 WSS 长连接。NAT、防火墙、动态 IP 之后的内网机器,也能被稳定访问。

出站连接即可

多个本地服务?

一个客户端可以注册多条隧道。Host 命中哪个域名,请求就进入哪个本地端口,互不打扰。

一条连接 · 多条隧道

实时协议受限?

WebSocket 升级、SSE 事件流、长连接下载都在隧道内原样传递,不会被缓冲成一次性响应。

HTTP · WS · SSE

02 — 架构

一次请求,穿过整条链路。

公网入口通常只做两件事:终止 TLS,把请求交给隧道代理。真正的路由发生在服务端按 Host 查表,转发发生在内网客户端按配置查表。

公网

浏览器

HTTPS 请求

边缘

接入层

nginx · TLS 终止

公网

隧道服务端

:18386 HTTP · :18385 WSS

内网

隧道客户端

主动外连 · 常驻内网

内网

本地服务

任意 HTTP 应用

没有隧道命中时,代理直接返回 404 No tunnel registered for domain: <host>,而不是把请求泄露到别处。
  1. 01

    接入

    接入层终止 TLS,把 HTTP 请求交给 :18386 代理。

  2. 02

    查表

    代理读取 Host,在已注册隧道中定位 clientId 与目标隧道。

  3. 03

    编码

    请求被编码为 DTNS 二进制帧,经 WSS 长连接送达内网。

  4. 04

    转发

    客户端按 config.js 转发到本地端口,响应沿原路返回。

  5. 05

    透传

    WebSocket 升级、双向帧与 SSE 事件流都走同一条通道。

03 — 能力

不只转发页面,
也转发「连接」。

很多穿透工具能打开一个网页,却在 WebSocket 或事件流上退化成轮询。dtns.http-proxy 把这些通道当作一等公民。

ROUTING

Host 即路由

每条隧道绑定一个域名。请求按 Host 精确匹配,多服务共用一个客户端也不会串线。

WEBSOCKET

WebSocket 全双工

升级握手同样走隧道;101 之后 text / binary 帧双向透传,帧类型用标志位无损保留。

STREAM

SSE 与流式响应

text/event-stream 增量透传。大响应按块下发,通过信用窗口控制节奏,不必整包缓冲。

PROTOCOL

DTNS 二进制协议

4 字节魔数 + 固定头 + 纯二进制负载,没有 Base64 膨胀;v2 用双字节状态码修复 4xx/5xx。

BACKPRESSURE

双向背压

浏览器侧与隧道侧各自设置高低水位(256 KB / 64 KB),慢消费者不会把内存吃满。

RESILIENCE

自愈连接

30 秒心跳、线性退避重连、健康校验失败后自动重连并全量重新注册隧道。

04 — 协议

为「原样转发」
设计的帧格式。

DTNS 协议不追求通用,只解决一件事:让 HTTP 与 WebSocket 在一条长连接里高效、无损地通过。

固定头 13 字节;LEN 只描述负载长度。请求体与响应体直接以 Buffer 传输,不做文本化。

register_tunnel 0x0001 http_request 0x0105 http_response 0x0004 websocket_data 0x000D stream_chunk 0x0021 heartbeat 0x0003 共 30+ 种

几个关键约束

  • 零 Base64。响应体、请求体、WebSocket 帧都是原始字节,避免 33% 的编码开销。
  • 多值头无损。头部按数组编码,多个 Set-Cookie 不会被逗号拼坏。
  • 帧类型显式。IS_BINARY 标志区分 WebSocket 的 text 与 binary 帧,不再靠 UTF-8 猜测。
  • 向后兼容。客户端读取服务端欢迎帧里的协议版本;旧服务端自动降级到 v1 或 JSON 分支。
FIX v1 → v2:状态码不再被截断

v1 的状态码只占 1 字节,401 会被截成 145,所有 4xx/5xx 接口在浏览器里都会变成非法状态。v2 改成 2 字节大端;遇到旧服务端时,客户端只把超范围响应切到 JSON 帧发送。

// protocol v1status = 401 & 0xff→ 145
// protocol v2writeUInt16BE(401)→ 401

05 — 上手

两台机器,三步跑通。

公网侧启动服务端,内网侧写一份 config.js 并启动客户端;域名解析与入口 TLS 交给 nginx。

公网服务器 · shell
cd server
npm install
node index.js

需要准备什么

一台有公网入口的服务器、一个指向它的域名,以及 nginx(或同类入口)把 80/443 转发到隧道服务端。

  • HTTP 代理监听 :18386
  • WebSocket 监听 :18385,路径 /tunnel
  • 入口层负责 TLS 与域名解析
server/config.js · 关键项
module.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 }
};

06 — 配置

两个 config.js,
覆盖全部行为。

客户端决定「暴露什么」,服务端决定「如何转发」。修改配置后重启对应进程即可生效。

客户端 client/config.js

内网侧
配置项示例 / 默认说明
server.wsServerUrlwss://res2.dtns.top服务端 WebSocket 地址
server.wsPath/tunnel握手路径,需与服务端一致
client.idprocess.env.CLIENT_ID客户端唯一标识,支持环境变量覆盖
client.heartbeat.interval30000心跳间隔(毫秒)
client.reconnect.interval5000重连基数,线性退避,上限 60 秒
client.performance.maxConcurrentRequests100并发请求上限
client.performance.requestTimeout30000请求超时(毫秒)
client.performance.maxPayloadSize100 * 1024 * 1024WebSocket 单帧上限(100 MB)
protocol.preferredbinarybinary / json / auto
tunnels[].domainapp.html.dtns.top公网域名,Host 精确匹配
tunnels[].local{ host, port, protocol }本地目标服务
tunnels[].rewriteHostfalse是否把 Host 改写为上游地址
tunnels[].rewriteOriginfalse是否把 Origin 改写为上游地址
tunnels[].rewriteLocationfalse把指向上游的绝对 Location 改写回公网地址
tunnels[].rewriteCookieDomainfalse把 Set-Cookie 的 Domain 改写为公网域名
tunnels[].auth{ enabled, username, password }访问上游时附加 Basic Auth

服务端 server/config.js

公网侧
配置项示例 / 默认说明
http.port / http.host18386 / 0.0.0.0HTTP 代理监听地址
http.domainBase.html.dtns.top文档中的域名基础部分
websocket.port / path18385 / /tunnel隧道长连接入口
clients.heartbeatTimeout60000心跳超时(毫秒)
clients.maxConnections1000客户端连接上限
streaming.enabledfalse流式通道总开关
streaming.chunkSize262144单块 256 KB
streaming.windowSize1048576每流信用窗口 1 MB
streaming.idleTimeoutMs60000空闲超时(非总时长)
limits.maxBodySize33554432请求体上限 32 MB,超出返回 413
limits.maxBodySizeByDomain{}按域名覆盖请求体上限

改配置前先备份:cp config.js config.js.bak-$(date +%Y%m%d-%H%M%S)。重启客户端后,新隧道才会注册。

07 — 发布

本站,就是一条隧道。

这个页面没有独立的托管平台:它由项目自己的客户端注册,由项目自己的服务端按 Host 路由。下面只展示本站的发布链路和多隧道配置示意。

当前页面 SELF-HOSTED
公网域名
dtns-proxy.html.dtns.top
隧道客户端
内网常驻 · WSS 出站
本地目标
127.0.0.1:3016
服务端
:18386 HTTP · :18385 WSS

页面是静态的,服务是零依赖的;隧道只负责把请求送进来。

当前实例的公网入口只配置了 HTTP。生产环境应在 nginx 侧为隧道域名配置证书,并强制跳转 HTTPS。

配置已加载 等待刷新…
6条已配置隧道

同一个客户端可以同时暴露多个本地服务。请求按 Host 精确匹配,互不串线。

app.html.dtns.top127.0.0.1:3000
ws.html.dtns.top127.0.0.1:10310
console.html.dtns.top127.0.0.1:7681

多隧道路由示意(非本实例真实配置)

出于安全考虑,本站不公开其他服务的域名与端口。GET /api/tunnels 只返回隧道数量。

08 — 结构

代码不多,边界清楚。

客户端只负责「连上去、查表、转发」,服务端只负责「接住、查表、送回去」。协议层被单独抽出来,两端共用同一套编解码逻辑。

dtns.http-proxy/
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 里的域名拼写、客户端是否在线,以及客户端日志中的「隧道注册成功」。

为什么有的 4xx / 5xx 状态码显示不正常?

这是 v1 协议的单字节状态码限制:401 & 0xff = 145。使用 v2 协议即可无损传输;客户端连接旧服务端时会自动把超范围响应切到 JSON 帧。

WebSocket 连接建立后马上断开?

先确认本地服务本身能完成升级,再检查路径、子协议与 Origin。若本地框架校验 Host / Origin,可以在对应隧道上开启 rewriteHost / rewriteOrigin。

上传大文件被 413 拒绝?

服务端默认请求体上限是 32 MB。可以在 limits.maxBodySize 调整全局值,或用 maxBodySizeByDomain 为特定域名放宽。

客户端断线后需要手动重连吗?

不需要。客户端内置心跳与线性退避重连(上限 60 秒);健康校验发现隧道未注册时,会主动重连并全量重新注册。

可以给隧道加访问控制吗?

建议在入口层(nginx basic auth、IP 白名单、OIDC 等)做访问控制。隧道配置里的 auth 是访问上游时附加的 Basic Auth,不是公网鉴权。