tunnel-use 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +59 -12
  2. package/package.json +2 -2
  3. package/README.zh-CN.md +0 -1101
package/README.md CHANGED
@@ -1,23 +1,70 @@
1
1
  # tunnel
2
2
 
3
- A local HTTP proxy and mock server for client tests. A phone, a simulator or
4
- anything else points its HTTP proxy at this machine, the way it would point at
5
- Charles; tunnel decrypts the hosts you name, shows every call as it happens,
6
- keeps what the real server said, and answers later calls from those recordings
7
- — or from literal JSON, or from a JavaScript handler you wrote a minute ago.
8
-
9
- tunnel is made for a coding agent to drive. Every command takes `--json`,
10
- `tunnel mcp` offers the same operations to an MCP client such as Claude Code or
11
- Codex as typed tools, and `tunnel agents` prints the guide an agent reads first.
12
- [Driving tunnel from a coding agent](#driving-tunnel-from-a-coding-agent) has
13
- the rest.
3
+ An AI-first local HTTP proxy and mock server for client tests: a coding agent
4
+ such as Claude Code or Codex drives it over MCP or the CLI, and test code embeds
5
+ it to set each case's answers and check what the app called.
14
6
 
15
- 📖 [中文文档](README.zh-CN.md)
7
+ A phone, a simulator or anything else points its HTTP proxy at this machine,
8
+ the way it would point at Charles; tunnel decrypts the hosts you name, shows
9
+ every call as it happens, keeps what the real server said, and answers later
10
+ calls from those recordings — or from literal JSON, or from a JavaScript
11
+ handler you wrote a minute ago.
12
+
13
+ 📖 [中文文档](README_zh-CN.md)
16
14
 
17
15
  [![Node](https://img.shields.io/badge/node-22.18%2B-brightgreen.svg)](https://nodejs.org)
18
16
  [![npm](https://img.shields.io/npm/v/tunnel-use.svg)](https://www.npmjs.com/package/tunnel-use)
19
17
  [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](LICENSE)
20
18
 
19
+ ## Why tunnel
20
+
21
+ ### AI-first: a coding agent drives it
22
+
23
+ tunnel is built to be run by an agent, not only by a person at the panel.
24
+ `tunnel mcp` gives an MCP client sixteen typed tools cut to what an agent
25
+ actually does, every command takes `--json` and exits with a code an agent can
26
+ branch on, and `tunnel agents` prints the guide an agent reads first — out of
27
+ the installed package, so it describes the version in hand.
28
+
29
+ ```bash
30
+ claude mcp add tunnel -- tunnel mcp # Claude Code
31
+ codex mcp add tunnel -- tunnel mcp # Codex
32
+ ```
33
+
34
+ From there a request in plain words is enough — "answer the feed with an empty
35
+ list, then show me what the app called" — and the agent sets the rule, waits
36
+ for the calls and reads them back.
37
+ [Driving tunnel from a coding agent](#driving-tunnel-from-a-coding-agent) has
38
+ the rest.
39
+
40
+ ### Embedded in your test code
41
+
42
+ Each test case sets its own answers and checks what the app called, from inside
43
+ the test. A Node or TypeScript suite starts tunnel in its own process, with
44
+ nothing to start first and no port to agree on:
45
+
46
+ ```ts
47
+ import assert from 'node:assert/strict'
48
+ import { startTunnel } from 'tunnel-use'
49
+
50
+ const tunnel = await startTunnel({ proxyHost: '127.0.0.1' })
51
+ try {
52
+ await tunnel.setRule({ key: 'GET:api.example.test/v5/feed', mode: 'json', json_response: { cards: [] } })
53
+ // ... drive the app through tunnel.proxyUrl; here one request stands in for it
54
+ const answer = await tunnel.check('GET', 'http://api.example.test/v5/feed')
55
+ assert.equal(answer.status, 200)
56
+ const wait = await tunnel.waitForRequest({ key: 'GET:api.example.test/v5/feed', timeout: 5 })
57
+ assert.equal(wait.timed_out, false)
58
+ } finally {
59
+ await tunnel.stop()
60
+ }
61
+ ```
62
+
63
+ A Swift or Kotlin test case running inside the app does the same over the admin
64
+ API. [Using tunnel from TypeScript tests](#using-tunnel-from-typescript-tests)
65
+ and [Calling tunnel from a test case](#calling-tunnel-from-a-test-case) have
66
+ the rest.
67
+
21
68
  ## What it is
22
69
 
23
70
  One process listens on two ports:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tunnel-use",
3
- "version": "3.0.0",
4
- "description": "Local HTTP mock server and HTTPS device proxy, driven by a coding agent over MCP or from a CLI",
3
+ "version": "3.0.1",
4
+ "description": "AI-first local mock server and HTTPS proxy for client tests: a coding agent drives it over MCP or the CLI, and test code embeds it as a library",
5
5
  "keywords": ["mcp", "agent", "mock", "proxy", "https", "record", "replay", "testing", "http", "api", "tunnel"],
6
6
  "homepage": "https://github.com/e2e-next/tunnel",
7
7
  "bugs": "https://github.com/e2e-next/tunnel/issues",
package/README.zh-CN.md DELETED
@@ -1,1101 +0,0 @@
1
- # tunnel
2
-
3
- 给客户端测试用的本地 HTTP 代理与 mock server。手机、模拟器或任何别的客户端,
4
- 像指向 Charles 一样把 HTTP 代理指到这台机器;tunnel 解密你点名的 host,
5
- 实时显示每一次调用,把真实服务端的回答留下来,之后就用这些录制作答 ——
6
- 也可以答字面量 JSON,或者一分钟前刚写好的 JavaScript handler。
7
-
8
- tunnel 是按 AI 编码助手来驱动设计的:每条命令都能加 `--json` 输出 JSON,
9
- `tunnel mcp` 把同样的操作做成带类型的工具,交给 Claude Code、Codex 这类 MCP
10
- 客户端,`tunnel agents` 打印 agent 该先读的那份文档。详见
11
- [用 AI 编码助手来驱动](#用-ai-编码助手来驱动)。
12
-
13
- 📖 [English](README.md)
14
-
15
- [![Node](https://img.shields.io/badge/node-22.18%2B-brightgreen.svg)](https://nodejs.org)
16
- [![npm](https://img.shields.io/npm/v/tunnel-use.svg)](https://www.npmjs.com/package/tunnel-use)
17
- [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](LICENSE)
18
-
19
- ## 这是什么
20
-
21
- 一个进程监听两个端口:
22
-
23
- | 端口 | 绑定 | 承载什么 |
24
- | --- | --- | --- |
25
- | 代理口 8890 | 所有网卡 | 设备发来的流量;不带任何管理能力 |
26
- | 管理口 8000 | `127.0.0.1` | 面板、JSON 管理 API、CA 下载 |
27
-
28
- 到达代理口的每个请求都由同一个内核作答。请求被完整记下来,按你配置的规则匹配
29
- —— key 的形状是 `METHOD:host/path` —— 再按那条规则的模式作答。没有自己规则的
30
- 端点走全局模式:`passthrough` 把它送到客户端原本要去的服务器,`replay` 用录制
31
- 作答、没有录制就拒绝,这样自动化跑起来就不会偷偷打到真实后端。
32
-
33
- 规则可以在客户端一直跑着的时候改:下一个请求就生效,不用重启,也不用重新打包。
34
-
35
- 管理 API 是契约,`tunnel` 只是它的一层薄封装。整轮跑动共用的准备 —— 启动服务、
36
- 导入录制、切换模式和 session、安装 CA —— 在宿主机上用 CLI 做。跑在设备里的测试
37
- 用例做同样的事情要走 HTTP,因为它没法在你的机器上起一个命令。两边下面都会讲到。
38
-
39
- ## 安装
40
-
41
- ```bash
42
- npm install -g tunnel-use # 包名;命令叫 `tunnel`
43
- tunnel --version
44
- ```
45
-
46
- 需要 Node 22.18 或更新版本。从源码 checkout 的话,先 `npm ci`,然后 `make run` 直接
47
- 从源码跑起来;或者 `npm run build && npm link`,把 checkout 里的 `tunnel` 放上
48
- PATH —— 改了 `src/` 之后要重新 build。
49
-
50
- ## 快速开始
51
-
52
- ```bash
53
- tunnel serve
54
- ```
55
-
56
- 它会打印要填进设备的地址和 CA 指纹(它拥有的三类目录会进启动日志,
57
- `tunnel ls` 也会打印):
58
-
59
- ```
60
- tunnel 3.0.0
61
- panel: http://127.0.0.1:8000/
62
- proxy: 0.0.0.0:8890
63
- set the device's HTTP proxy to 192.168.1.23 port 8890
64
- upstream: direct
65
- CA: http://ca.tunnel/ once the device is proxied, or http://192.168.1.23:8890/ca.pem
66
- 8B:6F:...:1C
67
- ```
68
-
69
- 把设备指过来(见下),打开 <http://127.0.0.1:8000/> 看请求到达,然后在另一个
70
- 终端里:
71
-
72
- ```bash
73
- tunnel requests # 客户端实际调了什么
74
- tunnel ssl add api.example.com # 解密这个 host 的 HTTPS
75
- tunnel set GET api.example.com/v5/feed -d '{"cards": []}' # 用这个作答
76
- tunnel check GET https://api.example.com/v5/feed # 经代理口真打一发
77
- ```
78
-
79
- ## 让设备走它
80
-
81
- `tunnel serve` 会打印这台机器的各个局域网地址;下面的 `PORT` 是代理端口,
82
- 没改过就是 8890。
83
-
84
- | 客户端 | 怎么接进来 |
85
- | --- | --- |
86
- | iPhone / iPad | 设置 > 无线局域网 > 当前网络 > 配置代理 > 手动,填这台机器的局域网地址和 `PORT` |
87
- | Android 手机 | WLAN > 当前网络 > 高级 > 代理 > 手动,同样的地址和端口 |
88
- | iOS 模拟器 | 跟随 Mac 自己的代理设置:系统设置 > 网络 > 当前网卡 > 详细信息 > 代理 > 网页代理和安全网页代理,填 `127.0.0.1:PORT`。用完记得关掉 —— 这是整台机器的设置。tunnel 认得出这个代理就是它自己,所以它往外发的流量直连出去,不会再绕回自己,横幅上写的是 `upstream: direct (system proxy points at tunnel itself, ignored)` |
89
- | Android 模拟器 | `adb shell settings put global http_proxy 10.0.2.2:PORT`,清除用 `adb shell settings put global http_proxy :0`。`10.0.2.2` 是模拟器眼里的宿主机 |
90
-
91
- 同一个 Wi-Fi 下的设备从私有网段(10/8、172.16/12、192.168/16、fc00::/7)和本机
92
- 过来都会被接受;config 里的 `allow_clients` 可以把范围收窄成一串地址或 CIDR。
93
- 其余的连接直接关掉并记一条日志。从网络上过来、却指向本机回环地址的请求会被
94
- 403 拒绝 —— 管理口就在那儿,挨着它的代理口不是绕过去的路。
95
-
96
- 要从设备访问**管理口**,请做端口转发而不是把它暴露出去:Android 上用
97
- `adb reverse tcp:8000 tcp:8000`,模拟器里用 `10.0.2.2:8000`,iOS 模拟器共享 Mac
98
- 的回环,直接 `127.0.0.1:8000`。
99
-
100
- ### CA 证书
101
-
102
- tunnel 用自己的一份 CA 给它提供的 TLS 证书签名,CA 以 `ca.pem` 和 `ca-key.pem`
103
- 存在 config 目录里。它在第一次用到时生成,之后就不动了:设备信过一次就一直信,
104
- 清空 state 目录也带不走它。
105
-
106
- ```bash
107
- tunnel ca info # 指纹、有效期和文件路径
108
- tunnel ca export -o tunnel-ca.pem # 要装到设备上的那份证书
109
- tunnel ca install --ios-simulator # xcrun simctl keychain booted add-root-cert
110
- tunnel ca install --android # adb push,然后在设置里安装
111
- tunnel ca install --macos # security add-trusted-cert,会要密码
112
- tunnel ca reset # 重新来过;所有设备都得重装
113
- ```
114
-
115
- 已经走 tunnel 代理的设备可以打开 <http://ca.tunnel/>,那是一页各平台的安装说明;
116
- 还没设代理的设备可以直接取 `http://<这台机器>:8890/ca.pem`,
117
- <http://127.0.0.1:8000/ca.pem> 给的是同一个文件。`ca` 系列命令在服务跑着时从
118
- 运行中的服务读证书,没跑时从 config 目录读,所以设备的准备工作不挑先后顺序。
119
-
120
- 装上不等于信任。下面这些注意事项和 Charles 的完全一样:
121
-
122
- - **iOS**:描述文件装好之后,还要在 设置 > 通用 > 关于本机 > 证书信任设置 里把
123
- 这张证书打开。模拟器不需要这一步 —— `tunnel ca install --ios-simulator` 就够了。
124
- - **Android 7 及以上**:应用默认不信任用户安装的 CA。测试构建要在
125
- `network_security_config` 里用 `<certificates src="user"/>`(或一个
126
- `<debug-overrides>` 块)显式打开。
127
- - **所有平台**:做了证书固定的应用,不管 CA 怎么装都会拒绝 tunnel 的证书,
128
- 所以你要测的那个构建必须把 pinning 关掉。
129
-
130
- ### 哪些 host 会被解密
131
-
132
- 光装上 CA 还不够:tunnel 只解密你列出来的 host,和 Charles 的 SSL Proxying
133
- Locations 一样。一开始列表是空的,所以每条 HTTPS 连接都原样按字节转发,直到你
134
- 另有交代 —— 做了证书固定的第三方 SDK 照常能用,日志也清爽。
135
-
136
- ```bash
137
- tunnel ssl ls # 两个列表,以及开关状态
138
- tunnel ssl add api.example.com '*.example.com:443'
139
- tunnel ssl rm api.example.com
140
- tunnel ssl exclude add cdn.example.com # 这个永远不解密
141
- tunnel ssl on / tunnel ssl off # 管着两个列表的总开关
142
- ```
143
-
144
- 条目形如 `host[:port]`;host 里 `*` 是通配,省略端口表示任意端口。exclude 优先
145
- 于 include。改动在下一条连接上生效;已经建立的隧道不受影响。没被解密的 host
146
- 仍会作为一条 `connect` 记录出现在日志里,带上双向传输的字节数,面板上可以一键
147
- 把它加进列表。
148
-
149
- ### 上游代理
150
-
151
- tunnel 自己不作答的流量 —— passthrough,以及发往不解密的 host 的 HTTPS 连接 ——
152
- 离开这台机器的方式和 Mac 自己的流量一样:
153
-
154
- 1. 先看环境变量:`https_proxy`、`http_proxy`、`all_proxy` 和 `no_proxy`,每个都先读
155
- 小写的,小写的没设才读大写的。前三个里只要设了一个,就只看环境变量:`https://`
156
- 的调用和不解密的隧道走 `https_proxy`,`http://` 的调用走 `http_proxy`,两者都
157
- 退回到 `all_proxy`。
158
- 2. 没有的话,在 macOS 上读系统代理:「网页代理」管 `http://`,「安全网页代理」管
159
- `https://` 和隧道,例外列表和「不包括简单主机名」都照办。读到的设置过了五秒就
160
- 重读,所以打开或关掉一个代理 App,五秒内就跟上,不用重启。
161
- 3. 再没有就直连服务器。
162
-
163
- 回环地址、私有网段、link-local 地址和 `*.local` 名字一律直连;代理就是 tunnel 自己
164
- 的时候 —— 上面 iOS 模拟器那种设法 —— 也全部直连,所以 tunnel 永远不会把自己的流量
165
- 交回给自己。`no_proxy` 不管代理是从哪儿来的都管用,`NO_PROXY=*` 让所有流量直连。
166
-
167
- 路线就是一行字:横幅和 `tunnel status` 把它打成 `upstream:`,
168
- `GET /__admin__/health` 把它报成 `upstream`;日志(后台运行时是 `tunnel.log`)在
169
- 第一次读到时和每次变化时各记一行 `Upstream route: …`:
170
-
171
- ```
172
- upstream: direct
173
- upstream: via 127.0.0.1:3128 (system proxy)
174
- upstream: via http://proxy:3128 (HTTPS_PROXY)
175
- upstream: via http://proxy:3128 (http_proxy) for http; direct for https
176
- upstream: direct (system proxy points at tunnel itself, ignored)
177
- upstream: direct (ALL_PROXY=socks5://127.0.0.1:7890 ignored: only http:// proxies are supported)
178
- ```
179
-
180
- 在代理那里失败的调用是一个写明代理的 502,比如
181
- `Could not reach https://api.example.com through the proxy 127.0.0.1:3128 (system proxy): …`;
182
- 隧道在那里失败的话,客户端拿到的只是一个空的 502,代理写在那条日志里。
183
-
184
- 只会说 `http://` 的代理:`socks5://` 或 `https://` 的代理会被忽略、那部分流量直连,
185
- `upstream:` 那一行会写明;PAC 文件、系统的 SOCKS 代理、要从钥匙串取密码的系统代理
186
- 都不支持。要用别的代理,就在启动 tunnel 时设环境变量。CLI 自己调管理口的请求直连;
187
- shell 里开着 Node 的 `NODE_USE_ENV_PROXY=1` 的话,把 `127.0.0.1` 加进 `NO_PROXY`,
188
- 它们才仍然直连。
189
-
190
- ## 面板
191
-
192
- <http://127.0.0.1:8000/> 是一个页面,一个预先构建好的 React 应用,不从互联网
193
- 加载任何东西:`make panel` 写出 `assets/panel/panel.js`、`assets/panel/panel.css`
194
- 和 `assets/panel/panel-InterVariable-latin.woff2`,三个文件都提交进仓库,所以安装
195
- 时什么都不用构建,断网也能把面板打开。
196
-
197
- 侧栏放着五个页面,以及所有属于这一轮运行、而不属于某一页的东西:顶上是 session
198
- 切换器;**This run** 一节是默认模式(**Live** 或 **Replay**,即 `tunnel mode`,
199
- 回放时带未命中的条数)和录制按钮(`tunnel record`);脚部是设备要用的代理地址、
200
- 版本号,以及一个小点 —— 事件流断了它就变灰,并显示 `reconnecting…`。⌘K 能到每
201
- 一页、两个开关、每个 session,以及按 key 找到每条规则。
202
-
203
- **Traffic** 是实时到达的日志,走 Server-Sent Events:调用一行一条列成一张表,
204
- 点开的那条在旁边的详情栏里 —— 概览、谁答的,然后是请求与响应的头和正文(窗口窄
205
- 到放不下两列时,详情改成对话框)。可以按 host、路径、query、状态或答它的规则
206
- 搜索;可以只看某个 host、某个方法、某段状态码,只看 **Unmatched** 或
207
- **Encrypted** 的行;勾上 **Search bodies**,搜索就交给服务端 —— 正文在那边。
208
- **Pause** 冻住列表,而这一轮运行在底下照常继续,**Clear** 清空日志;每行都有一
209
- 个菜单:Open、**Mock this**、**Copy as curl**、**Copy key**、只看这个 host、跳
210
- 到答它的那条规则 —— 没被解密的隧道还多一条 **Decrypt this host**。
211
-
212
- **Rules** 列出每条规则,带模式、状态码、命中次数和一个开关;点开就是这条规则的
213
- 每个字段:key —— 新建时分三段填,存过之后只读,因为换一个端点就是另一条规则
214
- —— 然后是模式,只展开所选模式的字段(JSON 正文、JavaScript handler、session 加
215
- 策略,或者要转发到的 target),再是状态码、延迟、额外的响应头、要注入的故障,
216
- 以及一条留给下一个人的备注。New rule、Duplicate、Delete、Reset state 就是这里的
217
- `tunnel set`、`tunnel rm` 和 `tunnel reset`;整套规则进出仍走 `tunnel apply`
218
- 与 `tunnel export`。
219
-
220
- **Recordings** 列出磁盘上的 session、各自有多少条录制、这一轮用的是哪一个、上次
221
- 录进去是多久以前;点进一个 session 是它录下的调用,再点一条就看全貌。把一个
222
- session 设为当前、往里录或者删掉,都在这里 —— 即 `tunnel session`、
223
- `tunnel record` 和 `tunnel recordings rm` —— 导出与导入仍在命令行。
224
-
225
- **Connect a device** 带一台设备接进来,三步,每一步的勾都是从日志里挣来的,不是
226
- 页面自己打的:要设的代理地址,每个网卡一条、各带复制按钮;证书 —— `/ca.pem`
227
- 下载、设备上打开的 `http://ca.tunnel/`、用来核对的指纹,以及 iOS 和 Android 的
228
- 步骤;要解密什么 —— SSL proxying 开关、include 与 exclude 两个列表,以及最近走
229
- 隧道的 host,每个都带一个 **Decrypt** 按钮(`tunnel ssl add`)。下面列出跟代理
230
- 说过话的每个客户端,以及它最后一次出现的时间。
231
-
232
- **Settings** 是设一次就不动的东西:默认模式与 replay marker、日志保留多少条、是
233
- 否每轮运行都自动录制、日志和录制里隐去哪些 header、允许的客户端(这里只读,要改
234
- 用 `tunnel config`),以及这台服务把文件放在哪儿,连证书的指纹和到期那天。
235
-
236
- ## 规则
237
-
238
- ### key
239
-
240
- 规则的 key 是 `METHOD:[host[:port]]/path`。**query 和 headers 不参与匹配** ——
241
- 要按它们分支就写脚本。`*` 在 host 和 path 里都是通配,在 path 里还能跨 `/`:
242
-
243
- ```bash
244
- tunnel set GET '/api/user/*' -d '{"id": 1}' # 任意 host,/api/user/42 及其下
245
- tunnel set GET 'api.example.com/v5/feed' -d '{"v": 1}' # 只有这一个 host
246
- tunnel set GET '*.example.com/api/*' -d '{"v": 1}' # 一族 host
247
- ```
248
-
249
- 不带 host 的 key 对所有 host 都作答。最具体的规则胜出:精确路径胜过通配路径,
250
- 精确 host 胜过通配 host、通配 host 胜过不限 host;两个同样具体的模式之间,
251
- 更长的那个胜出。`tunnel requests` 会在方括号里显示每次调用是被哪条规则答的。
252
-
253
- ### 模式
254
-
255
- **json** —— 原样返回 `json_response`,对象或数组都可以。
256
-
257
- ```bash
258
- tunnel set GET /v5/feed -d '{"code": 0, "cards": []}'
259
- tunnel set GET /v5/feed -d @feed.json --status 201
260
- ```
261
-
262
- **script** —— 每个请求跑一次 `handler(ctx)`:
263
-
264
- ```js
265
- function handler(ctx) {
266
- const page = Number(ctx.query.page ?? 1) // ctx.query 的值都是字符串
267
- const rec = ctx.recorded() // replay 本来会答的那一条
268
- if (rec === null) return { cards: [], page }
269
- rec.body.cards = rec.body.cards.slice(0, 2) // 真实回答,只改一个字段
270
- return rec // status、headers、body 一起带回去
271
- }
272
- ```
273
-
274
- ```bash
275
- tunnel set GET /v5/feed --script feed.js
276
- ```
277
-
278
- 脚本是 JavaScript;TypeScript 也收,跑之前先擦掉类型。`ctx` 带着 `method`、`path`、
279
- `query`、`headers`(名字小写)、`body`(一个 `Buffer`)、`json`(解析好的 body,
280
- 或者 `null`)、`scheme`、`host`、`port`、`url`、`client`,一个跨请求存活的 `state`
281
- 对象,以及两个读录制的方法 `ctx.recorded({ query, key, session })` 和
282
- `ctx.recordings({ key, session })` —— 它们读的是传进去的那个 session,没传就读本
283
- 规则钉死的那个,再没有就读当前 session;两个都当场作答、不返回 promise,而且只在
284
- 它们自己的请求正在答复时能用。返回对象或数组就是 JSON,字符串是文本,`Buffer` 是
285
- 字节,返回 `Recorded` 就是那条录制本身,要连状态码和 headers 一起定就返回
286
- `ctx.reply(body, status, headers)`。handler 可以是 async 的,`require('node:…')`
287
- 能用到 Node 自带的模块,超过 2^53 的整数在脚本里是 `BigInt`,写回去还是原来那个数。
288
- 整个脚本每个请求都会重新执行一遍,所以顶层变量会被重置,要活下来的东西请放进
289
- `ctx.state`;脚本在 worker 线程里跑,超时十秒,过了时限还卡着的脚本会被停掉,抛
290
- 异常会变成一个 500,JavaScript 的栈在里面。延迟请用 `--delay` 而不是在脚本里等
291
- —— 等待花的是那十秒超时。
292
-
293
- **passthrough** —— 让请求到它本来要去的服务器。`--target` 可以改送到别处:
294
- base URL 只替换 origin、保留路径,带路径的 URL 则替换整个地址(Charles 管这叫
295
- Map Remote)。
296
-
297
- ```bash
298
- tunnel set GET /v5/feed --target http://localhost:3000
299
- ```
300
-
301
- **replay** —— 用录制作答,完全不碰网络:
302
-
303
- ```bash
304
- tunnel set GET api.example.com/v5/feed --replay --session acct_a
305
- tunnel set GET api.example.com/v5/poll --replay --strategy sequence
306
- ```
307
-
308
- 样本按本次请求的 host 和 path 定位,再按策略挑:`match`(默认)取 query 与本次
309
- 相同的最新一条,没有就退回这个端点的最新一条;`sequence` 按顺序往下走、走完停在
310
- 最后一条,轮询到有变化为止的客户端要的就是它;`last` 永远取最新一条。通配规则
311
- 先找实际被调的 host,找不到再退回录在它自己那个模式下的样本。`tunnel reset` 会把
312
- `sequence` 的计数器归零,脚本状态也一样,这样一个用例不会去答下一个用例的调用。
313
-
314
- 一条规则读哪个 session 的录制,默认就是当前 session,除非它自己点名了一个:
315
- `--session acct_a` 把这个端点钉死在那儿,不管整轮跑动在回放别的什么,
316
- `--session ''` 把它放回当前 session。不给这个开关就不动这个字段,
317
- 所以写过一次的规则会跟着下面讲的那个开关走。
318
-
319
- 回放的回答带着录制的 status、headers 和 body,另加一个 `x-tunnel-replay: <seq>`
320
- 说明它来自哪条录制 —— 客户端对看到的 headers 很挑剔的话,把 `replay_marker`
321
- 设成 `false`。
322
-
323
- ### 全局模式与当前 session
324
-
325
- ```bash
326
- tunnel mode # 现在是哪个模式
327
- tunnel mode replay # 没有自己规则的端点用录制作答
328
- tunnel mode passthrough # …或者去真实服务器(默认)
329
- tunnel session # 当前是哪个 session,磁盘上还有哪些
330
- tunnel session acct_a # 切过去;一条命令挪动整轮跑动
331
- tunnel mode replay --session acct_a # 模式和 session 一次设好
332
- ```
333
-
334
- 当前 session 就是录制写进去、回放读出来的那个,你不去动它就是 `default`。
335
- 某次调用该读哪个 session,各处的规矩都一样:先看显式传进来的参数 —— 脚本的
336
- `session` —— 再看规则自己的 `--session`(点了名才算),最后才是当前 session。
337
- 所以没有自己规则的端点会跟着开关走,写的时候没给 `--session` 的规则也跟着走,
338
- 而钉死过的规则留在原地。
339
-
340
- 切换会把 `sequence` 的计数器归零 —— 它们走的是另一套录制,对这一套什么也说明
341
- 不了 —— 脚本的 `ctx.state` 不动。`tunnel session` 不带名字时打印当前 session
342
- 和它有多少条录制,并列出磁盘上其它的 session;切到一个谁也没录过的 session 是
343
- 允许的,它会提醒你一句,因为在 `replay` 模式下所有没有规则的端点随即都会 404。
344
- 当前是哪个 session,`tunnel status`、`GET /__admin__/health` 和面板侧栏都会报。
345
-
346
- `replay` 模式下,既没有规则也没有录制的端点会得到
347
- `404 {"detail": "no rule or recording for <key>", "key": "<key>"}`,带
348
- `x-tunnel-unmatched: 1`,而 `tunnel requests --unmatched` 列的正是这些:
349
- 还没录、也还没 mock 的端点。
350
-
351
- ### 修饰响应
352
-
353
- 有四个字段对任何模式产生的回答都生效,所以同一个开关在 mock 的、真实的、回放的
354
- 响应上都管用:
355
-
356
- ```bash
357
- tunnel set GET /v5/feed --merge '{"data": {"total": 0}}'
358
- tunnel set GET /v5/feed -H 'Set-Cookie: sid=1' --content-type application/json
359
- tunnel set GET /v5/pay --fault hang --fault-seconds 30 # 也可以是 garbage、close、none
360
- tunnel set GET /v5/feed --delay 1.5 --status 503
361
- tunnel set GET /v5/feed --disable # 一直 503,直到 --enable
362
- ```
363
-
364
- - `merge_json` 会深合并进 passthrough 或 replay 带回来的 JSON:对象递归,
365
- 其它一切 —— 包括数组 —— 整体替换。删字段,或者改数组里的某一个元素,是脚本的
366
- 活儿。不是 JSON 的 body 原样透传。
367
- - `headers` 最后覆盖到响应上,在模式之后、合并之后。`-H` 和 `--content-type`
368
- 一起决定这一个字段,所以每条命令都要把这条规则要保留的 header 全写出来。
369
- - `fault` 是够到客户端错误分支的办法。`hang` 把请求挂住 `--fault-seconds`
370
- (默认一小时,比任何客户端的超时都长)然后答 504;`garbage` 用 JSON 的
371
- content type 发一段中途断掉的 body;`close` 什么都不写就断连接,请求日志里
372
- 显示状态 0,因为确实没有东西回去。`--fault none` 让端点恢复正常作答。
373
- 客户端一旦不等了,hang 就立刻放手,而不是把连接守满剩下的那一小时;这次调用
374
- 照样进日志,状态 0,所以跑测试的人看得见它来过又被放弃了。
375
- - `response_delay` 把回答按住一会儿,不管是哪个模式产生的。它跑在故障注入之前、
376
- 模式之前。客户端一旦不等了就不再按住,但模式照样跑完:这次调用照样进日志,
377
- 状态是那个模式本来要答的状态,passthrough 也照样录进 session —— 上游说了什么
378
- 值得留下,哪怕没人读到。半关闭 socket 的客户端被当成已经走掉的:模式照样跑完,
379
- 这次调用照样按它本来要答的状态进日志,但回答到不了它那里。
380
-
381
- `tunnel set` 只动你传的字段;这个端点其它的一切保持原样。
382
-
383
- ## 场景文件
384
-
385
- 一组规则可以放在一个 JSON 文件里,和测试一起提交,整体应用。它的形状和管理 API
386
- 接受的、`tunnel export` 写出来的完全一致:
387
-
388
- ```json
389
- {
390
- "replace": true,
391
- "config": {"default_mode": "replay"},
392
- "mocks": [
393
- {"key": "GET:api.example.com/v5/feed", "mode": "json", "json_file": "feed_empty.json"},
394
- {"key": "GET:api.example.com/v5/user/*", "mode": "script", "script_file": "user.js"},
395
- {"key": "POST:api.example.com/v5/pay", "mode": "json", "json_response": {"code": 500},
396
- "status_code": 500, "description": "payment refused"}
397
- ]
398
- }
399
- ```
400
-
401
- ```bash
402
- tunnel apply scenarios/empty_feed.json # 要么全中要么全不中:一条不合法,整批不生效
403
- tunnel apply scenarios/empty_feed.json --replace
404
- tunnel export -o scenarios/current.json # 当前这些规则
405
- tunnel pack scenarios/empty_feed.json -o bundle/empty_feed.json
406
- ```
407
-
408
- `script_file` 和 `json_file` 相对场景文件所在的目录解析,由 CLI 读出来把内容送
409
- 进去:服务端不读你的任何文件,不管它是从哪个目录启动的。
410
-
411
- `tunnel pack` 也是因此而存在。跑在设备里的测试用例是自己经管理 API 应用场景的,
412
- 它够不到你的工作目录:`pack` 把同一个场景写出来,其中 `script_file` 和
413
- `json_file` 换成了它们指向的内容,可以直接进测试 bundle。不给 `-o` 时这两个命令
414
- 都打到标准输出。
415
-
416
- `tunnel export` 默认不带全局设置,除非给 `--with-config`,它写出的是默认模式和
417
- 当前 session:场景是按用例应用的,不该顺手改掉整轮跑动里其它端点怎么作答、
418
- 或者从哪套录制里作答。
419
-
420
- ## 请求日志
421
-
422
- tunnel 答过的每一次调用都被完整留下 —— query、headers、body、回去的那个回答、
423
- 决定它的那条规则,以及耗时。
424
-
425
- ```bash
426
- tunnel requests # 按端点聚合
427
- tunnel requests --each --last 20 # 日志本身,一次调用一行
428
- tunnel requests --each --unmatched # 没有东西答过的
429
- tunnel requests --each --host api.example.com -q pay
430
- tunnel requests show 17 # 某一次调用的全貌
431
- tunnel reset --requests # 清空;并打印它停在哪个 seq
432
- ```
433
-
434
- `--each` 支持 `--key`、`--host`、`--since SEQ`、`--last N`、`--unmatched`、
435
- `--encrypted` 和 `-q 关键字`(匹配 host、路径、query、状态和规则)。没被解密的
436
- 隧道显示成一条 `connect` 记录:host、端口和双向字节数,关于它能知道的就这些。
437
-
438
- 最近的条目留在内存里(`max_requests_history` 条),每一条同时追加到 state 目录的
439
- `requests.jsonl`,所以 `seq` 会接着上一次跑到的地方往下走。打印出来的内容会把
440
- `authorization`、`cookie`、`set-cookie`、`x-api-key`、`proxy-authorization`
441
- 的值隐去 —— `redact_headers` 可以改这个列表,`show --raw` 则原样打印。
442
- 落盘的文件两种情况下都保留原文,因为录制是要能回放的。
443
-
444
- ### 等待请求到达
445
-
446
- `tunnel wait` 是脚本该阻塞的地方,而不是 sleep 一秒然后碰运气。服务端把连接挂着,
447
- 等到够数的匹配调用一到就回答:
448
-
449
- ```bash
450
- tunnel wait POST api.example.com/v5/pay # 一次调用,十秒
451
- tunnel wait GET /v5/poll --count 3 --timeout 30 # 三次
452
- tunnel wait POST /v5/pay -q amount=100 --since 41 # 再收窄一点
453
- ```
454
-
455
- 它退出 0 并打印匹配到的 seq,或者在等不到时退出 3 并打印已经看到几条 ——
456
- 所以退出码就是断言。`--since SEQ` 只数那个 seq 之后到达的(`tunnel reset
457
- --requests` 会打印该用哪个);不给它就整本日志都算,包括 wait 之前发生的调用。
458
- 每个 `-q name=value` 是这次调用必须带着的一个 query 参数和它的值,query 的其余
459
- 部分不限。
460
-
461
- ## 录一次,在 CI 里回放
462
-
463
- 录制是全局开关,不是一种响应模式:开着的时候,每一次透传到真实服务器的调用都会
464
- 在 data 目录里写成一个独立文件。Mock 出来的回答已经写在产生它的规则里了,
465
- 没解密的隧道也没有什么可留的。
466
-
467
- ```bash
468
- # 1. 在你的机器上,设备驱动着 app
469
- tunnel record start --session acct_a
470
- tunnel record stop
471
- tunnel recordings ls --host api.example.com
472
- tunnel recordings show 17
473
-
474
- # 2. 放进客户端仓库
475
- tunnel recordings export -o tests/mocks/recordings --session acct_a
476
-
477
- # 3. 在 CI 里,没有设备也没有后端
478
- tunnel serve --background
479
- tunnel recordings import tests/mocks/recordings --replace
480
- tunnel mode replay --session acct_a # 用 acct_a 的录制作答
481
- tunnel apply tests/mocks/scenarios/empty_feed.json --replace
482
- # …跑测试用例…
483
- tunnel wait POST api.example.com/v5/pay --timeout 30
484
- tunnel requests --unmatched
485
- tunnel stop
486
- ```
487
-
488
- 一次调用被存成
489
- `recordings/<session>/<host>/<METHOD>/<path>/<seq>.json`,所以这个目录本身就是
490
- 索引,同一个端点调两次两个回答都留着。session 把一个测试账号、或者一个批次,
491
- 和另一个分开:`record start --session acct_a` 先把当前 session 切过去再开录,
492
- 这样随后起的回放读到的就是刚录的这批;不给这个开关,调用就落进已经在用的那个
493
- session。在 CI 里,把整轮跑动从一个批次挪到另一个的是 `tunnel session acct_a`,
494
- 或者上面那个 `--session`。散落在 session 目录之外的 `.json` 没有点名任何
495
- session,所以 `import` 把它归进当前 session。`export` 写出来的就是 `import`
496
- 读进去的;导出时 header 的值默认隐去,除非给 `--raw`,而服务端自己留的那一份是
497
- 原文,因为录制存在就是为了回放。提交前请审一遍:这些是真实后端的真实响应。
498
-
499
- ### 后台运行
500
-
501
- ```bash
502
- tunnel serve --background --admin-port 8000 --proxy-port 8890
503
- tunnel status # pid、端口、CA、三类目录、当前 session
504
- tunnel stop # SIGTERM,等它退出,清掉记录
505
- ```
506
-
507
- 子进程是脱离终端的 —— 新起一个 session,不是 fork —— 它本来要打印的一切都进
508
- state 目录的 `tunnel.log`,所以某个端口没绑上,事后也还读得到。旁边的
509
- `tunnel.pid` 存着它的 pid 和两个端口;不带 `--base` 也没有 `TUNNEL_BASE` 的命令
510
- 会从那里读管理口,所以后台起在别的端口上,也不用每条命令都传 `--base`。
511
-
512
- ## 在测试用例里调用 tunnel
513
-
514
- 上面这些都是宿主机这一侧的活儿。跑在 app 里的测试用例做同样的事情要走 HTTP,
515
- 因为它没法在你的机器上起一个命令。行得通的分工是:
516
-
517
- - **整轮级,在宿主机用 CLI**:`serve --background`、`recordings import`、
518
- `mode replay --session acct_a`、`ssl add`、`ca install`。
519
- - **用例级,在测试里调管理 API**:`POST /__admin__/mocks/apply` 应用本用例的
520
- 场景,`POST /__admin__/state/reset` 在用例之间重置,
521
- `GET /__admin__/requests/wait` 代替 sleep,
522
- `GET /__admin__/requests?each=1` 断言客户端调了什么。
523
-
524
- 测试用例应用的场景必须是自包含的 —— `tunnel pack` 写出来的正是这个 ——
525
- 因为服务端既不读设备上的文件,也不读你 checkout 里的文件。
526
-
527
- 从测试的角度看,管理口在哪里:
528
-
529
- | 测试跑在 | 管理 API 地址 |
530
- | --- | --- |
531
- | iOS 模拟器 | `http://127.0.0.1:8000` —— 它和 Mac 共享回环 |
532
- | Android 模拟器 | `http://10.0.2.2:8000` |
533
- | USB 连着的 Android 真机 | `adb reverse tcp:8000 tcp:8000` 之后用 `http://127.0.0.1:8000` |
534
- | iPhone / iPad | 没有反向转发这回事:开跑之前在宿主机上用 CLI 准备好 |
535
-
536
- ### Swift,用 URLSession
537
-
538
- ```swift
539
- enum Tunnel {
540
- static let base = URL(string: "http://127.0.0.1:8000/__admin__")!
541
-
542
- static func post(_ path: String, _ body: [String: Any]) async throws -> [String: Any] {
543
- var request = URLRequest(url: base.appendingPathComponent(path))
544
- request.httpMethod = "POST"
545
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
546
- request.httpBody = try JSONSerialization.data(withJSONObject: body)
547
- let (data, _) = try await URLSession.shared.data(for: request)
548
- return try JSONSerialization.jsonObject(with: data) as! [String: Any]
549
- }
550
-
551
- /// 应用本用例的场景,覆盖掉上一个用例留下的东西。
552
- static func apply(_ scenario: [String: Any]) async throws {
553
- _ = try await post("mocks/apply", scenario)
554
- _ = try await post("state/reset", [:])
555
- }
556
-
557
- /// 阻塞到客户端发出这次调用;false 表示它始终没发。
558
- static func waitFor(_ key: String, timeout: Int = 10) async throws -> Bool {
559
- var components = URLComponents(url: base.appendingPathComponent("requests/wait"),
560
- resolvingAgainstBaseURL: false)!
561
- components.queryItems = [URLQueryItem(name: "key", value: key),
562
- URLQueryItem(name: "timeout", value: String(timeout))]
563
- let (data, _) = try await URLSession.shared.data(from: components.url!)
564
- let answer = try JSONSerialization.jsonObject(with: data) as! [String: Any]
565
- return (answer["timed_out"] as? Bool) == false
566
- }
567
- }
568
-
569
- // 在测试里
570
- try await Tunnel.apply([
571
- "replace": true,
572
- "mocks": [["key": "GET:api.example.com/v5/feed",
573
- "mode": "json",
574
- "json_response": ["code": 0, "cards": []]]],
575
- ])
576
- // …驱动 app…
577
- XCTAssertTrue(try await Tunnel.waitFor("POST:api.example.com/v5/pay", timeout: 30))
578
- ```
579
-
580
- ### Kotlin,用 OkHttp
581
-
582
- ```kotlin
583
- object Tunnel {
584
- // 10.0.2.2 是模拟器眼里的宿主机;`adb reverse` 之后用 127.0.0.1。
585
- private const val BASE = "http://10.0.2.2:8000/__admin__"
586
- private val client = OkHttpClient()
587
- private val JSON = "application/json".toMediaType()
588
-
589
- fun apply(scenario: String) {
590
- post("mocks/apply", scenario)
591
- post("state/reset", "{}")
592
- }
593
-
594
- fun waitFor(key: String, timeoutSeconds: Int = 10): Boolean {
595
- val url = "$BASE/requests/wait".toHttpUrl().newBuilder()
596
- .addQueryParameter("key", key)
597
- .addQueryParameter("timeout", timeoutSeconds.toString())
598
- .build()
599
- val body = client.newCall(Request.Builder().url(url).build())
600
- .execute().use { it.body!!.string() }
601
- return !JSONObject(body).getBoolean("timed_out")
602
- }
603
-
604
- private fun post(path: String, body: String): String =
605
- client.newCall(
606
- Request.Builder().url("$BASE/$path").post(body.toRequestBody(JSON)).build()
607
- ).execute().use { response ->
608
- check(response.isSuccessful) { "tunnel said ${response.code}: ${response.body?.string()}" }
609
- response.body!!.string()
610
- }
611
- }
612
-
613
- // 在测试里
614
- Tunnel.apply(
615
- """
616
- {"replace": true,
617
- "mocks": [{"key": "GET:api.example.com/v5/feed", "mode": "json",
618
- "json_response": {"code": 0, "cards": []}}]}
619
- """.trimIndent()
620
- )
621
- // …驱动 app…
622
- assertTrue(Tunnel.waitFor("POST:api.example.com/v5/pay", timeoutSeconds = 30))
623
- ```
624
-
625
- 被测 app 自己不需要为这一切写任何代码:把它的流量送进 tunnel 的是代理设置和 CA。
626
- 给测试构建一个信任用户 CA 的 `network_security_config`,并在那里关掉证书固定。
627
-
628
- ## 在 TypeScript 测试里用 tunnel
629
-
630
- 上面的测试用例跑在 app 里,连的是别人起好的 tunnel。跑在 Node 里的测试套件 ——
631
- 铺数据、在模拟器或真机上驱动 app、再检查它调了什么的那一套 —— 可以直接在自己的
632
- 进程里起 tunnel:不用先起服务,不用约定端口,也不用自己写 HTTP 去打管理口。
633
- 装命令的那个包里也有这个库,ES module,带类型:
634
-
635
- ```bash
636
- npm install --save-dev tunnel-use
637
- ```
638
-
639
- ```ts
640
- import assert from 'node:assert/strict'
641
- import { startTunnel } from 'tunnel-use'
642
-
643
- // 模拟器和 CI 连 127.0.0.1 就够;网络上的真机要用默认的 0.0.0.0
644
- const tunnel = await startTunnel({ proxyHost: '127.0.0.1' })
645
- try {
646
- await tunnel.setRule({ key: 'GET:api.example.test/v5/feed', mode: 'json', json_response: { cards: [] } })
647
- await tunnel.setRule({
648
- key: 'POST:api.example.test/v5/pay',
649
- handler: (ctx) => ctx.reply({ paid: ctx.json?.amount ?? 0 }, 201),
650
- })
651
- // …经 tunnel.proxyUrl 驱动 app;这里用一个请求代替
652
- const answer = await tunnel.check('POST', 'http://api.example.test/v5/pay', {
653
- body: '{"amount": 100}',
654
- contentType: 'application/json',
655
- })
656
- assert.equal(answer.status, 201)
657
- const wait = await tunnel.waitForRequest({ key: 'POST:api.example.test/v5/pay', timeout: 5 })
658
- assert.equal(wait.timed_out, false)
659
- const exchange = await tunnel.getRequest(wait.seqs[0]!)
660
- assert.deepEqual(exchange.request.body, { amount: 100 })
661
- } finally {
662
- await tunnel.stop()
663
- }
664
- ```
665
-
666
- `startTunnel()` 起的就是 `tunnel serve` 起的那些 —— 代理口、管理口、core 和脚本
667
- worker —— 然后返回一个句柄,上面写着它们落在哪里:`proxyPort` 和 `proxyUrl` 给
668
- app 的代理设置,`caFile` 是设备要装的证书,`configDir` 是文件所在的目录,
669
- `adminUrl` 就是面板,调试测试时也可以当 CLI 的 `--base`。
670
-
671
- 每个方法都是一个管理 API 端点,在本进程里由管理口背后的同一套代码执行,所以默认值、
672
- 校验和报错就是 [管理 API v1](#管理-api-v1) 那张表说的。数据沿用 API 的字段名 ——
673
- `status_code`、`json_response`、`default_mode`、`timed_out`、`last_seq` ——
674
- 所以场景文件里的一条规则可以原样交给 `setRule()`;只有方法名和方法自己的选项是
675
- camelCase。
676
-
677
- | 方法 | 管理 API(在 `/__admin__` 下) | 返回 |
678
- | --- | --- | --- |
679
- | `status()` | `GET /health` 加 `GET /sessions`,合成一个对象 | 这个对象,其中 `mocks` 把函数规则也算上 |
680
- | `getConfig()`、`setConfig(patch)` | `GET`、`PATCH /config` | 设置 |
681
- | `decrypt(hosts, { exclude, remove })` | SSL Proxying 名单,照 `tunnel ssl add`、`ssl rm` 的方式改 | `ssl_proxying` |
682
- | `listRules(key?)` | `GET /mocks`,再接上函数规则 | 规则;给了 key 就是那一条,没有则 404 |
683
- | `setRule(rule)` | `PATCH /mocks`;带 `handler` 时是函数规则 | 规则现在的样子 |
684
- | `deleteRule(key)` | 有这个 key 的函数规则就删它,否则 `DELETE /mocks` | 规范化后的 key |
685
- | `applyScenario(scenario, { replace })` | `POST /mocks/apply`;字符串是场景文件,照 `tunnel apply` 的方式读 | `{ applied, replaced }` |
686
- | `resetState(key?)` | `POST /state/reset` | 清掉的 key |
687
- | `listRequests(filters)` | `GET /requests`:`qBody` 即 `q_body`,`each: true` 是每个请求一行 | `{ requests, last_seq, unmatched_count }` |
688
- | `getRequest(seq)` | `GET /requests/{seq}` | 一条 exchange,header 不遮 |
689
- | `waitForRequest({ key, count, since, timeout, query })` | `GET /requests/wait`,`query` 的每一项是一个 `q=name=value` | `{ seqs, count, wanted, timed_out, last_seq }`;超时不是错误 |
690
- | `clearRequests()` | `POST /requests/clear` | `last_seq`,之后用作 `since` |
691
- | `check(method, url, { body, contentType })` | 经本实例的代理口发一个请求,只信它的 CA,同 `tunnel check` | `{ status, contentType, body }`,body 是 `Buffer` |
692
- | `startRecording(session?)`、`stopRecording()` | `POST /record/start`、`POST /record/stop` | `{ active, session, count }` |
693
- | `listRecordings({ session, key, host })` | `GET /recordings` | 其中的 `recordings` |
694
- | `getRecording(session, seq)` | `GET /recordings/{session}/{seq}` | 一条 exchange |
695
- | `importRecordings(from, { session, replace })` | `POST /recordings/import`:exchange 数组导进 `session`;或者 `tunnel recordings export` 写出的目录,照 `tunnel recordings import` 的方式读,一个 session 一个 session 地导入,中途失败时之前的 session 留着 | `{ imported, sessions, replaced }` |
696
- | `deleteRecordings(session?)` | `DELETE /recordings`,一个 session 的或全部的 | 删了几条 |
697
-
698
- `check()` 发请求的方式和设备一样:https 的 URL 只有在它的 host 经过 `decrypt()`
699
- 之后才解密,否则直接去真服务器。不许出网的测试只打有规则的 host,或者在
700
- `setConfig({ default_mode: 'replay' })` 下跑:没有规则答的请求回 404。它和
701
- `tunnel check` 一样最多等 10 秒:函数 handler 撞上自己的 10 秒上限时,它以
702
- `timed out` reject,答这个请求的 500 在请求日志里。
703
-
704
- ### 函数规则
705
-
706
- `setRule({ key, handler })` 在脚本规则放源码的地方放一个函数。函数拿到的 `ctx`
707
- 和脚本拿到的一样,返回值按同一套规则变成答复 —— 对象是 JSON,
708
- `ctx.reply(body, status, headers)`、`Recorded`、字节也都一样 —— 同样是 10 秒上限,
709
- 抛错同样回 500 带栈。它是一条 `script` 模式的规则,其余字段照常起作用
710
- (`status_code`、`headers`、`enabled`、`fault`、`response_delay`,以及决定
711
- `ctx.recorded()` 读哪里的 `replay_session`),由默认值和给出的字段整条建出来:
712
- 要改其中一个字段,就带着 handler 再放一次。
713
-
714
- 和脚本的不同,都来自它跑在测试自己的进程里:
715
-
716
- - 只在内存里:从不写进 `config.json`,`stop()` 时随之消失。
717
- - 在测试的主线程上跑,几个调用同时跑:一个 handler 在等测试手里的 Promise 时,
718
- 别的请求照常答。
719
- - `ctx.state` 是每条规则一个的活对象,同时在跑的调用共用,直到 `resetState()`。
720
- 调用超时之后再往里放的东西照样留下。
721
- - `ctx.recorded()` 和 `ctx.recordings()` 返回 Promise。两种 handler 都写成
722
- async、用 `await ctx.recorded()` 就对了:`await` 对不是 Promise 的值也成立。
723
- - 同步的死循环停不下来:它占着计时器要用的那个线程。可能死循环的 handler 应该
724
- 写成脚本,脚本的 worker 到 10 秒会被停掉。
725
- - 场景里只放存盘的规则。`applyScenario()` 拒绝带 `handler` 的场景:不拦的话
726
- JSON 会悄悄丢掉函数,留下一条 passthrough 规则。
727
-
728
- 一个 key 只有一条规则。放函数规则会替换同 key 的存盘规则,把它从 `config.json`
729
- 里拿掉;放存盘规则(不带 handler 的 `setRule()`,或者 `applyScenario()`)会替换
730
- 同 key 的函数规则,`applyScenario(…, { replace: true })` 清掉全部函数规则。面板、
731
- CLI 和管理 API 看不到函数规则:`GET /__admin__/mocks` 只列存盘的规则,
732
- `GET /__admin__/health` 的 `mocks` 也只数它们(`status().mocks` 两种都数);在面板
733
- 上给函数规则的 key 存一条规则,它会被函数规则挡住,直到 `deleteRule()`;
734
- `tunnel apply --replace` 也不碰函数规则。在请求日志里它们和别的规则一样:`mode`
735
- 是 `script`,`matched_by` 是它的 key。
736
-
737
- ### 起和停
738
-
739
- 不给选项时,两个端口都由系统挑空闲的,代理口和 `tunnel serve` 一样监听所有网卡,
740
- 文件放进新建的 `<os.tmpdir()>/tunnel-XXXXXX`,里面生成新 CA。模拟器和 CI 有
741
- `proxyHost: '127.0.0.1'` 就够 —— iOS 模拟器和 Mac 共享回环,Android 模拟器里的
742
- `10.0.2.2` 就是这个回环 —— macOS 也就不会问要不要允许传入连接;只有网络上的真机
743
- 需要默认值。`proxyPort`、`adminPort` 用来固定端口。
744
-
745
- 模拟器或设备已经信任某个 CA 时,让 tunnel 用那个 CA 签名,它就照常解密:`caDir`
746
- 从一个目录(项目的 `.tunnel/`,或者 `~/.config/tunnel/`)把 `ca.pem` 和
747
- `ca-key.pem` 拷进临时目录,原文件不动。`configDir` 则让它跑在你指定的目录里,同
748
- `--config-dir`,用完保留:它写的规则和设置都和 CLI 写的一样落进那里的
749
- `config.json`,包括函数规则替换掉同 key 的存盘规则。给一个只给这次测试用的目录 ——
750
- 两个 tunnel 用同一个目录会互相覆盖文件。它不写 `tunnel.pid`,所以 `tunnel stop`
751
- 不会去停测试进程。
752
-
753
- 它起的东西都不会让进程留着不退。忘了 `stop()` 的测试文件照样结束,临时目录在进程
754
- 退出时删掉;进程被信号杀掉时,留下的目录由系统的临时文件清理收走。正在答的请求、
755
- 正在等的 wait 会把进程留到它们完成。反过来说:一个起了 tunnel 却什么也不等的脚本
756
- 会立刻退出,要常驻请用 `tunnel serve`。
757
-
758
- `stop()` 关掉两个端口、脚本 worker 和还在跑的函数调用,再删掉临时目录;再调一次
759
- 返回的是第一次的那个 Promise。正在等的 `waitForRequest()` 以 503
760
- `The server is shutting down` reject,`stop()` 之后的每个调用以 503
761
- `the tunnel is stopped` reject;句柄上的属性照旧可读。从 Node 24 起,
762
- `await using tunnel = await startTunnel()` 在块结束时停掉它 —— Node 22.18 认得
763
- `Symbol.asyncDispose`,但不认这个语句,所以上面的例子写 `try`/`finally`。同一个
764
- 进程里的几个 tunnel 各跑各的,端口、规则、目录各是各的;共用的只有日志的去向。
765
-
766
- ### 错误、大整数和日志
767
-
768
- 管理 API 回 400 及以上时 reject 一个 `TunnelError`:`status` 是那个状态码,
769
- `detail` 是答复里的 `detail` 原样 —— 一句话,或者 422 的 issue 列表 —— message
770
- 是那句话,或者把每个 issue 写成 `<字段>: <msg>`、用 `; ` 连起来。库自己的拒绝也是
771
- `TunnelError`:不认识的选项名是 400 —— 拼错的选项被拒绝而不是被忽略,和 API 拒绝
772
- 不认识的字段一样 —— 收不了的参数也是 400,比如 `caDir` 和 `configDir` 同给、
773
- handler 不是函数、函数规则给了别的 `mode` 或 `script`;停了以后是 503。端口被占
774
- 之类起不来的错误是 Node 自己的错误,`code` 是 `EADDRINUSE`。
775
-
776
- 超过 2^53 的整数,在库交出来的东西里 —— 规则、exchange、录制、`ctx.json` —— 都是
777
- `BigInt`,和脚本里一样;交给它的 `BigInt` 写成原来的数字。`assert.deepEqual` 和
778
- `console.log` 都能直接处理;`JSON.stringify` 遇到它会抛错,对任何 `BigInt` 都是
779
- 这样。
780
-
781
- 日志从 `INFO` 起写到 stderr,每个请求一行,和前台的 `tunnel serve` 一样。
782
- `setLogSink(sink)` 把它们送到别处,对本进程里所有的 tunnel 都生效,并返回原来的
783
- sink,方便之后还原:`setLogSink(() => {})` 让它们静音,`setLogSink((record) => {
784
- if (record.level === 'warning' || record.level === 'error')
785
- console.error(record.message) })` 只留下出了问题的那些。
786
-
787
- 类型随包发布。对着它们做类型检查需要 `@types/node`(声明里用到 `Buffer`),而
788
- TypeScript 7 只在 tsconfig 写了 `"types": ["node"]` 时才带上它。
789
-
790
- ## 用 AI 编码助手来驱动
791
-
792
- tunnel 就是一个命令加一套 JSON API,所以任何能执行 shell 的 agent 都能驱动它:
793
- 配一条规则、驱动客户端、读实际收到了什么。要让它在**别的工程**里也能用,需要两样
794
- 东西。
795
-
796
- **`tunnel agents` 会打印那份给 agent 看的文档**:命令与它们确切的形状、规则 key
797
- 怎么匹配、请求日志能回答什么、script 模式的 `handler(ctx)` 全部接口以及「交付前
798
- 用哪三条命令验」、replay 如何挑中一条录制、测试里哪些事在 run 级哪些在 case 级,
799
- 最后是一份「实际会犯的错」清单。它从**已安装的包**里出来,所以描述的是你装的这个
800
- 版本,而不是主干上的任意状态。
801
-
802
- **然后给 agent 一行字**,放进它自己会读的那个文件——Claude Code 是 `CLAUDE.md`,
803
- Codex、Cursor 等是 `AGENTS.md`:
804
-
805
- ```
806
- Working with tunnel (the local mock proxy)? Run `tunnel agents` first — it prints
807
- the command reference and the handler(ctx) API for the installed version.
808
- ```
809
-
810
- 是**一行指针,不是把文档拷进去**:那份文档 23 KB,贴进工程的指令文件等于每次会话
811
- 都无条件背着它,而且从此冻结在你粘贴的那个版本上。一行指针不花成本,且永远解析到
812
- 当前安装的版本。
813
-
814
- 除此之外不需要别的。agent 要用的其余东西命令里本来就有:每个子命令都支持
815
- `--json`、可分支的退出码(0 正常、1 用法错、2 没人应答、3 wait 超时)、`tunnel
816
- check` 打一发真请求穿过代理、`tunnel requests --each` 看实际到达了什么。
817
-
818
- ### 走 MCP
819
-
820
- 会说 MCP 的编辑器可以把上面这些操作换一副面孔递给 agent:它把 `tunnel mcp`
821
- 拉起来,在这个进程的 stdin 和 stdout 上跟它对话,于是 agent 是照着 schema
822
- 填字段,而不是自己拼一行命令 —— 字段填错了,回来的是针对那个字段的一句话,
823
- 而不是一段得自己读出门道的 400。它多出来的就是这一点,没有任何新能力。
824
-
825
- ```bash
826
- claude mcp add tunnel -- tunnel mcp # Claude Code
827
- codex mcp add tunnel -- tunnel mcp # Codex
828
- ```
829
-
830
- Cursor、Windsurf、Zed 这些读的是一份 JSON 配置;不管那个客户端把它放在哪儿,
831
- 条目都是同样两个字段:
832
-
833
- ```json
834
- {"mcpServers": {"tunnel": {"command": "tunnel", "args": ["mcp"]}}}
835
- ```
836
-
837
- 管理 API 不在 `127.0.0.1:8000` 上作答时,把 `TUNNEL_BASE` 放进这个对象的 `env`
838
- 里 —— 而通常用不着:`--background` 起的服务会把自己的管理口写进 pid 文件,每次
839
- 工具调用都重新解析一次 base,所以编辑器先起、tunnel 后起,或者 tunnel 换个端口
840
- 重启,都不用回来改这里。
841
-
842
- **工具是操作面,`tunnel agents` 仍然是知识面。** schema 能说明 `strategy` 是三个
843
- 词里的一个,却说不了 replay 接下来怎么挑中一条录制、`ctx` 上到底有什么、为什么
844
- query string 不参与规则匹配。所以两样都要;而只配了 MCP server 的 agent 也照样
845
- 够得着 —— `guide` 工具返回的就是那同一份文件。至于完全没配 MCP 的场合,也不损失
846
- 什么:`--json` 和退出码是同一套操作,只是得自己拼。
847
-
848
- **`tunnel mcp` 连的是一个已经在跑的服务,它绝不会自己起一个。** 它是编辑器的子
849
- 进程,编辑器一退它就死;而 tunnel 是手机要指过来的那个代理,通常比好几个编辑器
850
- 窗口活得都久 —— 何况起它的那个人,多半正是要看 banner 里的 LAN 地址和 CA 指纹的
851
- 人。服务没起时,工具调用会把这件事说清楚,并报出 `tunnel serve --background`。
852
-
853
- 16 个工具,是按「agent 真正要做的事」切的,而不是一个端点配一个,因为每个 schema
854
- 不管用不用都常驻在每一次提示里:
855
-
856
- | | 工具 | 做什么 |
857
- | --- | --- | --- |
858
- | 你在哪儿 | `status` | tunnel 在不在、此刻是怎么设的:版本、三类目录、当前 session、录制开关、代理地址、CA 指纹 —— 第一个该调的 |
859
- | | `guide` | `tunnel agents` 打印的那份文档,从已安装的包里出来;唯一一个不需要服务在跑的工具 |
860
- | 规则 | `list_mocks` | 当前全部规则,形状就是 `apply_scenario` 收回去的那个 |
861
- | | `set_mock` | 配置一个端点;只有传了的字段会变 |
862
- | | `delete_mock` | 删掉一条规则 |
863
- | | `apply_scenario` | 一个场景文件,或者直接给出的场景;要么全应用,要么一条都不应用 |
864
- | 收到了什么 | `list_requests` | 客户端实际调了什么,聚合或者一次调用一行;`unmatched` 就是「还有什么没人应答」 |
865
- | | `get_request` | 某一次调用的全貌,header 值默认脱敏,`raw` 才给原值 |
866
- | | `wait_for_request` | 阻塞到客户端真的调了它;等到超时会答 `timed_out`,那不是错误 |
867
- | | `reset` | 清掉一个用例留给下一个的东西:脚本状态和 replay 计数器,带上 `requests` 连日志一起 |
868
- | 整轮开关 | `set_config` | 全局模式、当前 session、日志留多少条、哪些 header 要脱敏 |
869
- | | `decrypt` | 把 host 加进 SSL Proxying 列表或者拿出来;host 不在表上就什么都不解密 |
870
- | 录制与回放 | `record` | 开、关、问一声 —— 这是个全局开关,不是一种响应模式 |
871
- | | `list_recordings` | 每条录制一行 |
872
- | | `get_recording` | 某条录制的全貌,和 `get_request` 一样默认脱敏:也就是写 `ctx.recorded()` 的脚本会拿到的东西 |
873
- | 证明它对 | `check` | 经代理口真打一发,用 tunnel 自己的 CA 验 —— 交付一个脚本前的那一步 |
874
-
875
- ## 命令行速查
876
-
877
- 每条命令都与管理 API 对话;`--base URL`(或 `TUNNEL_BASE`)说明它在哪儿作答,
878
- `--json` 让任何命令打印原始 JSON,出错也一样 —— 出错那份走 stderr,形如
879
- `{"error": "…", "exit_code": 2}`,而敲错的参数仍然是参数解析器自己的用法提示。
880
- 两个开关写在子命令前后都行。
881
-
882
- | 命令 | 做什么 |
883
- | --- | --- |
884
- | `tunnel serve [--admin-port 8000] [--proxy-port 8890] [--proxy-host 0.0.0.0] [--config-dir DIR] [--background]` | 启动两个端口;`--port` 仍然当作管理口接受 |
885
- | `tunnel status` / `tunnel stop` | 跟踪 `serve --background` 起的那个服务 —— pid、两个端口、三类目录和当前 session —— 以及结束它 |
886
- | `tunnel init` | 在当前目录建 `.tunnel/`,让这个项目有自己的 config、state 和录制 |
887
- | `tunnel agents` | 打印给 coding agent 看的那份指南,出自装好的包而不是默认分支 |
888
- | `tunnel mcp` | 在 stdin 和 stdout 上说 MCP,让编辑器里的 agent 用带 schema 的工具驱动 tunnel,而不是拼命令行 |
889
- | `tunnel ls` | 已配置的规则,第一行是三类目录 |
890
- | `tunnel set METHOD [host]/path ...` | 配置一个端点;可选项见下 |
891
- | `tunnel rm METHOD [host]/path` | 删掉一条规则 |
892
- | `tunnel requests [--each] [--key K] [--host H] [--since SEQ] [--last N] [--unmatched] [--encrypted] [-q TEXT]` | 客户端调了什么 |
893
- | `tunnel requests show SEQ [--raw]` | 某一次调用的全貌 |
894
- | `tunnel wait METHOD [host]/path [--count N] [--timeout S] [--since SEQ] [-q K=V]` | 阻塞到它到达;超时退出 3 |
895
- | `tunnel reset [METHOD path] [--state] [--requests]` | 清掉脚本状态和 replay 计数器,也可以顺带清日志 |
896
- | `tunnel mode [passthrough\|replay] [--session NAME]` | 没有规则的端点怎么作答、从哪套录制里作答;一次调用同时设好 |
897
- | `tunnel session [NAME]` | 录制写进去、回放读出来的那个 session;不给名字就打印它和磁盘上其它的 session |
898
- | `tunnel apply FILE [--replace]` | 应用一个场景文件 |
899
- | `tunnel export [-o FILE] [--with-config]` | 当前这些规则,写成场景的形状 |
900
- | `tunnel pack FILE [-o FILE]` | 同一个场景,把 `script_file` 和 `json_file` 内联进去 |
901
- | `tunnel record start [--session NAME]` / `stop` / `status` | 录制开关;调用落进当前 session,给了 `--session` 就先切过去 |
902
- | `tunnel recordings ls [--session S] [--key K] [--host H]` | 每条录制一行 |
903
- | `tunnel recordings show SEQ [--session S] [--raw]` | 某条录制的全貌 |
904
- | `tunnel recordings export -o DIR [--session S] [--raw]` | 写出可以提交的目录树 |
905
- | `tunnel recordings import DIR [--replace]` | 把那棵树读回来;直接躺在 `DIR` 下的 `.json` 归进当前 session |
906
- | `tunnel recordings rm [--session S]` | 删录制 |
907
- | `tunnel ssl ls / add / rm / on / off`、`tunnel ssl exclude add / rm` | 哪些 host 被解密 |
908
- | `tunnel ca info / export / install / reset` | 设备要装的那份 CA |
909
- | `tunnel check METHOD URL [-q K=V] [-d BODY]` | 经代理口真打一发并打印回答 |
910
- | `tunnel --version` | 版本号,`GET /__admin__/health` 也会报它 |
911
-
912
- `tunnel set` 接受 `-d JSON\|@file`、`--script FILE`、`--target URL`、
913
- `--replay`(配 `--strategy match\|sequence\|last`,以及把规则钉死在某个 session
914
- 上的 `--session NAME`、把它放回当前 session 的 `--session ''`)、
915
- `--merge JSON\|@file`、`-H 'Name: value'`(可重复)、`--content-type TYPE`、
916
- `--fault none\|hang\|garbage\|close`、`--fault-seconds SECONDS`、`--mode`、
917
- `--status`、`--delay`、`--desc`、`--enable` 和 `--disable`。只有你传的字段会变。
918
-
919
- 退出码:**0** 成功,**1** 用户错误(参数不对、文件不在、服务端答了 4xx),
920
- **2** 管理 API 那个地址上没人应答,**3** `tunnel wait` 超时。`tunnel check` 在
921
- 回答是 400 及以上时退出 1。
922
-
923
- ## 管理 API v1
924
-
925
- 全在管理口的 `/__admin__` 下面,进出都是 JSON。错误是 `{"detail": "..."}`
926
- 配上合适的状态码。`PATCH` 只更新它收到的字段;不认识的字段会被拒绝而不是忽略。
927
-
928
- | 方法 | 路径 | 请求 | 响应 |
929
- | --- | --- | --- | --- |
930
- | `GET` | `/__admin__/health` | | `{"status": "ok", "version": "3.0.0", "config_dir": "…", "state_dir": "…", "data_dir": "…", "mocks": 3, "exchanges": 42, "first_seq": 1, "last_seq": 42, "ca_fingerprint": "8B:6F:…", "proxy": {"host": "0.0.0.0", "port": 8890, "lan_ips": ["192.168.1.23"]}, "session": "default", "recording": {"active": false, "session": "default", "count": 12}, "upstream": "direct"}` |
931
- | `GET` | `/__admin__/config` | | `{"default_mode": "passthrough", "max_requests_history": 1000, "auto_record_requests": true, "ssl_proxying": {"enabled": true, "include": [], "exclude": []}, "allow_clients": [], "redact_headers": ["authorization", …], "recording": {"active": false}, "session": "default", "replay_marker": true}` |
932
- | `PATCH` | `/__admin__/config` | 上面这些字段的任意子集,例如 `{"default_mode": "replay", "session": "acct_a"}` | `{"success": true, "config": {…}}` —— `session` 不是一个目录名就 400 |
933
- | `GET` | `/__admin__/mocks` | `?key=` 只要一条 | `{"mocks": [{"key": "GET:api.example.com/v5/feed", "mode": "json", "json_response": {…}, "status_code": 200, "enabled": true, "description": "", "merge_json": {}, "headers": {}, "target": "", "response_delay": 0, "script": "", "replay_session": "", "replay_strategy": "match", "fault": "none", "fault_seconds": 3600}]}` —— `replay_session` 为空表示跟随当前 session |
934
- | `PATCH` | `/__admin__/mocks` | `{"key": "GET:/v5/feed", "mode": "json", "json_response": {"cards": []}}` —— 上面那些字段的任意子集 | `{"success": true, "mock": {…}}` |
935
- | `DELETE` | `/__admin__/mocks?key=GET:/v5/feed` | | `{"success": true, "deleted": "GET:/v5/feed"}` |
936
- | `POST` | `/__admin__/mocks/apply` | `{"replace": true, "config": {"default_mode": "replay", "session": "acct_a"}, "mocks": [{"key": …}, …]}` | `{"success": true, "applied": ["GET:/v5/feed"], "replaced": true}` —— 一条不合法,整批 400 |
937
- | `GET` | `/__admin__/export` | `?config=true` 连全局设置一起 | `{"config": {"default_mode": "passthrough", "session": "default"}, "mocks": […]}` |
938
- | `POST` | `/__admin__/state/reset` | `{"key": "GET:/v5/poll"}`,或 `{}` 表示全部 | `{"success": true, "cleared": ["GET:/v5/poll"]}` |
939
- | `GET` | `/__admin__/requests` | `?each=1&key=&host=&since=&last=&unmatched=1&encrypted=1&q=&q_body=1` | `{"requests": [row, …], "last_seq": 42, "unmatched_count": 0}` —— 行要么是按 key 的聚合,要么在 `each=1` 时每次调用一条摘要 |
940
- | `GET` | `/__admin__/requests/{seq}` | | 一条完整的 exchange,形状见下 |
941
- | `GET` | `/__admin__/requests/wait` | `?key=&count=1&since=&timeout=10&q=name=value`(可重复) | `{"seqs": [41], "count": 1, "wanted": 1, "timed_out": false, "last_seq": 41}` —— 数够 `count` 就答,或者等到超时 |
942
- | `POST` | `/__admin__/requests/clear` | | `{"success": true, "last_seq": 42}` |
943
- | `GET` | `/__admin__/events` | `?since=` 先补发缓冲里的 | Server-Sent Events,每个 `data:` 行一条 exchange 摘要,另有命名事件 `window` `{"first_seq": …, "last_seq": …}`:连上时、清空时、以及每 15 秒一次 |
944
- | `POST` | `/__admin__/record/start` | `{}` 录进当前 session;`{"session": "acct_a"}` 先切过去 | `{"success": true, "active": true, "session": "acct_a", "count": 0}` |
945
- | `POST` | `/__admin__/record/stop` | | `{"success": true, "active": false, "session": "acct_a", "count": 12}` |
946
- | `GET` | `/__admin__/record` | | `{"active": true, "session": "acct_a", "count": 12}` |
947
- | `GET` | `/__admin__/sessions` | | `{"session": "acct_a", "sessions": {"acct_a": 12, "acct_b": 3}}` —— 当前 session,以及磁盘上每个 session 各有多少条调用 |
948
- | `GET` | `/__admin__/recordings` | `?session=&key=&host=` | `{"recordings": [{"session": "acct_a", "seq": 17, "time": "2026-09-24T10:00:00.123", "key": "GET:api.example.com/v5/feed", "query": {"page": "1"}, "status": 200, "size": 1234}], "count": 1}` |
949
- | `GET` | `/__admin__/recordings/{session}/{seq}` | | 一条完整的 exchange |
950
- | `POST` | `/__admin__/recordings/import` | `{"session": "acct_a", "replace": true, "exchanges": [exchange, …]}` | `{"success": true, "imported": 12, "session": "acct_a", "replaced": true}` |
951
- | `DELETE` | `/__admin__/recordings` | `?session=`,不传表示所有 session | `{"success": true, "deleted": 12, "session": "acct_a"}` |
952
- | `GET` | `/__admin__/ca` | | `{"subject": "CN=tunnel CA …", "fingerprint": "8B:6F:…", "not_before": "…", "not_after": "…", "ca_file": "…/ca.pem", "ca_key_file": "…/ca-key.pem"}` |
953
- | `POST` | `/__admin__/ca/reset` | | `{"success": true, …同样那些字段}` —— 所有设备都得装新证书 |
954
-
955
- 有两个路径不在 `/__admin__` 下面:`/` 是面板,`/ca.pem` 是 CA 证书,用能让设备
956
- 提示安装的 content type 提供。代理口对发给它自己的请求也答这两个路径 ——
957
- `http://ca.tunnel/` 就是已设代理的设备打开的那页安装说明 —— 其余一律 400;
958
- 它不带任何管理 API。
959
-
960
- 一条 exchange 长这样,`GET /__admin__/requests/{seq}` 返回的和录制文件里存的
961
- 都是它:
962
-
963
- ```json
964
- {
965
- "seq": 17,
966
- "time": "2026-09-24T10:00:00.123",
967
- "kind": "http",
968
- "request": {
969
- "method": "GET", "scheme": "https", "host": "api.example.com", "port": 443,
970
- "path": "/v5/feed", "query": {"page": "1"},
971
- "url": "https://api.example.com/v5/feed?page=1",
972
- "headers": {"user-agent": "…"}, "body": null, "body_encoding": null
973
- },
974
- "response": {
975
- "status": 200, "headers": {"content-type": "application/json"},
976
- "body": {"code": 0, "cards": []}, "body_encoding": "json",
977
- "elapsed_ms": 123, "size": 1234
978
- },
979
- "matched_by": "GET:api.example.com/v5/feed", "mode": "passthrough",
980
- "replay_seq": null, "unmatched": false, "client": "192.168.1.23",
981
- "truncated": false, "encrypted": false, "bytes_up": 0, "bytes_down": 0
982
- }
983
- ```
984
-
985
- body 被解析成对象时 `body_encoding` 是 `json`,是字符串时是 `text`,都不是就是
986
- `base64`;超过上限的 body 会被截断,`truncated` 会说明。从未被解密的隧道,
987
- `kind` 是 `connect`:它有 host、端口、client、时间和双向字节数,别的什么都没有。
988
-
989
- ## 文件放在哪里
990
-
991
- tunnel 拥有三类文件,你磁盘上别的东西它一概不碰:**config**(`config.json`、
992
- `ca.pem`、`ca-key.pem`)、**state**(`requests.jsonl`,以及后台起过服务之后的
993
- `tunnel.pid` 和 `tunnel.log`)和 **data**(`recordings/`)。
994
-
995
- 它们放在哪里,和 git 选配置的方式一样:先显式指定,再看项目,最后才是你。
996
-
997
- 1. `tunnel serve` 的 `--config-dir`,或者 `TUNNEL_CONFIG_DIR`
998
- 2. 从服务启动的位置向上找到的最近一个 `.tunnel/` —— `tunnel init` 给你所在的
999
- 项目建一个
1000
- 3. 都没有就用用户级目录
1001
-
1002
- 前两种情况下三类目录都落在那一个目录里,这正是项目的 `.tunnel/` 删起来是一件
1003
- 事的原因。在用户级它们按各平台的习惯分开放:
1004
-
1005
- | | macOS 与 Linux | Windows |
1006
- | --- | --- | --- |
1007
- | config | `~/.config/tunnel/`(`$XDG_CONFIG_HOME`) | `%APPDATA%\tunnel` |
1008
- | state | `~/.local/state/tunnel/`(`$XDG_STATE_HOME`) | `%LOCALAPPDATA%\tunnel` |
1009
- | data | `~/.local/share/tunnel/`(`$XDG_DATA_HOME`) | `%LOCALAPPDATA%\tunnel` |
1010
-
1011
- macOS 上用 `~/.config` 而不是 `~/Library/Application Support` 是有意的:后者是
1012
- 带 bundle identifier 的图形应用的惯例,而命令行工具 —— git 和 gh 都在其中 ——
1013
- 在 macOS 上也和在 Linux 上一样住在 `~/.config`。把 config 和 state 分开,
1014
- 才能清掉 state 目录而不丢掉设备已经信任的那份 CA。
1015
-
1016
- 服务启动时会把三类目录都记进日志,`tunnel ls` 的第一行会打印它们,
1017
- `GET /__admin__/health` 也会报。
1018
-
1019
- > ⚠️ 录制和透传的响应都是原样留下的,所以这些文件里经常有真实的主机名、
1020
- > 认证头、会话 cookie,以及你指向的那个后端上的用户数据。别让它们进版本库,
1021
- > 也别进 bug 报告。`tunnel init` 建出来的每个 `.tunnel/` 都会忽略自己:
1022
- > 里面有一个内容为 `*` 的 `.gitignore`。
1023
-
1024
- ## 测试与开发
1025
-
1026
- ```bash
1027
- make help # 显示这份帮助
1028
- make setup # 安装依赖(npm ci)
1029
- make test # 跑整套测试,直接从源码跑
1030
- make check # 类型检查,有没格式化的文件就失败
1031
- make format # 格式化 TypeScript
1032
- make check-install # 打包,装进一个用完即弃的目录,检查文件清单、体积预算、命令,以及库入口和它的类型
1033
- make run # 直接从源码跑 tunnel serve
1034
- make clean # 删掉构建产物
1035
- make panel # 把面板构建进 assets/panel/(只有改 panel/ 时才用得上)
1036
- make panel-check # 给面板做类型检查,提交进仓库的产物过期了就失败
1037
- make panel-smoke # 用 Chrome 把面板每一页都打开一遍,控制台报任何错就失败(需要 Playwright;不在 make test 里)
1038
- ```
1039
-
1040
- 单跑一个文件:`npm run test:file -- tests/cli.test.ts`。慢的测试 —— 大多是在第二个
1041
- 进程里真起一个服务的 —— 标题以 `[slow]` 结尾,`npm run test:fast` 会跳过它们。
1042
- 整套测试都不出网:上游服务在进程内伪造,socket 绑在 `127.0.0.1` 上、端口由操作
1043
- 系统挑,每个测试都写临时目录。
1044
-
1045
- 面板是预先构建的:`make panel` 用 Vite 构建 `panel/`,写出 `assets/panel/panel.js`、
1046
- `assets/panel/panel.css` 和它用的那个字体。三个文件都提交进仓库,所以安装时不需要
1047
- 用 Node 构建什么、也不从网上取任何东西 —— 只有改 `panel/` 本身才需要构建。产物要跟
1048
- 引起它变化的那次改动一起提交:`make panel-check` 先做类型检查,再在提交的产物与
1049
- 源码构建出来的不一致时失败 —— 构建是逐字节可复现的,所以这是一道真的过期检查,
1050
- 不是抛硬币。`make panel-smoke` 是浏览器那一关:它自己起一个临时服务,用本机的
1051
- Chrome 打开五个页面和两个详情,浏览器有任何抱怨就失败。这一关需要 Playwright,
1052
- 所以不进 `make test` —— 它是发布前手动跑的关卡。其余细节在 `panel/README.md`。
1053
-
1054
- ```
1055
- src/ # 平铺,一层一行:命令在最前,底层在最后
1056
- ├── cli.ts # `tunnel` 命令
1057
- ├── mcp.ts # `tunnel mcp`:同一套操作,做成 MCP 工具
1058
- ├── index.ts # startTunnel():同一套操作,在测试自己的进程里
1059
- ├── cli-*.ts # cli.ts 跑的那些:参数、公共部分、规则、流量、CA、服务
1060
- ├── proxy.ts proxy-url.ts admin.ts app.ts # 代理口;管理 API 和面板
1061
- ├── core.ts # resolve(request) -> response
1062
- ├── script*.ts upstream*.ts # 在 worker 线程里跑的 handler(ctx),或者调用方进程里的
1063
- │ # 函数;去真实服务器的路
1064
- ├── recordings.ts # 录制目录,以及样本怎么挑
1065
- ├── exchanges.ts certs.ts # 请求日志;CA 和它签的证书
1066
- ├── config.ts rule-keys.ts messages.ts # 设置;规则的 key;请求和响应
1067
- ├── models.ts # 规则和 config,连同它们的默认值
1068
- └── json.ts validate.ts glob.ts number-format.ts certs-der.ts errors.ts log.ts package-info.ts
1069
- # 底层:留得住超过 2^53 的整数的 JSON、校验、
1070
- # glob 匹配、%g、DER、错误、日志和版本号
1071
-
1072
- assets/ # 原样放进包里发出去
1073
- ├── AGENTS.md # `tunnel agents` 打印的那份给 agent 看的文档
1074
- └── panel/ # 构建好的面板:panel.js、panel.css 和它的字体
1075
-
1076
- tests/
1077
- ├── *.test.ts # 整套测试:一个模块一个文件,或者一种行为一个文件
1078
- ├── contract/ # 黑盒:把 `tunnel` 命令当成子进程来测
1079
- ├── support/ # 共用的辅助:临时目录、假的服务器
1080
- └── fixtures/ # 契约测试比对用的定稿数据
1081
-
1082
- panel/ # 面板的源码:React、Catalyst 和 Vite
1083
- └── src/ # `make panel` 构建的就是它;见 panel/README.md
1084
- ```
1085
-
1086
- ### 发版
1087
-
1088
- npm 不会收同一个版本两次,所以每次发版都有自己的版本号,而它只写在 `package.json`
1089
- 这一个地方:`tunnel --version`、横幅、`GET /__admin__/health` 和面板的页脚都从那里
1090
- 读。
1091
-
1092
- 1. `npm version <x.y.z> --no-git-tag-version` 把它同时写进 `package.json` 和
1093
- `package-lock.json`。
1094
- 2. `make check-install` 打包,检查 tarball 里不多不少正是该有的那些文件,装上它,
1095
- 跑一下命令和库入口,再对着库的类型检查一个小消费者。
1096
- 3. 提交,给这个提交打 tag `v<x.y.z>`:轻量 tag,三段都写全。
1097
- 4. 在这个提交上、干净的工作树里 `npm publish`。它的 `prepack` 会先重新构建 `dist/`。
1098
-
1099
- ## 许可证
1100
-
1101
- MIT —— 见 [LICENSE](LICENSE)。