splify2
Ядро steer

Управляющий сокет steer (протокол версии 1)#

steerd daemon — демон ядра: долгоживущий сервер, который держит спеку в памяти, сторожит выходы, держит помощников и резолвер и отвечает на запросы по unix-сокету. steerd ctl-serve — синоним steerd daemon. Сокет сделан для Android: приложению splify2 (домен SELinux splify2_app) политика не даёт ни исполнить ядро, ни прочитать его файлы, и единственная дверь к ядру — этот сокет. И на роутере, и на телефоне демон — сама служба: один steerd daemon --watch --supervise --apply (на роутере его держит /etc/init.d/steer, на телефоне — служба steerd в init/steerd.rc у vendor/der), а rpcd splify2 и все, кто зовёт steer <команда>, говорят с ним через клиент steer (ниже, «Клиент steer»).

Код — src/daemon/ctl.c (в шапке те же правила с доводами), цикл событий — src/daemon/loop.c, состояние в памяти — src/daemon/state.c, сторож выходов (--watch) — src/daemon/watchd.c, помощники и резолвер (--supervise) — src/daemon/supd.c и src/daemon/helpers.c; стенды — tests/ctlmatch.sh, tests/supdmatch.sh.

Где сокет и кто его создаёт#

Путь /data/misc/steer/steer.sock (сборка под Android; вообще — <каталог спеки>/steer.sock, флаг --socket)
Создаёт сам сервер при старте: мёртвый прежний файл удаляется, живой соседний сервер — отказ стартовать
Права файла 0666; пускает не DAC, а SELinux и проверка собеседника (ниже)
Метка steerd_socket — по type_transition на имя steer.sock (vendor/der, steerd.te)
Каталог списков /data/misc/steer/lists — сюда put-file кладёт файлы (флаг --lists-dir)
Сервис init steerd (vendor/der, init/steerd.rc), поднимается при каждой загрузке, независимо от выключателя

Почему не опция socket у init (/dev/socket/…): каталог /dev/socket может листать любой домен (domain.te: allow domain socket_device:dir r_dir_perms), и имя сокета увидело бы любое приложение. /data/misc/steer приложениям не листается.

Кого пускают#

  1. SELinux: connectto к домену steerd разрешён splify2_app (и su на userdebug).
  2. Сервер по SO_PEERCRED/SO_PEERSEC:
    • uid 0 (root) и 1000 (system) — всегда;
    • процесс в домене splify2_app (флаг --allow-domain, по умолчанию в сборке под Android), если он у владельца устройства — uid меньше 100000, то есть пользователь 0;
    • uid, названные --allow-uid N (до восьми; стенд и отладка).

UID приложения нигде не записан: сервер спрашивает у ядра контекст SELinux собеседника, а домен splify2_app назначается только пакету com.der.splify2 с платформенной подписью (seapp_contexts). Остальным — ответ denied и строка steer[warn] ctl: отказ: … в журнале.

Запрос#

Одно соединение — один запрос и один ответ; после ответа сервер закрывает соединение. Исключение одно — subscribe (ниже): после ответа соединение остаётся открытым и несёт события.

  • Строка ASCII до 512 байт с \n в конце: имя команды и слова через один пробел.
  • У команд с телом (apply, check, put-file, sub-check) последнее слово — длина тела в байтах, десятичным числом, и сразу за \n идут ровно эти байты. Тело спеки (apply, check) — не больше 1 МиБ, тело файла (put-file, sub-check) — не больше 16 МиБ.
  • На весь запрос (строка и тело) — 5 секунд.
  • Слова проверяются по виду до запуска: имя выхода — [A-Za-z0-9_.-], до 31 знака; адрес или имя у explain — ещё : и /, до 253 знаков; ни одно не может начинаться с - или .. Имя файла списка (put-file, rm-file) — [A-Za-z0-9_.-], до 64 знаков, не с - и не с ., без .. где угодно.
status\n
explain youtube.com\n
vless-probe loc 2 5\n
apply 1834\n{"schema":2, ...}
put-file yt.lst 52311\n<байты списка>

Ответ#

Один объект JSON в одну строку с \n в конце. Читать до \n, а не до закрытия.

{"v":1,"cmd":"status","code":0,"stdout":"{\"schema\":1,...}\n","stderr":""}
Поле Когда Что
v всегда версия протокола, 1
cmd если команда опознана имя команды
code команда исполнялась код возврата одноимённой подкоманды ядра; убитой сигналом — 128 + номер
stdout, stderr команда исполнялась её вывод строками; у status и diag stdout — сам JSON (разбирать второй раз)
truncated вывод обрезан true; предел stdout — 1 МиБ, stderr — 64 КиБ
error, message отказ сервера вид отказа и фраза для человека

Значения error:

error Что случилось
bad-request не та форма запроса: пустое или лишнее слово, негодный аргумент, нет длины тела, запрос не пришёл за 5 с
too-large строка длиннее 512 байт или объявленное тело больше предела команды — 1 МиБ у спеки, 16 МиБ у файла (тело при этом не читается); у put-file ещё — в каталоге списков уже 4096 файлов или файлы заняли бы больше 128 МиБ
unknown-command команды нет в таблице
denied собеседника не пускают (см. выше)
busy уже идут четыре запроса (подписчики в их число не входят) или, у subscribe, уже восемь подписчиков; повторить позже
internal не удалось запустить ядро или записать файл
timeout команда не уложилась в свой срок и убита вместе со своими процессами; code и вывод до этого момента приложены
in-use rm-file файла, на который ссылается сохранённая спека (см. ниже)

Команды#

«Где» — как демон исполняет команду: в процессе — сам, из спеки в памяти, тем же кодом, что печатает одноимённая подкоманда (ответ байт в байт тот же, что дала бы подкоманда по той же спеке); ребёнок — запуском подкоманды ядра, не останавливая остальных запросов. Срок есть только у детей: по нему ребёнок и всё, что он запустил, убиваются, а ответ — timeout.

Команда Слова Что делает Где Срок
version — steer version; ещё spec_path и state_dir — спека и каталог состояния демона полными путями (по ним клиент steer сверяет, к тому ли демону пришёл) в процессе —
status [fast] steer status (с fast — --fast, запомненный ответ) в процессе —
diag — steer diag; код 1 — поломка. Ни одного процесса: ядро — по netlink, резолвер и обфускатор — обходом /proc в процессе —
explain <адрес\|имя> steer explain в процессе —
vless-nodes <выход> steer vless-nodes (расширенная сборка) ребёнок 15 с
vless-probe <выход> [узел [срок]] steer vless-probe --node узел --timeout срок; узел −1…9999, срок 1…30 ребёнок 180 с
check тело — спека проверить спеку (apply --dry-run), ничего не меняя ребёнок 120 с
apply тело — спека проверить, сохранить, применить изменившееся, перечитать (ниже) ребёнок (план и применение) 120 + 300 с
reload — то же без новой спеки: сверить спеку на диске с применённой, перечитать ребёнок (план, применение, dnsd-sig) 120 + 300 + 30 с
conns — steer conns: соединения, которые ядро повело в свои выходы в процессе —
dns-log — steer dns-log: недавние имена резолвера и их каналы в процессе —
put-file <имя>, тело — файл положить файл списка в /data/misc/steer/lists/<имя> в процессе —
list-files — что лежит в каталоге списков в процессе —
rm-file <имя> убрать файл из каталога списков в процессе —
sub-check тело — файл подписки разобрать подписку, ничего не сохраняя (steer vless-nodes по временному файлу) ребёнок 15 с
subscribe — оставить соединение открытым и получать события (ниже) в процессе —
select <группа> <член> выбрать член группы pick: manual без apply (ниже); изменяющая — ждёт своей очереди за apply/reload в процессе —
helper <выход> живое состояние помощников выхода из памяти демона (--supervise), строка JSON на помощника (ниже); помощника нет — код 1 в процессе —

Детям демон сам добавляет --spec и --state-dir — те, с которыми запущен.

Спека в памяти. Демон читает спеку при старте и перечитывает после apply, reload и по SIGHUP. status и explain отвечают по ней, а всё, что меняется без спеки (/sys, счётчики nft, реестр меток), читают на каждый вызов. Устройство, выбранное сторожем, status берёт из памяти демона, если сторож — сам демон (--watch, ниже), и из файла active, если сторожит steer failover --loop. Спеку, заменённую в файле руками, демон увидит после reload. Не прочиталась при старте — status и explain отвечают тем же отказом, что подкоманда (код 2, причина в stderr); не прочиталась при перечитывании — демон продолжает отвечать по последней годной и шлёт подписчикам spec-error.

