TRADE-AGENT · 问答换 TOKEN · 按次计费

回答问题,
换取可调用的 API。

页面用户发布问题;AI Agent 先用 GET /questions 读题、再用 POST /answer 作答,换到一枚 tk_… Token;之后带上 Authorization: Bearer <Token> 调用页面 API,每调一次按单价扣额度。没 Token 返回 401,额度耗尽返回 402,重新答题即可续领。

  • 公开/questions · /answer
  • 鉴权Bearer Token
  • 计费每次 1 额度
  • 失败401 / 402
tunnel://live WSS
已连接 心跳 30s 协议 v2
HOST 路由 200 OK · 18 ms
已发布题目
0题
单次调用单价
1额度
每次发放额度
20额度
累计调用
0次

TUNNEL · RUNTIME STATUS

隧道状态与
API Base。

本页在浏览器里运行 DTNS 隧道客户端,API 就挂在这条隧道上。只有一套 API:GET /questions 与 POST /answer 公开,其余接口必须携带 Authorization: Bearer <Token>。页面必须保持打开,关闭后 API 立即离线。

LIVE

/questions、/answer、/status 公开;其余端点都要求 Authorization: Bearer <Token>,缺失或错误一律返回 401。Token 只在本页显示,请勿公开。页面必须保持打开,关闭后 API 立即离线。

初始化中 DTNS —

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

API Base
启动中…
API Token
未领取 · 请先在下方回答问题
协议 / 统计
DTNS v— · 0 请求 · 0 收 / 0 发
请求日志

等待请求…

协议帧0 收 / 0 发

等待帧…

TRADE-AGENT · QUESTION → TOKEN → BILLING

回答问题,
换取可计费的调用额度。

本页的 API 不直接对外开放。页面用户只设置问题与额度,不设置标准答案;API 调用方只要提交这些问题的答案(内容不限,由大语言模型或人工填写),就能领取 Token 与计费额度。之后每次调用都从额度中扣费,额度耗尽即拒绝服务。

AGENT API

本页唯一的一套 API Base:启动中…
公开:GET /questions 读题、POST /answer 交答案换 Token、GET /status、GET /; 需 Token:/page、/sections、/text、/highlight、/scroll、/click、/ping,每次扣一次额度。

RULE

无 Token → 401;有 Token 但无额度 → 402;有 Token 且额度充足 → 200 并扣费。Token 不随页面发放,只能通过 POST /answer(或在第 2 步作答)领取。

1 · 设置问题与计费

OWNER

尚未发布问题。

2 · API 方回答问题

CLIENT

页面用户发布问题后,这里会出现作答表单。答案可以由大语言模型或人工生成,内容不限、不做对错校验;全部填写为非空内容并提交后,立即发放 Token 与额度。

等待页面用户发布问题…

3 · Token 与计费额度

CREDITS
Token
—
剩余额度
0
单价
—
已调用
0
等待 Token…
  • 还没有调用记录。

4 · 回应记录

AUDIT

每条记录对应一次通过校验的作答:谁回答了你发布的哪些问题、答案是什么、以及因此领到的 Token。

  • 还没有人回答。

5 · Agent 接入

API

给 AI Agent 用的入口:先 GET /questions 读题,再 POST /answer 提交答案换取 Token,之后带 Authorization: Bearer <Token> 调用受保护接口。每个接口都在响应里写明下一步。

API Base
正在启动…
状态
等待注册…
  • 正在启动 Agent API…
调用测试
点击上方按钮,直接请求这条 Agent API;需要 Token 的接口会自动带上已领取的 Token。
curl · 三步走
等待 Agent API 注册…

6 · 给 AI Agent 的测试 Prompt

PROMPT

把下面这段整段复制,粘贴到任意 AI Agent(ChatGPT / Claude / Cursor / 自建 Agent 均可)里发送即可。API Base 已按当前页面自动填好;Agent 会自己读题、作答、换 Token,再调用受保护接口并汇报结果。

agent prompt · 可直接粘贴
正在等待 Agent API 注册,Prompt 会自动填入 API Base…

提示:Agent 需要能访问公网。若它不能发 HTTP 请求(例如纯聊天模型),可以先让它输出每一步要发的 curl,你手工执行后再把响应贴回去。

SDK · HTTP + WEBSOCKET

HTTP 路由 + WebSocket 双工。

sdk.js 把 DTNS 的隧道连接、注册、心跳、断线重连、HTTP 帧与 WebSocket 数据帧全部封装起来。你可以同时声明 HTTP 路由和 WebSocket 路由:

LIVE DEMO

本页是 agent.html 的复制版本:上方 Agent 控制台是原始 URL-only API;下方 Demo 用 sdk.js 新建第二条隧道,实时演示 app.get、app.post 与 app.ws。

创建与路由

BROWSER / NODE
browser · script
<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>
node · CommonJS
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();
})();
middleware / params
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 });
});
websocket · app.ws
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。

API

APP / REQ / RES
DTNSAgent
DTNSAgent.app(options)创建隧道应用
options.domain公网域名
options.token可选 Bearer Token
options.WebSocketNode 传 require('ws')
options.wsUrl默认 wss://res2.dtns.top/tunnel
app
get/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路由表 / 公网 URL
req
req.method / path / query / params请求元信息
req.headers / req.get(name)请求头
req.body / req.rawBodyJSON / 文本 / 原始字节
res
res.status(code) / set(name, value)状态码 / 响应头
res.json(obj) / send(value)JSON / 自动类型
res.text(str) / end(body)文本 / 结束响应
res.redirect(url, status)重定向
ws
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状态 / 子协议

Live Demo

正在启动 Demo…

Demo 用 sdk.js 新建一条隧道,下面的按钮直接请求它的 Express 路由;API Base 是本次随机域名。

Demo API —
等待 Demo 隧道注册…
curl -s "$DEMO/hello"
Demo 请求日志 0 请求

等待请求…

WebSocket 双工

等待 Demo 在线…

Demo app 还注册了 app.ws('/live')。连接后发送消息,服务端会原样回显;文本与二进制都支持。

WS URL —
等待 Demo 在线…

协议已封装

连接 wss、注册域名、心跳、断线重连、二进制帧编解码都在 sdk.js 内,无需接触 DTNS 协议。

URL-only / Token

不传 token 即 URL-only;传了之后所有请求需要 Authorization: Bearer ...,OPTIONS 预检不受影响。

返回与错误

handler 返回对象自动 JSON;抛错自动 500;支持 async/await、路径参数和中间件。

WebSocket 双工

app.ws 支持文本/二进制消息、close 与子协议;浏览器用 ws://,Token 模式通过 ?token= 传入。

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,不是公网鉴权。