JavaScript-скриптинг

Chute поддерживает JavaScript-скриптинг для расширенной модификации запросов/ответов, пользовательского сопоставления правил, разрешения DNS и запланированных задач. Скрипты выполняются на JavaScriptCore на платформах Apple и на QuickJS на Android и следуют 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 Да — Локальный путь к файлу или HTTP(S) URL к JS-скрипту
pattern Нет (совпадает со всем) URL Regex для фильтрации запуска скрипта. Он сопоставляется с любым местом полного URL запроса — https://… после расшифровки, http://… для обычного HTTP — и никогда не сопоставляется с одним лишь путём, поэтому шаблон вроде ^/api не совпадает
requires-body Нет автоопределение Принудительно передавать скрипту полное тело запроса/ответа
max-size Нет 131072 (128 КБ) Максимальный размер тела в байтах для скриптов с доступом к телу. Если собранное тело превышает этот размер, скрипт пропускается для этого запроса (тело не усекается). На HTTP/1.x — обычном HTTP и расшифрованном HTTP/1.1 — Chute буферизует для скриптов не более 131072 байт, что бы здесь ни было указано, так что там этот параметр может только понизить предел; расшифрованное сообщение HTTP/2 передаётся целиком и проверяется только по max-size
timeout Нет 5.0 секунд Тайм-аут одного выполнения, отсчитываемый с его начала: загрузка исходного кода (для удалённого скрипта — его скачивание), выполнение кода и ожидание таймеров и запросов — всё укладывается в него. Значения выше 30 секунд ограничиваются 30
argument Нет — Пользовательский строковый аргумент, доступный как $argument в JS
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 Нет — Принимается и сохраняется, но игнорируется: каждый скрипт выполняется на собственном движке платформы

Типы скриптов

Строка типа Enum Описание
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 либо через POST /api/scripts/run в HTTP API управления

Неизвестный type= отмечается в журнале и обрабатывается как http-request.

Автоопределение тела: для скриптов http-request и http-response исходный код, содержащий $request.body или $response.body, автоматически получает тело до max-size. Используйте requires-body=true для принудительного включения этого поведения. Скрипт, который читает только .bodyBytes или .rawBody, не распознаётся, а скрипты http-request-before-send не распознаются никогда: им нужен requires-body=true.


Справочник JavaScript API

Скрипты выполняются в изолированной среде JavaScript — JavaScriptCore на платформах Apple, QuickJS на Android — со следующими доступными глобальными объектами.

$request (Только чтение)

Доступно в: http-request, http-response, http-request-before-send, rule, dns

Свойство Тип Описание
.url String Полный URL запроса; порт указывается, если он не является портом схемы по умолчанию, а хост IPv6 заключается в квадратные скобки. Цель запроса, которая уже является абсолютным URL, используется как есть, перед целью, не начинающейся с /, ставится /, а если хост неизвестен, значение — только путь. В скрипте rule это хост назначения, в скрипте dns — запрашиваемый домен
.method String HTTP-метод (GET, POST и др.) или QUERY для DNS
.headers Object или Array Заголовки запроса: каждое имя со своим значением; значения заголовка из нескольких строк объединяются в одну строку (см. Заголовки из нескольких строк). С full-header-mode=true — массив {field, value}. Имена находятся независимо от регистра (см. Имена заголовков)
.body String или null Тело запроса (в кодировке UTF-8)
.bodyBytes Bytes или null Сырые байты тела запроса (см. примечание ниже)
.hostname String Целевое имя хоста
.destPort Number Порт назначения
.processPath String Путь запрашивающего процесса (macOS; на Android — путь к APK приложения, для TCP-соединений, которые перехватывает VPN)
.userAgent String Значение заголовка User-Agent
.sourceIP String IP-адрес источника
.listenPort Number Порт прослушивания прокси
.requestId String Уникальный идентификатор запроса
.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 Тело ответа (в кодировке UTF-8)
.bodyBytes Bytes или null Сырые байты тела ответа (см. примечание ниже)
.rawBody Bytes или null Псевдоним для .bodyBytes