apply#

  1. Спека во временный файл рядом с spec.json и план по нему (служебная подкоманда apply-plan): те же проверки и предупреждения, что у apply --dry-run, и отпечатки частей. Отказ — spec.json не тронут: "saved":false,"applied":false, в stderr — причина.
  2. Кандидат становится spec.json переименованием (снаружи виден либо прежний файл, либо новый).
  3. Ядро выключено (свойство persist.der.steer.enabled не 1) — на этом всё: "saved":true,"applied":false,"enabled":false. Правила поставит init, когда ядро включат.
  4. Сверка с применённым (ниже) и применение только изменившихся частей (служебная подкоманда apply-commit); не изменилось ничего — в ядро демон не идёт вовсе. Отказ ядра (план ядро не спрашивает) — прежняя спека возвращается: "saved":false,"applied":false,"rolled_back":true.
  5. Успех — как reload: "saved":true,"applied":true,"enabled":true,"reload":{…},"changed":{…}.
{"v":1,"cmd":"apply","code":0,"stdout":"steer: applied 2 channel(s), 2 output(s)\n","stderr":"",
 "saved":true,"applied":true,"enabled":true,"reload":{"dnsd":"table","outputs":"daemon"},
 "changed":{"ruleset":false,"routing":[],"helpers":["b"],"dnsd":false}}

Сверка по частям. Демон помнит, что применил сам, и сравнивает с этим новую спеку, а то, что применил, — с тем, что сейчас стоит в ядре:

Часть Как сравнивается Что трогается, если изменилась
набор правил отпечаток текста правил (без счётчиков и без адресов карты fake-IP, которые текст берёт из раздачи резолвера); в ядре — номер нашей таблицы и отпечаток её содержимого: цепочки, правила по порядку, заголовки наборов (без счётчиков и элементов наборов), и сводка элементов статических наборов — против снятых сразу после нашего nft -f весь набор одной транзакцией nft -f, как у подкоманды: счётчики каналов переносятся, отказ ядра оставляет прежний набор
маршрутизация по выходу: вид, метка, таблица, on_fail, пул устройств, файл awg; в ядре — правило fwmark (и IPv6) и таблица выхода правило и таблица только этого выхода; у убранного выхода (или сменившего метку) они снимаются
помощники подпись параметров помощника (с --supervise) перезапуск только помощника этого выхода; новые поднимаются, убранные гаснут
таблица резолвера текст таблицы и файлы списков, на которые она ссылается (с --supervise) новая таблица в трубу резолвера, процесс тот же
сторож выходы (маршрут, via, выбор по задержке) и помощники (с --watch) внеочередной проход

Не изменилось ничего и в ядре всё на месте — nft и ip не запускаются, наборы (и адреса, которые в них положил резолвер), счётчики, помощники и резолвер остаются как были. Новые поддельные адреса, которые резолвер выдал после прошлого применения, изменением не считаются: в карту fake-IP и наборы каналов их кладёт сам резолвер, а при замене набора правил и карта, и наборы каналов fake-IP приходят заполненными всей раздачей на момент замены.

Порядок применения — у apply-commit тот же, что у подкоманды steer apply (и у применения при старте, и у reapply из init без демона):

  1. устройства выходов kind=awg, затем правила fwmark и таблицы выходов (у выхода без устройства — то, что ставит его on_fail: запрет при drop, пустая таблица при direct и zapret). До набора правил — чтобы метка выхода ни мгновения не существовала без своего правила: помеченный пакет без правила уходит по таблице main, то есть в WAN. Правило без меток ничего не ловит и безвредно;
  2. набор правил одной транзакцией nft -f. Карта подмены fake-IP и наборы каналов fake-IP засеяны в том же тексте из файла состояния резолвера (fakeip.state): подмена поддельного адреса появляется в ядре только вместе с его меткой, а поддельный адрес без подмены отбрасывается правилом steer-fakeip-nomap за dnat и никуда не уходит. Прямо перед засевом загрузчик просит резолвер этого каталога состояния записать файл (просьба flush по dnsd-ctl.sock, ответ — после записи, ждётся до 1 с): сам резолвер переписывает его не чаще раза в минуту, и без просьбы засев отставал бы от его памяти — новые имена без подмены, сменившиеся адреса прежними. Таблицу от демона резолвер сверяет с картой ядра по своей памяти и ставит свой адрес там, где засев оказался другим;
  3. сразу после загрузки — просьба резолверу вернуть элементы real-ip из его памяти (в тексте они не засеваются: память о них только у резолвера), отметки «пущен напрямую» по таблицам выходов, карта раздачи balance — к живым членам;
  4. снятие правил и таблиц меток, которых больше не несёт ни один выход, — только после удачной загрузки: до неё ими метит прежний набор правил;
  5. (демон, после удачной загрузки нового набора правил) снятие соединений правил, у которых сменился выход, — ниже.

Смена выхода правила. Метка выхода пишется и в метку соединения, и по ней группа balance держит установленное соединение на том члене, где оно началось. Правило перевели на другой выход — и без снятия его установленные соединения жили бы на прежнем выходе (а у группы balance, в которой прежний выход — член, — до самого конца), а новые шли бы новым путём: один сеанс сайта с разных внешних адресов. Поэтому после загрузки демон сравнивает правила прежней и новой спеки (по имени, без имени — по номеру) и для каждого изменившегося — выход другой, правило снято или выключено — снимает записи conntrack с метками его прежнего выхода (у группы balance — и с метками её членов):

  • метку не держит ни одно правило, у которого выход не менялся, — сняты все записи с ней;
  • держит — сняты только записи, чьё назначение (у fake-IP — поддельный адрес) не лежит ни в одном наборе групп, ведущих в выход с этой меткой, после загрузки; есть такая группа без набора («весь трафик») или набор не прочитать — сняты все записи с меткой.

Следующий пакет снятого соединения заводит запись заново по новым правилам и идёт новым путём, сервер отвечает сбросом, и приложение соединяется заново уже через новый выход (у QUIC — когда браузер бросит прежнее соединение). В журнале демона — steer[info] ctl: смена выхода правил (p → all): снято соединений метки 0x00400000 — 29 (целиком). Привязка выхода сторожем к другому устройству (тот же выход, другое устройство) снимает соединения своим путём — «Сторож выходов».

Если ядро отвергнет набор правил, прежний остаётся стоять, а привязка выходов (шаг 1) уже новая: правила новых меток безвредны, сменённое устройство выхода несёт трафик прежнего набора правил того же выхода.

Сверка с ядром. apply и reload с той же спекой — это и починка: то, что в ядре изменили снаружи, они возвращают, а не отвечают «менять нечего». Спрашивается ядро по netlink, в процессе демона (элементы наборов — в ребёнке-плане), без запуска nft и ip:

  • набор правил — снятое или изменённое правило, снятая или добавленная цепочка, чужой набор в нашей таблице: набор ставится заново одной транзакцией (changed.ruleset), в журнале демона — steer[warn] ctl: набор правил в ядре изменён снаружи … — ставлю набор правил заново. Адреса, которые клал в наборы резолвер, после замены на месте: постоянные элементы fake-IP приходят в самом наборе правил (засев из файла состояния), элементы real-ip с оставшимся сроком резолвер возвращает по просьбе загрузчика сразу после nft -f и ещё раз по таблице (резолверу уходит таблица и неизменной);
  • элементы статических наборов — адреса и подсети списков и .srs, которые целиком ставит набор правил: снятый руками или добавленный чужой адрес — набор ставится заново так же, в журнале — steer[warn] ctl: элементы статических наборов в ядре не те, что ставило ядро steer (в ядре N, после загрузки было M) — ставлю набор правил заново. Сверяется сводка элементов (хэш, не зависящий от порядка), снятая сразу после nft -f, против сводки ядра, которую снимает план (ребёнок демона, не сам демон). Не сверяются: карты (fake-IP, раздача balance), набор «пущен напрямую» и в доменных наборах — адреса, которые кладёт резолвер (со сроком — real-ip, без срока из пула fake-IP — поддельные); подсети списка, которые лежат в доменном наборе выхода (у выхода есть и адресный, и доменный канал), сверяются — по началу диапазона. Стоит это дампа всех элементов — на больших списках роутера это секунды на каждый apply и reload, — поэтому на проходе сторожа элементы не сверяются;
  • цепочка разметки на ingress (ingress_mark, docs/contract-v1.md, §7): её устройства входят в отпечаток, и устройство раздачи, которое пересоздали (ядро вынимает его из цепочки, последнее — вместе с цепочкой), — это расхождение, после которого набор ставится заново уже на новое устройство: ближайшим проходом сторожа или apply/reload. Устройство раздачи, которого не было при прошлом применении, меняет сам текст правил и попадает в цепочку тем же apply или reload. До них пакеты с такого устройства размечает prerouting_mark;
  • правило fwmark и таблица выхода — у выхода, который сторож считает работающим, правило должно стоять, а таблица — вести в одно из его устройств (или, если устройств нет, стоять так, как ставит on_fail): иначе выход привязывается заново (changed.routing), в журнале — steer[warn] ctl: выход … — привязываю заново. У выхода в отказе маршрутизацию возвращает проход сторожа (… — сторожу внеочередной проход).

