[MITM]、[URL Rewrite]、[Header Rewrite]、[Body Rewrite]、[Map Local]、[Script] 六段的完整语法。

前提

改写与脚本只对解密后的 HTTP/HTTPS 流量生效。要用它们,必须:

  1. 在客户端的「HTTPS 解密」页打开开关,生成根证书,安装并完全信任它。iOS 上「安装描述文件」之后还要去「关于本机 → 证书信任设置」里再勾一次,少这一步全部改写都不生效。
  2. 在 [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):// 链接是现实可用的:脚本本体在启动时下载并缓存,链接挂了就用缓存,拉不到就跳过这个脚本(不影响其它段)。

本地路径虽然也认,但它是相对模块文件所在目录解析的,而模块文件在应用沙盒里,普通用户放不进去。自己写脚本就把代码放在能公开访问的链接上。