.body, .bodyBytes и .rawBody содержат одно и то же тело: прежде чем его увидит скрипт, разбиение chunked снимается, а Content-Encoding gzip или deflate раскрывается. .bodyBytes и .rawBody содержат байтовый объект. Передайте его в вызов, принимающий байты: в $utils.ungzip(), результат которого — тоже байтовый объект, или в body у $done(), который принимает его как сырое тело. Чем именно является этот объект, зависит от движка: на платформах 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, который возвращает скрипт запроса. url с переводом строки, NUL или пробелом игнорируется, тоже с предупреждением. Путь и строка запроса url отправляются в том виде, в каком написаны: экранирование вроде %20 не раскрывается.

Возвращаемые значения скрипта HTTP Request:

$done({
    url: "https://new.example.com/path",     // Перезаписать URL
    headers: {"X-Custom": "value"},           // Изменить заголовки
    body: "new request body",                 // Изменить тело
    response: {                               // Вернуть синтетический ответ (пропустить отправку)
        status: 200,
        headers: {"Content-Type": "text/html"},
        body: "<html>Blocked</html>"
    }
})

Когда указан response, запрос замыкается: Chute возвращает синтетический ответ напрямую клиенту, а запрос не отправляется на исходный сервер. Так происходит везде, где Chute обрабатывает запрос, включая HTTP/2, и независимо от того, работает ли скрипт на заголовке, на полном теле или как 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 не несёт ни тела, ни Content-Length.

Возвращаемые значения скрипта HTTP Response:

$done({
    status: 200,                              // Изменить код статуса
    headers: {"X-Custom": "value"},           // Изменить заголовки ответа
    body: "new response body",                // Изменить тело ответа
    url: "https://other.example.com"          // Редирект (по умолчанию 302)
})

body заменяет тело ответа и больше ничего: строка состояния и заголовки остаются серверными, с влитыми в них возвращёнными status и headers, а собственное тело сервера отбрасывается. Если скрипт работал только на заголовке, после него, как обычно, выполняются правила перезаписи тела и скрипты http-response, получающие тело, — уже над новым телом. У ответа на HEAD и у ответа со статусом 1xx, 204, 205 или 304 тела нет, поэтому возвращённый body там игнорируется, а status и headers по-прежнему применяются.

status — число от 200 до 599 или строка, которая после удаления пробелов по краям является таким целым числом ("301"). Любое другое значение, включая true и false, игнорируется, и ответ сохраняет свой статус. Если status меняет код, строка состояния получает и стандартную поясняющую фразу нового кода. При status 204, 205 или 304 ответ уходит без тела — ни серверного, ни возвращённого body — и без заголовка длины; в HTTP/1.x соединение закрывается, как только отправлен заголовок.

Когда указан url, Chute вместо исходного ответа отвечает редиректом на него. Его статус — возвращённый status (например, 301, 307 или 308) или 302, если статус не задан или проигнорирован; возвращённые headers вливаются как обычно, а Location — это url. Без body у редиректа нет тела, и тело сервера не отправляется; в HTTP/2 не отправляются и трейлеры сервера. 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"

Дата Expires в cookie содержит запятую, поэтому объединённую строку 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 — отдельная строка.

Запись

headers в $done() меняет только те заголовки, которые называет; все остальные остаются как есть. Те же правила действуют для headers внутри response, которым отвечает скрипт запроса.