После любого найденного расхождения сторожу — внеочередной проход (с --watch).

На каждом проходе сторожа (с --watch) набор правил сверяется с ядром и без apply: перед проходом демон сравнивает номер своей таблицы и отпечаток её цепочек, правил и заголовков наборов с тем, что стояло сразу после его последнего nft -f, — четыре запроса по netlink, без процессов и без элементов наборов (элементы — только на apply и reload). Разошлось — в журнале steer[warn] ctl: проход сторожа: набор правил в ядре разошёлся с поставленным (…) — сверка в очереди, и демон ставит в очередь изменяющих команд reload — тот же, что пришёл бы по сокету: набор правил встаёт заново одной транзакцией, адреса резолвера возвращаются, итог — строкой steer[info] ctl: набор правил сверен с ядром проходом сторожа — поставлен заново, подписчикам — applied с by: "watch". Проход ждёт конца починки, как любой изменяющей команды.

  • Расхождение — снятое, изменённое или добавленное правило, цепочка или набор в любой из наших таблиц (в раскладке старого ядра — и в ip/ip6), и таблица, которую заменили мимо демона (steerd apply без сокета, скрипт): у неё другой номер.
  • Своя замена набора правил, которая идёт сейчас (apply, reload, починка), расхождением не бывает: проход не начинается, пока идёт изменяющая команда, а новое ожидаемое демон запоминает до её конца.
  • Таблицы нет вовсе (steer down) — не чинится: ядро сняли нарочно.
  • Не больше трёх починок за пять минут: то, что правит нашу таблицу без остановки, не превращает каждый проход в компиляцию. Отказ по лимиту — строка в журнал, одна на окно: steer[warn] ctl: проход сторожа: набор правил в ядре разошёлся с поставленным (…), но починок по сверке уже 3 за 5 мин — следующая через N с. Расхождение после того, как устройство раздачи пропало или появилось, а плана с тех пор не было (цепочку ingress_mark ядро снимает вместе с устройством), в счёт трёх не идёт: у него свой предел — двадцать сверок по устройствам раздачи за пять минут (см. «Страж правил выходов», «Устройства раздачи»), в журнале — … разошёлся с поставленным (…) после пересоздания устройства раздачи — сверка в очереди.
  • Починка применяет спеку, которая на диске, — как reload.
  • Демон перезапущен и ещё ничего не применял сам (без --apply) — сверять не с чем, до первого apply или reload.

После перезапуска демона применённое ему неизвестно, и первый apply или reload применяет всё, как подкоманда steer apply. Так же — после выключения ядра, после отказа применения и когда нашу таблицу в ядре сменил или снял кто-то другой (steer down, steer apply из init).

changed — что тронуто:

Поле Что
ruleset набор правил поставлен заново
routing выходы, чьи правило и таблица привязаны заново
helpers выходы, чей помощник поднят, перезапущен или погашен (с --supervise)
dnsd резолверу отправлена новая таблица (с --supervise)

При отказе ("saved":false) поля changed нет: в ядре остаётся прежнее.

Команды apply, check, reload и rm-file идут по одной: следующая ждёт в очереди демона, пока не кончится предыдущая (check спеку не трогает, но dry-run раздаёт выходам метки в реестре состояния). Остальные команды, в том числе status, отвечают и пока идёт apply: проверка и компиляция идут в детях демона, а не в нём самом.

После шагов 3 и 5 демон перечитывает спеку в память и шлёт подписчикам applied.

reload#

{"v":1,"cmd":"reload","code":0,"enabled":true,"reload":{"dnsd":"hup","outputs":"hup"},
 "changed":{"ruleset":false,"routing":[],"helpers":[],"dnsd":false}}

При включённом ядре steer reload сверяет спеку на диске с применённой, а применённое — с ядром, и применяет изменившееся и разошедшееся — так же, как apply (выше, «Сверка с ядром»), только без новой спеки и без отката: спека на диске остаётся той же. code не 0 — спека не прошла проверку или ядро не приняло набор правил; причина — в stderr, в ядре остаётся прежнее, а остальное (reload, перечитывание) делается как обычно. При выключенном ядре steer в ядро Linux reload не идёт.

  • dnsd: hup — подпись доменных каналов (steer dnsd-sig) совпала с той, что резолвер записал при запуске, и ему послан SIGHUP (списки перечитаны без потери запросов); restart — не совпала, послан SIGTERM, init поднимет сервис заново; none — резолвер не запущен.
  • outputs: hup — супервизору помощников (steer supervise) послан SIGHUP: новые выходы поднимаются, убранные гасятся, у выходов с изменёнными параметрами (узел подписки, сервер обфускации, файл xsteer, домен tgws, файл стратегии zapret или его содержимое) помощник перезапускается сразу; none — не запущен.

С --supervise (ниже) резолвер и помощники — дети демона, и поле другое: "reload":{"dnsd":"table","outputs":"daemon"} — демон перечитал спеку, написал резолверу новую таблицу и сам сверил помощников; сигналов процессам по /proc он в этом режиме не шлёт.

Сам демон перечитывает спеку в память и шлёт подписчикам applied (или spec-error, если она не прочиталась). Сторожу сигнал не нужен: failover --loop читает спеку заново на каждом проходе, а сторож демона (--watch) после перечитывания спеки делает внеочередной проход, если изменились выходы или помощники.

conns#

Ответ — как у любой подкоманды: code и stdout, в stdout — объект JSON (разбирать второй раз).

{"schema":1,
 "conns":[
   {"family":"ipv4","proto":"tcp","src":"10.0.0.5","sport":40312,"dst":"142.250.74.14","dport":443,
    "mark":"0x00400000","out":"vpn","state":"established",
    "packets":12,"bytes":2310,"reply_packets":10,"reply_bytes":8812},
   {"family":"ipv6","proto":"udp","src":"2a00:…","sport":51000,"dst":"2a00:…","dport":443,
    "mark":"0x00800000","out":null}
 ],
 "shown":2,"total":2,"truncated":false}
Поле Что
family ipv4 | ipv6
proto tcp, udp, icmp, icmpv6, sctp, udplite, dccp, gre; иначе номер протокола строкой ("50")
src, sport, dst, dport исходное направление соединения (кто начал и куда); портов нет у протоколов без портов (ICMP)
mark значение поля метки ядра steer у соединения, 0x%08x (чужие биты вне поля отброшены)
out имя выхода по реестру меток применённой спеки; null — метки нет в реестре (выход убран, соединение доживает)
state только TCP: none, syn_sent, syn_recv, established, fin_wait, close_wait, last_ack, time_wait, close, syn_sent2
packets, bytes, reply_packets, reply_bytes счётчики в обе стороны — только если ядро их ведёт (net.netfilter.nf_conntrack_acct=1 на момент рождения соединения); иначе полей нет вовсе
shown, total, truncated в ответе не больше 2000 записей; total — сколько всего подошло, truncated — total > shown

Что попадает: записи conntrack обоих семейств, у которых поле метки ядра steer не ноль. Собственный трафик ядра (запросы резолвера наверх, значение поля «все биты» на телефоне) не показывается. UID приложения в ответе нет: conntrack его не хранит. code 1 — conntrack недоступен (нет модуля nf_conntrack_netlink или прав), причина в stderr, stdout пуст.

