[MITM]、[URL Rewrite]、[Header Rewrite]、[Body Rewrite]、[Map Local]、[Script] 六段的完整语法。
前提
改写与脚本只对解密后的 HTTP/HTTPS 流量生效。要用它们,必须:
- 在客户端的「HTTPS 解密」页打开开关,生成根证书,安装并完全信任它。iOS 上「安装描述文件」之后还要去「关于本机 → 证书信任设置」里再勾一次,少这一步全部改写都不生效。
- 在
[MITM]段里写明要解密哪些域名。没写进 hostname 的域名,后面几段一个字都不会执行。
限制:
- Android 端不支持解密。 系统不信任用户安装的证书,
[MITM]及其后面几段永远不生效。模块照常能装,[Rule]段仍然有用。 - 做了证书钉扎(certificate pinning)的 App 会拒绝连接,这是预期行为,没有绕过办法。
- QUIC / HTTP/3 不经过解密层。 见下文。
[MITM]
[MITM]
hostname = %APPEND% httpbin.org, *.httpbin.org, -redirector*.httpbin.org
只识别 hostname 一个键。ca-p12、ca-passphrase、h2、tcp-connection 等键被忽略,写了不报错。
值按逗号分隔。%APPEND% / %INSERT% 前缀会被去掉(兼容其它客户端的合并写法),写不写都一样。
匹配语义
| 写法 | 含义 |
|---|---|
example.com |
精确匹配这一个域名 |
*.example.com |
匹配任意子域,不含 example.com 本身 |
* 单独一个 |
全量解密 |
a?c.example.com |
? 匹配单个字符 |
-cdn.example.com |
排除 |
-cdn*.example.com |
排除项也能带通配 |
排除优先于包含:只要命中任意一条 - 规则就不解密,不管有没有被别的规则包含。
要同时覆盖主域和子域,两条都写:example.com, *.example.com。
多个已启用模块的 hostname 会合并成一份。合并后如果一条包含规则都没有(全是 - 排除项),整个解密层不启用,所有改写和脚本一起失效。
不要写裸 *:所有流量都要过一遍解密和脚本、整包读进内存,设备会明显变慢;做了证书钉扎的 App 会直接连不上。
阻断 QUIC
解密只作用于 TCP 上的 TLS / HTTP。目标默认使用 HTTP/3(QUIC over UDP)时,流量不会经过解密层。可拦截该目标的 UDP 流量,使其改用 HTTP/2:
[Rule]
AND,((DOMAIN-SUFFIX,googlevideo.com),(PROTOCOL,UDP)),REJECT
Google 系、YouTube、以及一部分 CDN 都需要这一步。
处理顺序
每个被解密的请求,按这个顺序走:
[URL Rewrite] 首条命中即返回,后面全部跳过
→ [Map Local] 首条命中即返回,后面全部跳过
→ [Header Rewrite] 请求方向
→ [Body Rewrite] 请求方向
→ [Script] 的 http-request 脚本(可能直接合成响应,短路掉后面全部)
→ 发给上游
→ [Header Rewrite] 响应方向
→ [Body Rewrite] 响应方向
→ [Script] 的 http-response 脚本
→ 写回给 App
[URL Rewrite]和[Map Local]是首条命中即生效,同一段里后面的规则连看都不看,而且它们命中后连改写和请求脚本都不再执行。[Header Rewrite]和[Body Rewrite]是全部命中的按声明顺序叠加。- 响应方向的改写与
http-response脚本,匹配的都是请求被改写之前的原始 URL。 - 同一方向上,匹配到的多个脚本按声明顺序依次执行,共享同一份正文:前一个脚本改过的内容是后一个脚本看到的。
改写四段的共同规则
[URL Rewrite]、[Header Rewrite]、[Body Rewrite]、[Map Local] 都按空白切分字段,于是:
- 正则和取值里不能有空格。 要空格就把整段用双引号包起来。
- 解析时会移除双引号,且不支持
\"转义。需要在正则或替换字符串中表示双引号时,使用\x22。 - 正则是 RE2:没有环视
(?=)(?!),没有反向引用\1,(?i)写在开头可用。
[URL Rewrite]
<正则> [目标] <动作>
<正则>匹配完整 URL(含https://和查询串)。- 行尾必须是一个已知动作,否则整行被丢弃。
- 只有
header/transparent/302/307需要中间那个<目标>;其它动作不用写目标,或者写个_占位。
动作全表
| 动作 | 效果 |
|---|---|
header |
把正则匹配到的那一段替换成 <目标>,请求照常发出去。目标里可以用 $1 $2 引用捕获组 |
transparent |
同 header |
302 |
回一个 302 跳到 <目标>,目标同样支持 $1 $2 |
307 |
回一个 307 跳到 <目标>,目标同样支持 $1 $2 |
reject |
回空 200 后断开 |
reject-200 |
回空 200 |
reject-img |
回一张 1×1 PNG |
reject-tinygif |
回一张 1×1 GIF |
reject-dict |
回 200 {} |
reject-array |
回 200 [] |
reject-video |
回一段空 mp4 |
header 的正则没用 ^...$ 锚住整个 URL 时,只有命中的那一段被替换,前后原样保留。
-drop / -no-drop 后缀
只有下面这几个组合被认识,写了别的组合整行被丢弃:
| 写法 | 认识吗 |
|---|---|
reject-drop / reject-no-drop |
是 |
reject-img-no-drop / reject-dict-no-drop / reject-array-no-drop / reject-video-no-drop |
是 |
reject-img-drop / reject-dict-drop / reject-array-drop / reject-video-drop |
否 |
reject-200-no-drop / reject-tinygif-drop / reject-tinygif-no-drop |
否 |
后缀不改变行为。不确定时请使用不带后缀的形式。
例子
[URL Rewrite]
# 拦截埋点接口,回空 JSON,让 App 以为请求成功了
^https?:\/\/api\.example\.com\/v1\/track _ reject-dict
# 拦截广告图,回 1x1 图片,页面不会留一块破图
^https?:\/\/ads\.example\.com\/.*\.(png|jpg|gif)$ _ reject-img
# 把搜索强制加上参数
^https:\/\/www\.example\.com\/search\?(.*)$ https://www.example.com/search?$1&safe=off header
# 去掉 URL 里的一个查询参数,多个捕获组拼回去
^(https?:\/\/v\.example\.com\/play\?.+?)&ad=1(&.*)$ $1$2 302
这一段是按 URL 路径拦截的正确做法,[Rule] 里的规则只能按域名和 IP 拦,拦不了路径。
[Header Rewrite]
<方向> <正则> <操作> <参数...>
<方向> 是 http-request 或 http-response,省略时按请求处理。<正则> 匹配完整 URL —— 这一段只能按 URL 挑请求,不能按请求头的内容做条件。
| 操作 | 参数 | 作用 |
|---|---|---|
header-add |
<名> <值> |
追加一个值。同名的原有值保留,变成多值,不是「已存在就跳过」 |
header-del |
<名> |
删掉这个头 |
header-replace |
<名> <值> |
覆盖这个头的值 |
header-replace-regex |
<名> <匹配正则> <新值> |
对这个头的值做正则替换。原值为空时不动 |
header-add / header-replace 的值取行尾剩下的全部内容(空格保留),所以值里可以有空格,不用加引号。
[Header Rewrite]
http-request ^https:\/\/api\.example\.com\/ header-add X-Debug 1
http-request ^https:\/\/api\.example\.com\/ header-replace User-Agent Mozilla/5.0 (Macintosh)
http-response ^https:\/\/www\.example\.com\/ header-del Set-Cookie
http-request ^https:\/\/api\.example\.com\/ header-replace-regex Cookie sid=[^;]+ sid=REDACTED
[Body Rewrite]
http-request <正则> <查找1> <替换1> [<查找2> <替换2> ...]
http-response <正则> <查找1> <替换1>
http-request-jq <正则> '<jq 表达式>'
http-response-jq <正则> '<jq 表达式>'
- 非 jq 变体:
<查找>是正则,<查找>/<替换>必须成对出现,落单的最后一个会被丢弃。一行可以写多对。 - 非 jq 变体里写不了字面双引号,要匹配 JSON 里的引号用
\x22。改 JSON 优先用 jq 变体。 - jq 变体:表达式整段取用、不按空格切分,外层单引号会被剥掉。
- 方向前缀不能省。
- 正文大小上限 10 MiB,超过的不改写。
[Body Rewrite]
# 把响应里的 "vip":false 改成 true。\x22 表示双引号;直接写 " 会在解析时被移除
http-response ^https:\/\/api\.example\.com\/user \x22vip\x22:false \x22vip\x22:true
# 用 jq 删掉一个字段
http-response-jq ^https:\/\/api\.example\.com\/feed 'del(.ads)'
# 用 jq 改一个字段
http-response-jq ^https:\/\/api\.example\.com\/user '.data.level = 9'
[Map Local]
匹配到的请求直接由本地回一个响应,不发给服务器。
<正则> data-type=<类型> data=<内容> status-code=<码> header=<头>
| key | 取值 |
|---|---|
data-type |
text(默认)/ base64 / tiny-gif |
data |
响应正文。data-type=base64 时填 base64;tiny-gif 时不用填 |
data-path |
同 data |
status-code |
状态码,不写按 200 |
header |
键:值,多个用 ` |
tiny-gif 直接回一张 1×1 GIF,Content-Type 自动设成 image/gif。base64 解不开时按纯文本处理。
data-type=text 无法保留双引号(解析时会移除引号,且不支持 \"),因此 JSON 正文应使用 data-type=base64。
[Map Local]
# data 是 {"ads":[]} 的 base64
^https:\/\/api\.example\.com\/ads data-type=base64 data=eyJhZHMiOltdfQ== status-code=200 header=Content-Type:application/json
^https:\/\/img\.example\.com\/banner data-type=tiny-gif
正文或头里有空格时整段加双引号。
[Script]
<脚本名> = type=<类型>, pattern=<正则>, script-path=<链接>, requires-body=1, timeout=10, argument=<字符串>
= 前面是脚本名(只用于显示和日志),后面是逗号分隔的 key=value 列表。
参数
| key | 类型 | 说明 |
|---|---|---|
type |
见下表 | 必填,缺了整行丢弃 |
pattern |
正则 | 匹配完整 URL |
script-path |
http(s):// 链接 |
脚本本体。别名 script-url |
requires-body |
布尔 | 为真才能拿到请求/响应正文。别名 require-body |
binary-body-mode |
布尔 | 正文以二进制形式给脚本。别名 binary-mode |
timeout |
整数(秒) | 不写按 10 秒 |
argument |
字符串 | 原样交给脚本的 $argument |
max-size |
整数 | 解析但不生效,正文上限统一是 10 MiB |
cronexp |
字符串 | 解析但不执行 |
布尔值认 1 / true / yes / on。值外层的双引号会被剥掉。
KV 列表按逗号切,但不含 = 的片段会并回上一段。所以正则里的 {1,3} 量词不会被切断,argument= 里塞一整段 JSON 也是靠这条才不散架:
argument="{"lang":"off","block":true}"
反过来说:argument 的值里出现 = 会被当成一个新的 key,那一串就从这里断了。argument 里带 = 时改用别的写法(比如把整段 base64 后再传)。
脚本类型
type |
会执行吗 |
|---|---|
http-request |
是 |
http-response |
是 |
cron / generic / dns / rule / network-changed 等 |
否。保留但从不运行 |
只有 http-request 和 http-response 有意义。 定时任务类脚本装了也不会跑。
脚本运行时
脚本是标准 JavaScript,可用的全局对象:
| 全局 | 内容 |
|---|---|
$done(结果) |
结束脚本。必须调用,且只能调一次 |
$argument |
模块里 argument= 写的原始字符串 |
$request |
{url, method, headers};requires-body=1 时另有 body / bodyBytes |
$response |
仅 http-response 脚本:{status, statusCode, headers, body / bodyBytes} |
$persistentStore / $prefs |
read / write / valueForKey / setValueForKey / removeValueForKey |
$notification.post(标题, 副标题, 正文) / $notify |
记一条日志;部分平台上还会弹系统通知 |
$httpClient |
get / post / put / delete / head / options / patch,回调式 |
$task |
仅 Quantumult X 格式的模块里有,等同 $httpClient |
console.log / info / debug / warn / error |
写日志 |
$environment |
{system: "iOS"},按源格式另带 surge-version / stash-version |
$script.startTime |
脚本开始时的 Unix 秒 |
setTimeout / clearTimeout |
可用 |
setInterval / clearInterval |
空实现,不会重复调用 |
$utils.geoip / ipasn / ipaso |
空实现,恒返回空串 |
日志有硬上限:单条 512 个字符;单次运行打到第 20 行就停,实际能看到 19 行。别把整个响应体打进日志。
$httpClient 的请求:目标是回环地址、私网地址、链路本地地址时直连,其余走默认出口。超时 30 秒。
$done 的返回值
http-request 脚本:
- 返回的对象里带
status或statusCode→ 直接合成响应返回,不发给服务器,后面的请求脚本也不执行。 - 否则:
url改写请求地址,headers整体替换请求头,body/bodyBytes/rawBody替换正文。 - 返回空对象
{}= 什么都不改,照常发出去。
http-response 脚本:status / statusCode 改状态码,headers 替换响应头,body / bodyBytes / rawBody 替换正文。
包装写法 {response: {...}}:http-response 脚本全字段都认。http-request 脚本只有 status / statusCode 认包装写法,url / headers / body 只认扁平写法 —— 请求脚本一律写扁平的最省心。
正文可以是字符串、字节数组、ArrayBuffer 或数字数组。
二进制正文
binary-body-mode=1 时,正文对象的类型取决于模块的源格式:
| 源格式 | 类型 |
|---|---|
| Quantumult X | ArrayBuffer |
| Surge / Loon / Stash / Shadowrocket | Uint8Array |
两者不能互换。移植 Quantumult X 脚本时需相应调整。
脚本出错时
- 脚本抛出异常,或者超时前未调用
$done→ 请求和响应保持原样,不执行改写。 - 日志里会记一次,重复出现的降级成 debug 级。
脚本异常是模块启用后未产生预期效果的常见原因。发生异常时,请求会保持原样继续处理,客户端不会显示脚本错误。
远程脚本
script-path 只有 http(s):// 链接是现实可用的:脚本本体在启动时下载并缓存,链接挂了就用缓存,拉不到就跳过这个脚本(不影响其它段)。
本地路径虽然也认,但它是相对模块文件所在目录解析的,而模块文件在应用沙盒里,普通用户放不进去。自己写脚本就把代码放在能公开访问的链接上。