Значение Действие
строка У заголовка остаётся ровно одна строка с этим значением; все его прежние строки заменяются
массив строк По строке на каждое значение, по порядку, вместо строк заголовка. {"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: массив, заданный для Cookie в headers скрипта http-request или http-request-before-send, объединяется через "; ", так что {"Cookie": ["a=1", "b=2"]} отправляет Cookie: a=1; b=2.

$done() принимает headers и в виде массива объектов {field, value}, с full-header-mode или без него:

$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: Строка ошибки или null
  • response: {status: Number, headers: Object} или null. headers находит заголовок независимо от регистра имени и перечисляет имена так же, как $request.headers, — каждое слово с заглавной буквы, например Content-Type, Etag, X-Api-Key, — с policy и без него
  • data: тело ответа, декодированное как строка UTF-8; тело, не являющееся корректным UTF-8, приходит байтовым объектом, как и .bodyBytes, а не как null. null — только когда тела нет

$httpClient доступен в скриптах http-request, http-response, http-request-before-send, cron и event. Он недоступен в скриптах rule и dns.

У одного скрипта может быть не более 8 незавершённых запросов, а у всех скриптов вместе — 16. Запрос сверх любого из этих пределов не отправляется, и его колбэк не вызывается никогда; журнал сообщает об этом один раз за запуск. Тело ответа больше 4 МБ приводит к ошибке запроса.

$persistentStore — Хранилище ключ-значение

Постоянное хранилище ключ-значение, сохраняющееся между перезапусками скриптов и процессов; все скрипты читают и записывают одни и те же ключи. На Android оно хранится в закрытом хранилище Chute и исключено из резервных копий устройства.

$persistentStore.write(data, key)   // Сохранить значение
$persistentStore.read(key)          // Получить значение
$persistentStore.remove(key)        // Удалить значение

$notification — Локальные уведомления

Отправка локальных системных уведомлений. На Apple TV Chute их не показывает.

$notification.post("Title", "Subtitle", "Notification body text")

Необязательный четвёртый аргумент передаёт параметры Surge: url (ссылка, открываемая при нажатии на уведомление), action (при наличии url подразумевается open-url, и это единственное действие, которое обрабатывают приложения, — остальные, например clipboard из Surge, передаются, но их ничто не обрабатывает) и 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 в Chute Android. Выключите его — и ничего не показывается. Chute Android отправляет их в собственный канал уведомлений, Уведомления скриптов, который можно отдельно выключить в системных настройках уведомлений. См. Отчеты об уведомлениях.

$network — Информация о сети

Информация о состоянии сети (только чтение).

$network.dns   // Массив IP-адресов DNS-серверов
$network.wifi          // {ssid: "WiFiName", bssid: null}
$network.v4            // {primaryAddress, primaryRouter}
$network.v6            // {primaryAddress, primaryRouter}
$network.primaryRouter // IPv4 default gateway, or null
$network.cellularData  // {radio: "LTE" | "5G" | ..., carrier: null}

ssid и bssid заполняются на iOS, где туннель может прочитать данные сети Wi-Fi, и на Android, когда у Chute есть разрешение, которое Android требует для чтения имени сети (см. Начало работы на Android); на macOS и tvOS ssid пуст, а bssid равен null. primaryAddress пропускает собственный адрес туннеля и link-local-адреса, то есть это адрес, которым устройство выходит в сеть. carrier всегда null — в iOS 16 имя оператора убрали.

$environment — Информация о среде выполнения

$environment.system     // "iOS", "macOS" или "Android"
$environment.appVersion   // Строка версии KLNEKit SDK
$environment.surgeVersion // Версия самого движка, под именем, которое читают скрипты Surge
$environment.language     // Preferred language as a BCP 47 tag, e.g. "en-US"
$environment.deviceModel  // "iPhone", "Mac" or "AppleTV"; on Android the device's model, e.g. "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")           // Закрывает соединение по id, который вернул getActiveConnections()
$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() возвращает Array с описанием текущих соединений:

[{
  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, перезапись заголовков, перезапись тела и имитацию ответов; принимает логическое значение.

$surge доступен для скриптов Surge с вызовами, значение которых совпадает: $surge.setSelectGroupPolicy(group, policy) (то же, что selectPolicy), $surge.selectGroupDetails() (то же, что selectGroupDetails), $surge.setOutboundMode(mode), $surge.setHTTPCaptureEnabled(enabled), $surge.setRewriteEnabled(enabled) (всё семейство перезаписи: перезапись URL, перезапись заголовков, перезапись тела и имитация ответов) и $surge.retestGroup(name). Остальное из $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
})

$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  // Монотонная временная метка (секунды от точки отсчета загрузки системы, не Epoch)
$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. Поддерживаются только локальные пути к файлам; URL http(s):// и file:// отклоняются. ($script зарезервирован для объекта метаданных скрипта.)

$scriptImport("/path/to/helper.js")

// Передача данных между скриптами с использованием $persistentStore

Подробности типов скриптов

Три типа HTTP-скриптов видят обычный HTTP-запрос, только если он приходит в Chute через его HTTP-прокси, а HTTPS-запрос — только если его хост расшифровывается: добавьте хост в hostname в разделе Расшифровка HTTPS.

Скрипт HTTP Request

Выполняется при получении заголовков запроса. Может изменять URL, заголовки и тело до отправки запроса.

[Script]
ModifyHeaders = type=http-request, script-path=modify.js, pattern=^https://api\.example\.com

Скрипт HTTP Response

Выполняется при получении заголовков ответа. Может изменять статус, заголовки и тело до возврата клиенту.

[Script]
ModifyResponse = type=http-response, script-path=response.js, pattern=^https://api\.example\.com

Скрипт, получающий тело, запускается, когда оно пришло целиком. Если на HTTP/1.x сервер закрыл соединение раньше, тело без Content-Length и без chunked-кодирования завершено — закрытие и есть его конец, — и скрипт выполняется как обычно. Тело с Content-Length или chunked-кодированием, которое закрытие оборвало, скрипту не передаётся: оно уходит клиенту ровно в том виде, в каком пришло, после чего соединение закрывается. Правило перезаписи тела, совпавшее с тем же сообщением, применяется первым, и скрипт видит уже перезаписанное тело.

Скрипт HTTP Request Before Send

Выполняется непосредственно перед отправкой запроса. Когда тело удерживается — у этого скрипта requires-body=true или совпало правило перезаписи тела либо скрипт http-request, получающий тело, — скрипт запускается, когда тело пришло целиком. Запрос без тела или с телом больше max-size не удерживается, а тело, которое при удержании вырастает за max-size, уходит дальше как пришло: в обоих случаях скрипт запускается при отправке заголовка с пустым телом, а скрипт с requires-body=true пропускается. Полезно для изменения тел POST/PUT запросов. Он выполняется последним: после правила перезаписи тела и скрипта http-request, получающего тело, и видит тело, которое получилось после них. Тело, которое вернул скрипт, заменяет собственное тело запроса (оно отбрасывается), и Content-Length устанавливается по новому телу.

[Script]
BeforeSend = type=http-request-before-send, script-path=before-send.js, pattern=^https://api\.example\.com, requires-body=true

Скрипт Rule

Пользовательское сопоставление правил. Скрипт должен вызвать $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 никогда не запускается, о чём журнал сообщает при загрузке конфигурации.


Практические примеры

Перенаправление мобильных устройств

Скрипт http-request, который перенаправляет мобильных пользователей на основе User-Agent:

[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-ответах

Скрипт http-response, который удаляет рекламу и спонсируемый контент из JSON-ответа API:

[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)})