dns-log#

stdout — объект JSON:

{"schema":1,"running":true,"size":256,
 "names":[
   {"name":"youtube.com","channel":"YouTube","out":"vpn","count":14,"last":1790272505,"ago":3},
   {"name":"example.org","channel":null,"out":null,"count":1,"last":1790272410,"ago":98}
 ]}
Поле Что
running false — резолвер не запущен (тогда names пуст, code 0)
size сколько имён держит журнал (256); самое давнее уступает место новому
names свежие первыми (по last, при равенстве — по count)
name спрошенное имя, нижним регистром; байты вне печатного ASCII — \u00XX
channel, out правило спеки (name канала) и его выход, куда имя попало при последнем запросе; null — ни в один доменный канал. Если несколько правил с одним выходом, клиентами и режимом делят набор, называется первое из них с доменными списками
dns сервер или группа из dns.upstreams (у своего сервера правила — имя правила), которыми резолвер спрашивает это имя; у имени вне правил — dns.other; null — прежний путь (DNS роутера)
count сколько раз спрашивали (UDP и TCP вместе) с запуска резолвера
last, ago последний запрос: секунды Unix и сколько секунд назад (ago считается по монотонным часам и не прыгает при переводе часов телефона)
upstreams апстримы, которыми пользуются каналы (пусто без dns.upstreams); нет ключа — резолвер не запущен
upstreams[].name, url, proto, via имя, адрес, транспорт (udp, tcp, dot, doh, doq) и выход, через который уходит запрос (null — напрямую)
upstreams[].state ready — есть соединение (у udp — ответы идут); idle — соединения нет, вопросов не было; connecting; down — последняя попытка не удалась, до следующей вопросы получают SERVFAIL сразу; unmarked — выход не размечен (резолвер без демона); no-tls — в сборке нет TLS (у DoQ — обёртки QUIC)
upstreams[].conns, inflight, sent, ok, failed открытых соединений, вопросов в пути, отправлено, отвечено, отказано с запуска
upstreams[].early, early_rejected только у DoQ и только когда 0-RTT был: вопросов, ушедших до конца рукопожатия, и соединений, где сервер 0-RTT отверг
upstreams[].last_ok_ago секунд с последнего ответа; null — не было
upstreams[].error, error_ago последняя ошибка словами (bootstrap не разрешил имя, рукопожатие не удалось, нет ответа за 4000 мс, HTTP 4xx…) и сколько секунд назад; null — не было
upstreams[].http только у DoH после первого соединения: h2 или http/1.1 — что выбрал сервер
группа серверов в upstreams proto — group, url пуст, via — null; mode — race или failover; state — ready (есть член без паузы в рабочем состоянии и группа уже отвечала), idle, down (все члены на паузе или не работают); sent, ok, failed, last_ok_ago — вопросы группы; active — у failover член, которого спросят первым (null у race или когда все на паузе); servers[] — члены по порядку спеки: name, pause (у failover — секунд до конца паузы после отказа, 0 — нет; у race всегда 0), ok, failed
cache null — кэша нет; иначе entries, max, hits, misses, stored, evicted
other null — dns.other не задан; иначе name, pause (секунд до конца паузы после отказа, 0 — нет), ok, failed (отказы и ответы SERVFAIL/REFUSED), fallback — сколько вопросов ушло вместо него прежним путём

Журнал живёт только в памяти резолвера: на диск не пишется ни периодически, ни по запросу, и перезапуск резолвера (смена состава доменных каналов, выключение ядра) его обнуляет. Кто спрашивал и что ответили, в журнале нет. Резолвер отдаёт журнал через свой сокет <каталог состояния>/dnsd.sock (права 0600, только домен ядра); приложению он виден только через dns-log управляющего сокета.

put-file, list-files, rm-file#

Файлы, на которые ссылается спека (domains_files, prefixes_files, файл подписки), приложение заливает в каталог ядра через сокет: писать туда само оно не может (SELinux). В спеку пишется путь из ответа put-file. Спека с путями вне этого каталога по-прежнему законна — каталог лишь место, куда может писать приложение.

put-file yt.lst 52311\n<52311 байт>
{"v":1,"cmd":"put-file","code":0,"name":"yt.lst","size":52311,"path":"/data/misc/steer/lists/yt.lst"}
  • Файл кладётся атомарно: во временный файл того же каталога, fsync, переименование. Резолвер и apply видят либо прежний файл целиком, либо новый целиком. Существующее имя заменяется.
  • Каталог создаётся при первом put-file с правами 0700. Пределы: файл — 16 МиБ, каталог — 4096 файлов и 128 МиБ всего (too-large с числом; замена существующего имени при 4096 файлах разрешена).
  • Слишком большое тело сервер отвергает, не читая: мост должен проверить размер до отправки, иначе запись тела может оборваться ошибкой (ответ too-large при этом всё равно в сокете).
  • Замена файла, на который уже ссылается применённая спека, сама ничего не меняет в работе ядра: подсети из списков стоят в наборах nft, собранных при apply, а доменные правила резолвер перечитывает по сигналу. Поэтому после заливки обновлённых списков — apply той же спеки: он пересоберёт наборы и сам перечитает списки резолвером (как reload). Одного reload хватает только для доменных строк.
list-files\n
{"v":1,"cmd":"list-files","code":0,"dir":"/data/misc/steer/lists",
 "files":[{"name":"yt.lst","size":52311,"mtime":1790272515}]}

По имени; mtime — секунды Unix (время последнего put-file этого имени). Каталога ещё нет — "files":[].

rm-file yt.lst\n
{"v":1,"cmd":"rm-file","code":0,"name":"yt.lst","removed":true}

removed:false — такого файла не было (повторное удаление не ошибка). Файл, путь которого ("<каталог>/<имя>" строкой JSON) есть в сохранённой спеке, не удаляется: ответ {"error":"in-use",…}. Порядок у приложения поэтому такой: собрать спеку без списка, apply (при выключенном ядре он спеку только сохраняет — этого достаточно), затем rm-file.

sub-check#

sub-check 20480\n<20480 байт подписки>

