没有公网 IP?
客户端主动向外建立 WSS 长连接。NAT、防火墙、动态 IP 之后的内网机器,也能被稳定访问。
出站连接即可TRADE-AGENT · 问答换 TOKEN · 按次计费
页面用户发布问题;AI Agent 先用 GET /questions 读题、再用 POST /answer 作答,换到一枚 tk_… Token;之后带上 Authorization: Bearer <Token> 调用页面 API,每调一次按单价扣额度。没 Token 返回 401,额度耗尽返回 402,重新答题即可续领。
TUNNEL · RUNTIME STATUS
本页在浏览器里运行 DTNS 隧道客户端,API 就挂在这条隧道上。只有一套 API:GET /questions 与 POST /answer 公开,其余接口必须携带 Authorization: Bearer <Token>。页面必须保持打开,关闭后 API 立即离线。
正在加载协议与连接隧道…
启动中…等待请求…
等待帧…
TRADE-AGENT · QUESTION → TOKEN → BILLING
本页的 API 不直接对外开放。页面用户只设置问题与额度,不设置标准答案;API 调用方只要提交这些问题的答案(内容不限,由大语言模型或人工填写),就能领取 Token 与计费额度。之后每次调用都从额度中扣费,额度耗尽即拒绝服务。
尚未发布问题。
页面用户发布问题后,这里会出现作答表单。答案可以由大语言模型或人工生成,内容不限、不做对错校验;全部填写为非空内容并提交后,立即发放 Token 与额度。
等待页面用户发布问题…
—0—0等待 Token…
每条记录对应一次通过校验的作答:谁回答了你发布的哪些问题、答案是什么、以及因此领到的 Token。
给 AI Agent 用的入口:先 GET /questions 读题,再 POST /answer 提交答案换取 Token,之后带 Authorization: Bearer <Token> 调用受保护接口。每个接口都在响应里写明下一步。
正在启动…点击上方按钮,直接请求这条 Agent API;需要 Token 的接口会自动带上已领取的 Token。
等待 Agent API 注册…
把下面这段整段复制,粘贴到任意 AI Agent(ChatGPT / Claude / Cursor / 自建 Agent 均可)里发送即可。API Base 已按当前页面自动填好;Agent 会自己读题、作答、换 Token,再调用受保护接口并汇报结果。
正在等待 Agent API 注册,Prompt 会自动填入 API Base…
提示:Agent 需要能访问公网。若它不能发 HTTP 请求(例如纯聊天模型),可以先让它输出每一步要发的 curl,你手工执行后再把响应贴回去。
SDK · HTTP + WEBSOCKET
sdk.js 把 DTNS 的隧道连接、注册、心跳、断线重连、HTTP 帧与 WebSocket 数据帧全部封装起来。你可以同时声明 HTTP 路由和 WebSocket 路由:
<script src="/assets/sdk.js"></script>
<script>
(async () => {
const app = DTNSAgent.app({
domain: 'agent-xxxx.html.dtns.top'
});
app.get('/hello', (req, res) => {
res.json({ ok: true, query: req.query });
});
app.post('/echo', (req, res) => {
res.status(201).json({ body: req.body });
});
await app.listen();
})();
</script>
const DTNSAgent = require('./sdk.js');
const WebSocket = require('ws');
(async () => {
const app = DTNSAgent.app({
domain: 'agent-xxxx.html.dtns.top',
WebSocket
});
app.get('/hello', (req, res) => {
res.json({ ok: true });
});
await app.listen();
})();
app.use((req, res, next) => {
res.set('x-demo', '1');
next();
});
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id, query: req.query });
});
app.ws('/live/:room', (ws, req) => {
ws.send('welcome ' + req.params.room);
ws.on('message', (data, isBinary) => {
// data: string (text) / Uint8Array (binary)
ws.send(data, isBinary);
});
ws.on('close', (code, reason) => {
console.log('closed', code, reason);
});
});
await app.listen();
// ws://agent-xxxx.html.dtns.top/live/room1
Token 模式只需传 token;HTTP 用 Authorization: Bearer ...,浏览器 WebSocket 无法自定义头,用 ws://.../live?token=...。Node 需要传入 WebSocket,浏览器使用全局 WebSocket。
DTNSAgent.app(options)创建隧道应用options.domain公网域名options.token可选 Bearer Tokenoptions.WebSocketNode 传 require('ws')options.wsUrl默认 wss://res2.dtns.top/tunnelget/post/put/patch/delete/head/options/all绑定 HTTP 路由app.ws(path, handler)绑定 WebSocket 路由app.use([path], middleware)中间件app.listen() / close()连接注册 / 断开app.status() / on(event, fn)状态 / 事件app.routes / app.apiBase路由表 / 公网 URLreq.method / path / query / params请求元信息req.headers / req.get(name)请求头req.body / req.rawBodyJSON / 文本 / 原始字节res.status(code) / set(name, value)状态码 / 响应头res.json(obj) / send(value)JSON / 自动类型res.text(str) / end(body)文本 / 结束响应res.redirect(url, status)重定向ws.send(data[, binary])文本 / 二进制发送ws.sendText(str) / sendBinary(bytes)显式类型发送ws.on('message', (data, isBinary) => ...)接收双工消息ws.on('close', (code, reason) => ...)连接关闭ws.close(code, reason)主动关闭ws.readyState / ws.protocol状态 / 子协议Demo 用 sdk.js 新建一条隧道,下面的按钮直接请求它的 Express 路由;API Base 是本次随机域名。
—
等待 Demo 隧道注册…
curl -s "$DEMO/hello"
等待请求…
Demo app 还注册了 app.ws('/live')。连接后发送消息,服务端会原样回显;文本与二进制都支持。
—
等待 Demo 在线…
连接 wss、注册域名、心跳、断线重连、二进制帧编解码都在 sdk.js 内,无需接触 DTNS 协议。
不传 token 即 URL-only;传了之后所有请求需要 Authorization: Bearer ...,OPTIONS 预检不受影响。
handler 返回对象自动 JSON;抛错自动 500;支持 async/await、路径参数和中间件。
app.ws 支持文本/二进制消息、close 与子协议;浏览器用 ws://,Token 模式通过 ?token= 传入。
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,不是公网鉴权。