Изменение тела запроса перед отправкой

Скрипт http-request-before-send, который очищает POST-данные:

[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 для внутренних доменов

Скрипт dns, который разрешает внутренние имена хостов в локальные IP:

[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.

Периодическая проверка работоспособности

Скрипт cron, который проверяет работоспособность прокси каждые 30 минут:

[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 секунд) истечёт раньше, запрос отменяется и его колбэк не вызывается никогда. Оба примера выше задают timeout=15 в строке [Script].

Обогащение API-ответов внешними данными

Скрипт http-response, который обогащает пользовательские данные, вызывая вторичный API:

[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 МБ на iOS и tvOS или на 512 МБ на macOS, каждый скрипт пропускается, а его сообщение проходит без изменений; на Android мерой служит используемая куча Java, с запасом 512 МБ. Этот запас рассчитан на весь процесс, а не на один скрипт, и точка отсчёта не берётся заново, пока Chute работает.
  • Одновременно может идти не более 16 выполнений скриптов на iOS, tvOS и Android (64 на macOS), и вместе у них может быть не более 4 МБ исходного кода (8 МБ на macOS). Выполнение сверх любого из этих пределов пропускается, а его сообщение проходит без изменений; журнал пишет execution admission is full.
  • Исходный код скрипта может занимать не более 1 МБ на macOS и 512 КБ на iOS, tvOS и Android. Сколько скриптов может объявить конфигурация, не ограничено; ограничено то, сколько исходников скриптов может загружаться одновременно, — 32 на macOS и 8 на iOS, tvOS и Android. Сверх этого предела журнал пишет source load queue is full, и это одно выполнение идёт без исходника, то есть оборачивается пропуском.
  • Удалённые скрипты (пути HTTP/HTTPS) загружаются при каждом выполнении. Положительное значение script-update-interval или ровно -1 дополнительно заставляет Chute опрашивать URL условным HEAD-запросом каждые 10 минут. Значение здесь — только переключатель, а не период: опрос идёт раз в 10 минут, какое бы число ни стояло. 0 (значение по умолчанию) и любое другое отрицательное значение оставляют опрос выключенным.

Скрипты в модулях

Скрипты также можно определять в файлах модулей (.sgmodule) в разделе [Script].

S. Smart Rabbit LLC © All Rights Reserved            updated 2026-09-29 21:57:05

Эта страница — перевод английской версии. При расхождениях приоритет имеет английская версия.

results matching ""

    No results matching ""