Ответ — как у подкоманды vless-nodes с путём файла: code и stdout с объектом JSON. Приложению нужны из него usable (пригодных узлов), skipped (непригодных ссылок vless://), foreign (ссылок чужих протоколов), nodes (пригодные узлы: index, name, host, port, type, security, …) и skipped_reasons ([{"reason","count","example"}], плюс skipped_other, если причин больше, чем влезло). Поля output, sub_file, node, chosen здесь ничего не значат: sub_file — имя временного файла, удалённого сразу после разбора.

{"v":1,"cmd":"sub-check","code":0,"stdout":"{\"output\":\"\",\"sub_file\":\"/data/misc/steer/tmp/sub-check.ctl-aARWzB\",\"node\":-1,\"chosen\":[],\"usable\":1,\"skipped\":1,\"foreign\":1,\"nodes\":[…],\"skipped_reasons\":[{\"reason\":\"tls по адресу без sni: нечем сверить\",\"count\":1,\"example\":\"wsnode\"}]}\n","stderr":""}

Разбор — тот же vless_parse_sub, которым узлы потом поднимаются. В базовой сборке (без VLESS) code 2 и отказ в stderr, как у vless-nodes.

subscribe#

subscribe\n
{"v":1,"cmd":"subscribe","code":0,"spec":"9c1f4e0a7b2d3e61"}
{"v":1,"ev":"applied","by":"apply","spec":"5a0e2f9d4c7b1e38","enabled":true,"changed":{"ruleset":true,"routing":["vpn"],"helpers":[],"dnsd":false}}
{"v":1,"ev":"spec-error","by":"reload","message":"spec: нет закрывающей скобки — файл оборван?"}

Первая строка — ответ на сам запрос: spec — отпечаток спеки, с которой демон работает сейчас (нет поля — спека не прочитана). Дальше соединение не закрывается, и демон пишет в него события: одна строка JSON на событие, \n в конце, у каждой "v":1 и имя в ev. Читать построчно; события неизвестного имени и неизвестные поля — пропускать: список будет расти в той же версии протокола.

ev Когда Поля
applied демон перечитал спеку после apply (сохранённой — и применённой, и при выключенном ядре), reload, SIGHUP или починки по сверке прохода сторожа by: apply | reload | hup | watch (починка: проход сторожа нашёл набор правил в ядре не таким, как ставил демон, — «Сверка с ядром»); spec — отпечаток новой спеки; enabled — включено ли ядро steer (false: спека сохранена, в ядро Linux не шла); changed — что тронуто, как в ответе apply (у hup набор правил и маршрутизация не сверяются: ruleset — false, routing — пустой)
spec-error спека не прочиталась при перечитывании; демон отвечает по прежней by — как у applied; message — причина, тем же текстом, что у подкоманды
switched сторож (--watch) сменил устройство выхода — или команда select сменила член группы out — выход; from — прежнее устройство (null — его не было: первый выбор или выход был в отказе); to — новое; why — причина (ниже). У группы спеки v2 (kind: group с именованными членами) ещё member — выбранный член; у переключения командой — by: "select". У пула v1 (devices) событие без этих полей
failed сторож: у выхода не осталось живых устройств, режим отказа применён; у группы pick: manual — ещё и когда не работает член, выбранный человеком out; from — устройство, которое несло трафик (null — не было); on_fail: drop (трафик остановлен) | direct | zapret (пущен напрямую); why: down — ни одно устройство не ответило (у manual — выбранный член), via — не работает выход, через который идёт этот. От команды select — ещё member и by: "select"
balance группа pick: balance: сменился состав живых членов, и сторож переписал карту раздачи в ядре out — группа; alive — живые члены (массив имён; пустой — живых нет, группа по своему on_fail)
revived сторож оживил устройство (перезапуск интерфейса, перенастройка awg, ожидание подъёма туннеля) и оно ответило out, dev
helper-up помощник выхода (--supervise) сообщил, что поднялся и готов нести трафик; у vless — и когда клиент снова нашёл живой узел, потеряв все out — выход; helper — помощник: vless, xsteer, obfs, tgws, nfqws
helper-down помощник сообщил об отказе, или его процесс вышел после up; у vless — и когда после подъёма не осталось ни одного живого активного узла out, helper; why — причина для человека: текст помощника («ни один узел подписки не отвечает», у потерянного узла — причина проверки: «TCP не соединился») или демона («процесс вышел (код 1)», «процесс убит (сигнал 9)», «перезапуск: параметры выхода изменились», «выход убран из спеки»)
node помощник VLESS перебирает узлы подписки: проверяется узел n из total out, n, total (нумерация с единицы)
health мост tgws отставил путь до ДЦ: данные через домен не шли, и мост не ходит им до конца срока out, helper; dc — номер ДЦ; media — медийный ДЦ (true/false); domain — домен пути; cool — на сколько секунд отставлен. Тот же путь виден в status выхода (paths_down), пока срок не вышел
repaired правила выходов сняли снаружи (netd при перезапуске, netifd на network restart, чужой ip rule flush), и демон (--watch) их вернул outputs — выходы, чьи правила возвращены; masq — true, если заодно возвращён masquerade (телефон, запасная починка apply-commit); при обычном возврате в процессе демона — false, masquerade сверяет следующий проход сторожа

why у switched:

why Что случилось
start первый выбор: записи о выходе ещё не было
recovered выход был в отказе (failed), и устройство ожило
down прежнее устройство не отвечает
preferred ожило более предпочтительное устройство пула и отвечало несколько проходов подряд
latency выбор по замеру задержки (prefer_latency, pick: latency)
spec прежнего устройства больше нет в пуле выхода (спека изменилась)
leaf группа v2 на том же члене, но у вложенной группы-члена сменился лист
select группа pick: manual: выбор человека (команда select или возврат на выбранный член, когда он ожил)

select#

select eu nl\n
{"v":1,"cmd":"select","code":0,"stdout":"steer: группа eu — выбран nl (vl-nl)\n","stderr":""}

Группа pick: manual (docs/spec-v2.md) отдаёт трафик члену, выбранному этой командой; до первой команды — default группы. Apply не нужен: демон сразу переписывает маршрут таблицы группы на лист члена (устройство, которое сейчас несёт трафик члена, у вложенной группы — её текущее), как сторож при переключении, и шлёт подписчикам switched с by: "select". Идущий проход сторожа при этом прерывается (его решения легли бы поверх выбора) и повторяется через несколько секунд.

Выбор хранится в файле select рядом со спекой (/etc/steer/select на роутере — файл объявлен в keep.d и переживает и обновление прошивки; /data/misc/steer/select на телефоне), а не в каталоге состояния: тот на роутере — tmpfs. Пишется только при изменении выбора, заменой через временный файл с fsync, и переживает перезапуск демона и перезагрузку. Выбор, оставшийся от прежнего ядра в каталоге состояния, читается оттуда, пока файла рядом со спекой нет.

Если выбранный член не работает (по последнему приговору сторожа), группа НЕ переходит на другой член — manual значит выбор человека: применяется on_fail группы (drop — трафик группы стоит, direct — идёт напрямую), подписчикам — failed с by: "select", в stdout — об этом прямо. Когда член поднимется, сторож вернёт группу на него сам (switched, why: "select").

Так же и при запуске: после перезапуска и перезагрузки стартовый apply сразу ставит таблицу группы на лист выбранного члена (выбор — из файла select, даже если его сменили, пока демон стоял), а если у члена нет устройства или последний приговор сторожа — «не работает», сразу ставит on_fail группы, не дожидаясь первого прохода.

Отказы — code 2 и причина в stderr: группы нет, у группы не pick: manual, член не из группы. При выключенном ядре выбор только запоминается. Клиент steer select <группа> <член> шлёт команду демону, а без демона тот же выбор делает само ядро (память сторожа — файлы каталога состояния).

helper#

helper xa\n
{"v":1,"cmd":"helper","code":0,"stdout":"{\"schema\":1,\"out\":\"xa\",\"helper\":\"xsteer\",\"running\":true,\"up\":true,\"since\":1790000000,\"started\":1789999990,\"restarts\":0}\n","stderr":""}

Что демон с --supervise знает о помощниках выхода по их событиям — строка JSON на помощника: running (процесс есть), up (последнее событие — up), since (когда up сменилось, секунды Unix; 0 — ни разу), started, restarts (перезапусков с подъёма демона), last_down — причина последнего отказа (поля нет — отказа не было); у vless ещё node и total (перебор узлов или номер вне подписки) и active — номера активных живых узлов пула ([3,7], номера среди пригодных, как у steer vless-nodes; [] — живых нет; поля нет — клиент ещё не сказал), у tgws — paths_down (отставленные пути, как в status). Если помощник — бинарник модуля (steer-vless, steer-xsteer, steer-obfs, steer-tgws), ещё module (имя бинарника), module_ver (версия из его hello, если он представился) и rejected: true, если демон его отверг: чужая версия, первое сообщение не hello, hello без версии; причина — в last_down («модуль steer-vless версии 1.9.0, а ядро 1.10.0 — обновите пакеты steer вместе»). Бинарника модуля нет — last_down «нужен пакет steer-<имя>», running ложно. Счётчиков и рукопожатия клиента xsteer здесь нет: он сообщает только смену состояния. Этим отвечает steer xsteer-peers <выход> у выхода под демоном — его клиент с трубой событий файла xsteer-<выход>.json не пишет. Помощника у выхода нет (демон без --supervise, вид без помощника) — code 1 и причина в stderr.

switched и failed приходят один раз — на перемене, а не на каждом проходе: пока выход в отказе или на том же устройстве, событий нет. События сторожа шлёт только демон с --watch; steer failover --loop — отдельный процесс, и о его переключениях подписчик не узнаёт. Так же и helper-up, helper-down, node, health: их шлёт только демон с --supervise, а помощник пишет событие только при смене состояния.

spec — FNV-1a 64 по байтам файла спеки, 16 шестнадцатеричных знаков: чтобы отличить «та же спека» от «другая», а не для проверки подлинности.

Что подписчик пишет после subscribe, демон читает и выбрасывает; полузакрытие записи с его стороны (как у steer ctl subscribe) — не уход. Уход — закрытие соединения. Подписчиков — не больше восьми (девятому — busy), и в четыре одновременных запроса они не входят. Очередь событий у каждого — 32 КиБ поверх буфера сокета: подписчик, который не забирает события, по переполнению отключается (в журнале демона — steer[warn] ctl: подписчик не забирает события), и остальным это не мешает. Отключённому — переподключиться и спросить status: пропущенные события не повторяются.

Из adb root shell: steer ctl subscribe печатает события по мере прихода.

Сторож выходов (--watch)#

steer daemon --watch — демон сам сторожит выходы: выбирает живое устройство пула, возвращается на предпочтительное, применяет on_fail, оживляет молчащие туннели и сверяет маршрутизацию выходов с ядром. Это тот же проход, что у steer failover (код один), только расписание и память между проходами — в демоне.

Включение флаг --watch; без него демон не сторожит
Период --watch-period СЕК, по умолчанию 60 — тот же, что у failover --loop на роутере
Первый проход сразу при старте демона; с --apply — сразу после стартового применения (удачного или нет), не раньше
До первого прохода группа спеки v2 уже на том члене, которого выбрал бы проход, только без проб: жив ли член — его последний приговор и наличие устройства (после перезагрузки приговоров нет, и живы члены с устройством). manual — член из select, а если он не работает — сразу on_fail группы; balance — карта сразу только из живых членов; order и latency — член, который нёс трафик, пока он жив, иначе первый живой (у latency — лучший по последнему замеру; замеров после перезагрузки нет, и до первого замера группа идёт по порядку). Так же решают apply и status в любой момент. Выход с одним устройством и пул devices спеки v1 до первого прохода привязаны к устройству: жив ли он, скажет только проба
События сети смена интерфейса или адреса (netlink) — внеочередной проход через 5 с после первого события пачки
Спека перечитана: после apply и reload — внеочередной проход через 5 с, если изменились выходы (маршрут, via, выбор по задержке) или помощники; после SIGHUP — всегда
Ядро выключено ни прохода, ни таймера, ни сокета событий сети: демон спит без срока до запроса (на телефоне — свойство persist.der.steer.enabled). Положение выключателя сторож узнаёт по apply, reload и SIGHUP: включили — проход сразу, дальше по периоду
Члены групп v2 молчащий член в проходе сразу получает приговор «не отвечает», и группа в том же проходе уходит на живого члена; оживление члена (ifdown/ifup и шаги ожидания, до ≈70 с) — внеочередным проходом через 5 с, если перезапуск устройства сейчас разрешён (не чаще раза в 300 с). У steer failover проход один, оживление — в нём
Замеры групп pick: latency своим таймером каждой группы на её interval (умолчание 180 с), а не в проходе: interval короче периода — это и есть частота замера. Первый замер — в проходе (у живого члена замера ещё нет: старт, член ожил), дальше — таймером. Замер, по которому выбор сменился бы (лучший другой, и выигрыш больше tolerance), зовёт внеочередной проход сразу; замер без смены выбора только обновляет память. idle_timeout: без трафика через группу таймер запросов не шлёт. Ядро выключено — таймеров нет. У steer failover замер — в проходе по сроку
Идёт изменяющая команда apply, check, reload, rm-file — проход откладывается до её конца
Набор правил перед каждым проходом — сверка номера и отпечатка таблицы с ядром (netlink, без процессов, без элементов наборов); разошлось — починка reload в очереди изменяющих команд, проход — после неё (выше, «Сверка с ядром»)
Память между проходами в демоне: выбор устройств, серии гистерезиса, замеры задержки, время последних оживлений, замеры awg; выбор устройств ещё и в файле active (только при изменении) — его читают status, diag и apply подкомандой
Префикс хоста (ipv6: routed) в конце каждого прохода — набор v6donor к префиксу, который действует сейчас: prefix: спеки или выведенный по ядру (адрес устройства раздачи внутри нуль-маршрута netifd, без процессов). Разошёлся — элементы набора переписываются одной транзакцией nf_tables, без замены набора правил; строка steer[info] failover: выход <донор>: префикс хоста — P (или «не найден»). Событие адреса IPv6 зовёт проход, так что набор догоняет netifd, выдавший LAN адрес позже старта ядра. У steer failover — то же в каждом проходе
Здоровье помощников вместе с --supervise — из памяти демона (ниже); без него — из файлов probe-<выход> и xsteer-<устройство>.json, как у failover --loop

Не больше одного сторожа. Демон с --watch рядом с steer failover --loop — два сторожа, которые дерутся за одни и те же таблицы выходов и оживляют одни и те же устройства дважды. На роутере сторож один — демон (init.d/steer другого круга не держит, а hotplug шлёт демону SIGHUP вместо прохода steer failover); на телефоне тоже — служба steerd в init/steerd.rc. Сам демон второго сторожа не обнаруживает.

Проход идёт в самом демоне, на его цикле событий, без отдельного процесса: пока проход ждёт ответа пробы или подъёма туннеля, демон отвечает на запросы. Проход по здоровым выходам не запускает процессов; процесс появляется только на действие — перезапуск интерфейса (ifdown/ifup), сигнал помощнику, перепривязку таблицы. События switched, failed, revived уходят после конца прохода, когда status уже отвечает новым выбором. В тишине, когда выходы здоровы и событий нет, демон просыпается только по периоду. Строки прохода в журнале — те же, что у steer failover (steer[warn] failover: …).

Вместе с --supervise. Помощники — дети демона, и их состояние сторож берёт из памяти демона, а не из файлов:

  • выход, чьё устройство создаёт наш процесс (туннели vless, hysteria2 и протоколов прокси, xsteer, и устройство такого выхода в пуле interface): помощник не запущен, сообщил down или ещё ничего не сообщил — устройство не живо, без пробы; сообщил up — дальше та же проверка, что и без супервизора (наличие устройства, мера вида);
  • клиент vless (и протоколов прокси) сам следит за своими активными узлами (active выхода, docs/spec-v2.md, «Пул узлов туннеля»): проверяет каждый раз в interval (60 с) и сразу после трёх несостоявшихся подряд соединений с ним или обрыва живого соединения по порогу молчания (silence, 20 с); две неудачные проверки подряд — узел мёртв: его соединения клиент сбрасывает (RST) и ставит на его место следующий свободный кандидат по порядку предпочтения — сам, без выхода процесса. down с причиной (подписчику — helper-down) — только когда живых узлов не осталось; тогда круг проверок (сперва прежние узлы, потом свободные кандидаты, через 15 с, после каждого пустого круга вдвое дольше, до 5 мин), и первый ответивший — снова up (helper-up). Состав активных узлов клиент сообщает событием active, демон отдаёт его в helper (поле active). Его up устройство считается живым без пробы TCP (она узла и не мерила: SYN-ACK клиент отдаёт сам, до соединения с узлом); по down выход уходит в отказ или группа — на другого члена, внеочередным проходом, а оживления (ожидания в проходе) нет — выход вернёт проход по up клиента. Без супервизора событий слушать некому: клиент следит за узлами и заменяет их так же, а сторожу у vless остаётся проба TCP;
  • перебор узлов подписки (node) при оживлении — ожидание, а не отказ; номер узла вне подписки (nonode) — отказ с причиной в журнале, без ожидания;
  • смена состояния помощника такого выхода (up, down, процесс вышел после up) — внеочередной проход через 5 с, как событие сети;
  • обфускатор выхода interface, чьё устройство не отвечает, демон перезапускает сам: подписчику — helper-down с причиной «перезапуск: выход не отвечает» и helper-up после подъёма; ubus не зовётся. Дальше, как и без супервизора, ifdown/ifup интерфейса.

Туннель xsteer, поднятый netifd (устройство в пуле interface, а не выход kind: xsteer), — не ребёнок демона: его здоровье сторож и здесь читает из файла, который пишет его клиент.

Страж правил выходов (--watch)#

netd на телефоне при каждом (пере)запуске стирает все правила маршрутизации, кроме приоритета 0, и перестраивает iptables — вместе с ними пропадают правила выходов и masquerade. На роутере то же делает netifd на каждом своём старте (/etc/init.d/network restart) и чужой ip rule flush. Демон со --watch замечает это сам:

Повод событие rtnetlink об удалении правила IPv4 или IPv6 с меткой ядра steer (с его маской или на его приоритете) или запасного запрета в таблице IPv6 (prohibit default metric 65535; blackhole, оставленный прежней версией ядра steer до первой привязки, — тоже; страж возвращает всегда prohibit: в IPv6 запрет висит на lo, и ядро снимает его вместе с lo — так делает netifd на network restart)
Когда проверяет через 100 мс после последнего события пачки, не позже 300 мс после первого: пачка flush — одна починка. Шторм — больше трёх возвратов правил за минуту (возврат одного запрета IPv6 не в счёт: его снимает ядро вместе с lo) — через секунду после последнего события, не позже двух после первого, и строка steer[warn] rules: правила выходов снимают снаружи снова и снова … один раз на минуту
Что считается снятым снаружи правила IPv4 выхода нет, а таблица IPv4 выхода занята (маршрут на устройство или запрет). Правила IPv6 нет, а правило IPv4 стоит или таблица IPv4 (или IPv6) занята: пустая таблица IPv6 сама по себе не довод — её опустошает и ядро (lo и устройство выхода легли). Запрета IPv6 нет, а таблица IPv4 занята. Свои снятия демон делает вместе с таблицей IPv4 (apply убрал выход, отказ on_fail=direct, steer down) — это не починка
Чем чинит сам, в процессе демона: правило — одним сообщением rtnetlink, с тем приоритетом, что был у снятого (на телефоне — свой, 9000), запрет IPv6 — тоже одним (ставится и при лежащем lo); маршрут в устройство и таблица IPv4 не перепривязываются (в ней может стоять запрет on_fail=drop, устройство выбирает проход сторожа). Не вышло — apply-commit --rule <выходы> --masq-ensure в очереди изменяющих команд (запрет IPv6 тогда вернёт следующий за ней проход сторожа)
После событие repaired (masq: false — masquerade на телефоне сверяет следующий за починкой внеочередной проход сторожа; true — его вернула запасная починка apply-commit), строка steer[info] ctl: правила выходов сняты снаружи — возвращены: … (у запрета IPv6 — … запрет IPv6 в таблице выхода снят (вместе с lo или снаружи) — возвращён: …) и внеочередной проход сторожа
Не чинит ядро выключено (сокет событий тогда закрыт), таблицы ядра steer в ядре Linux нет (steer down); идёт своя команда, которая сама снимает правила выходов, — проверка после неё, для этих выходов: метки, которые apply или reload снимает как убранные, и выходы, которые они привязывают заново из-за изменившейся спеки. Целиком до конца команды проверка ждёт починку ребёнком, выключатель и прочие изменяющие команды; пока команда идёт, проверка повторяется каждые 50 мс, а после её конца — сразу. План apply и reload, применение одного набора правил (сверка по новому устройству раздачи, сверка прохода) и привязка выходов заново только из-за расхождения с ядром проверку не держат: правила возвращаются сразу, одним сообщением, идемпотентным (точная копия — не ошибка), и сверка, увидев их на месте, выходы не перепривязывает — перед решением, что применять, она сама проверяет, не сняты ли правила
Пока правила нет помеченный пакет выхода, уходящий не в его устройство, отбрасывает набор правил (postrouting_guard, docs/contract-v1.md, §7): окно — «не работает», а не «мимо туннеля»

Устройства раздачи. Сторож читает и события устройств: у устройства из lan_devices появился новый номер (мост пересоздан — так делает netifd на network restart) — через 300 мс ставится сверка набора правил с ядром (reload без соединения, как у прохода), и цепочка ingress_mark возвращается на новое устройство, не дожидаясь прохода сторожа. В журнале — steer[info] ctl: устройство раздачи появилось заново — набор правил сверяется с ядром и … сверен с ядром по новому устройству раздачи — поставлен заново; подписчикам — applied с by: "watch". Такие сверки не идут в счёт «трёх за пять минут» у сверки прохода; у них свой предел — двадцать за пять минут, и строка в журнал, когда он исчерпан. Только на роутере и только там, где раскладка ставит ingress.

Помощники и резолвер (--supervise)#

steer daemon --supervise — демон сам держит процессы, которые нужны выходам и доменным каналам: помощников (steer vless, xsteer, obfs, tgws — по выходу; обработчик zapret steer-nfqws <очередь> <файл ключей>) и резолвер steer dnsd. Состав, порядок и перезапуск — тот же код, что у steer supervise (src/daemon/helpers.c).

Включение флаг --supervise; без него демон детей не держит
Состав по спеке в памяти: помощник — у выходов, вид которых его называет; резолвер — всегда (как needs-dnsd)
Порядок подъёма по via: цель раньше выхода, чей туннель через неё идёт; остальные — в порядке спеки
Перезапуск упавший — через 5 с; падает сразу снова (жил меньше минуты) — пауза удваивается до 5 мин; проработал дольше минуты — снова 5 с
Спека перечитана apply, reload, SIGHUP: убранные выходы — помощник гасится; новые — поднимается; у оставленных с новыми параметрами (узел подписки, сервер обфускации, файл xsteer, домен tgws, цель via и её метка, очередь, файл zapret и его содержимое) — перезапуск сразу; остальных не трогает; упавшим пауза сбрасывается. Таблица резолверу уходит, только если изменился её текст или файлы списков, на которые она ссылается
Версия модуля помощник-модуль первым пишет {"ev":"hello","ver":"<версия его сборки>","mod":"<имя>"}; демон сверяет ver со своей версией (steer version) и при расхождении, а также если первое сообщение — не hello, гасит процесс (SIGTERM), не верит его событиям и пишет steer[warn] supervise: vless vl — модуль steer-vless версии X, а ядро Y — обновите пакеты steer вместе: отвергнут. Формат остальных сообщений между модулем и демоном одной версии — прежний; между версиями он не обещан
Здоровье помощник пишет события в трубу (STEER_EVENT_FD); демон держит по выходу состояние (поднят ли, причина отказа, узел, время, у моста tgws — отставленные пути) и шлёт подписчикам helper-up, helper-down, node, health; клиент vless с трубой ещё и следит за узлом сам (down при его потере, up при возврате); сторож (--watch) берёт здоровье выходов отсюда (выше); команда helper отдаёт это состояние
Маршрут выхода его ставит демон, а не помощник. Клиенты vless и xsteer пишут up с полем dev — имя устройства (TUN), которое подняли: vless — сразу после подъёма устройства, xsteer — после рукопожатия с хабом. По первому такому up за жизнь процесса демон привязывает таблицу выхода к устройству (маршрут по умолчанию, правило по метке, половина IPv6 у выхода, который несёт IPv6, снятие соединений выхода из conntrack, если маршрут прежде вёл не в это устройство, — та же привязка, что у сторожа), в журнале — steer[info] supervise: vless vl — vl поднят, маршрут выхода привязан (таблица N); подписчику после этого — helper-up. Следующие up того же процесса маршрут не трогают: после down или выхода процесса решает сторож (on_fail), и маршрут возвращает его проход. Устройство не то, что у выхода в спеке, — строка steer[warn] supervise: … и без привязки. До привязки таблица держит то, что поставили apply или сторож (запрет у on_fail=drop, «напрямую» у direct/zapret), а помеченный пакет не в устройство выхода отбрасывает postrouting_guard. Без трубы событий (ручной запуск, стенды) маршрут не привязывает никто — клиент говорит об этом строкой в журнал
Файлы состояния помощник с трубой событий не пишет probe-<выход> (клиент vless) и xsteer-<выход>.json (клиент xsteer): ход перебора узлов status и diag берут из памяти демона, дети демона — из окружения, которое демон им ставит. Без трубы (steer supervise, ручной запуск) файлы пишутся. steer xsteer-peers у выхода под демоном печатает конфигурацию и живое состояние клиента из памяти демона (команда helper: процесс, up, причина отказа, перезапуски — без счётчиков и рукопожатия)
Стратегия zapret новое содержимое файла стратегии и reload (или apply, SIGHUP) — перезапуск обработчика только этого выхода; файл не опрашивается
Резолвер steer dnsd --table-fd 3: спеку не читает, таблицу доменных каналов демон пишет ему в трубу при запуске и после перечитывания спеки, если таблица или файлы её списков изменились, — резолвер меняет её без перезапуска и перечитывает файлы списков. После замены набора правил таблица уходит и неизменной: по ней резолвер возвращает в пустые наборы постоянные элементы fake-IP и элементы real-ip — адреса из ответов, которые клиенты ещё держат в кэше, с оставшимся сроком (память резолвера, до 2048 записей, не диск)
Флаги резолвера --dnsd-flag ФЛАГ (до восьми раз) — передать резолверу ещё и этот флаг, например --dnsd-flag --upstream-origdst
Демон упал резолвер не выходит: труба таблицы закрылась — он отвечает по последней таблице и ждёт нового демона 60 с (--orphan-timeout СЕК у резолвера; 0 — выйти сразу). Новый демон на том же каталоге состояния находит его по <каталог состояния>/dnsd-ctl.sock (0600, собеседник — root) и забирает: отдаёт новую трубу таблицы и свой stderr (SCM_RIGHTS), процесс резолвера тот же, в журнале — supervise: dnsd подхвачен (pid N). Нового нет за срок — резолвер выходит сам. Демон, которому резолвер не нужен (спеки нет, ядро выключено), оставшийся гасит сразу. Помощники демона не переживают: им до exec ставится PR_SET_PDEATHSIG (SIGTERM приходит от ядра, когда демон умер) и метка STEER_SUPD=<pid>:<время старта>:<каталог состояния> в окружении; новый демон до запуска своих и steerd down гасят помощников, чей демон мёртв (SIGTERM, через 3 с SIGKILL; в журнале — помощник прежнего демона (pid N, …) остался без него — гашу) и ждут их выхода — свой не застанет устройство TUN или порт занятым. На телефоне init гасит группу процессов службы, и резолвер уходит вместе с демоном
Ядро выключено детей нет: поднятые гаснут при следующем перечитывании спеки
SIGTERM демону резолверу SIGTERM и закрыть трубу (закрытую трубу без сигнала он пережил бы — строка выше), помощники гаснут по одному в обратном порядке подъёма (3 с каждому, 10 с всем, дальше SIGKILL), дети идущих команд (apply, check…) — SIGTERM группе, через 2 с SIGKILL

Не больше одного супервизора. Демон с --supervise рядом с другим супервизором поднял бы второй экземпляр каждого помощника (второй обфускатор на том же порту, второй резолвер на 5300). На роутере супервизор один — демон; на телефоне тоже (служба steerd — steerd daemon --watch --supervise --apply в init/steerd.rc у vendor/der). Сам демон второго супервизора не обнаруживает.

status при --supervise меняется только добавлением: поле probe у выхода vless (перебор узлов, отказ, номер вне подписки) то же, что и без супервизора, только берётся из памяти демона; у выхода vless, чей клиент потерял узел, — node_down (причина и время, docs/contract-v1.md, §2), а diag говорит о нём «узел перестал отвечать» с той же причиной; у выхода моста tgws — paths_down, отставленные мостом пути (docs/contract-v1.md, §2). Остальное состояние помощников видно по событиям и команде helper.

status и diag, запущенные подкомандой мимо клиента (steerd status — так их зовёт rpcd), при живом демоне тех же спеки и каталога состояния спрашивают его (version, затем status или diag, как клиент) и печатают его ответ байт в байт: ход перебора узлов, замеры групп и состояние помощников есть только в его памяти. Демона нет, он чужой или не ответил за 10 с — подкоманда считает сама. status --fast демона не спрашивает (снимок на диске у них общий), а подкоманда, которую клиент steer отдал ядру, уже спросив демон (STEER_DAEMON_ASKED в окружении), не спрашивает его второй раз.

Применение при старте (--apply)#

steerd daemon --apply применяет спеку сам, сразу после старта, — тем же reload, что по сокету (сверка с применённым; после старта применённое неизвестно, поэтому применяется всё). Так одна служба и применяет спеку, и держит её: отдельного steer apply перед демоном не нужно. Идёт в очереди изменяющих команд: apply, пришедший по сокету сразу после старта, ждёт его, а сторож заводится придержанным: его первый проход — сразу после конца применения, удачного или нет (проход раньше видел бы ядро без правил и писал бы «маршрутизация разъехалась»). Итог — строкой в журнал демона (steer[info] ctl: спека применена при старте или steer[warn] ctl: применение при старте не прошло … с причиной). Спеки нет — применять нечего, демон всё равно слушает сокет. Выключенное ядро steer (телефон) — в ядро Linux не идёт, как reload.

С --watch демон ещё и раз в пять минут пересчитывает status — ради снимка status.json, который отдаёт status --fast. При выключенном ядре этого таймера нет, как и таймеров сторожа и стража правил.

Клиент steer#

steer — отдельный маленький бинарник (src/client/main.c), ядро — steerd рядом с ним. Команды, которые обслуживает демон, клиент посылает в сокет и печатает ответ так, чтобы вывод и код возврата были те же, что у одноимённой подкоманды ядра: stderr из ответа — в stderr, stdout — в stdout, выход с code. Остальное — и любую команду, когда демона нет, — клиент отдаёт ядру: execv файла steerd с теми же аргументами (включая argv[0]).

Вызов К демону Запрос
steer status [--fast] да status / status fast
steer diag, conns, dns-log да одноимённый
steer explain <адрес\|имя> да explain <слово>
steer select <группа> <член> да select <группа> <член>; демона нет — выбор делает само ядро
steer apply да apply <длина> с содержимым файла спеки телом: демон проверяет, кладёт тот же файл на место и применяет изменившееся
steer apply --dry-run нет ядро (компилятор)
steer reload только демон reload; демона нет — отказ с кодом 3
steer subscribe только демон subscribe, события печатаются по мере прихода; демона нет — код 3
остальное (check-проверки, outputs, fit, srs-read, помощники, daemon…) нет ядро
  • Какой сокет. STEER_SOCKET, иначе путь платформы (/etc/steer/steer.sock на роутере, /data/misc/steer/steer.sock на телефоне). Платформа выбирается так же, как у ядра (--platform, STEER_PLATFORM, умолчание сборки, признаки среды).
  • Тот ли демон. Перед командой клиент спрашивает version и сверяет spec_path и state_dir демона со своими (--spec, --state-dir или пути платформы; после realpath). Не совпали — команда идёт ядру: ответ про чужую спеку был бы неверным ответом.
  • Какое ядро. STEER_ENGINE, иначе steerd в каталоге самого клиента.
  • Слово, флаги. Вызов, который не укладывается в форму команды демона (незнакомый флаг, --help, лишнее слово, слово с пробелом), идёт ядру: оно и откажет своими словами.
  • Отказы сервера. busy — клиент повторяет запрос до десяти секунд; denied, internal, bad-request и прочие отказы без code — команда идёт ядру (у reload и subscribe — отказ с кодом 3). timeout — печатается приложенный вывод, фраза сервера в stderr, код — из ответа. Обрезанный ответ (truncated) читающей команды повторяется ядром целиком.
  • Выключенное ядро (телефон): steer apply через демон только сохраняет спеку — клиент добавляет строку об этом в stderr, код 0.

steer-tools — ссылка на steerd: под этим именем ядро отвечает только на инструменты (fit, srs-read, obfs-server, sub-fetch, sub-quota, sub-hwid, dev-id, tls-probe, tgws-probe, vless-nodes, xsteer-key, xsteer-link, xsteer-check, xsteer-hub, dnsd-table), а на остальное — отказом с кодом 2.

Включение и выключение#

Выключатель — свойство persist.der.steer.enabled, и ставит его само приложение (SystemProperties.set, право set_prop(splify2_app, steerd_prop) в политике): init по нему сообщает демону о выключателе (steer reload) и при выключении снимает правила (steerd down); сам демон работает при любом положении (init/steerd.rc). Демону положение выключателя сообщают apply, reload или SIGHUP: при выключенном ядре у демона со --watch нет ни одного таймера и ни одного сокета событий, кроме управляющего, — он не просыпается сам ни разу (стенд tests/daemonmatch.sh: не больше одного переключения контекста за 20 с). Таймеры цикла идут по CLOCK_MONOTONIC: во сне телефона они стоят, не будят его и не догоняют пачкой после пробуждения; wakelock демон не берёт. Через сокет выключатель не идёт: домену ядра писать это свойство нельзя нарочно, чтобы ядро не могло включить сам себя. Сервер сокета работает при любом положении выключателя — status и сохранение спеки доступны и при выключенном ядре.

Пример (Kotlin, набросок для моста)#

fun steer(cmd: String, body: ByteArray? = null): JSONObject {
    LocalSocket().use { s ->
        s.connect(LocalSocketAddress("/data/misc/steer/steer.sock",
                                     LocalSocketAddress.Namespace.FILESYSTEM))
        val line = if (body != null) "$cmd ${body.size}\n" else "$cmd\n"
        s.outputStream.write(line.toByteArray(Charsets.US_ASCII))
        body?.let { s.outputStream.write(it) }
        val reply = s.inputStream.bufferedReader(Charsets.UTF_8).readLine()
        return JSONObject(reply)
    }
}