splify2
splify2

Контракт объекта 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. Путей у неё три, по очереди:

    1. прямой адрес;
    2. тот же файл с других адресов — сначала зеркало на GitLab (gitlab.com/<владелец>/<репозиторий>/-/raw/<ветка>/<путь>: тот же путь, сырой файл со своего домена, без заголовка и без счётчика запросов), затем хосты самого GitHub — поштучно через api.github.com/repos/…/contents/… (заголовок Accept: application/vnd.github.raw), а если API отказал — из архива ветки через codeload.github.com. Для ссылок релиза ветка — dist: релизный workflow выкладывает в неё те же пакеты;
    3. через туннель роутера — но ТОЛЬКО если человек это включил (fetch_mode): на время скачивания добавляется ip rule to <адрес> lookup <таблица выхода> с приоритетом 30000 и снимается сразу после. Включённый туннель идёт ПЕРВЫМ, а не последним.

    Причина — splify2#15: провайдеры закрывают githubusercontent.com целиком (raw., objects. и release-assets. — одни адреса Fastly), и тогда роутер лишается и списков, и пакетов, хотя сам GitHub доступен. Третий путь не делается, если адрес издателя совпал с адресом узла подписки (это была бы петля), если имя разрешается в fake-IP 198.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-extended 1.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. Проверяется, по порядку:

  1. Размеры — накопленный архив ≤ 2 МБ (считается по сумме кусков, а не по куску: предел «на кусок» обходится двадцатью кусками), спека ≤ 64 КБ, подписка ≤ 128 КБ, раздел настроек ≤ 8 КБ, каждый список ≤ 1 МБ (тот же предел, что у list_put). Два мегабайта на архив — не «сколько-то разумно для настроек», а следствие предела на один свой список: предел ниже него означал бы настройку, которую можно завести, но нельзя вернуть из бекапа. Тем же числом ограничен и экспорт: backup_get отказывается отдавать архив, который его же импорт не примет, — иначе человек узнал бы об этом в тот день, когда бекап понадобился.
  2. Двоичные данные — весь файл, кроме перевода строки и табуляции, обязан быть печатным.
  3. Разделы — строгий разборщик: непонятный заголовок, повторный раздел, строка вне раздела и чужая первая строка отвергают файл целиком. Имена файлов в песочнице собираются из проверенных регулярным выражением частей; из присланного файла ни один путь не берётся.
  4. Спека — экранированные управляющие символы (\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, в v1 sub_file) — /etc/steer/sub.txt или под /etc/steer/subs, conf/strategy выхода — под /etc/steer, всё без ..; адрес сервера DNS — https://, tls://, quic://, udp:// или tcp://. Последним судьёй остаётся компилятор ядра, как в spec_set.
  5. Подписка — либо все непустые строки — ссылки узлов (vless://, hysteria2:///hy2://, trojan://, ss://, socks:// и socks4/4a/5://, vmess://, прокси http(s)://адрес:порт), либо один блок base64.
  6. Настройки — ключи по белому списку: 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) принимаются и отбрасываются.
  7. Списки — тем же 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 } } }

Ссылка 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 и поднять его заново.

  • Вход: {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 мс на каждый вызов. Если документ и код расходятся — прав код, а документ устарел.