Управляющий сокет 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 приложениям не листается.
Кого пускают#
- SELinux:
connecttoк доменуsteerdразрешёнsplify2_app(иsuна userdebug). - Сервер по
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#
- Спека во временный файл рядом с
spec.jsonи план по нему (служебная подкомандаapply-plan): те же проверки и предупреждения, что уapply --dry-run, и отпечатки частей. Отказ —spec.jsonне тронут:"saved":false,"applied":false, вstderr— причина. - Кандидат становится
spec.jsonпереименованием (снаружи виден либо прежний файл, либо новый). - Ядро выключено (свойство
persist.der.steer.enabledне1) — на этом всё:"saved":true,"applied":false,"enabled":false. Правила поставит init, когда ядро включат. - Сверка с применённым (ниже) и применение только изменившихся частей (служебная подкоманда
apply-commit); не изменилось ничего — в ядро демон не идёт вовсе. Отказ ядра (план ядро не спрашивает) — прежняя спека возвращается:"saved":false,"applied":false,"rolled_back":true. - Успех — как
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 без демона):
- устройства выходов
kind=awg, затем правилаfwmarkи таблицы выходов (у выхода без устройства — то, что ставит егоon_fail: запрет приdrop, пустая таблица приdirectиzapret). До набора правил — чтобы метка выхода ни мгновения не существовала без своего правила: помеченный пакет без правила уходит по таблицеmain, то есть в WAN. Правило без меток ничего не ловит и безвредно; - набор правил одной транзакцией
nft -f. Карта подмены fake-IP и наборы каналов fake-IP засеяны в том же тексте из файла состояния резолвера (fakeip.state): подмена поддельного адреса появляется в ядре только вместе с его меткой, а поддельный адрес без подмены отбрасывается правиломsteer-fakeip-nomapза dnat и никуда не уходит. Прямо перед засевом загрузчик просит резолвер этого каталога состояния записать файл (просьбаflushпоdnsd-ctl.sock, ответ — после записи, ждётся до 1 с): сам резолвер переписывает его не чаще раза в минуту, и без просьбы засев отставал бы от его памяти — новые имена без подмены, сменившиеся адреса прежними. Таблицу от демона резолвер сверяет с картой ядра по своей памяти и ставит свой адрес там, где засев оказался другим; - сразу после загрузки — просьба резолверу вернуть элементы real-ip из его памяти (в тексте они не
засеваются: память о них только у резолвера), отметки «пущен напрямую» по таблицам выходов,
карта раздачи
balance— к живым членам; - снятие правил и таблиц меток, которых больше не несёт ни один выход, — только после удачной загрузки: до неё ими метит прежний набор правил;
- (демон, после удачной загрузки нового набора правил) снятие соединений правил, у которых сменился выход, — ниже.
Смена выхода правила. Метка выхода пишется и в метку соединения, и по ней группа 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)
}
}