JavaScript 脚本
Chute 支持 JavaScript 脚本,用于高级请求/响应修改、自定义规则匹配、DNS 解析和定时任务。脚本在 Apple 平台上运行于 JavaScriptCore,在 Android 上运行于 QuickJS,遵循 Surge 兼容的脚本 API。
脚本在配置文件的 [Script] 段中定义。
配置
[Script]
MyScript = type=http-request, script-path=/path/to/script.js, pattern=^https?://example\.com, requires-body=true, max-size=65536, timeout=10, argument=myArg
CronJob = type=cron, script-path=/path/to/cron.js, cron-expression=*/30 * * * *
脚本参数
| 参数 | 必填 | 默认值 | 描述 |
|---|---|---|---|
type |
否 | http-request |
脚本触发类型(见下文) |
script-path |
是 | — | JS 脚本的本地文件路径或 HTTP(S) URL |
pattern |
否 | (匹配所有) | 过滤脚本触发时机的 URL 正则表达式。它在请求完整 URL 的任意位置匹配——解密后是 https://…,明文 HTTP 是 http://…——从不单独拿路径去匹配,所以 ^/api 这样的模式不会命中 |
requires-body |
否 | 自动检测 | 强制脚本接收完整的请求/响应 Body |
max-size |
否 | 131072 (128KB) | 需要访问 Body 的脚本的最大 Body 大小(字节)。如果收集到的 Body 超过此大小,该请求将跳过脚本(Body 不会被截断)。在 HTTP/1.x——明文 HTTP 和解密后的 HTTP/1.1——上,无论这里怎么写,Chute 最多只为脚本缓冲 131072 字节,所以在那里它只能调低上限;解密后的 HTTP/2 报文会被完整交给脚本,只按 max-size 检查 |
timeout |
否 | 5.0 秒 | 每次执行的超时时间,从执行开始时计时:加载脚本源(对远程脚本来说就是下载它)、运行代码以及等待定时器和请求都共用这段时间。超过 30 秒的值将被限制为 30 秒 |
argument |
否 | — | 自定义字符串参数,在 JS 中以 $argument 访问 |
full-header-mode |
否 | false | 为 true 时,$request.headers 和 $response.headers 不再是对象,而是 {field, value} 数组,每个头部行一项(Surge 的格式)。适用于 http-request、http-request-before-send 和 http-response 脚本;无论是否开启,$done() 都接受两种形式。见有多行的头部 |
debug |
否 | false | 保留参数;目前无任何效果 |
cron-expression |
否 | — | Cron 调度表达式(仅 cron 类型);也接受 cronexp= 和 Shadowrocket 的 cronexpr=,视为同一个键 |
event-name |
否 | (全部事件) | event 脚本监听的事件——network-changed、engine-started 或 profile-reloaded;不写名称时三个事件都会运行(见 Event 脚本) |
wake-system |
否 | false | 保留参数;会被解析但目前无任何效果 |
enable |
否 | true | 启用或禁用此脚本 |
script-update-interval |
否 | 0 | 远程脚本更新轮询(见执行模型) |
binary-body-mode |
否 | — | 为兼容 Surge 而接受(也可写作 binary-mode),保存配置时会保留,但不改变任何行为:原始字节始终在 .bodyBytes 中 |
engine |
否 | — | 会被接受并保留,但会被忽略:每个脚本都在所在平台自己的引擎上运行 |
脚本类型
| 类型字符串 | 枚举 | 描述 |
|---|---|---|
http-request |
HTTP Request | 在请求发送到上游之前拦截并修改 HTTP 请求 |
http-response |
HTTP Response | 在响应返回给客户端之前拦截并修改 HTTP 响应 |
http-request-before-send |
HTTP Request Before Send | 在请求即将发往上游前修改请求 |
rule |
Rule | 自定义规则匹配逻辑 |
dns |
DNS | 自定义 DNS 解析 |
cron |
Cron | 定时/计划脚本 |
event |
Event | 系统事件处理(例如 network-changed) |
generic |
Generic | 为兼容 Surge 而接受;它不挂接到请求、规则、DNS 或事件上,所以 Chute 不会自行运行它。可以在 Chute iOS 或 Chute Android 的脚本列表中用运行来执行它,或者使用 HTTP 控制 API 的 POST /api/scripts/run |
未知的 type= 会在日志中报告,并按 http-request 处理。
Body 自动检测:对于
http-request和http-response脚本,源码中包含$request.body或$response.body时会自动获得 Body,最大不超过max-size。使用requires-body=true可强制此行为。只读取.bodyBytes或.rawBody的脚本不会被检测到,http-request-before-send脚本也永远不会被检测:这些都需要requires-body=true。
JavaScript API 参考
脚本在沙盒化的 JavaScript 环境中运行——Apple 平台上是 JavaScriptCore,Android 上是 QuickJS——以下全局对象可用。
$request(只读)
可用范围:http-request、http-response、http-request-before-send、rule、dns
| 属性 | 类型 | 描述 |
|---|---|---|
.url |
String | 完整请求 URL;除非是该 scheme 的默认端口,否则会写出端口,IPv6 主机放在方括号中。请求目标本身已是绝对 URL 时原样使用,不以 / 开头的目标会在前面补上 /,不知道主机时只有路径。在 rule 脚本中是目标主机,在 dns 脚本中是被查询的域名 |
.method |
String | HTTP 方法(GET、POST 等),DNS 查询时为 QUERY |
.headers |
Object 或 Array | 请求头,每个名称对应它的值;有多行的头部,各行的值合并成一个字符串(见有多行的头部)。full-header-mode=true 时是 {field, value} 数组。按名称读取时不区分大小写(见头部名称) |
.body |
String 或 null | 请求 Body(UTF-8 解码) |
.bodyBytes |
Bytes 或 null | 原始请求 Body 字节(见下面的说明) |
.hostname |
String | 目标主机名 |
.destPort |
Number | 目标端口 |
.processPath |
String | 发起请求的进程路径(macOS;在 Android 上,对于 VPN 捕获的 TCP 连接,是应用的 APK 路径) |
.userAgent |
String | User-Agent 请求头值 |
.sourceIP |
String | 来源 IP 地址 |
.listenPort |
Number | 代理监听端口 |
.requestId |
String | 唯一请求 ID |
.dnsResult |
String | 解析后的 IP 地址 |
.srcPort |
Number | 来源端口 |
.protocol |
String | 检测到的协议:http、https、tcp、dns |
$response(只读)
可用范围:http-response
| 属性 | 类型 | 描述 |
|---|---|---|
.status |
Number | HTTP 状态码 |
.headers |
Object 或 Array | 响应头,每个名称对应它的值;有多行的头部,各行的值合并成一个字符串(见有多行的头部)。full-header-mode=true 时是 {field, value} 数组。按名称读取时不区分大小写(见头部名称) |
.body |
String 或 null | 响应 Body(UTF-8 解码) |
.bodyBytes |
Bytes 或 null | 原始响应 Body 字节(见下面的说明) |
.rawBody |
Bytes 或 null | .bodyBytes 的别名 |
.body、.bodyBytes与.rawBody拿到的是同一份 Body:交给脚本之前已去掉 chunked 分块,并解开 gzip 或 deflate 的Content-Encoding。.bodyBytes与.rawBody存放的是一个字节对象。把它传给接受字节的调用——$utils.ungzip()(其结果同样是字节对象),或者$done()里的body,它接受这种对象作为原始 Body。这个对象具体是什么取决于引擎:在 Apple 平台上是一个没有.length、也不能下标访问的不透明包装对象,在 Android 上是Uint8Array,所以打算在所有平台上运行的脚本不应依赖.length或下标访问。要读内容请用.body,那就是同样这些字节按 UTF-8 解码的结果。
$done(value) — 完成处理函数
必须在脚本结束时恰好调用一次,以表示脚本完成。脚本返回时若一次都没调用过它,会立即按直通处理——除非还有未完成的 setTimeout 定时器或 $httpClient 请求。timeout= 从执行开始的那一刻计时:加载脚本源、运行代码以及这段等待都计入其中。
$done({}) // 直通 — 不做任何修改
$done() // 中止连接
$done({matched: true}) // 规则匹配结果(仅 rule 脚本)
$done({address: "1.2.3.4"}) // DNS 结果(仅 dns 脚本)
在 http-request、http-request-before-send 或 http-response 脚本里,不带参数(或参数不是对象)地调用 $done() 会中止连接:Chute 不会发出任何东西来代替这个请求或响应,被中止的请求也不会到达服务器。已经在发往客户端的数据会先写完,然后连接立即关闭。在 HTTP/2 上,只有这个请求的流会被重置(RST_STREAM,错误码 CANCEL),不会发出任何替代内容;连接以及其上的其他请求照常进行。
脚本给出的头部和 url 在使用前会先检查。出现以下情况时,头部会被丢弃,并在日志里记一条警告:
- 名称或值里含换行(CR 或 LF)或 NUL 字符:
[JS] Dropped header <name>: a header name or value cannot contain a line break; - 名称不是合法的 HTTP 字段名(RFC 9110 §5.1)。合法的名称由一个或多个字母、数字或
!#$%&'*+-.^_`|~中的字符组成,所以空名称,或含空格、冒号、非 ASCII 字母的名称都会被丢弃:[JS] Dropped header <name>: not a valid header name。
结果中的其余部分照常生效。以数组形式给出的头部,以及请求脚本返回的 response 里的头部,也按同样的规则检查。含换行、NUL 或空格的 url 会被忽略,同样记警告。url 的路径和查询按原样发送,%20 这类转义不会被解开。
HTTP 请求脚本返回值:
$done({
url: "https://new.example.com/path", // 重写 URL
headers: {"X-Custom": "value"}, // 修改请求头
body: "new request body", // 修改 Body
response: { // 返回合成响应(跳过上游)
status: 200,
headers: {"Content-Type": "text/html"},
body: "<html>Blocked</html>"
}
})
当提供 response 时,请求被短路:Chute 直接向客户端返回合成响应,请求不会发往上游服务器。无论 Chute 在哪条路径上处理这个请求(包括 HTTP/2),也无论脚本是在请求头到达时运行、拿到完整 Body 后运行,还是作为 http-request-before-send 运行,都是如此。在 HTTP/1.x 上,状态行带该状态码的标准原因短语(HTTP/1.1 404 Not Found),响应发完后连接关闭;在 HTTP/2 上只应答这一个请求的流。status 是 200 到 599 之间的数字,或去掉首尾空白后是这样一个整数的字符串("404");其他值或不给时为 200。Content-Length 按 body 计算,不管 headers 里怎么写。这对于阻止请求、模拟 API 或返回缓存内容非常有用。
Chute 自己生成的应答——模拟响应(Map Local)、脚本的
response,以及 URL 重写和 REJECT 类策略的应答与错误页——遵守 HTTP:对 HEAD 请求的应答只有头部,其中的 Content-Length 与 GET 得到的相同;状态码为 204、205 或 304 的应答既不带 Body,也不带 Content-Length。
HTTP 响应脚本返回值:
$done({
status: 200, // 修改状态码
headers: {"X-Custom": "value"}, // 修改响应头
body: "new response body", // 修改响应 Body
url: "https://other.example.com" // 重定向(默认 302)
})
body 只替换响应的 Body,其他不变:状态行和头部仍是服务器的,脚本返回的 status 和 headers 合并进去,服务器原来的 Body 被丢弃。如果脚本只在响应头上运行,之后 Body 重写规则和要读取 Body 的 http-response 脚本照常运行,作用在新 Body 上。对 HEAD 的响应,以及状态码为 1xx、204、205 或 304 的响应没有 Body,所以返回的 body 在这些响应上会被忽略,但 status 和 headers 仍然生效。
status 是 200 到 599 之间的数字,或去掉首尾空白后是这样一个整数的字符串("301")。其他值(包括 true 和 false)都会被忽略,响应保留原来的状态码。改变了状态码的 status 也会让状态行带上新状态码的标准原因短语。status 为 204、205 或 304 时,响应不带 Body(服务器的 Body 和返回的 body 都不发出),也不带长度头;在 HTTP/1.x 上,头部发完后连接即关闭。
当提供 url 时,Chute 返回一个指向它的重定向,替代原始响应。重定向的状态码是返回的 status(例如 301、307 或 308),没有给出或被忽略时为 302;返回的 headers 照常合并进去,Location 就是 url。没有 body 时重定向不带 Body,服务器的 Body 不会发出;在 HTTP/2 上,服务器的 trailers 也不会发出。url 必须与 status / headers / body 中的至少一项一起返回;仅包含 url 的结果会被视为直通。
DNS 脚本返回值:
$done({address: "1.2.3.4"}) // 单个 IP
$done({addresses: ["1.2.3.4", "5.6.7.8"]}) // 多个 IP
$done({address: "10.0.0.1", ttl: 300}) // 自定义 TTL(秒,默认 60)
$done({server: "1.1.1.1"}) // 改由该服务器解析
$done({servers: ["tls://dns.google", "8.8.8.8#disable-ipv6"]})
server/servers不是直接给出答案,而是指定由谁解析:每一项都是普通的 DNS 服务器条目——地址、tls://、https://,并可带常见的#选项——它们会被同时询问,最先回答者胜出。若其中没有一项可用,该次查询直接失败,而不会回落到已配置的服务器池。
有多行的头部
一个头部可以出现在多行上:设置两个 Cookie 的响应带有两行 Set-Cookie。http-request、http-request-before-send 和 http-response 脚本可以读到每一行,也可以写出多行。
读取
默认情况下,$request.headers 和 $response.headers 是对象,每个头部名称对应一个字符串。头部有多行时,各行的值按顺序合并:Cookie 的值用 "; " 连接,其他头部(包括 Set-Cookie)的值用 ", " 连接。
$response.headers["Set-Cookie"] // "a=1; Path=/, b=2; Path=/"
$request.headers["Cookie"] // "a=1; b=2"
Cookie 的 Expires 日期里含有逗号,所以合并后的 Set-Cookie 字符串无法可靠地拆回一个个 Cookie。要逐行读取,在脚本那一行加上 full-header-mode=true。这时 $request.headers 和 $response.headers 是数组,每行一个 {field, value} 对象,与 Surge 的格式相同。同名的各行保持原来的顺序。
// full-header-mode=true
const cookies = $response.headers
.filter(h => h.field.toLowerCase() === "set-cookie")
.map(h => h.value) // ["a=1; Path=/", "b=2; Path=/"]
在 HTTP/2 上也是如此:客户端把 Cookie 拆成多个 cookie 字段时,脚本读到的是用 "; " 连接成的一个 Cookie;每个 set-cookie 各占一行。
写入
$done() 里的 headers 只改动它列出的头部,其他头部保持原样。请求脚本用来应答的 response 里的 headers 也遵循同样的规则。
| 值 | 效果 |
|---|---|
| 字符串 | 该头部只剩一行,值为这个字符串;原有的各行都被替换 |
| 字符串数组 | 每个值一行,按顺序替换该头部原有的各行。{"Set-Cookie": ["a=1", "b=2"]} 发出两行 Set-Cookie |
[] |
删除该头部 |
其他值(数字、null、对象、含非字符串元素的数组) |
忽略:该头部保持原样 |
脚本把读到的合并字符串原样返回时,该头部保留原来的各行。所以 const h = $response.headers; h["X-A"] = "1"; $done({headers: h}) 只改动 X-A,两行 Set-Cookie 发出时仍是两行。其他任何字符串都会把该头部的所有行替换成一行。要添加一个 Cookie,请用数组写出完整列表:先在 full-header 模式下读出现有的各行,再返回 {"Set-Cookie": [...existing, "c=3"]}。
一个请求只带一行 Cookie:http-request 或 http-request-before-send 脚本在 headers 里给 Cookie 的数组会用 "; " 连接,所以 {"Cookie": ["a=1", "b=2"]} 发出的是 Cookie: a=1; b=2。
无论是否开启 full-header-mode,$done() 也接受数组形式的 headers,数组元素是 {field, value} 对象:
$done({headers: [{field: "Set-Cookie", value: "a=1"},
{field: "Set-Cookie", value: "b=2"},
{field: "X-A", value: "1"}]})
各项按名称分组(不区分大小写),每组按给出的顺序替换该头部原有的各行。数组里没有列出的头部保持原样,所以漏写一个头部并不会删除它;要删除头部,请用对象形式并给 []。缺少字符串 field 或字符串 value 的项会被忽略。
- 头部名称不区分大小写:
set-cookie和Set-Cookie是同一个头部,所以每个名称只写一次。不是合法头部名称的名称会被丢弃,见$done中的说明。 - 含换行或 NUL 的值会被丢弃,见
$done中的说明。在数组里只丢弃这一个值;如果所有值都被丢弃,该头部保持原样。 - 在 HTTP/2 上,每一行都成为一个单独的字段,名称为小写。只对单个连接有效的头部(例如
Connection和Keep-Alive)不会发出。
$request.headers 与 $response.headers 中的头部名称
$request.headers 和 $response.headers 查找头部时不管名称的大小写怎么写:$response.headers['ETag']、$response.headers['etag'] 和 $response.headers['Etag'] 读到的是同一个头部。这同样适用于 in('etag' in $response.headers)、赋值(headers['content-type'] = 'text/html' 改的是对象里已有的 Content-Type,而不是为它另加一个名称)以及 delete。
列出名称时(Object.keys()、for…in、JSON.stringify()),每个头部只出现一次,写法是 Chute 存储它的写法——不管客户端或服务器怎么写,每个单词首字母大写:Content-Type、Etag、X-Api-Key、Www-Authenticate。
只有从 $request.headers 或 $response.headers 读到的那个对象才这样匹配名称。你自己做的副本,例如 Object.assign({}, $request.headers) 或 {...$response.headers},是普通对象;在副本里请用上面那种写法。full-header-mode=true 时,headers 是 {field, value} 对象组成的数组,field 也是同样的写法,这种查找不适用;请改为比较 field.toLowerCase()。
$httpClient — 异步 HTTP 客户端
在脚本中发起 HTTP 请求。所有请求在 $done() 或超时时被取消。
$httpClient.get(url, function(error, response, data) {
if (error) {
console.log("Request failed: " + error)
} else {
console.log("Status: " + response.status)
console.log("Response: " + data)
}
})
$httpClient.post(url, {headers: {...}, body: "...", timeout: 5}, callback)
$httpClient.put(url, options, callback)
$httpClient.del(url, options, callback)
$httpClient.head(url, options, callback)
$httpClient.options(url, options, callback)
$httpClient.patch(url, options, callback)
选项对象还可以携带 policy,即配置中已定义的策略名称:该请求会改为经该策略发出,而不是走默认路由。配置中没有定义的名称会在日志中给出警告,请求走默认路由。Surge 的内联 policy-descriptor 形式不受支持:它会在日志中给出警告,请求同样走默认路由——请在配置中定义该策略并传入它的名称。
回调签名:callback(error, response, data)
error:错误字符串或nullresponse:{status: Number, headers: Object}或null。headers查找头部时不区分名称的大小写,列出的名称与$request.headers的写法相同——每个单词首字母大写,如Content-Type、Etag、X-Api-Key;带不带policy都一样data:按 UTF-8 解码的响应 Body 字符串;不是合法 UTF-8 的 Body 会以像.bodyBytes那样的字节对象到达,而不是null。只有根本没有 Body 时才是null
$httpClient可在http-request、http-response、http-request-before-send、cron和event脚本中使用。它在rule和dns脚本中不可用。单个脚本最多同时有 8 个未完成的请求,所有脚本合计最多 16 个。超过任一上限的请求不会被发送,其回调也永远不会运行;每次运行只在日志中提示一次。响应 Body 超过 4 MB 时请求失败。
$persistentStore — 键值存储
持久化的键值存储,在脚本和进程重启后仍然保留;所有脚本读写的是同一组键。在 Android 上,它保存在 Chute 的私有存储中,不会被纳入设备备份。
$persistentStore.write(data, key) // 存储一个值
$persistentStore.read(key) // 读取一个值
$persistentStore.remove(key) // 删除一个值
$notification — 本地通知
发送本地系统通知。Chute Apple TV 不显示任何通知。
$notification.post("Title", "Subtitle", "Notification body text")
可选的第四个参数携带 Surge 的选项:url(点击通知时打开的链接)、action(写了 url 时默认为 open-url,这也是应用唯一会处理的动作——其他动作,如 Surge 的 clipboard,会被携带,但没有任何地方处理它们)和 auto-dismiss(秒)。它们随通知一起附带;其他键会被忽略并给出警告。url 由 Chute iOS、Chute Mac 与 Chute Android 处理,点击通知时会打开它。auto-dismiss 只有 Chute Android 会处理,它会在这么多秒后移除该通知;Apple 平台上的应用则让通知留在原处。
$notification.post("Title", "", "Tap to open", {url: "https://example.com", "auto-dismiss": 5})
脚本发出的通知与带 notification-text 的规则发出的通知一样,遵循应用里的通知开关:Chute iOS 是允许通知,Chute Mac 是显示事件上报通知,Chute Android 是系统授予 Chute 的通知权限。把它关掉就什么都不会显示。Chute Android 会把它们发到单独的通知渠道脚本通知中,这个渠道可以在系统的通知设置里单独关闭。参见通知报告。
$network — 网络信息
只读的网络状态信息。
$network.dns // DNS 服务器 IP 数组
$network.wifi // {ssid: "WiFiName", bssid: null}
$network.v4 // {primaryAddress, primaryRouter}
$network.v6 // {primaryAddress, primaryRouter}
$network.primaryRouter // IPv4 默认网关,没有时为 null
$network.cellularData // {radio: "LTE" | "5G" | ..., carrier: null}
ssid与bssid在 iOS 上有值——那里隧道可以读取 Wi-Fi 身份;在 Android 上,当 Chute 拥有 Android 读取网络名称所需的权限时也有值(见 Android 快速上手);在 macOS 与 tvOS 上ssid为空、bssid为null。primaryAddress会跳过隧道自身的地址和链路本地地址,因此它就是设备接入网络所用的地址。carrier恒为null——iOS 16 起不再提供运营商名称。
$environment — 运行时信息
$environment.system // "iOS"、"macOS" 或 "Android"
$environment.appVersion // KLNEKit SDK 版本字符串
$environment.surgeVersion // 引擎自身的版本,使用 Surge 脚本读取的名称
$environment.language // 以 BCP 47 标签表示的首选语言,例如 "en-US"
$environment.deviceModel // "iPhone"、"Mac" 或 "AppleTV";Android 上是设备型号,例如 "Pixel 8"
$utils — 工具函数
$utils.geoip("1.2.3.4") // 国家代码(例如 "US")
$utils.ipasn("1.2.3.4") // ASN 号码(例如 "13335")
$utils.ipaso("1.2.3.4") // ASN 所属组织(例如 "CLOUDFLARENET")
$utils.ungzip(data) // 解压 gzip 数据
$klne — 代理控制 API
从脚本中控制代理运行时。$klne 在所有脚本类型中均可用。
$klne.getPolicyGroups() // 获取所有策略组
$klne.selectGroupDetails() // 同样这些策略组,采用 Surge 的数据结构
$klne.selectPolicy("Group", "Proxy") // 切换策略组中的策略
$klne.getActiveConnections() // 列出活跃连接
$klne.closeConnection("id") // 按 getActiveConnections() 给出的 id 关闭一条连接
$klne.flushDNS() // 清除 DNS 缓存
$klne.startURLTest("Group") // 对策略组触发 URL 测试
$klne.reloadConfiguration() // 重新加载所有配置
$klne.setOutboundMode("rule") // 设置模式:"global"/"proxy"、"direct"、"rule"
$klne.setHTTPCaptureEnabled(true) // 启用/禁用 MitM
$klne.setRewriteEnabled(true) // 启用/禁用改写族
getPolicyGroups() 返回可选择的策略组:
{
count: 1, // Number — 策略组数量
policyGroups: [{
name: "MainGroup", // String — 策略组名称
type: 0, // Number — 0 select、1 url-test、2 fallback、3 ssid、5 load-balance
policyNames: ["A", "B"], // Array of String — 组的实际成员,包括订阅带来的节点
selectedIndex: 0, // Number — 当前生效策略在 policyNames 中的下标(无法确定时不存在此字段)
selectedPolicy: "A" // String — 当前生效策略的名称(无法确定时不存在此字段)
}]
}
selectGroupDetails() 按 Surge 的结构返回同样这些策略组——policyGroups 把每个组名映射到其成员名列表,decisions 把每个组名映射到当前选中的成员:
{
policyGroups: {MainGroup: ["A", "B"]},
decisions: {MainGroup: "A"}
}
选择结果无法确定的组不会出现在 decisions 里。
getActiveConnections() 返回描述当前连接的数组:
[{
id: 1042, // Number —— 连接号,也就是 closeConnection 接受的值
host: "example.com", // String — 目标主机(未知时不存在此字段)
port: 443 // Number — 目标端口
}]
其余方法接受普通参数,无返回值:
selectPolicy(group, policy)— 将policy(取自该组policyNames中的名称)设为group的当前生效策略。未知的组名或策略名会被忽略,并在日志中记录一条警告。closeConnection(id)— 关闭该 id 对应的连接,也就是getActiveConnections()返回的那个编号。若该 id 并未打开,则忽略并记录警告。flushDNS()— 清除 DNS 缓存。startURLTest(group)— 对url-test、fallback或load-balance类型的策略组发起异步延迟测试;其他组类型或未知组名会被忽略并记录警告。reloadConfiguration()— 立即重新拉取配置的#!MANAGED-CONFIG源(而不是等到更新间隔),内容有变化时应用它。重新应用引擎已经持有的配置不会带来任何变化,因此没有托管源的配置只会记录一条警告。setOutboundMode(mode)—"global"与"proxy"均表示全部走代理,"direct"表示全部直连,其他任何值都会选择规则模式。setHTTPCaptureEnabled(enabled)— 在运行时启用或禁用 HTTPS 解密(MitM);参数为布尔值。setRewriteEnabled(enabled)— 在运行时启用或禁用整个改写族——URL 重写、Header 重写、Body 重写和模拟响应;参数为布尔值。
为兼容 Surge 脚本,也提供 $surge,其中含义完全相同的调用有:$surge.setSelectGroupPolicy(group, policy)(与 selectPolicy 相同)、$surge.selectGroupDetails()(与 selectGroupDetails 相同)、$surge.setOutboundMode(mode)、$surge.setHTTPCaptureEnabled(enabled)、$surge.setRewriteEnabled(enabled)(改写族总开关,涵盖 URL 重写、Header 重写、Body 重写与模拟响应)以及 $surge.retestGroup(name)。Surge $surge 的其余部分在这里没有对应实现,读取结果为 undefined,脚本可据此判断。
$httpAPI — 控制 API 桥接
从脚本里调用引擎自己的 HTTP 控制 API。$httpAPI 在所有脚本类型中均可用。
$httpAPI("GET", "/api/status", null, function(result) {
console.log(result.statusCode) // Number —— HTTP 状态码
console.log(result.body.data) // Object —— 解析后的 JSON Body
})
$httpAPI("/api/status") // 只给一个参数:对该路径做 GET
$httpAPI("DELETE", "/api/dns/cache") // 方法加路径
var result = $httpAPI("GET", "/api/status") // 同一个对象也会作为返回值给出
这个调用是同步的——请求在引擎内部路由,{statusCode, body} 对象既传给回调,也作为返回值给出。path 必须以 / 开头。字符串形式的 body 原样发送,其他任何值都会编码成 JSON。因为请求根本不经过监听器,所以不涉及令牌,API 也不必开启。POST /api/scripts/run 会以 409 和 would_reenter 被拒绝——它需要的正是调用方脚本此刻占着的那个引擎;12 秒内没有应答的路由会得到 504。
$script — 脚本元数据
$script.name // 配置中的脚本名称
$script.type // 脚本类型字符串
$script.startTime // 单调时间戳(自系统启动基准以来的秒数,非纪元时间)
$script.sessionID // 每次执行都不同,用于把同一次运行的状态串起来
每次执行的全局变量
以下变量在每次脚本执行时注入,并且特定于某些脚本类型。
$argument — 脚本参数
来自脚本配置中 argument= 参数的字符串值;脚本没有该参数时为 null。可用范围:http-request、http-response、http-request-before-send、rule、dns、cron、event。
console.log("Argument: " + $argument)
$domain — DNS 域名(仅 DNS 脚本)
正在查询的域名。仅可在 dns 脚本中使用。
var domain = $domain // 例如 "example.com"
$cronexp — Cron 表达式(仅 Cron 脚本)
来自脚本配置的 Cron 调度表达式。仅可在 cron 脚本中使用。
console.log("Schedule: " + $cronexp) // 例如 "*/30 * * * *"
$event — 事件信息(仅 Event 脚本)
有关触发事件的信息。Chute 会触发 network-changed、engine-started 和 profile-reloaded——见 Event 脚本。
console.log("Event: " + $event.name) // "network-changed"
$event.name 是实际触发的那个事件。写了 event-name= 的脚本只会为那一个事件运行,所以这个名字永远是它自己声明的那个;没写 event-name= 的脚本三个事件都会跑,靠 $event.name 来区分。
console — 日志
console.log("Debug message") // 详细日志
console.warn("Warning message") // 警告日志
console.error("Error message") // 标记为 JavaScript 错误的警告日志
每条消息会被截断到 256 个字符,其中的凭据——Authorization 或 Cookie 请求头、password= 或 token= 的值之类——会被记录为 <redacted>。console.log 以 verbose 级别写入,所以只有 loglevel 为 verbose 时才会显示;console.warn 和 console.error 在默认的 warning 级别下就会显示。
setTimeout(fn, seconds) — 定时器
安排函数在延迟后执行。
setTimeout(function() {
console.log("Delayed execution")
}, 2.5) // 2.5 秒
$scriptImport(subScriptPath) — 子脚本加载器
加载并执行另一个 JavaScript 文件。仅支持本地文件路径;http(s):// 和 file:// URL 会被拒绝。($script 保留给脚本元数据对象使用。)
$scriptImport("/path/to/helper.js")
// 使用 $persistentStore 在脚本之间传递数据
脚本类型详解
三种 HTTP 脚本类型只在明文 HTTP 请求经 Chute 的 HTTP 代理进入时才能看到该请求;HTTPS 请求则只有在其主机被解密时才能看到——把该主机加入 HTTPS 解密中的
hostname。
HTTP 请求脚本
在收到请求头时执行。可以在请求转发之前修改 URL、请求头和 Body。
[Script]
ModifyHeaders = type=http-request, script-path=modify.js, pattern=^https://api\.example\.com
HTTP 响应脚本
在收到响应头时执行。可以在返回给客户端之前修改状态码、响应头和 Body。
[Script]
ModifyResponse = type=http-response, script-path=response.js, pattern=^https://api\.example\.com
需要 Body 的脚本要等 Body 全部到齐后才运行。在 HTTP/1.x 上,如果服务器先关闭了连接:既没有 Content-Length 也不是分块编码的 Body 以连接关闭为结尾,此时已经完整,脚本照常运行;按 Content-Length 或分块编码分帧、却被关闭截断的 Body 不会交给脚本,而是按收到的原样发给客户端,随后关闭连接。同一条报文匹配的 Body 重写规则会先应用,脚本看到的是改写后的 Body。
HTTP 请求发送前脚本
在请求即将发往上游之前执行。Body 被暂存时(本脚本设了 requires-body=true,或有 Body 重写规则、要读取 Body 的 http-request 脚本命中),脚本等 Body 全部到齐后运行;没有 Body 的请求,或 Body 超过 max-size 的请求不会被暂存,暂存途中超过 max-size 的 Body 则按原样继续发送:这两种情况下,脚本都在请求头发出时以空 Body 运行,设了 requires-body=true 的脚本则不运行。适用于修改 POST/PUT 请求 Body。它最后执行:在任何 Body 重写规则和要读取 Body 的 http-request 脚本之后,看到的是它们处理后的 Body。脚本交回的 Body 会替换请求原来的 Body(原 Body 丢弃),Content-Length 随之按新 Body 设置。
[Script]
BeforeSend = type=http-request-before-send, script-path=before-send.js, pattern=^https://api\.example\.com, requires-body=true
规则脚本
自定义规则匹配。脚本必须调用 $done({matched: true}) 或 $done({matched: false})。
[Rule]
SCRIPT,MyRuleScript,DIRECT
[Script]
MyRuleScript = type=rule, script-path=rule.js
DNS 脚本
自定义 DNS 解析。接收 $domain 并返回解析后的地址。
// dns.js
var domain = $domain
if (domain === "internal.example.com") {
$done({address: "10.0.0.1", ttl: 300})
} else {
$done({}) // 直通到正常的 DNS 解析
}
[Host] 中的 <域名> = script:<名称> 条目会把匹配域名的解析交给指定名称的 DNS 脚本,参见本地 DNS 映射。
Cron 脚本
使用 Cron 表达式进行定时执行。最小间隔为 60 秒。
[Script]
HalfHourlyTask = type=cron, script-path=task.js, cron-expression=*/30 * * * *
表达式必须正好是五个字段,字段之间用单个空格分隔。其他写法——双空格、制表符、四个字段、六个字段——都会让该脚本静默失效:它根本不会被排期,日志里也不会写任何东西。
这五个字段里只有分钟字段会被采用:*/N 表示每 N 分钟运行一次。其他任何分钟字段都会每 60 秒触发一次,脚本必须自行检查当前时间来决定是否执行操作。
Event 脚本
由系统事件触发。会触发的事件有三个:
| 事件 | 何时触发 |
|---|---|
network-changed |
Wi-Fi 或蜂窝网络发生了变化 |
engine-started |
引擎启动完成——策略、规则和监听器都已就绪 |
profile-reloaded |
一次配置重载完成,触发的是被重载的那份配置自己的脚本 |
[Script]
NetChange = type=event, script-path=network-changed.js
$event 对象可用:
$event.name // "network-changed"、"engine-started" 或 "profile-reloaded"
Surge 用 event-name= 指定事件,Chute 同样会读取:event-name=engine-started 表示该脚本只在这个事件上运行。没有指定事件的脚本三个事件都会跑,靠读 $event.name 判断是哪一个触发的。绑定到其他事件的脚本(Surge 还会触发 notification 等事件)在 Chute 中永远不会运行,配置载入时日志会说明这一点。
实用示例
重定向移动设备
一个根据 User-Agent 重定向移动用户的 http-request 脚本:
[Script]
MobileRedirect = type=http-request, script-path=mobile-redirect.js, pattern=^https://example\.com
// mobile-redirect.js
var ua = $request.headers["User-Agent"] || ""
if (/Mobile|Android|iPhone/.test(ua)) {
$done({
response: {
status: 302,
headers: {"Location": "https://m.example.com" + $request.url.replace(/.*example\.com/, "")},
body: ""
}
})
} else {
$done({})
}
屏蔽 API 响应中的内容
一个从 JSON API 响应中移除广告和赞助内容的 http-response 脚本:
[Script]
RemoveAds = type=http-response, script-path=remove-ads.js, pattern=^https://api\.example\.com/feed, requires-body=true
// remove-ads.js
var body = JSON.parse($response.body)
if (body.ads) {
delete body.ads
}
if (body.recommendations) {
body.recommendations = body.recommendations.filter(function(r) {
return !r.sponsored
})
}
$done({body: JSON.stringify(body)})
发送前修改请求 Body
一个清理 POST 负载的 http-request-before-send 脚本:
[Script]
SanitizePayload = type=http-request-before-send, script-path=sanitize.js, pattern=^https://api\.example\.com/submit, requires-body=true
// sanitize.js
var body = JSON.parse($request.body)
body.clientSecret = "[REDACTED]"
body.timestamp = Math.floor(Date.now() / 1000)
$done({body: JSON.stringify(body)})
自定义规则:基于时间的路由
一个根据时间选择不同代理的 rule 脚本:
[Rule]
SCRIPT,TimeBasedRule,ProxyA
[Script]
TimeBasedRule = type=rule, script-path=time-rule.js
// time-rule.js
var hour = new Date().getHours()
if (hour >= 9 && hour < 18) {
$done({matched: false}) // 工作时间内让给下一条规则处理
} else {
$done({matched: true}) // 非工作时间使用 ProxyA
}
为内部域名自定义 DNS
一个将内部主机名解析为本地 IP 的 dns 脚本:
[Script]
InternalDNS = type=dns, script-path=internal-dns.js
// internal-dns.js
var internalHosts = {
"gitlab.local": "10.0.0.10",
"registry.local": "10.0.0.11",
"monitor.local": "10.0.0.12"
}
if (internalHosts[$domain]) {
$done({address: internalHosts[$domain], ttl: 3600})
} else {
$done({}) // 直通到正常的 DNS 解析
}
网络变更时自动切换策略
一个在网络变更后探测连通性并据此切换策略组的 event 脚本:
[Script]
NetSwitch = type=event, script-path=network-switch.js, timeout=15
// network-switch.js
$httpClient.head("https://www.google.com/generate_204", {timeout: 5},
function(error, response, data) {
if (error) {
// 新网络下探测失败 — 切换到备用策略组
$klne.selectPolicy("MainGroup", "BackupProxy")
console.log("Probe failed after " + $event.name)
} else {
$klne.selectPolicy("MainGroup", "MainProxy")
}
$done({})
})
注意:
$network对所有脚本都可见,但其wifi.ssid只在 iOS 和 Android 上有值(见 $network 的注意事项)。macOS 与 tvOS 上该名称为空,所以要在所有平台上都能工作的 event 或 cron 脚本请照上面用$httpClient探测,而不是读 SSID。
定期健康检查
一个每 30 分钟检查代理健康状况的 cron 脚本:
[Script]
HealthCheck = type=cron, script-path=health-check.js, timeout=15, cron-expression=*/30 * * * *
// health-check.js
$httpClient.head("https://www.google.com/generate_204", {timeout: 10},
function(error, response, data) {
if (error || response.status !== 204) {
console.error("Health check failed: " + (error || "status " + response.status))
$notification.post("Chute Alert", "Health Check", "Cannot reach Google")
} else {
console.log("Health check OK")
}
$done({})
}
)
请在回调内部调用
$done({})——脚本完成时会取消所有待处理的$httpClient请求,因此若在脚本末尾同步调用$done(),健康检查会在收到响应之前就被取消。出于同样的原因,请求自己的timeout必须比脚本的短:脚本的timeout(默认 5 秒)先到期时,请求会被取消,其回调永远不会运行。上面两个示例都在[Script]行上设置了timeout=15。
用外部数据丰富 API 响应
一个通过调用辅助 API 来丰富用户数据的 http-response 脚本:
[Script]
EnrichUsers = type=http-response, script-path=enrich.js, pattern=^https://api\.example\.com/users, requires-body=true, timeout=20
// enrich.js — 同时最多 8 个请求在途,即单个脚本的上限
var users = JSON.parse($response.body)
var next = 0
var pending = 0
function launch() {
while (pending < 8 && next < users.length) {
fetchAvatar(next++)
}
if (pending === 0) {
$done({body: JSON.stringify(users)})
}
}
function fetchAvatar(index) {
pending++
$httpClient.get("https://internal-api.example.com/avatar/" + users[index].id,
function(error, resp, data) {
if (!error && resp.status === 200) {
users[index].avatar = JSON.parse(data).url
}
pending--
launch()
}
)
}
launch()
执行模型
- 所有脚本在专用的串行队列上运行,以保证线程安全。
- 每次脚本执行都有按脚本设置的超时时间,从执行开始时计时:加载脚本源(远程脚本在这段时间内下载)、运行代码以及等待定时器和请求都共用它。如果在超时时间内未调用
$done(),该脚本将被视为直通。脚本返回时没有调用$done()、又没有未完成的setTimeout定时器或$httpClient请求,会立刻按直通处理。 - 维护一个包含 3 个预热上下文的池;每次执行后上下文会被丢弃并替换为全新的上下文,因此全局变量绝不会在多次运行之间泄漏。
- 脚本引擎启动时,Chute 会记下进程的内存占用。此后一旦增长超过 10 MB(iOS 和 tvOS)或 512 MB(macOS),所有脚本都会被跳过,其报文直通;在 Android 上衡量的是已使用的 Java 堆,余量为 512 MB。这个余量针对整个进程,而不是单个脚本;起点在 Chute 运行期间也不会重新记录。
- iOS、tvOS 和 Android 上同时最多有 16 个脚本执行在进行(macOS 为 64 个),它们的脚本源合计最多 4 MB(macOS 为 8 MB)。超过任一上限的执行会被跳过,其报文直通;日志会写
execution admission is full。 - 单个脚本源上限为 macOS 1 MB、iOS/tvOS/Android 512 KB。一份配置能声明多少个脚本没有限制;有上限的是同时在加载脚本源的数量——macOS 32 个,iOS/tvOS/Android 8 个。超过上限时日志会写
source load queue is full,那一次执行会在没有脚本源的情况下跑,也就是直通。 - 远程脚本(HTTP/HTTPS 路径)在每次执行时获取。
script-update-interval为正数,或恰好是-1时,Chute 还会每 10 分钟用条件 HEAD 请求轮询该 URL。这个值只是一个开关,不是周期:不管写多少,轮询都是每 10 分钟一次。0(默认值)和其他任何负值都不开启轮询。
模块脚本集成
脚本也可以在模块文件(.sgmodule)的 [Script] 段中定义。
本页为英文版的翻译。如内容有出入,以英文版为准。