# HTTPS 解密与改写

`[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]`

```ini
[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:

```ini
[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)` 写在开头可用。

## <a id="url-rewrite"></a>`[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` | **否** |

后缀不改变行为。不确定时请使用不带后缀的形式。

### 例子

```ini
[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` 的值取行尾剩下的全部内容(空格保留)，所以值里可以有空格，不用加引号。

```ini
[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，超过的不改写。

```ini
[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`。

```ini
[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)://` 链接是现实可用的：脚本本体在启动时下载并缓存，链接挂了就用缓存，拉不到就跳过这个脚本(不影响其它段)。

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