Контракт объекта splify2 в rpcd#
splify2 отдаёт веб-интерфейсу (и любому авторизованному вызывающему rpcd) объект ubus/rpcd с
именем splify2. Этот документ — контракт того объекта: методы, их вход и выход.
splify2 — тонкая обёртка на shell поверх ядра steer. Методы,
спрашивающие состояние или диагностику (status, diag, vless_nodes, explain), зовут
соответствующую подкоманду ядра и возвращают её JSON дословно. Это сделано намеренно: разбор
вывода ядра здесь означал бы вторую модель данных на shell, которая расходится с ядром при первом
же его изменении. Печатает ядро JSON — обёртка пропускает его без правок; не печатает — обёртка
отвечает единым объектом ошибки.
Соглашения#
- Транспорт. Каждый метод, принимающий вход, читает его как JSON на stdin, а не позиционным
аргументом. (
steer_installраньше читал свой блок из$2; его привели к общему виду, и теперь он тоже читает stdin. Вызывающий, передающий блок позиционно, получит пустые значения и ошибку.) - Успех — форма, описанная у каждого метода.
- Ошибка — единый объект у всех методов:
json
{ "ok": false, "error": "причина по-русски, для человека" }
Методы, проксирующие ядро, отвечают этим же объектом, когда ядро не выдало годного JSON —
например, `{"ok":false,"error":"ядро не ответило"}`.
**Ненулевой код возврата ядра сам по себе ошибкой не является:** `diag` и `vless-probe`
завершаются с `1`, когда нашли проблему, но JSON при этом валидный, и он возвращается как успех.
Полная таблица кодов — в контракте steer, §6.
- Важно для вызывающего: отказ приезжает УСПЕШНЫМ вызовом. Объект ошибки отдаётся с нулевым
кодом возврата скрипта, поэтому промис в браузере резолвится, а не отклоняется. Проверять надо
поле
ok, а не только факт отказа вызова. Интерфейс однажды именно на этом и обжёгся: ядро не отвечало,statusвозвращал объект ошибки, а страница красила роутер зелёным и писала «Работает». - Права. Методы разделены на группы read и write в
luci/root/usr/share/rpcd/acl.d/luci-app-splify2.json. Проверка на этапе сборки вbuild.shроняет сборку, если объявленный метод отсутствует в ACL. Новый метод обязан появиться и в блокеlist)скрипта rpcd, и в файле ACL. - Имя метода написано в четырёх местах, и каждое ребро между ними сторожится отдельно: блок
list)скрипта rpcd, ACL LuCI (барьер вbuild.sh), таблица ниже (стендtests/pkgmatch.sh, в обе стороны) и вызовы интерфейса вui/src/lib/rpc.ts(барьер вbuild.sh, направление «каждый вызов имеет метод»). Метод, которого интерфейс не зовёт, законен — у объекта есть вызовы со страницы протокола и из ssh; вызов без метода — нет: ubus отвечает «Method not found», а страница показывает «нет данных». - Менеджер пакетов. Методы установки работают и с
apk(OpenWrt 25.12+), и сopkg(24.10, 23.05, 22.03): менеджер определяется на старте скрипта, и от него зависят расширение файла пакета (apkилиipk) и суффикс для пакета без бинарного кода (noarchилиall). -
Скачивание. Всё, что бэкенд берёт из сети (манифест, списки, пакеты), идёт через одну функцию —
downloadиз/usr/lib/splify2/fetch.sh. Путей у неё три, по очереди:- прямой адрес;
- тот же файл с других адресов — сначала зеркало на GitLab
(
gitlab.com/<владелец>/<репозиторий>/-/raw/<ветка>/<путь>: тот же путь, сырой файл со своего домена, без заголовка и без счётчика запросов), затем хосты самого GitHub — поштучно черезapi.github.com/repos/…/contents/…(заголовокAccept: application/vnd.github.raw), а если API отказал — из архива ветки черезcodeload.github.com. Для ссылок релиза ветка —dist: релизный workflow выкладывает в неё те же пакеты; - через туннель роутера — но ТОЛЬКО если человек это включил (
fetch_mode): на время скачивания добавляетсяip rule to <адрес> lookup <таблица выхода>с приоритетом 30000 и снимается сразу после. Включённый туннель идёт ПЕРВЫМ, а не последним.
Причина — splify2#15: провайдеры закрывают
githubusercontent.comцеликом (raw.,objects.иrelease-assets.— одни адреса Fastly), и тогда роутер лишается и списков, и пакетов, хотя сам GitHub доступен. Третий путь не делается, если адрес издателя совпал с адресом узла подписки (это была бы петля), если имя разрешается в fake-IP198.18.0.0/15, или если он выключен —uci set splify2.main.fetch_via_tunnel=0.Методы, у которых обход сработал, добавляют в ответ поле
via— строку на русском о том, каким путём приехал файл. Прямое скачивание поля не добавляет. -
Перечень выпусков. Версии ядра и интерфейса и адреса их файлов бэкенд берёт сначала из
version.jsonрепозитория splify2/releases — с трёх адресов одного файла по порядку:raw.githubusercontent.com/splify2/releases/main/version.json,cdn.jsdelivr.net/gh/splify2/releases@main/version.json,splify2.github.io/releases/version.json(FETCH_REL_URLSвfetch.sh). Каждый адрес — туннелем, если он включён, затем напрямую; обходов по хостам GitHub у самого перечня нет. Файл принимается, только еслиschemaравно1. Не ответил ни один адрес, схема другая или нужного продукта в файле нет — прежний путь целиком (api.github.com, затемVERSIONветкиdist). Перечень и отказ его адресов помнятся тот же срок, что и список версий (GH_CACHE_TTL_MIN, полчаса).Пакет выпуска качает
download_rel: адреса файла изversion.jsonпо порядку (сначала выпуск в splify2/releases, потом исходный выпуск проекта), у каждого скачанного сверяетсяsha256из перечня — если на роутере естьsha256sum; не сошлось — следующий адрес. Потом прежняя лестницаdownloadпо прямой ссылке выпуска проекта (зеркало gitlab.com, contents API, архив через codeload — веткаdistдержит только последнюю версию, и её адресов в перечне нет); прямой заход по этой ссылке второй раз не делается, если она уже была среди адресов перечня. Сумма сверяется и у файла, взятого лестницей. Версии или файла в перечне нет — это ровно прежнийdownload.
Установка ядра и версии#
steer_versions (read)#
Версии ядра, доступные к установке. Спрашиваются у перечня выпусков splify2/releases
(version.json, см. «Перечень выпусков» в соглашениях), а когда он не ответил — у GitHub; из
зашитого списка не берутся: зашитый список молча ставил бы устаревшее ядро.
- Вход: нет.
- Выход:
json
{
"arch": "aarch64_cortex-a53",
"versions": ["2.0.0", "1.5.9", "1.5.8"],
"names": { "2.0.0": "2.0.0", "1.5.9": "1.5.9", "1.5.8": "1.5.8" },
"prerelease": "2.0.0"
}
`versions` — версии продукта `steer` из `versions[]` перечня выпусков в его порядке (от новой к
старой: до десяти стабильных и текущая предварительная), а на запасном пути — теги последних
релизов `splify2/steer` без ведущего `v`; в обоих случаях **отфильтрованные до цифр и точек**. Фильтр — смысл функции, а не украшение: установка принимает только такую форму, и
показывать в списке то, что она отвергнет, значит обещать невозможное (I-052). Поэтому вернуться
может меньше десяти значений, и это не сбой.
**Число составляющих не проверяется:** `26.9` — такая же законная версия, как `1.2.0`. Требование
трёх частей отвергало бы собственные выпуски проекта.
`names` — **название выпуска по его версии**. Версия и название — две разные строки, и разные они
по необходимости: версией собирается имя файла пакета (`steer-26.9-1_<арх>.apk`) и тег (`v26.9`),
поэтому в ней могут быть только цифры и точки, а выпуск называется «26.9 Andromeda» — так он
подписан на странице релизов и так о нём говорят.
Название берётся из поля `name` версии в перечне выпусков, на запасном пути — из заголовка релиза
(поле `name` ответа GitHub), и **на установку не влияет никак**: интерфейс показывает название, а
отправляет версию. Названия для версии может не быть — в перечне выпусков поля `name` у версии нет
(сейчас его нет ни у одной), у релиза нет заголовка (в ответе `null`) или название содержит `|`
(разделитель внутренней разметки); тогда версия называет себя сама. Объект, а не второй массив: параллельные списки
расходятся молча, а по версии название находится всегда.
С перечня выпусков версии читаются одним запуском `jsonfilter` (`versions[*].version`), названия —
по индексу версии и только если `name` есть хоть у одной. На запасном пути оба поля собираются
ОДНИМ обращением к GitHub, разбор — через `jsonfilter` по индексу релиза
(`@[N].tag_name`, `@[N].name`), два выражения за один запуск. Прежний `grep -o` по `"tag_name"`
пары тег—заголовок построить не мог: поле `name` встречается в ответе ещё у автора релиза и у
каждого вложения.
`prerelease` — **необязательное**: версия, которую перечень выпусков называет предварительной
(поле `prerelease` продукта), если она есть в `versions`. По полю, а не по виду строки: версия
предварительного выпуска steer — тоже `X.Y.Z`. На запасном пути поля нет.
`note` — **необязательное**, появляется только на последнем запасном пути. Когда не ответил и
перечень выпусков, список релизов живёт на `api.github.com`: зеркалирование копирует коммиты,
ветки и теги, а релизы GitHub лежат вне репозитория и на зеркало не переезжают. Там, где этот
хост закрыт или где за CGNAT выбран его неавторизованный лимит (60 запросов в час на адрес — так пришла
splify2#5), метод откатывается на файл `VERSION` главной ветки, который берётся общей
`download()` со всей её лестницей обходов (splify2#15). Тогда `versions` содержит ОДНУ версию,
`names` называет её ею же, а `note` говорит словами, почему список короткий: без него одна
строка в списке читается как «релиз всего один». На здоровом пути поля нет вовсе.
steer_install (write)#
Скачивает и ставит указанную версию ядра.
- Вход (JSON на stdin):
json
{ "version": "2.0.0", "modules": "vless hysteria2 proxy" }
- `version` — обязательно, только цифры и точки. Иное отвергается с
`"в версии допустимы только цифры и точки"`.
- `modules` — для ядра 2.0 и новее: какие модули поставить вместе с ядром, имена через пробел
или запятую (`vless`, `hysteria2`, `proxy`, `xsteer`, `obfs`, `tgws`). Незнакомое имя — отказ
`"неизвестный модуль: <имя>"` до скачивания.
- `extended` — только для выпусков 1.x: `1` или `true` выбирают `steer-extended`, иное — `steer`.
-
Ядро 2.0 и новее — пакет
steer-coreи модулиsteer-<модуль>, одной транзакцией менеджера пакетов (apk addилиopkg installс несколькими файлами). Модуль зависит отsteer-coreточной версии, поэтому в транзакцию входят: запрошенные модули, все уже стоящие (без них менеджер обновление не пропустит), модули, которые нужны текущей спеке (выходtunnelсprotocol: vless/hysteria2— свой модуль, протоколы прокси —proxy,xsteerиtgws— свои,interfaceсobfs—obfs), а при переходе соsteer-extended1.x —vless,xsteer,obfs,tgws, вшитые в него.kmod-tunменеджер берёт из фидов сам. Не скачался хоть один файл — не ставится ничего.Переход с 1.x. Прежние пакеты (
steer,steer-extended,libsteer,libsteer-wolfssl) у apk снимаются в той же транзакции:!имяв той жеapk add(уsteer— снятием закрепления имени за файлом в/etc/apk/world, после чегоsteer-coreзамещает его поreplaces); после успеха эти имена убираются изworld./etc/steer(спека, списки, подписки) пакетам не принадлежит и остаётся на месте. У opkg запретов нет: на конфликте прежние пакеты снимаются и установка повторяется (полеremovedпри неудаче повтора). -
Ядро занято коннектором (поле
busyуengine) — отказ"ядро занято: <кем>", ничего не скачивается. -
Выход (успех):
json
{ "ok": true, "installed": "steer-core 2.0.0", "modules": ["vless", "proxy"], "restarted": true }
- `installed` — у ядра 2.0 — «steer-core <версия>», а `modules` — модули, поставленные с ним;
у 1.x — имя поставленного файла.
- `via` — есть только тогда, когда пакет взят не первым адресом без замечаний: туннелем, обходом
после отказа прямой ссылки (см. «Скачивание» в соглашениях) или со следующего адреса перечня,
потому что `sha256` первого не сошёлся (см. «Перечень выпусков» там же).
- `output` — слова менеджера пакетов, если ему было что сказать. У opkg там, например,
оказывается строка о том, что списки пакетов были пусты и их пришлось обновить: зависимости
локального файла он ищет в них, а на свежей прошивке списков нет, и установка падала на
«cannot find dependency ip-full for steer». Теперь списки обновляются и установка
повторяется — один раз.
- `restarted` — **перезапустилась ли служба ядра.** Отдельно от `ok`, потому что «пакет встал» и
«роутер маршрутизирует» — разные вещи. Внимание: `false` означает не только неудачу, но и
«перезапускать было нечего» — на свежем роутере, где спеки ещё нет, служба не перезапускается
намеренно.
-
Выход (отказ): стандартный объект ошибки с одной из причин:
"в версии допустимы только цифры и точки","неизвестный модуль: <имя>","ядро занято: <кем>","не определилась архитектура","не скачалось: <имя> (нет такой версии для <арх>?)"(в том числе когдаsha256не сошёлся ни на одном адресе — тогда причина названа после тире) либо перехваченный вывод менеджера пакетов. Отказ менеджера пакетов несёт ещёremoved— снимался ли по дороге прежний вариант пакета; приtrueтекст ошибки добавляет, что маршрутизации сейчас нет. -
Откуда файлы. Имя —
steer-core-<версия>-1_<арх>.<apk|ipk>иsteer-<модуль>-<версия>-1_<арх>.…(у 1.x —steer-<версия>-1_<арх>.…илиsteer-extended-…), адреса иsha256берутся из перечня выпусков, затем прежняя лестница по ссылкеgithub.com/splify2/steer/releases/download/v<версия>/<имя>(см. «Перечень выпусков»). -
Порядок установки. Сначала ставим, и только если менеджер пожаловался на конфликт — снимаем прежний вариант и ставим снова. Порядок здесь не стиль, а разница между «установка не удалась» и «роутер больше не маршрутизирует»: удаление пакета запускает его
pre-deinstall, который останавливает и отключает службу, а остановка ядра steer сносит таблицу nft и вычищаетip rule. Пока удаление стояло первым, любой отказ установки — не та архитектура, кончилось место, битый файл — оставлял роутер без ядра, без правил и со снятым автозапуском, то есть перезагрузка уже не спасала. И всё это в ответ на «обновить». Обновление в пределах одного варианта, самый частый путь, через удаление вообще не проходит.Шаблон распознавания конфликта намеренно узкий — только слово «конфликт». Общее «unable to select packages» сюда не годится: менеджер печатает его на любую неудовлетворимую зависимость, включая «нет nftables», и снимать по нему рабочий пакет значило бы вернуть ровно ту поломку, ради которой порядок и переставлен.
-
Ставится с
--force-overwrite(базовый и расширенный владеют одним/usr/sbin/steer), дляapkдополнительно--allow-untrusted: пакеты не подписаны ключом репозитория OpenWrt, они лежат в GitHub Releases. Уopkgпроверки подписи локального файла нет вовсе, и такого флага он не знает. - После установки служба ядра включается в автозапуск.
steer_modules (read)#
Модули ядра 2.0 для карточки «Модули ядра» в «Настройки → Интерфейс».
- Вход: нет.
- Выход:
json
{ "core": "2.0.0", "modules": [
{ "name": "vless", "installed": true, "version": "2.0.0", "needed": true, "outputs": "nl" },
{ "name": "hysteria2", "installed": false, "needed": false } ] }
- `core` — версия пакета `steer-core`; пусто — ядра 2.0 нет (ядро 1.x или никакого), и ставить
модули некуда.
- `modules` — все шесть по порядку: `vless`, `hysteria2`, `proxy`, `xsteer`, `obfs`, `tgws`.
`installed` — лежит ли бинарник модуля рядом с ядром; `version` — версия пакета
`steer-<модуль>`, если он стоит пакетом; `needed` — нужен ли модуль текущей спеке, `outputs` —
каким выходам (через запятую).
- `busy` — как у `engine`: ядро ведёт коннектор.
steer_module_add (write) и steer_module_del (write)#
Поставить или снять один модуль.
- Вход:
{ "module": "hysteria2" }— имя из перечня выше; иное —"неизвестный модуль: <имя>". - Поставить. Качается
steer-<модуль>-<версия steer-core>-1_<арх>.<apk|ipk>(адреса иsha256— как уsteer_install) и ставится: модуль зависит отsteer-coreточной версии. Ядро видит модуль после перезапуска — его делает скрипт пакета модуля, если служба включена. Выход:{ "ok": true, "installed": "steer-hysteria2 2.0.0", "via"?, "output"? }. Отказы:"нет ядра steer-core","не скачалось: …", вывод менеджера пакетов. - Снять. Модуль, нужный спеке, не снимается:
"модуль нужен выходам: <выходы>". Иначеapk del/opkg removeпакетаsteer-<модуль>; стоящий мета-пакетsteer-extended(он держит свои модули зависимостью и сам ничего не несёт) снимается вместе с ним. Выход:{ "ok": true }. - Оба при коннекторе отвечают
"ядро занято: <кем>".
splify2_versions (read)#
То же для самого интерфейса.
- Вход: нет.
- Выход:
{ "current": "26.9", "versions": ["26.9", "1.2.0", "..."], "names": { "26.9": "26.9 Andromeda" } }— установленная версия, доступные (тем же фильтром «цифры и точки») и названия выпусков. Источник и разбор полей — как уsteer_versionsвыше: продуктsplify2перечня выпусков, затем релизы GitHub, включая необязательныеprereleaseиnoteи откат наVERSIONветкиdist. Установленной версии в перечне может не быть (сборка новее последнего выпуска) — перечень всё равно отдаётся, а интерфейс подписывает кнопку тем, что она сделает: «Обновить до …», «Установить …» (версия старше установленной) или «Переустановить».
splify2_install (write)#
Ставит указанную версию интерфейса. Нужен потому, что пакет лежит в GitHub Releases, а не в
репозитории OpenWrt: apk upgrade его не видит, и обновиться можно было только по ssh.
- Вход:
{ "version": "0.8.0" }. - Выход (успех):
{ "ok": true, "installed": "<имя файла>", "output": "<слова менеджера пакетов>", "reload_needed": true }.reload_neededозначает, что страницу надо перезагрузить: бандл сменился; rpcd перезапускается через две секунды в фоне, уже после ответа. Файлluci-app-splify2-<версия>-1_<noarch.apk|all.ipk>качается по адресам перечня выпусков со сверкойsha256, затем прежней лестницей — как уsteer_install. При обходе закрытогоgithubusercontent.comили несовпадении суммы первого адреса добавляетсяvia. - Выход (отказ): стандартный объект ошибки; при отказе менеджера пакетов — ещё
removed, как уsteer_install.
Состояние и здоровье ядра#
live (read)#
Весь круг опроса одним вызовом: применённое состояние, счётчики устройств, сведения о сети и — по просьбе — диагностика. Это тот метод, который интерфейс зовёт раз в пять секунд, пока открыта страница; остальные в этом разделе остаются законными и отвечают тем же, но по одному.
- Вход:
{ "diag": <bool>, "fast": <bool> }, необязательно. - Выход:
json
{
"status": { "...": "вывод steer status дословно" },
"devices": { "awg0": { "rx": "...", "tx": "...", "rx_packets": "...", "tx_packets": "..." } },
"net": { "uptime": 1200, "active_clients": 3 },
"diag": { "...": "вывод steer diag дословно, только если просили" }
}
- `status`, `devices`, `net`, `diag` — ровно то же, что отдают `status`, `dev_stats`, `net_info` и
`diag` по отдельности: считает их один и тот же код (`/usr/lib/splify2/fast.sh`), второй
реализации нет.
- `diag` приходит **только когда его просили**. Проверки ядра вдвое дороже состояния
(замер на стенде: круг 240 мс против 437 мс с ними), а меняются реже — решает вызывающий.
Ядро, которое `diag` не умеет, обозначается полем `"diag_old": true`.
- `fast` просит ядро отдать **запомненное**: `steer status --fast` печатает свой последний
полный ответ немедленно, не разбирая спеку и не спрашивая ни `/sys`, ни `nft`. Тогда в
`status` приходят `"cached": true` и `"at"` — время сборки ответа; по ним видно, что это
память, и какой она давности. Ключ уходит **только ядру**: счётчики устройств и число
клиентов считаются как обычно (они стоят единицы миллисекунд), иначе один ответ смешивал
бы память с измерением и описывал бы мгновение, которого не было.
Ядро старее ключа отвергает его кодом 2 — тогда метод молча спрашивает состояние
по-старому, и ответ приходит без `cached`.
- Ядро не ответило — общий объект ошибки, как у `status`.
- **Журнала ядра здесь нет намеренно:** его читает один экран, а `logread` стоит 76 мс на
каждом круге. За ним ходят в `engine_state`, и только пока этот экран открыт.
Зачем метод появился: каждый вызов ubus запускает скрипт объекта заново, а busybox ash разбирает его 126 мс — при том что сам ответ считается 30-90 мс. Пять вызовов на круг стоили роутеру 1232 мс каждые пять секунд, из них 630 мс уходило на пятикратный разбор одного и того же файла. LuCI к тому же складывает вызовы одного такта в ОДИН запрос к ubus и выполняет их подряд, поэтому это была и задержка на экране. Одним вызовом — 240 мс (замеры на стенде 10.8.1.87, mipsel 24kc, 880 МГц).
live, spec_get и applied_get обслуживаются до разбора остального скрипта (первые строки
files/usr/libexec/rpcd/splify2 уводят их в /usr/lib/splify2/fast.sh). На поведение это не влияет
никак — только на цену: spec_get стоит 33 мс вместо 123.
status (read)#
Применённое состояние ядра. Пропускает вывод steer status дословно. Если ядро не выдало
объекта, начинающегося с {, отвечает {"ok":false,"error":"ядро не ответило"}. Состав полей —
контракт steer §2, включая down_packets/down_bytes, at и — у запомненного ответа — cached.
Память здесь не спрашивается никогда: за ней ходят через live с fast: true.
Обратите внимание: channels в этом выводе — объединённые группы, а не исходные правила один к
одному, и name там — служебное имя набора nft. Читаемые имена исходных правил приходят в поле
channels каждой группы; именно их и стоит показывать человеку.
У выхода kind: vless без устройства бывает поле probe (контракт steer §2): ядро перебирает
узлы подписки, и up: false в это время не означает отказа. Интерфейс показывает «проверяю узлы:
3 из 26» вместо «нет устройства» и не спрашивает у такого выхода отклик — устройства ещё нет,
мерить нечего. Поля нет — «не знаем», и прежний вид остаётся: ядро может быть старее
интерфейса.
diag (read)#
Диагностика ядра, вывод steer diag дословно. Ядро завершается с 1, когда нашло вердикт
fail, но JSON печатает валидный — он возвращается как обычный ответ, ненулевой код не считается
ошибкой. Набор вердиктов (ok/note/warn/fail, note исключён из счётчиков) — контракт steer
§4. Если JSON не получен вовсе, отвечает объектом ошибки.
engine (read)#
Наличие и версия ядра.
- Вход: нет.
- Выход:
{ "present": true, "vless": <bool>, "modules": ["vless", "proxy"], "arch": "...", "version": "...", "min_version": "2.0.0", "enabled": <bool>, "running": <bool>, "ui_version": "26.10.0" }. Без ядра —{ "present": false, "vless": false, "enabled", "running", "ui_version" }(иbusy, если есть).ui_version— установленная версия самого интерфейса (пакетluci-app-splify2; пусто, если пакет поставлен мимо менеджера пакетов). Есть и без ядра (present: 0). Отдаётся здесь, а не только вsplify2_versions: тот перед ответом идёт в сеть за перечнем выпусков, а установленная версия — местное знание, и карточка «Обновление интерфейса» и подпись версии слева её не ждут.modules— модули ядра 2.0, лежащие рядом с ним:vless,hysteria2,proxy,xsteer,obfs,tgws(бинарникиsteer-<модуль>в каталоге ядра; файл, а не база пакетов — так видит их и само ядро).vless— умеет ли ядро VLESS: есть модульsteer-vless, а у прежнего ядра 1.x (один бинарник) — стоит пакетsteer-extended. Отказsteer vless ''для этого не читается.busy— есть только тогда, когда ядро ведёт steer-box-connector (sing-box для podkop и forkop на ядре steer): кем занято ядро —podkop,forkop,podkop, forkopлибоsteer-box-connector, если ни одной из двух служб нет. Признак — служба/etc/init.d/sing-boxс именем коннектора внутри (то же ищут скрипты пакетов ядра). Пока поле есть,engine_start,apply,steer_installи методы модулей отвечают"ядро занято: <кем>"и ничего не делают; коннектором splify2 не управляет.enabled— стоит ли служба в автозапуске;running— работает ли хотя бы один экземпляр. Спрашивается у procd, а не по наличию процесса: у ядра их несколько (сам steer, резолвер, клиенты vless), и «жив ли процесс с таким именем» на это не отвечает. Эти два поля — источник состояния для переключателя «Остановить всё».
engine_state (read)#
Состояние экземпляров ядра и последние строки их журнала. Появился потому, что «выход настроен» и «туннель несёт трафик» — разные вещи, а упавший туннель VLESS раньше был виден только как отсутствующее устройство, без причины.
- Вход: нет.
- Выход:
{ "instances": { "<экземпляр procd>": { "running": <bool>, "pid": <n> } }, "log": ["…"] }— экземпляры службыsteerпо ответу procd и последние двенадцать строк системного журнала со словомsteer, дословно (общим списком, а не по экземплярам). Обёртка намеренно не разбирает текст сообщений ядра: формулировки меняются вместе с ядром, и разбор здесь ломал бы отображение при каждой правке сообщения. Классифицировать строки следует по префиксуsteer[warn]/steer[info]— см. контракт steer §5.
engine_stop (write) и engine_start (write)#
Останавливают и запускают службу ядра целиком, вместе со снятием и постановкой автозапуска. Правила из ядра Linux сносит сама остановка ядра steer.
- Вход: нет.
- Выход:
{ "ok": true, "enabled": <bool>, "running": <bool> }— состояние после операции, теми же полями, что уengine. engine_startпри коннекторе (полеbusyуengine) службу steer не включает и не запускает:{ "ok": false, "error": "ядро занято: <кем>", "busy": "<кем>", "enabled": …, "running": … }.
Известное поведение: ночное обновление списков применяет настройки и поднимает службу, поэтому остановка не переживает суточный цикл. Автозапуск при этом остаётся снятым.
dev_stats (read)#
Счётчики байтов и пакетов по сетевым устройствам из /sys/class/net/*/statistics (кроме lo).
Нужны потому, что счётчик канала nft стоит на правиле метки в направлении LAN→WAN, и обратный поток
с устройства туннеля в него не попадает: на живом роутере это давало ~4,3 МБ против 223 МБ реально
скачанных. У устройств туннеля rx — то, что ядро отдало клиентам (скачано), tx — то, что взяло у
них.
- Вход: нет.
- Выход:
json
{ "devices": { "awg0": { "rx": "...", "tx": "...", "rx_packets": "...", "tx_packets": "..." } } }
support_report (read)#
Один готовый к копированию текст для чата поддержки. Собирает в него то, о чём помогающий
спрашивает первым делом: время сборки, модель роутера и версию OpenWrt, версии интерфейса и ядра,
состояние службы ядра, приговоры steer diag читаемым видом, выходы с их состоянием и число наборов
правил в ядре Linux, правила из спеки (имя, выход, вид списков), строку о резолвере доменов ядра и
по строке на подписку — есть ли файл и сколько в ней пригодных, пропущенных и чужих узлов.
- Вход: нет. Состав отчёта решает бэкенд: набор полей и есть договорённость с помогающим, а право выбрать половину означало бы отчёты, по которым нельзя задать один и тот же вопрос.
- Выход:
{ "ok": true, "text": "<весь отчёт одной строкой с переводами строк>" }. Именно текст, а не объект полей: читатель здесь не интерфейс, а человек, который жмёт «скопировать», и формулировки («поднят», «автозапуск снят») — половина смысла отчёта.
В отчёте нет и не должно быть секретов, и это его главное требование, а не оговорка: текст
уезжает в публичный чат. Не попадают ссылка подписки (в ней ключ доступа к панели), ссылки узлов,
приватные ключи туннелей, UUID и адреса узлов, имена узлов подписки (их пишет панель, и там
встречаются и рекламные сообщения, и логин владельца) и внешний адрес роутера. Способ, которым это
обеспечено, — отчёт читает только НАЗВАННЫЕ поля чужих ответов и никогда не пересказывает документ
целиком: состояние выхода несёт поля спеки (контракт steer §2), а ответ vless-nodes — весь список
узлов, поэтому дословный пересказ любого из двух был бы готовой утечкой. Проверки на каждую из этих
подстрок стоят в tests/rpcdmatch.sh.
Ничего своего метод не считает: версии, вариант сборки и состояние службы приходят от своего же
метода engine (он зовётся подпроцессом — его помощники живут в группе ядра, а копия здесь была
бы вторым местом, где решается «какая версия стоит»), приговоры и выходы — от ядра, подписки — из
common.sh и ядра. Версия интерфейса берётся
номером сборки (build-id.txt, тот же файл читает загрузчик страницы): у выпуска он равен версии
пакета, у сборки из main вдобавок несёт ревизию, а splify2_versions перед ответом идёт в сеть — в
отчёте, который собирают как раз тогда, когда с сетью беда, это недопустимо.
Цена вызова — примерно вдвое дороже обычного метода (лишний разбор скрипта объекта, 126 мс на mipsel 880 МГц, плюс три запуска ядра). Платится она раз на нажатие кнопки, а не на круге опроса.
Спека#
spec_get (read)#
Текущая спека из /etc/steer/spec.json. Файла нет или он пуст (и нет spec.yaml) — метод заводит
пустую спеку v2 {"version":2,"outputs":{"direct":{"kind":"direct"}}}, ту же, что кладёт установка
пакета, поднимает службу ядра и отдаёт её. Когда ядро ведёт steer-box-connector (поле busy у
engine), спека не заводится и служба не поднимается — отдаётся та же пустая спека, файла нет.
Так же спеку заводят applied_get и live — с той же оговоркой.
applied_get (read)#
Снимок спеки в момент последнего успешного apply — из /etc/steer/spec.applied.json. По нему
интерфейс считает «Применить · N»: сколько правил и выходов отличается от применённого.
Метода могло не быть в более старой версии пакета, поэтому вызывающий обязан ловить отказ. До
первого apply (и когда снимка нет) отдаётся текущая спека: чего не применяли — то и применено,
иначе свежепоставленный пакет показывал бы неприменённые изменения, которых человек не делал.
Почему помнит бэкенд, а не браузер: перезагрузка страницы не должна обнулять счётчик.
spec_set (write)#
Проверяет и сохраняет новую спеку.
- Вход:
{ "spec": "<вся спека строкой JSON>" }. - Выход:
{ "ok": true, "warn"?: "<что не скачалось>" }или объект ошибки с текстом отказа ядра (первой — причина нескачанного списка, если она есть).
Списки, на которые ссылается присланная спека, сначала доскачиваются (fetch_missing_lists), затем
спека проверяется ядром до записи — через управляющий сокет демона (steer ctl check), а без
него steer apply --dry-run: ядро — единственный судья, оно же будет её применять, и на диске не
должно оказаться спеки, которую оно отвергнет при следующей загрузке. Нескачанный список сохранение
не отменяет — он приезжает полем warn.
Запись атомарна: временный файл создаётся рядом с целевым, а не в /tmp. /tmp — это tmpfs, а
спека лежит на overlay, и mv между файловыми системами в busybox — это копирование, а не
rename(): обрыв на полном overlay оставлял обрубок на месте рабочей спеки, а метод отвечал
«сохранено».
Формат: записывается спека v2 (version: 2, JSON — подмножество YAML, ядро узнаёт формат по
содержимому). Если рядом с spec.json лежит spec.yaml, метод отказывает: у ядра было бы две
спеки. Перед первой записью поверх файла прежней схемы ("schema") копия остаётся как
spec.json.v1.bak.
Отпечатков и сигналов экземплярам метод больше не считает: клиентами туннелей (VLESS, hysteria2,
obfs) управляет демон ядра, а apply и reload перечитывают спеку целиком.
apply (write)#
Применяет текущую спеку через steer apply.
- Вход: нет.
- Выход:
{ "ok": <bool>, "output": "<вывод ядра целиком>" }. Обратите внимание: здесь нет поляerror— причина лежит вoutput, потому что ядро объясняет её лучше, чем обёртка могла бы пересказать. Вoutputпопадает и вывод создания зоны firewall, и предупреждения ядра (например, что у туннеля нет NAT), и сообщения о перезапущенных экземплярах. - Ядро занято коннектором. Если на роутере стоит steer-box-connector (служба
/etc/init.d/sing-box— его), ответ —{ "ok": false, "error": "ядро занято: <кем>" }, иsteer applyне вызывается: без службы steer его исполнило бы само ядро и поставило бы свою таблицуinet steerповерх таблицы коннектора (см.engine, полеbusy).
Перед применением создаются зоны firewall: для устройств туннелей (vless, hysteria2, прокси trojan, shadowsocks, socks, http, vmess, а также xsteer) — steer_vless, для interface и awg — steer_iface (с NAT), а для устройств клиентов из спеки, у которых зоны нет (tailscale0, сервер WireGuard), — steer_clients. fw4 отвергает форвардинг на
устройство, не входящее ни в одну зону, и клиент получил бы «Operation not permitted» ещё до
туннеля. Зона создаётся здесь, а не в ядре, потому что это управляющее решение и делается через
uci.
explain (read)#
- Вход:
{ "address": "1.2.3.4 | example.com" }. - Выход:
{ "text": "<вывод steer explain>" }— текст ядра дословно.
Аргумент уходит ядру позиционным, поэтому значение, начинающееся с дефиса, будет разобрано как флаг и вызовет отказ разбора аргументов, а не «это не адрес и не имя».
Списки и подписка#
lists (read)#
Манифест каталога списков; при первом вызове скачивает его. Пропускается дословно.
local_lists (read)#
Файлы списков, лежащие на роутере: { "files": { "<путь от /etc/steer/lists>": { "count": <строк>,
"mtime": <unix-время> } } }. Наборы .srs, которые правила берут файлом (см. list_fetch), тоже
здесь — с count 0: строк у двоичного файла нет.
list_fetch (write)#
Скачать один список из манифеста.
- Вход:
{ "id": "<id списка>", "kind": "prefixes|domains" }. - Выход:
{ "ok": true, "count": <строк>, "path": "<путь>", "tag": "<версия>" }или объект ошибки.tag— версия записи каталога (у второго издателя — тег его выпуска); у записи без версии его нет. При обходе закрытого издателя добавляетсяvia(см. «Скачивание» в соглашениях).
Набор каталога, который списком не выразим. Запись каталога с format: "srs" разбирается
steer srs-read на две половины. Набор с исключениями (@@ фильтра AdGuard) или с логикой «и/не»
ядро понимает, но списком не выражает (код 2). Тогда на роутер ложится сам набор: путь записи без
.lst (jinndi/domains/adguard.srs.lst → jinndi/domains/adguard.srs, от адресной записи, если у
набора их две). Ответ: { "ok": true, "srs": "<путь набора>", "path": "<он же>", "tag": … }, без
count. Интерфейс ставит такой файл в правило ключом srs списка спеки v2. Сохранение спеки,
применение и ночное обновление доскачивают и обновляют его по той же записи каталога, list_remove
убирает вместе с записью.
kind обязателен и объявлен в сигнатуре метода: categories и domain_lists — разные
пространства имён, и один id законно есть в обоих (у издателя так с news и hodca). Без вида
запрос уходит в ветку «искать где найдётся», где адресные категории проверяются первыми, и доменный
запрос скачал бы адресный файл под именем доменного.
Путь из манифеста проверяется: он обязан состоять из безопасных символов и не может выходить за
каталог списков. Поле file приходит из интернета, и без проверки значение вида
../../../etc/crontabs/root давало запись файла от root куда угодно.
Сужение подсетей (narrow). У второго издателя набор бывает ограничен протоколом и портами
(discord: подсети Cloudflare только для udp 50000-65535 и 19000-20000). steer srs-read отдаёт
это третьим потоком (--meta-out), бэкенд кладёт его рядом со списком подсетей файлом
<список без .lst>.meta и возвращает в ответе list_fetch для вида prefixes:
"narrow": { "proto": "udp", "ports": ["50000-65535", "19000-20000"] }. То же поле у сервиса в
allow_domains, когда список уже лежит. Сужение — свойство канала, а не списка (steer, srs.c),
поэтому в спеку его переносит интерфейс: правило человека остаётся одним, а в файл пишутся два
канала — правило с доменами и канал-спутник с подсетями, proto/ports и полем
"part_of": "<имя правила>" (ядро незнакомые поля терпит). При чтении спутники складываются
обратно в правило (ui/src/lib/model.ts: normalizeSpec/expandNarrow). Спека с сужением пишется
схемой 2.
allow_domains (read)#
Каталог ВТОРОГО издателя списков — itdoginfo/allow-domains — и то, что из него уже лежит на роутере.
- Вход: нет.
- Выход:
{
"ok": true,
"id": "itdog",
"repo": "itdoginfo/allow-domains",
"tag": "2026-08-31_16-18",
"tag_default": "2026-08-31_16-18",
"tag_warn": "",
"base_url": "https://github.com/itdoginfo/allow-domains/releases/download",
"services": [
{ "id": "telegram", "name": "Telegram", "kinds": ["domains", "prefixes"],
"have": { "domains": { "tag": "2026-08-31_16-18", "lines": 1234 },
"prefixes": { "tag": "2026-08-31_16-18", "lines": 567 } } },
{ "id": "anime", "name": "Аниме", "kinds": ["domains"] }
]
}
Типы: ok булев; id, repo, tag, tag_default, tag_warn, base_url — строки;
services — массив объектов { id: строка, name: строка, kinds: массив строк ("domains" и/или
"prefixes"), have?: объект }; ключ have — объект, ключами которого могут быть только domains и
prefixes, а значением — { tag: строка, lines: целое }.
Метод НИЧЕГО НЕ КАЧАЕТ. Он читает таблицу сервисов из /usr/share/splify2/allow-domains.sh,
тег из uci и сами файлы на диске — и всё. В сеть он не ходит НИКОГДА, файлов не создаёт и отметок
не пишет. Причина простая: его зовёт открытие вкладки, а поход в сеть на опросе — это полминуты
ожидания у того, у кого GitHub закрыт, то есть ровно у той аудитории, которой этот издатель и нужен.
Скачивание — отдельное действие по нажатию, и делает его list_fetch.
Почему id уезжает с приставкой. Скачивают список этого издателя через тот же list_fetch, но с
id вида itdog:telegram — имя источника (id в ответе) плюс двоеточие плюс services[].id.
Приставкой, а не вторым методом: метод — это запись в сигнатуре объекта и в ACL, и второй метод «то
же, но у другого издателя» означал бы, что каждое место, которое умеет качать список, надо учить
дважды. Пространство id при этом разделено честно — у нашего манифеста id это слово из категорий,
двоеточия в нём нет и быть не может. Собирать itdog: в интерфейсе руками не надо гадать: имя
источника отдаётся полем id этого же ответа.
have — это «что скачано и какой версии», и только про то, что ЛЕЖИТ. Вида нет на диске — ключа
нет вовсе; нет ни одного вида — нет и самого have. Ноль строк не заменяет отсутствие файла: пустой
скачанный список — законное состояние, и подмена сделала бы кнопку «Загрузить» висящей над уже
загруженным. tag внутри have берётся из отметки версий (/etc/splify2/allow-domains.tag), а не
из нынешней настройки: файл мог приехать при прежнем теге, и ответ на вопрос «какая у меня версия»
обязан говорить про файл, а не про настройку. Пустой tag при существующем файле законен — так
выглядит список, положенный руками или вернувшийся из архива настроек.
tag против tag_default против tag_warn. Версия списков зафиксирована ТЕГОМ релиза, а не
latest: при плавающей ссылке состав списков меняется сам собой, ночью, и объяснить «вчера пускало,
сегодня нет» нечем. tag_default — зашитый в пакет, tag — тот, по которому реально качаем.
Владелец роутера может поднять версию явным uci set splify2.main.allow_domains_tag=…; негодное
значение (latest, тег с косой чертой или точками — оно уезжает в URL) отбивается, tag остаётся
зашитым, а tag_warn объясняет, что именно отбито. Непустой tag_warn обязан доехать до
человека: настройка, отбитая молча, — ровно та беда, ради которой предупреждение и заведено.
lists_update (write)#
Обновить разом все списки, на которые ссылаются правила.
- Вход: нет.
- Выход:
{ "ok": true, "updated": <сменившихся файлов>, "failed": <оставшихся прежними>, "lines": ["<строки прогона>"] }.
Метод не делает работу сам, а запускает /usr/sbin/splify2-update-lists — тот же прогон, что
ходит по расписанию: скачивание, подгонка адресных списков под память (steer fit), проверка
скачанного до подмены, откат на прежние копии при отказе ядра и apply в конце. Вторая
реализация этого в rpcd разошлась бы с первой на первом же изменении.
ok: false означает, что хотя бы один список обновить не удалось. Прогон, в котором ничего не
изменилось, — это успех с updated: 0: списки уже свежие.
Строки прогона скрипт дублирует в файл (REPORT), потому что его log() печатает в stdout
только под терминалом, а вызов из rpcd терминала не имеет.
list_put (write)#
Свой список — текстом, по ссылке или порциями из файла.
- Вход:
{ "name": "<имя>", "kind": "prefixes|domains", "text": "...", "url": "...", "append": <bool>, "source": "text|file|url", "filename": "..." }.name— только латиница, цифры, дефис и подчёркивание: имя становится частью пути на диске.- Ровно одно из
textилиurl. Ссылка обязана начинаться сhttp://илиhttps://. append— булево, не число. Метод объявлен в сигнатуре как boolean, и число приезжает другим типом blobmsg: rpcd молча отбросит несовпавший атрибут, и каждая порция файла заместит предыдущую вместо дописывания.source— откуда список, и спрашивается явно, а не выводится из присланного: файл едет тем же полемtext, порциями, и отличить его от набранного руками здесь нечем. Различать надо: правку предлагают по тому же разделению, каким список завели (см.list_custom). Поля нет — считается по присланному: ссылка значитurl, текст значитtext.filename— как файл назывался у человека, только для показа («выбран blocked.txt»). Запоминается вместе сsourceна первой порции: порции — это один и тот же файл.
- Выход:
{ "ok": true, "path": "...", "count": <строк итого>, "dropped": <отброшено строк> }.
Содержимое проверяется и приводится к годному виду: одиночный адрес дополняется до /32, домены
приводятся к нижнему регистру и лишаются ведущего *., у адресов проверяются диапазоны октетов и
длина префикса. Отброшенные строки считаются и называются числом — молчаливая потеря строк была
бы худшим из возможных поведений. Проверка стоит здесь, а не в интерфейсе: интерфейс можно обойти, а
последствие непроверенного списка молчаливое — nft отвергнет весь набор правил целиком.
Предел размера — 1 МБ, и считается он по итоговому файлу, а не по одной порции: overlay на роутере мал, и список, который туда не влезет, положит не только себя.
list_custom (read)#
Свои списки с происхождением: чем каждый завели и, значит, чем его править.
- Вход: нет.
- Выход:
{ "lists": [ { "name", "kind", "path", "count", "bytes", "source", "url", "filename", "at" } ] }.source—text(набран руками),file(выбран файлом),url(дан ссылкой) либо пустая строка: происхождение неизвестно. Так выглядят списки, заведённые до появления этой записи, положенные в каталог руками и вернувшиеся из архива настроек (архив происхождения не носит). Интерфейс тогда предлагает все три способа правки — признать незнание честнее, чем угадать неверно.at— когда происхождение записано, unix-время;0значит «не записано».
Отдельным методом, а не полем local_lists, и это разделение по цене: тот считается одним awk
по всему каталогу (46 файлов на роутере) и зовётся на каждое открытие настроек, а происхождение
бывает только у своих списков — их единицы. Само происхождение лежит рядом со списком, файлом
<список>.lst.src в формате «ключ=значение»: список — это файл на диске, и его происхождение
обязано теряться вместе с ним, а не отдельно. Расширение не .lst нарочно — иначе local_lists
и сборка архива увидели бы его как ещё один список.
list_get (read)#
Записи своего списка обратно, порциями по байтам. Нужно редактору набранного руками: запись списка ЗАМЕЩАЕТ его целиком, поэтому пустое поле вместо записей — это не «начни заново», а предложение молча потерять набранное.
- Вход:
{ "name": "<имя>", "kind": "prefixes|domains", "offset": <байт> }. - Выход:
{ "ok": true, "name", "kind", "total": <байт всего>, "offset", "next": <байт>, "eof": <bool>, "text": "..." }.
Читаются только свои списки: список издателя приезжает по расписанию, и всякая правка была бы
стёрта следующим обновлением молча. Нарезка по байтам, а не по строкам, и кусок приезжает байт
в байт (та же заплатка printf X, что в backup_get): потерянный на границе перевод строки склеил
бы две записи в одну, а склеенная не отбрасывается санитайзером — она молча уносит обе.
list_remove (write)#
Удалить один список.
- Вход:
{ "id": "<id из манифеста>", "kind": "prefixes|domains" }— для списка издателя,{ "id": "itdog:<сервис>", "kind": "..." }— для списка второго издателя, либо{ "name": "<имя>", "kind": "..." }— для своего. Веткаname— единственный способ удалить свой список. - Выход: стандартный объект успеха или ошибки. Список, задействованный правилом, не удаляется, и метод говорит об этом причиной.
Вместе со своим списком снимается и запись о его происхождении (<список>.lst.src): оставленная,
она переехала бы на следующий список того же имени и рассказала бы про него чужую правду.
Id с приставкой itdog: метод понимает так же, как list_fetch, и путь строит ТОЙ ЖЕ функцией
раскладки: своя укладка здесь означала бы, что удаление стирает не тот файл, который создало
скачивание. Пока приставки здесь не было, id уезжал в манифест первого издателя, ответ был честным
(«списка itdog:telegram нет в манифесте») и потому особенно вредным — список, который кнопка
«Загрузить» кладёт на роутер, убрать было нельзя вовсе.
Для второго издателя kind обязателен, когда на диске лежат оба вида: один набор .srs даёт
два наших списка, и list_fetch кладёт оба сразу — то есть «оба на диске» здесь обычное состояние,
а угадывание удалило бы не то и промолчало об этом. Лежит ровно один вид — он и удаляется.
Вместе с файлом снимается и отметка версии (ad_stamp_del). Отметка утверждает «лежащий файл набит
этим тегом»; пережившая свой файл, она заявляет о том, чего на роутере нет, — и первый же читатель
версий (allow_domains) показал бы её как версию удалённого списка. Отметка ведётся по виду,
поэтому и снимается по виду: второй вид того же набора остаётся на диске вместе со своей.
sub_info (read)#
Сведения о подписке: источник, вид, размер, идентификатор устройства.
-
Вход:
{ "name": "<имя подписки>" }. Поля нет — первая подписка (main), та, что лежит в/etc/steer/sub.txt. Так отвечают и интерфейсы, которые про несколько подписок не знают. -
Выход:
{ "url", "name", "kind", "path", "hwid", "auto", "auto_at"?, "present", "bytes"?, "mtime"?, "quota"? }— поля как уsub_list;bytesиmtimeесть, когда файл скачан. kind—urlилиlinks. Поле значимо: для вставленных руками ссылок узлов обновление подписки не значит ничего, и предлагать «Обновить» там не нужно — адреса, откуда обновляться, не существует.- Поле
hwid— идентификатор ЭТОГО роутера для панели подписки (см. ниже). Отдаётся всегда, а не только после скачивания: устройство в панели заводят заранее. Пустая строка значит «считать не из чего» — ни одного физического порта с постоянным MAC. - Поле
quota— остаток трафика, как его назвала панель. Приходит БЕЗ обращения наружу: отдаётся запомненное с последнего запроса. Обновляет его отдельный методsub_quota.
json
"quota": { "up": "1288490188", "down": "139458183168",
"total": "214748364800", "expire": 1789200000,
"at": 1787428800, "since": 1786132800, "since_used": "2147483648" }
- `up` / `down` / `total` — байты, **строками**. 200 ГБ — это 2·10¹¹, а `json_add_int` у jshn
32-битный: числом такая подписка приехала бы обрезанной, и остаток выглядел бы законным. Тот же
довод, по которому строками отдаются счётчики `dev_stats`.
- `total` пустой ИЛИ РАВНЫЙ `"0"` — подписка без ограничения по объёму: нулём безлимит
обозначают сами панели, и клиенты читают его так же. Бэкенд отдаёт слово панели как есть,
а интерфейс в обоих случаях рисует знак бесконечности; прочитанный буквально, такой ноль
давал «0 Б из 0 Б осталось» на безлимитном тарифе.
- `expire` — unix-время конца периода, `0` — панель срока не назвала.
- `at` — когда спрашивали. Нужно, чтобы интерфейс мог сказать «обновлено 12 минут назад» и решить,
не пора ли спросить заново. `mtime` файла для этого не годится: файл переписывается и когда
числа не изменились.
- `since` / `since_used` — ПЕРВОЕ наблюдение этого периода: время и расход на тот момент.
Панель начала периода не сообщает, только конец, а без начала средний расход в сутки не
посчитать. Угадывать длину периода нельзя: на подписке на девяносто дней догадка
«тридцать дней» завысила бы темп втрое и обещала бы, что трафик кончится, когда он не
кончится. Поэтому бэкенд запоминает первое наблюдение и переносит его из опроса в опрос;
период считается новым при смене срока или объёма и при уменьшении расхода (панель
обнулила счётчик). Делит и предсказывает интерфейс — второе место, где то же число
считалось бы иначе, разошлось бы с первым.
- **Поля `quota` нет вовсе** — значит панель остатка не сообщала: либо ни разу, либо в последний
раз промолчала. Интерфейс говорит об этом прямо («Панель не сообщает остаток»), а прежние числа
при молчании СНИМАЮТСЯ: «осталось 68 ГБ» от предыдущей подписки выглядит свежим и ничем не
отличимо от правды.
sub_quota (write)#
Спросить у панели остаток трафика. Без входа: ссылку метод берёт из uci
(splify2.main.sub_url) — второй источник той же ссылки означал бы, что остаток можно
спросить у панели, которой роутер не пользуется.
- Выход:
{ "ok": true, "kind": "url|links|none", "name", "asked": <bool> }плюсquota(см.sub_info), если её удалось узнать или она была запомнена раньше, иwhy, если спросить не удалось. asked: false— спрашивать некого: узлы заданы ссылками или не заданы вовсе. Это не отказ: человек ничего не сделал не так, иokостаётсяtrue.
Почему отдельный метод, а не поле sub_info. sub_info спрашивают в общем опросе страницы
каждые пять секунд, а здесь уходит запрос в интернет с таймаутом двадцать секунд. Ходить с роутера
к панели двенадцать раз в минуту нельзя — ни по цене, ни по тому, как на это посмотрит панель.
Почему в группе write. Метод ходит наружу и переписывает файл на роутере. Подписку он при этом не подменяет: ни файл узлов, ни выбранный узел, ни признак «перечитать клиента». Поэтому его безопасно звать при открытии обзора — единственное следствие вызова — свежие числа.
Где есть curl, уходит запрос одних заголовков (HEAD): тело подписки для двух чисел качать
незачем, а на мобильном канале это десятки килобайт на каждое открытие обзора. Часть панелей на
HEAD отвечает отказом — тогда обычная загрузка, но в выбросной файл.
Где эти числа берутся. Панель называет их заголовком ответа
subscription-userinfo: upload=…; download=…; total=…; expire=… — соглашение панелей (Marzban,
3x-ui, Remnawave и родня), то же, что читают мобильные клиенты. В теле подписки их нет вовсе,
поэтому узнать остаток можно только в момент запроса; запомненное лежит в
<файл подписки без .txt>.userinfo и объявлено в keep.d ядра, то есть переживает обновление
прошивки «с сохранением настроек».
Средний расход в сутки отдельным полем не отдаётся: интерфейс считает его сам из
total − (up + down) и даты сброса. Второе место, где то же число считалось бы иначе, — это второе
место, которое разойдётся с первым.
sub_list (read)#
Все подписки роутера одним ответом: имя, название, источник, вид, путь, размер, время скачивания, число выходов на неё и запомненный остаток.
- Выход:
{ "subs": [ { "name", "title", "link"?, "url", "kind", "path", "present", "bytes", "mtime", "used", "used_nodes", "auto", "auto_at"?, "due", "quota"? } ], "hwid": "<идентификатор роутера>" }. auto— через сколько минут роутер обновляет подписку сам (0— не обновляет),auto_at— когда пытался в последний раз,due— пора ли обновлять сейчас. Считает это бэкенд, а не тот, кто спросил: интервал, отметка и часы живут в одном месте, и вторые такие же в задании крона разошлись бы с первыми молча.name— имя файла узлов,title— название, как его назвала САМА панель (заголовокprofile-title, обычно base64; запасной путь —content-disposition). Опознанный по ссылке продавец (sub_brand) задаёт своё название иlink— ссылку на продавца; у прочих поляlinkнет.used— сколько выходов ссылается на файл этой подписки,used_nodes— сколько узлов выбрано у этих выходов (nodes) в сумме.- Списком, а не по одной: экран показывает подписки вместе, и десять вызовов ради десяти строк — это десять запусков shell на роутере с 64 МБ.
sub_auto (write)#
Как часто роутер обновляет подписку САМ: минуты, 0 — не обновляет.
- Вход:
{ "name": "<подписка>", "minutes": <число> }. - Выход:
{ "ok": true, "name", "auto": <минуты> }. - Пределы: 30 минут … 4320 минут (трое суток). Чаще получаса — это хождение к чужой панели чаще, чем она меняется, и первым от этого страдает тот, кто раздаёт подписку; дольше трёх суток — обновление, которого человек не дождётся. Проверяются здесь, а не только в интерфейсе: метод зовут и мимо страницы.
- Вставленным руками ссылкам узлов интервал не назначается: обновлять их нечем.
У каждой подписки свой интервал, и это не прихоть: у одной панели узлы меняются раз в сутки, у другой — при каждой смене сервера. Общая настройка означала бы либо лишние хождения к первой, либо устаревшие узлы у второй.
Отметка о последней попытке снимается прямо при вызове: иначе включение интервала «раз в трое суток» обновляло бы подписку немедленно, при первом же тике расписания.
sub_refresh (write)#
Обновить подписку по СОХРАНЁННОЙ ссылке. Этим методом ходит расписание
(/usr/sbin/splify2-update-subs, задание крона раз в десять минут); человек нажимает «Обновить», и
тогда работает sub_set — у него на входе ссылка.
- Вход:
{ "name": "<подписка>" }. - Выход:
{ "ok": true, "name", "changed": <bool>, "bytes", "usable" }плюсquotaи — только приchanged: true—restarted: сколько выходов этой подписки попрошено перечитать узлы (0, если перечитывание отложено неприменёнными правками). changed: false— узлы те же самые. Туннель тогда не трогается вовсе: подписку опрашивают по часам, а узлы у панели меняются редко, и перезапуск на каждом опросе означал бы обрыв связи по расписанию.changed: true— живым туннелям этой подписки уходит сигнал перечитать узлы, тем же способом, каким это делает применение спеки. Ждать, пока человек нажмёт «Применить», здесь нельзя: обновление затем и по часам, чтобы он в него не вмешивался.- Отметка о попытке ставится до похода наружу: иначе мёртвая панель опрашивалась бы на каждом тике расписания, то есть 144 раза в сутки вместо одного-двух.
sub_del (write)#
Удалить подписку целиком: файл узлов, запомненный остаток и ключи uci.
- Вход:
{ "name": "<имя подписки>" }. - Выход:
{ "ok": true, "name" }или объект ошибки. Занятую выходом подписку метод не удаляет и говорит, сколькими выходами она занята ("подписка занята выходами: N"): ядро читает файл узлов при подъёме, и снос под живым выходом оставил бы правило вести в туннель без единого узла.
sub_set (write)#
- Вход:
{ "url": "<адрес подписки | одна или несколько ссылок узлов>", "name": "<имя>", "title": "<название>", "links": "<ссылки прокси http(s)://>" }. Имени нет — первая подписка (main). Названия нет — берётся то, что назвала панель заголовкомprofile-title. - Выход:
{ "ok": true, "kind": "url|links", "name": "<имя>", "title": "<название>", "path": "<файл подписки>", "bytes": <n>, "usable": <пригодных узлов> }плюсwarnиhwid, либо объект ошибки. Если панель назвала остаток трафика, он приезжает тем же ответом полемquota(см.sub_info): человек нажал «Обновить» именно затем, чтобы увидеть свежие числа, и ждать следующего опроса ему незачем.usableсчитает ядро тем же кодом, которым читает подписку при подъёме туннеля, — то есть это то самое число, по которому туннель и поднимется.
Принимает обе формы: адрес подписки http(s):// и одну или несколько ссылок узлов через
пробел или перевод строки — vless://, hysteria2:// (и hy2://), прокси пакета steer-proxy
trojan://, ss://, socks:// (socks4://, socks4a://, socks5://), vmess://. Вторая форма
заведена не для полноты: человек с одним сервером от знакомого без неё не мог сделать вообще
ничего. Ссылки ложатся в файл подписки строками как есть (kind: links); какому протоколу
принадлежит узел, различает ядро.
Прокси http:// и https:// приходят полем links, а не url: по виду https://адрес:порт
их не отличить от адреса подписки, и в url такая ссылка скачивалась бы как подписка. В links
принимаются прокси http(s)://[имя:пароль@]адрес:порт[?sni=…][#имя] — без пути (адрес панели с
путём здесь отвергается) — и любые ссылки узлов выше; ссылки узлов из url, если они есть,
ложатся туда же. Адрес подписки в url вместе с links — отказ («адрес подписки и ссылки узлов —
разными подписками»): одно из двух потерялось бы молча.
Подписку скачивает и разбирает ЯДРО STEER#
Скачивание, заголовки запроса, разбор ответных заголовков, base64 названия, арифметика остатка
трафика и повтор за другим форматом живут в steer sub-fetch / steer sub-quota /
steer sub-hwid — см. контракт steer и steer/src/ext/subfetch.c. Объект rpcd ведёт УЧЁТ:
какая подписка выбрана, где лежат её файлы, что записано в uci, сколько выходов на неё ссылается.
Почему так: каждый из этих шагов упирался в то, чего у оболочки нет. Идентификатор устройства
считался внешними sha256sum/md5sum; названия подписки декодировались СВОЕЙ реализацией
base64 на awk (на роутере нет ни base64, ни openssl); число пригодных узлов, по которому
решается «не заглушка ли это», добывалось отдельным запуском ядра и регулярным выражением по его
JSON — то есть решение принималось по пересказу нашего же ответа из третьего процесса. Всё это у
ядра было готово, проверяемо стендом и в одном месте.
Практические следствия для вызывающего:
sub_setотдаётusable— сколько узлов ядро сочло ПРИГОДНЫМИ, тем же кодом, которым читает подписку при подъёме туннеля. Прежде этого числа в ответе не было вовсе.- Сохранённая ссылка может отличаться от присланной: ядро могло перезапросить подписку с суффиксом
/json, и обновлять в следующий раз надо по ТОЙ ссылке (панели с привязкой к устройствам выбирают формат ответа по клиенту, и списка ссылокvless://среди вариантов может не быть). warn— то, что ядро вычитало из ответа панели про устройство:x-hwid-not-supported,x-hwid-limit, либо «идентификатор не ушёл, послать заголовок нечем».hwidво всех ответах — отsteer sub-hwid; пустая строка значит «не из чего считать». Значение кэшируется на время работы метода:sub_listспрашивает его один раз на весь ответ.- Остаток трафика остаётся в файле
<подписка>.userinfo— он и есть контракт между ядром и объектом. Пишет его ядро, аsub_infoиsub_listтолько читают, никуда не ходя. Формат и смыслat0/used0описаны в контракте steer.
Несколько подписок#
Подписок на роутере бывает несколько: у человека две панели, и локации из обеих он складывает в
один пул. Первая живёт на прежнем месте (/etc/steer/sub.txt) — на этот путь ссылаются выходы в
спеках установленных роутеров, — остальные в /etc/steer/subs/<имя>.txt; ключи uci у первой
остались в splify2.main, у остальных — в секции splify2.sub_<имя>.
Ядро про этот учёт не знает ничего: ему называют --out и --info явно, и это не мелочь.
Ошибка здесь молчаливая в худшем виде — остаток второй панели, записанный поверх остатка первой,
выглядит как правда, а узлы, скачанные в чужой файл, уводят трафик к другому поставщику. Имя
подписки поэтому чистится до латиницы, цифр и подчёркивания (sub_slug): оно становится и именем
файла, и именем секции uci.
За списками категорий идентификатор не уходит. download() остался как был: издателю списков
незачем знать, какой у человека роутер, — это другой сервер и другая надобность.
vless_nodes (read)#
Узлы подписки глазами ядра — дословный ответ steer vless-nodes. Спросить можно двумя способами,
и указывается ровно один:
{output}— по имени выходаkind: vless: узлы его подписки, вchosen— выбранные им номера;{sub}— по пути к файлу подписки (тот, что печатаетsub_listполемpath): узлы подписки, на которую ещё не заведён ни один выход,chosenпуст.
Вторая форма нужна редактору выхода: локации подписки показываются до того, как из неё что-то
взято, — иначе выход собирался бы вслепую. Путь принимается только из перечня подписок роутера
(sub_names), чужой — отказ нет такой подписки: <путь>: метод читает файл с диска, и произвольный
путь означал бы чтение произвольного файла. Нескачанная подписка — отказ подписка ещё не скачана.
Ядро не выдало JSON — стандартный объект ошибки со словами ядра, и вызывающий обязан его проверить:
иначе отказ выглядит как пустой список узлов.
insecure: true вместе с sub — перечень так, как подписку разберёт выход с «не проверять
сертификат узла» (ключ insecure выхода): ядру уходит --insecure, узлы TLS с allowInsecure
входят в перечень, и номер узла совпадает с номером у такого выхода. Без ключа такие узлы
непригодны и в перечень не входят — как у выхода без insecure. Ядро без флага (steer 2.0.0)
отвечает «неизвестный флаг: --insecure» кодом 2 — тогда бэкенд спрашивает его без флага. С
output ключ не действует: у выхода пригодность решает его собственный insecure. То же у
vless_probe, proxy_nodes и proxy_probe; у hysteria2_* ключа нет (insecure=1 там —
параметр ссылки узла, такие узлы пригодны всегда).
telemetry_state (read) · telemetry_set (write)#
Учёт роутера в счётчике на splify2.github.io; что и когда уходит — docs/TELEMETRY.md.
Отправки здесь нет: её делает /usr/sbin/splify2-ping по расписанию.
telemetry_state → {consent, on, last_at, last_error}. consent — unset (ключа
splify2.main.telemetry нет), on (1) или off (0 или непонятное значение); on —
уйдёт ли отклик: везде, кроме off. last_at — когда сайт последний раз принял отклик
(unix-время, 0 — с загрузки ещё не было), last_error — слово причины последнего сбоя или
пусто: rejected (400), toomany (429), unavailable (5xx и прочие ответы), network (нет
ответа), noid (ядро не дало идентификатор), nosender (нет ни curl, ни uclient-fetch). Оба
берутся из /tmp/splify2-ping.
telemetry_set {on} пишет ключ; выключение вдобавок удаляет splify2.main.telemetry_id и
отметку /tmp/splify2-ping. Ответ — {ok, consent}.
Архив настроек#
Экспорт и восстановление пользовательской настройки одним файлом (R-005). Файл собирается и
разбирается в браузере: сюда он приезжает и отсюда уезжает строкой, кусками по 16 КБ. Второго
пути наружу (cgi-io, отдельный обработчик uhttpd) намеренно нет — он означал бы вторые права и
вторую проверку формата, тот же довод, по которому свой список грузится через list_put, а не
загрузкой файла.
Что в архиве. Спека (/etc/steer/spec.json), подписка (/etc/steer/sub.txt) и именованные
подписки (/etc/steer/subs/<имя>.txt) со своими ссылками, ключи туннелей xsteer
(/etc/steer/xsteer/*.conf), СВОИ списки (/etc/steer/lists/custom/**), поля uci splify2.main
(sub_url, sub_kind, sub_title, wizard, manifest_url, fetch_via_tunnel,
list_shrink_factor, geo_url) и выбор у групп pick: manual — файл ядра select рядом со спекой
(/etc/steer/select, строки «группа член»; с формата 3).
Чего в архиве нет. Зеркал категорий издателя в /etc/steer/lists — на стенде это 43 файла и
284 КБ (I-037). Они не настройка, а копия публикуемого upstream: одинаковы у всех, обновляются
суточным cron и доскачиваются сами перед каждой проверкой спеки (fetch_missing_lists). Возить их
между роутерами значило бы носить 284 КБ, которые роутер и так возьмёт сам, да ещё и в виде
устаревающего среза чужих данных.
Формат — строчный, с разделами в квадратных скобках, а не один JSON: разбирать присланный файл приходится в shell, и построчный разбор в awk читается глазами, а вторая реализация разбора JSON в оболочке — нет. Содержимое разделов дословное.
splify2-backup 3
# комментарий
[spec]
{"version":2,...}
[sub]
vless://...
[select]
eu wg1
[sub work]
vless://...
[xsteer home]
[Interface]
PrivateKey = ...
[list domains mine]
example.org
[list prefixes mine]
10.0.0.0/8
[options]
sub_url=https://example.org/sub
sub_kind=url
wizard=<непрозрачная строка мастера>
sub_work.url=https://example.org/sub2
Разбор принимает архивы версий 1..3 (новее своей — отказ целиком); разделы, которых в старом архиве нет, просто не приезжают.
backup_get (read)#
- Вход:
{ "offset": <байт, 0 — собрать заново> }. - Выход:
{ "ok": true, "format": 3, "total": <байт>, "offset": <байт>, "next": <байт>, "eof": <bool>, "text": "<кусок архива>" }.offset/next/total— байты, которые считает роутер: вызывающий обязан запрашивать следующий кусок ровно темnext, который приехал, и не считать смещение сам (в UTF-8 символ длиннее байта).
Архив собирается во временный файл на offset: 0 и дальше отдаётся из него: пересборка на каждом
куске означала бы, что куски приезжают из разных мгновений и склеиваются в файл, которого не было.
backup_put (write)#
- Вход:
{ "text": "<кусок>", "append": <bool>, "final": <bool> }.append— дописать к уже принятому,final— это последний кусок, начинай разбор. Оба поля булевы: число приезжает как INT32 иblobmsg_parseмолча отбросит его (та же ловушка, что стоила правкиlist_put). - Выход на промежуточном куске:
{ "ok": true, "bytes": <накоплено> }. - Выход на последнем:
{ "ok": true, "spec": <bool>, "sub": <bool>, "subs": N, "xsteer": N, "select": <bool>, "lists": [ { "name": "...", "kind": "domains|prefixes", "count": N, "dropped": N } ], "warn": "<если что-то требует внимания>" }.subsиxsteer— сколько именованных подписок и ключей туннелей легло на место,select— лёг ли выбор у групп.
Восстановление не применяет. Спека кладётся на место, но steer apply не зовётся, а снимок
применённого (spec.applied.json) остаётся прежним — поэтому интерфейс показывает восстановленное
как неприменённое и человек применяет его сам. Это модель проекта «сохранено ≠ применено», и импорт
ей подчиняется: тихо перенастроить маршрутизацию по файлу из мессенджера — не то, чего ждут от
кнопки «Восстановить».
Присланный файл — недоверенный ввод, и это основная часть метода. Импорт делает spec.json
источником, которого не касался root: на том конце строки спеки попадают в командные строки —
имена устройств из lan_devices уходят в текст правил nftables и в командные строки ядра steer (I-003), а сам
скрипт rpcd подставляет имена выходов vless в ubus call service signal. Проверяется, по порядку:
- Размеры — накопленный архив ≤ 2 МБ (считается по сумме кусков, а не по куску: предел «на
кусок» обходится двадцатью кусками), спека ≤ 64 КБ, подписка ≤ 128 КБ, раздел настроек ≤ 8 КБ,
каждый список ≤ 1 МБ (тот же предел, что у
list_put). Два мегабайта на архив — не «сколько-то разумно для настроек», а следствие предела на один свой список: предел ниже него означал бы настройку, которую можно завести, но нельзя вернуть из бекапа. Тем же числом ограничен и экспорт:backup_getотказывается отдавать архив, который его же импорт не примет, — иначе человек узнал бы об этом в тот день, когда бекап понадобился. - Двоичные данные — весь файл, кроме перевода строки и табуляции, обязан быть печатным.
- Разделы — строгий разборщик: непонятный заголовок, повторный раздел, строка вне раздела и чужая первая строка отвергают файл целиком. Имена файлов в песочнице собираются из проверенных регулярным выражением частей; из присланного файла ни один путь не берётся.
- Спека — экранированные управляющие символы (
\n,\u00xx) отвергаются: ими значение разложили бы на две строки там, где строки читаются по одной. Имена выходов,outправил и каналов, члены иoverгрупп,outсерверов DNS,device,lan_deviceи КАЖДАЯ записьlan_devicesиlan.devices— только[A-Za-z0-9_.:@-]; имена правил и каналов — без кавычек,$, обратной кавычки и разделителей команд. Множественную форму проверять отдельно обязательно: проверка по одному старому ключу — это дыра ровно того вида, ради которого её и писали. Пути списков (prefixes_file(s),domains_file(s),srs) — только под/etc/steer/lists, файл подписки (subscription, в v1sub_file) —/etc/steer/sub.txtили под/etc/steer/subs,conf/strategyвыхода — под/etc/steer, всё без..; адрес сервера DNS —https://,tls://,quic://,udp://илиtcp://. Последним судьёй остаётся компилятор ядра, как вspec_set. - Подписка — либо все непустые строки — ссылки узлов (
vless://,hysteria2:///hy2://,trojan://,ss://,socks://иsocks4/4a/5://,vmess://, проксиhttp(s)://адрес:порт), либо один блок base64. - Настройки — ключи по белому списку:
sub_url,manifest_url,geo_url(http(s)://без пробелов, кавычек и подстановок),sub_kind(url|links|none),fetch_via_tunnel,list_shrink_factor(число),sub_titleиwizard(непрозрачный текст) и поля именованных подписокsub_<имя>.url|kind|title. Ключи убранных функций из архивов до 2.0 (zm_fix,doh_via_tunnel,doh_out,zapret_autoselect,zapret_source) принимаются и отбрасываются. - Списки — тем же
sanitize_list, что иlist_put: расхождение между «что принимает загрузка» и «что принимает восстановление» означало бы список, который нельзя вернуть на свой же роутер.
Порядок записи: сначала списки и подписка, потом спека. Это не небрежность — спека из архива
ссылается на них путями, а ядро при --dry-run читает и списки, и файл подписки; положив спеку
первой, метод получил бы отказ компилятора на файлах, которые едут в том же архиве. Если после этого
спеку отверг компилятор, ответ говорит и это: списки с подпиской уже на диске.
Состояние интерфейса#
ui_get (read) и ui_set (write)#
Состояние мастера настройки, которое создал человек. Хранится непрозрачной строкой в uci
(splify2.main.wizard), отдельно от спеки: формат принадлежит интерфейсу и меняется вместе с ним,
обёртка его не разбирает.
ui_get— вход: нет; выход:{ "state": "<строка, возможно пустая>" }.ui_set— вход:{ "state": "<строка>" }; выход:{ "ok": true }.
lists_source (read) · lists_source_set (write)#
Откуда роутер берёт каталог списков — то есть перечень «какие списки бывают, как они
называются и где лежит каждый». Ссылка живёт в splify2.main.manifest_url; пустое значение
означает каталог по умолчанию (MANIFEST_URL_DEFAULT в объекте rpcd — релиз splify2-lists).
Задать её можно было и раньше, но только по ssh, то есть настройка существовала для одного
человека из ста.
Каталог — это то, что видно на вкладке «Каталог» и в выборе списков у правила. Прежде обе показывали таблицу второго издателя, зашитую в пакет, и смена источника не меняла на экране ничего: человек вписывал адрес своего каталога и видел прежние записи. Теперь источник один и тот же для показа и для скачивания, а зашитая таблица осталась ЗАПАСНЫМ путём — каталог живёт в сети, и роутер, которому его нечем скачать, не должен оставаться с пустым экраном. Когда показана запасная таблица, вкладка говорит об этом вслух: «показана запасная таблица» и «ваш источник пуст» — разные новости.
lists_source—{ "ok": true, "url": "<действующая>", "default_url": "<наша>", "default": <bool> }. Признак «свой каталог» считает бэкенд: умолчание живёт здесь, и второе его написание в интерфейсе разошлось бы с первым при первой же смене.lists_source_set {url}— записать. Пустая строка возвращает каталог по умолчанию, и это отдельный нужный ответ: человек, попробовавший чужой каталог, должен уметь вернуться, не вспоминая адрес. Ссылка обязана начинаться сhttps://илиhttp://; пробелы, кавычки и переводы строк отвергаются — значение уезжает в аргумент скачивания.
Кеш перечня (/etc/splify2/manifest.json) при записи удаляется: он от прежнего каталога, и
оставить его значило бы показать старые списки под именем нового источника. Уже скачанные
списки не трогаются: они лежат в /etc/steer/lists, на них ссылаются каналы, а смена
каталога — это «дальше спрашивать вот здесь», а не «выбросить всё».
Каталог может называть НАБОРЫ, а не только файлы. У записи бывают format: "srs" и своя
url: тогда скачивается набор sing-box по этой ссылке, ядро раскладывает его на домены и
подсети (steer srs-read), а на диск ложатся обе половины по путям из file — обычно
<источник>/<имя>.srs.lst и <источник>/domains/<имя>.srs.lst. Имя половины от набора, а не
от списка, потому что иначе ни в спеке, ни в журнале не видно, откуда файл взялся. Так каталог
описывает чужие релизы, не перекладывая их к себе, — и добавить сервис становится правкой
каталога, а не новой сборкой splify2.
fetch_mode (read) и fetch_mode_set (write)#
Качать ли списки и пакеты через туннель. Хранится в splify2.main.fetch_via_tunnel, значения
два:
off(по умолчанию) — туннель не трогать. Остаются прямой адрес и обходные адреса самого GitHub (см. «Скачивание» в соглашениях).always— качать через туннель: файл едет по своему прямому адресу одним запросом, без обходных. Если поднятого выхода нет, порядок обычный — режим не превращается в отказ.
Прежнее третье значение auto (туннель последней ступенью) убрано: переключатель в интерфейсе
значит то, что на нём написано, а не «решим сами, если не вышло». Значения 1, on, yes,
true понимаются как always, всё остальное — как off.
fetch_mode— вход: нет; выход:{ "mode": "always|off", "out": "<имя выхода или пусто>" }.out— тот выход, через который пойдёт скачивание; пусто означает, что поднятого выхода со своей таблицей нет и включать нечего.fetch_mode_set— вход:{ "mode": "always|off" }; выход:{ "ok": true, "mode": "..." }. Иное значение отвергается с"режим бывает always или off".
DNS#
Шифрованный DNS (DoH, DoT, DoQ) и обычный DNS с выбором выхода — встроенный резолвер ядра,
он настраивается в спеке (раздел dns, см. ниже методы spec_get / spec_set). Отдельных
методов для https-dns-proxy больше нет: настройка этого пакета — дело человека, и splify2 её
не создаёт, не читает и не правит.
Проверки и устройства#
outbound_probe (read)#
- Вход:
{ "output": "<имя выхода>" }. - Выход:
{ "output": "...", "state": "ok|нет ответа|нет устройства", "ms": <мс или -1>, "how": "..." }.
Для туннелей, которые поднимает само ядро, спрашивает ядро, потому что ICMP через их TUN не идёт:
vless — steer vless-probe (ms — ответ через туннель, ttfb_ms), hysteria2 — steer
hysteria2-probe, прокси steer-proxy (trojan, shadowsocks, socks, http, vmess) — steer proxy-probe
(у обоих ms — рукопожатие с запросом, handshake_ms). Задержка берётся только у ответившего
узла. Для выхода-интерфейса пингует через устройство.
outbound_geo (read)#
Где выходит трафик выхода: страна и адрес, как их видит внешняя сторона.
- Вход:
{ "output": "<имя выхода>", "fresh": <bool> }.freshзаставляет сходить в сеть, даже если запомненное ещё свежее. - Выход:
{ "output": "...", "ip": "1.2.3.4", "cc": "DE", "ms": <время ответа через выход>, "at": <когда измерено>, "cached": <bool> };ip,cc,ms,at— когда они известны. В системе нетcurl— вдобавок"curl": false. - Без
freshметод отвечает запомненным сразу (cached: true), а если измерения нет или оно старше 15 минут — запускает новое в фоне; следующий вызов получит свежее. - С
freshмеряет сейчас (cached: false); не вышло — прежнее измерение иwhy.
Меряется, а не читается из подписки: имя узла («🇩🇪 Германия №2») пишет продавец, у выходов
поверх WireGuard и xsteer имени нет вовсе, а узел мог переехать. Запрос уходит через сам
выход — привязкой к его устройству (curl --interface), поэтому способ один на все виды
выходов и ответ описывает ту точку, из которой роутер выходит наружу.
Отвечает https://1.1.1.1/cdn-cgi/trace (переопределяется через
uci set splify2.main.geo_url=...): отвечает ближайший узел Cloudflare, ключа не требует и
отдаёт две строки простого текста. Адресом, а не именем: по имени сервер туннеля может сам
выбрать IPv6 сайта, и тогда виден IPv6 сервера, а не IPv4, которым выход ходит наружу. Запрос идёт
по IPv4 (curl -4); ms — полное время этого запроса (соединение, TLS, ответ). Измерение
запоминается в /var/lib/splify2/geo-<выход> на 15 минут — обращение наружу стоит секунд, а
страница открывается часто — вместе с устройством выхода и его номером в системе: пересоздано
устройство (сменился узел) — запомненное не отдаётся.
vless_probe (read)#
- Вход:
{ "output": "<имя выхода>", "node": <номер, -1 — первый рабочий> }или{ "sub": "<путь к файлу подписки>", "node": <номер, -1 — все по порядку подписки> }; сsub— необязательныйinsecure(как уvless_nodes: номера как у выхода сinsecure). Путь принимается только из перечня подписок роутера — тот же рубеж, что уvless_nodes: метод читает файл с диска, и произвольный путь означал бы чтение произвольного файла. - Выход: JSON
steer vless-probeдословно.
Вариант с подпиской нужен редактору выхода: узел выбирают там, где выход собирают, а «какой из них живой» надо знать до того, как на подписку заведён хоть один выход. Ядро старше 1.3.1 пути не понимает — тогда интерфейс проверяет через любой выход на этой подписке, а без него не предлагает проверку вовсе.
Один узел на вызов: проверка ограничена таймаутом, и «все узлы» вышли бы за время жизни вызова rpcd.
hysteria2_nodes (read) и hysteria2_probe (read)#
То же, что vless_nodes и vless_probe, для выхода protocol: hysteria2: те же входы
(output либо sub; для проверки ещё node), те же рубежи на имя и путь, ответ — JSON
steer hysteria2-nodes / steer hysteria2-probe дословно (формат узла общий). Нужен пакет
steer-hysteria2: без него ядро отвечает отказом «нужен пакет steer-hysteria2», и ответ
доезжает до экрана как есть.
proxy_nodes (read) и proxy_probe (read)#
То же для выходов прокси пакета steer-proxy — protocol: trojan, shadowsocks, socks, http,
vmess: те же входы (output либо sub; для проверки ещё node) и те же рубежи, ответ — JSON
steer proxy-nodes / steer proxy-probe дословно; поле type узла называет протокол. Номера
узлов у двух входов разные, так их считает ядро: у файла подписки (sub) — сквозные по всем пяти
протоколам, у выхода (output) — среди узлов его протокола, и именно их ждёт nodes выхода.
proxy_probe с sub принимает сквозной номер. С sub — необязательный insecure, как у
vless_nodes (узлы trojan и https с allowInsecure). Задержка у прокси — handshake_ms (рукопожатие
и запрос через узел), ttfb_ms ядро печатает -1. Без пакета ядро отвечает «нужен пакет
steer-proxy».
dns_log (read) и conns (read)#
Ответ steer dns-log и steer conns дословно. dns_log: последние имена, которые спрашивали у
резолвера (правило, выход, число, возраст), upstreams[] — серверы DNS (name, url,
proto: udp/tcp/dot/doh/doq, via — выход или null, state: ready/idle/connecting/down/
unmarked/no-tls, счётчики, last_ok_ago, последняя ошибка словами) и cache (entries, max,
hits, misses) либо null, когда кэша нет. conns: соединения, которые ядро повело в свои
выходы (не больше 2000 записей, поле truncated); ядро не отдало ответ (conntrack недоступен) —
{ok: false, error} словами ядра. «Диагностика» спрашивает conns раз в пять секунд, пока экран
открыт: кто (имя из аренд DHCP или адрес), куда, выход и правило — точно, когда в выход ведёт одно
правило (метка соединения — метка выхода, а не правила), иначе перечнем правил этого выхода.
Имена из dns_log «Диагностика» показывает тем же кругом («Недавние имена»: имя, правило, выход
или «мимо правил», сколько раз и когда спрашивали); серверы и кэш из того же ответа — в разделе
«DNS».
helper (read)#
Вход: { "output": "<имя выхода>" }. Живое состояние помощников выхода из памяти демона
(steer ctl helper): ответ управляющего сокета как есть — {code, stdout, stderr}, где stdout —
строка JSON на помощника (running, up, since, restarts, last_down, у VLESS node и
total, у модуля module, module_ver и rejected: true, если демон отверг модуль чужой
версии, у моста tgws paths_down). Модуля нет — last_down «нужен пакет steer-<имя>».
Помощника у выхода нет или демон не запущен — code 1.
Интерфейс спрашивает его у выходов с помощником (VLESS, hysteria2, прокси, xsteer, tgws, zapret, интерфейс с обфускацией) при открытии «Выходов» и заново при смене состояния выходов, и пишет в строке выхода «модуль другой версии — обновите ядро», «не запущен» или «нужен пакет steer-<имя>» и число перезапусков. Причину отказа словами процесса строка не пересказывает.
group_select (write)#
Вход: { "group": "<группа>", "member": "<член>" }. Выбрать член группы pick: manual
(steer select), без apply: таблица группы сразу ведёт в устройство члена, выбор запоминается
в файле select рядом со спекой и переживает перезагрузку. Выбранный член не работает — группа
применяет свой on_fail, а другой член вместо него не берётся. Имена — только имена (без
ведущего дефиса): они уходят в командную строку. Выход: { "ok": true } либо { "ok": false,
"error": "<слова ядра>" }.
devices (read)#
Кандидаты в выход kind=interface и в члены группы: { "devices": [ { "name", "up": <bool>, "kind":
"<DEVTYPE>" } ] }. Только туннельные устройства (ARPHRD_NONE и ARPHRD_TUNNEL, без ссылки
device). Отбор по типу, а не по имени: на современном OpenWrt порты SoC называются lan2, wan, и
выход в физический порт молча ничего не маршрутизирует. Устройства выходов, которые поднимает само
ядро (vless, hysteria2, прокси, xsteer), есть в перечне и когда они не подняты — с up: false и
пустым kind: имена спрашиваются у ядра (--devices).
xsteer_state (read)#
Живое состояние туннелей xsteer: то, чего в настройке нет.
Зачем отдельным методом, а не полем live. Туннель xsteer, поднятый через netifd, ядру не
принадлежит: в спеке он — обычный kind: interface, и status знает про него ровно то, что знает
про любое устройство. Какой хаб отвечает, сколько секунд назад было рукопожатие, встала ли
разгрузка сегментации, сколько раз соединение переподнималось и почему — знает только процесс пира,
и он пишет это в свой файл состояния (/var/lib/steer/xsteer-<устройство>.json, схема описана в
steer/docs/xsteer.md). Читать эти файлы на каждом круге опроса всем, у кого xsteer нет вовсе,
незачем.
- Вход: нет.
- Выход:
{ "ok": true, "tunnels": {
"home": { "device": "xs-home", "age": 2, "state": { "up": true, "hub": "203.0.113.7:443",
"offload": {"gso": true, "gro": true, "rx": true}, "resets": 3, "…": "…" } },
"work": { "device": "xs-work", "state": null } } }
Перечень туннелей берётся из настройки сети (секции proto xsteer), а не из списка устройств:
иначе выключенный интерфейс исчезал бы с экрана вместо того, чтобы показать, что он выключен.
state — содержимое файла ядра как есть, без пересборки схемы: разбирать чужой JSON, чтобы
тут же собрать заново, значило бы переписывать чужую схему у себя (так же поступает status).
state: null означает «туннель не поднимался в эту загрузку» — это НЕ то же, что «поднят и молчит»,
и различать их человек сюда и приходит.
age — возраст файла в секундах. Порог «устарело» решает интерфейс, потому что он знает, как часто
спрашивает: процесс, которого убили, оставляет файл лежать навсегда, и без возраста его последнее
up: true выглядело бы живым.
Выходы спеки kind: xsteer — отдельной картой outputs (ключ — имя выхода, перечень —
steer outputs --kind xsteer). Их клиента держит демон ядра steer, и файла состояния такой клиент
не пишет: helper — строка помощника из ответа steer ctl helper <выход> (running, up,
since, restarts, last_down, module, module_ver, rejected; см. метод helper), null —
демон о нём не знает (не запущен, без --supervise). Хаба, рукопожатия и счётчиков у демона нет.
Клиент, запущенный без демона, пишет файл xsteer-<выход>.json — он отдаётся в state с age, как
у туннеля netifd; нет файла — state: null.
{ "ok": true, "tunnels": {},
"outputs": { "xa": { "helper": { "helper": "xsteer", "running": true, "up": true,
"since": 1790000000, "restarts": 0, "…": "…" },
"state": null } } }
xsteer_link (read)#
Ссылка xs:// в обе стороны, и направление выбирает вход:
{"iface":"home"}→{ok, link}— ссылка на этот туннель (перенести тот же доступ на телефон);{"link":"xs://…"}→{ok, conf}— текст конфигурации из ссылки (им заполняет поля страница сети).
Так же устроена подкоманда ядра steer xsteer-link, и по той же причине: человек здесь всегда
хочет «дай мне другой вид того же самого».
Печатает и разбирает ядро. Формат ссылки описан один раз (steer, src/ext/xslink.c) и
сверяется побайтово с половиной на Go; сборка или разбор строки здесь были бы третьей реализацией
одного формата — и первой, у которой нет стенда. Ссылка уходит ядру стандартным вводом, а не
аргументом: аргументы видны в списке процессов всякому, кто есть на роутере, а в ссылке лежит
приватный ключ.
Источник для печати — готовый файл /var/run/xsteer/<интерфейс>.conf, собранный обработчиком
протокола из uci. Собирать конфигурацию здесь во второй раз нельзя: два места, превращающие uci в
настройку, однажды разойдутся, и разойдутся молча. Отсюда следствие, названное человеку прямо: у
выключенного интерфейса файла нет, и ссылку отдать неоткуда.
xsteer_link_put (write)#
Принять ссылку: записать её в настройку существующего интерфейса xsteer и поднять его заново.
- Вход:
{iface, link}. - Выход:
{ok, iface, hub}или{ok:false, error}.
Интерфейса не создаёт. У интерфейса есть то, чего в ссылке нет и быть не может: зона фаервола, имя устройства, участие в спеке ядра. Созданный здесь туннель без зоны выглядел бы настроенным и не вёз бы трафик — то есть мы бы своими руками сделали ровно то состояние, отличать которое учит весь остальной этот контракт. Создание остаётся за страницей сети.
Ссылку разбирает ядро, оно же её и проверяет: она приходит от человека, то есть это недоверенный ввод, и разбирать её здесь значило бы держать третью реализацию формата и надеяться, что она отвергает то же самое. Значения берутся из текста конфигурации, которое вернуло ядро, — по строкам, а не по позициям.
Пиры интерфейса замещаются целиком: ссылка описывает доступ, а не добавку к нему, и оставленный рядом прежний пир означал бы туннель к двум хабам сразу — состояние, которого в звезде не бывает.
Единственный метод в этом контракте, который пишет /etc/config/network. Интерфейс поднимается
заново сразу после записи: человек вставил ссылку, чтобы туннель заработал, а не чтобы получить
запись в файле.
client_nets (read)#
Обратный вопрос к devices: не куда выпустить трафик, а откуда приходят клиенты. Устройства
роутера, которые могут быть сетью клиентов, и подсети, выведенные из их адресов.
- Вход: нет.
- Выход:
json
{ "nets": [
{ "name": "br-lan", "up": true, "wan": false, "usable": true, "why": "",
"subnets": ["192.168.1.0/24"] },
{ "name": "tailscale0", "up": true, "wan": false, "usable": true, "why": "",
"subnets": ["100.64.1.5/32"] },
{ "name": "tun-vless", "up": true, "wan": false, "usable": false,
"why": "через него мы уходим наружу — это устройство выхода",
"subnets": [] },
{ "name": "wan", "up": true, "wan": true, "usable": false,
"why": "внешний интерфейс: весь мир по ту сторону", "subnets": ["46.42.16.0/22"] }
] }
Отсеивается ровно два случая, и оба не по имени: lo (петля клиентом не бывает) и порт внутри
моста (есть ссылка master — клиенты приходят через мост, правило по порту не увидело бы ни одного
пакета). Внешний интерфейс помечается, а не прячется: какое устройство наружу, знает uci
(network.wan.device), а выкинутое из списка устройство человек ищет глазами и решает, что перечень
сломан — помеченное он просто не выберет.
usable и why отвечают на вопрос, которого прежде не было: годится ли устройство в
источники. Различаются два вида туннелей, и различие содержательное. Туннель, через который
уходим НАРУЖУ мы сами (клиент wireguard до провайдера, tun-vless, устройство любого нашего
выхода), источником быть не может: отметив его, человек ждёт «маршрутизировать тех, кто ко мне
подключается», а получает попытку забирать трафик из туннеля, через который сам и уходит. А
туннель, через который к нам ПРИХОДЯТ другие устройства (сервер wireguard, Tailscale, ZeroTier),
— законный источник, ради него перечень lan_devices и заведён.
Признаки, по которым это решается: устройство названо в спеке устройством нашего выхода (точный,
спрашивается у ядра); через него идёт маршрут по умолчанию; proto=xsteer в конфигурации сети;
у интерфейса wireguard есть пир с allowed-ips 0.0.0.0/0 (клиент до провайдера) против узких
allowed-ips (сервер для своих); и отдельным поводом — wan. По ИМЕНИ не отсеивается ничто:
tailscale0 ни одним признаком не задет, а если человек сам сделал его устройством выхода, его
поймает первый признак.
Негодные не удаляются из ответа, как и wan: выкинутое устройство человек ищет глазами и
решает, что перечень сломан. Уже отмеченный в спеке интерфейс, ставший негодным, вкладка обязана
показать и назвать — это его настройка, и молча выбросить её нельзя. Поля печатаются ВСЕГДА:
«поля нет» значит «объект старее приговора», и отличать это от false обязательно.
subnets — подсказка, по которой человек узнаёт устройство, и ничего больше: ядро отвечает
на «кто» правилом iifname по именам из lan_devices, а не по адресам. Считается она тем же
правилом, каким адрес обнуляется по маске префикса, — чтобы показанное совпадало с тем, что видно в
ip addr. У Tailscale адрес обычно /32, то есть подсеть пиров из него не выводится вовсе; именно
поэтому клиентов и описывают именем устройства. Устройство без адреса отдаётся с пустым subnets,
а не пропускается: выбрать его законно, адрес появится при поднятии.
leases (read)#
Аренды DHCP — имена устройств по MAC: { "leases": [ { "mac", "ip", "name" } ] } из
/tmp/dhcp.leases (* вместо имени — пустая строка). Нужны, чтобы в правиле можно было выбрать
устройство по имени, а в спеку положить MAC: адрес меняется, MAC живёт.
net_info (read)#
Сводка сведений о туннеле одним вызовом (вместо трёх отдельных вызовов ubus на каждый опрос — на роутере с 64 МБ и одним ядром процессора каждый вызов стоит запуска shell).
Внешний адрес здесь намеренно отсутствует: чтобы его узнать, запрос должен уйти через туннель, а steer маршрутизирует по метке пакета, а не по адресу источника — привязка к адресу устройства туннеля всё равно отправит запрос по маршруту по умолчанию и вернёт адрес провайдера. Уверенный неверный ответ хуже отсутствующего.
Перечень методов#
Все 66 объявленных методов (из блока list) скрипта rpcd). Группа ACL в скобках.
| Метод | ACL | Вход на stdin |
|---|---|---|
status |
read | — |
spec_get |
read | — |
applied_get |
read | — |
spec_set |
write | {spec} |
apply |
write | — |
explain |
read | {address} |
lists |
read | — |
list_fetch |
write | {id, kind} |
lists_update |
write | — |
devices |
read | — |
client_nets |
read | — |
local_lists |
read | — |
allow_domains |
read | — |
list_custom |
read | — |
list_get |
read | {name, kind, offset} |
list_remove |
write | {id, kind} (id может быть itdog:<сервис>) или {name, kind} |
list_put |
write | {name, kind, text, url, append, source, filename} |
engine |
read | — |
sub_set |
write | {url, name, title, links} |
sub_info |
read | {name} |
sub_list |
read | — |
sub_del |
write | {name} |
sub_auto |
write | {name, minutes} |
sub_refresh |
write | {name} |
sub_quota |
write | {name} |
backup_get |
read | {offset} |
backup_put |
write | {text, append, final} |
live |
read | {diag, fast} |
dev_stats |
read | — |
diag |
read | — |
net_info |
read | — |
outbound_probe |
read | {output} |
outbound_geo |
read | {output, fresh} |
leases |
read | — |
engine_state |
read | — |
support_report |
read | — |
ui_get |
read | — |
ui_set |
write | {state} |
xsteer_state |
read | — |
xsteer_link |
read | {iface} или {link} |
xsteer_link_put |
write | {iface, link} |
telemetry_state |
read | — |
telemetry_set |
write | {on} — 1 включить, 0 выключить |
lists_source |
read | — |
lists_source_set |
write | {url} |
fetch_mode |
read | — |
fetch_mode_set |
write | {mode} |
vless_nodes |
read | {output} или {sub, insecure?} |
vless_probe |
read | {output, node} или {sub, node, insecure?} |
hysteria2_nodes |
read | {output} или {sub} |
hysteria2_probe |
read | {output, node} или {sub, node} |
proxy_nodes |
read | {output} или {sub, insecure?} |
proxy_probe |
read | {output, node} или {sub, node, insecure?} |
dns_log |
read | — |
conns |
read | — |
helper |
read | {output} |
group_select |
write | {group, member} |
engine_stop |
write | — |
engine_start |
write | — |
splify2_versions |
read | — |
splify2_install |
write | {version} |
steer_versions |
read | — |
steer_install |
write | {version, modules} (у 1.x — {version, extended}) |
steer_modules |
read | — |
steer_module_add |
write | {module} |
steer_module_del |
write | {module} |
Источник истины#
Документ выведен из объекта rpcd: диспетчер files/usr/libexec/rpcd/splify2 (быстрый путь круга
опроса, переменные, блок list, таблица «метод → группа») и файлы files/usr/lib/splify2/rpcd/
(common.sh — общие помощники, m-<группа>.sh — помощники и ветки методов группы). Разнесено
ради скорости: busybox ash разбирает файл целиком, и один файл в 4500 строк стоил 110 мс на каждый
вызов. Если документ и код расходятся — прав код, а документ устарел.