模拟与故障注入
看流量回答的是应用 做了什么。这一页说的是另一半:让网络回答一些它本来不会回答的东西,好看看应用会怎么处理。一个还没写好的后端、一个返回 500 的端点、一个要八秒才回来的响应、一个干脆消失了的 API。
这里的一切在 Chute 能跑的所有平台上都成立;一切涉及 HTTPS 的,都要先对那个主机打开解密——一条 Chute 读不懂的加密请求,它也没法替它作答。
Chute 能模拟什么,不能模拟什么
Chute 介入的是连接和 HTTP 报文,它没有流量整形器,所以:
| 能 | 返回固定正文、返回指定状态码、在请求或响应前加固定延迟、直接拒绝连接、把 HTTP/3 客户端逼回 TCP、把请求发给它没要的另一台主机 |
| 不能 | 限制带宽、丢包或乱序、加抖动、让一条连接中途劣化,或者在传输层模拟某个指定的 RTT |
配置里没有 [Throttle] 段,任何地方也没有限速。如果你要的是慢的链路而不是慢的响应,那属于网络条件模拟器(Apple 的 Network Link Conditioner,或者路由器),不属于 Chute。
挑一种机制
| 要模拟 | 用 | 在哪 |
|---|---|---|
| 一段还不存在的响应正文 | [Map Local] |
模拟响应 |
| 正好是 503 | [URL Rewrite] … reject |
URL 重写 |
| 空的 200、空白图片、空 JSON 对象或数组 | reject-200、reject-img / reject-tinygif、reject-dict、reject-array |
URL 重写 |
| 其他任意状态码——401、429、500 | 带 status-code= 的 [Map Local],或一个 http-request 脚本 |
模拟响应、JS 脚本 |
| 延迟 | 一个 http-request 或 http-response 脚本 |
JS 脚本 |
| 一个压根连不上的端点 | 一条 REJECT 规则 |
内置策略 |
| 一个一直挂着、直到客户端放弃的请求 | 一条 REJECT-DROP 规则 |
内置策略 |
| 一个不肯回落到 TCP 的客户端 | block-quic |
杂项选项 |
| 同一个 URL 背后换一个后端 | [Host],或 [URL Rewrite] 的 header 模式 |
本地 DNS 映射 |
固定的响应正文
[Map Local] 用一个文件或者内联数据来应答匹配的请求。解密后的 HTTP/2 流完全不会去问真实服务器;HTTP/1.1 的请求仍会发出去,只是响应被替换:
[Map Local]
^https://api\.example\.com/v1/profile.* data="/Users/me/mocks/profile.json"
^https://api\.example\.com/v1/flags.* base64="eyJiZXRhIjogdHJ1ZX0="
它能不能生效由三件事决定:
- 正则在 URL 的任意位置匹配,和 URL 重写一样:
^https://api\.example\.com/v1/profile已经覆盖了这个路径和它后面的任何查询串,不用补.*。 data=由运行 Chute 的那台设备读取。在 Mac 上这很方便——改文件,下一个请求就看到新内容。在手机或 Apple TV 上,一个来自你 Mac 的路径毫无意义;那里请改用base64=,或者把文件用 HTTP 提供出来再用 URL 重写。- 状态码默认是
200 OK,可以用status-code=改,而且应答之后连接就关掉了。响应头写在header="Key: value|…"里。
正文支持 {{ "{{url}}" }}、{{ "{{host}}" }}、{{ "{{path}}" }}、{{ "{{method}}" }} 和 {{ "{{ua}}" }} 这几个模板变量,足够做一个把请求内容回显出来的模拟响应。
一个错误状态码
要503不需要脚本——一条 reject 模式的 URL 重写就返回 HTTP/1.1 503:
[URL Rewrite]
^https://api\.example\.com/v1/orders.* _ reject
同族的另外几种覆盖了「没有有用内容」的其他形态:reject-200(200 加空正文)、reject-img 和 reject-tinygif(一张 1×1 的 GIF)、reject-dict(JSON 的 {},200)、reject-array(JSON 的 [],200)。它们对 HTTPS 都只在该主机正在被解密时才生效。
要其他任意状态码,最短的写法是带 status-code= 的模拟响应规则:
[Map Local]
^https://api\.example\.com/v1/orders data-type=text data="rate limited" status-code=429 header="Retry-After: 30"
如果应答要随请求变化,就改用一个 http-request 脚本把请求短路掉:
[Script]
Fail429 = type=http-request, script-path=/Users/me/mocks/fail429.js, pattern=^https://api\.example\.com/v1/orders
// fail429.js —— 不联系服务器就作答
$done({
response: {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": "30"
},
body: JSON.stringify({ error: "rate_limited" })
}
})
脚本的 pattern 是在 URL 里任意位置匹配的,和各改写族一样——像 ^https://api\.example\.com/v1/orders 这样的前缀就够了,不用补 .*。
在 HTTP/1.1 上,状态行带该状态码的标准原因短语(
HTTP/1.1 429 Too Many Requests);注册表里没有名称的状态码(例如 599)不带原因短语(HTTP/1.1 599)。
延迟
注册了定时器的脚本会一直活到它调用 $done(),所以这个定时器就是一段延迟。(什么都没注册的脚本不会等:它一返回就放行。)
[Script]
SlowAPI = type=http-response, script-path=/Users/me/mocks/slow.js, pattern=^https://api\.example\.com/v1/, timeout=15
// slow.js —— 晚 8 秒再把真实响应交回去
setTimeout(function () {
$done({})
}, 8)
预算是脚本自己的 timeout:默认 5 秒,大于 30 的会被限制为 30。超时时还没调用 $done() 的脚本会被当作放行——报文原样继续——所以延迟设得比 timeout 长不会响亮地失败,它只是不再延迟了。把 timeout 设得比你想要的延迟大,就像上面的例子。
用 type=http-request 可以在联系服务器之前就延迟(应用看到的是一次慢的往返),用 type=http-response 则在之后延迟(服务器很快,应用照样等)。
一个干脆没了的端点
模拟响应规则替换的是响应;REJECT 规则拒的是连接。它作用在连接层面,所以覆盖的是任意协议而不只是 HTTP,而且不需要解密:
[Rule]
DOMAIN-SUFFIX,api.example.com,REJECT
REJECT 的各个变体应答方式不同,在这里也用得上。REJECT-DROP 从不应答,客户端会一直等到超时——服务器死掉时就是这个样子。REJECT-200、REJECT-DICT、REJECT-ARRAY、REJECT-TINYGIF / REJECT-IMG 和 REJECT-VIDEO 会给明文或已解密的 HTTP/1 请求回一个合成的 200(空内容、{}、[]、1×1 GIF、空白 MP4)。同一主机 30 秒内被拒超过 50 次后,REJECT、REJECT-TINYGIF 和 REJECT-IMG 会在接下来 30 秒变成 REJECT-DROP;REJECT-NO-DROP 和其他变体永远不会。详见内置策略。对 HTTP 请求来说,show-error-page-for-reject = true 会把干巴巴的拒绝换成一个能读的错误页,在浏览器里一眼就知道是你拦的。
这也是检查「到底有没有回落路径」的办法——把主用主机拒掉,看应用是去够备用的那个,还是就在那儿转圈。
把客户端从 HTTP/3 上赶下来
QUIC 跑在 UDP 上,Chute 解不了它的密,所以一个走 HTTP/3 的应用对本页所有机制都是隐形的。把它的 QUIC 流拒掉,兼容的客户端就会改用 TCP 重试,那里这一切都有效:
[General]
block-quic = on
auto 只在流要走代理时拒 QUIC;on 到处都拒,包括 DIRECT。对于经 TUN 进来的流量,Chute 会用 ICMP 端口不可达来应答被拒的 QUIC 流,让客户端立刻回落,而不用干等一次超时。
把请求送到别处
两种办法,在两个层面:
[Host]
api.example.com = 10.0.0.5
[Host] 映射用你指定的地址来应答 DNS 查询——可以是一台预发布机器,也可以是一个哪儿都不通的地址,如果你要的是「连接超时」而不是「连接被拒」。它对所有协议都生效,也不需要解密。改完记得清一下 DNS 缓存。
[URL Rewrite]
^https://api\.example\.com/v1/(.*) https://staging.example.com/v1/$1 header
header 模式就地改写请求并把 Host 头一并修好。这一种在 HTTP 层,对 HTTPS 需要解密。只要请求的主机不变,客户端就根本不知道它被改写了。解密后的 HTTPS 请求绑定在它原本发往的主机上,所以指向另一台主机的规则——比如上面这条——会以一个指向新 URL 的 307 来应答,由客户端自己跟过去;其他目标没法就地改写的地方,Chute 也是这样应答。
确认它真的生效了
一条从来没匹配上的规则,看起来和一条匹配了但什么也没做的规则一模一样——这正是整页内容的失败方式。
- 改写与模拟响应:Web 控制台的规则页列出本次运行中生效过的每一条 URL 重写、Header 重写、Body 重写和模拟响应规则,带次数。不在列表里就意味着它从来没匹配过。同样的数据在
GET /api/rules的rewrite_hits里。 - 逐条连接:在控制台里打开这条连接,读它的命中的改写行,那里会用规则自己的话把规则报出来。在 Dashboard 里,这条连接的常规标签页在规则下把它们计为命中,并在命中的规则列表里逐条列出。
- 脚本不出现在那张表里。脚本的证据是它自己的输出:
console.log的内容会进日志,在控制台的日志页或者GET /api/logs里读。这些行以 verbose 级别写入,所以要先把loglevel调到verbose——PUT /api/loglevel不用重载就能做到;console.warn和console.error在默认级别下就会显示。每行会被截断到 256 个字符,其中的凭据会被遮蔽。
收拾干净
从控制台、从 Dashboard 或者通过 POST /api/rewrites/:family 加的规则活在运行中的引擎里,下次重启就没了——这让它们非常适合做实验,也非常不适合放你要长期依赖的东西。写在配置文件里的规则能扛住重启,这让它很适合放一条模拟响应规则,同时也是一件极其不该忘掉的事:一条留在配置里的 [Map Local] 几周后还在应答请求,而它看起来和一个坏掉的服务器一模一样。
本页为英文版的翻译。如内容有出入,以英文版为准。