Контракт steer (схема 1)#
Речь о схеме выхода — того JSON, который ядро отдаёт наружу (status, diag, explain).
Схема спеки, которую ядро читает, версионируется своим полем schema и сейчас бывает 1 или
2 (см. ниже); версии контракта и версии спеки — два разных числа и меняются независимо.
Это интерфейс между управляющим слоем (например, splify2) и ядром маршрутизации steer. Всё, что здесь описано, — обещание ядра наружу; всё, чего здесь нет, может измениться между версиями без предупреждения.
Формат настройки — JSON. Выбран потому, что в OpenWrt он поддержан из коробки (jsonfilter,
blobmsg, LuCI) и сохраняет порядок массивов, а для каналов steer порядок значим. Незнакомые ключи
ядро молча пропускает — это запас на совместимость вперёд: спека, написанная более новым
интерфейсом, не должна ронять маршрутизацию на не обновлённом роутере.
1. Спека (вход)#
Обычно лежит в /etc/steer/spec.json.
{
"schema": 1,
"lan_devices": ["br-lan", "tailscale0"],
"outputs": {
"vpn": { "kind": "interface", "devices": ["awg0", "wg0"], "on_fail": "drop" },
"direct": { "kind": "direct" }
},
"channels": [
{ "name": "geoblock", "match": { "domains_files": ["/etc/steer/lists/geo.lst"] }, "out": "vpn" }
]
}
Верхний уровень#
-
schema—1или2. Неизвестное число — отказ загрузки с кодом 2, и в этом весь смысл поля: скомпилировать в правила фаервола настройку, которую ядро не понимает, хуже, чем не применить ничего.Чем отличаются версии. Ровно одним: в схеме 2 у
matchканала есть измерение «протокол и порты» (proto,ports, см. ниже). Всё остальное совпадает буква в букву — спекаschema: 1применяется ядром, знающим схему 2, без единого изменения в поведении, включая текст правил и имена наборов nftables.Почему версия, а не просто новый ключ. Незнакомые ключи ядро пропускает молча, и для всего, что совпадение расширяет, этого достаточно: не понял — не получил новой возможности, а старое работает как работало. Порты совпадение сужают. Пропущенный ключ здесь означает «сузить забыли»: канал Discord — это
104.16.0.0/12(Cloudflare) плюсudp 50000-65535, и без портов он забирает в туннель весь TCP к Cloudflare. Молча. Разница между «не получил новое» и «сделал не то, что написано» — это и есть граница major.Что делать управляющему слою. Проверять спеку компилятором (
steer apply --dry-run) до записи на диск, как это делает splify2: ядро постарше ответит кодом 2, и человеку надо сказать «обновите ядро», а не оставить роутер без правил. Умение названо и вfeatures(spec_schema2), то есть узнать поколение можно и не применяя спеку.Поля схемы 2 в спеке
schema: 1— отказ, а не молчаливое игнорирование, и проверяются они у всех каналов, включая выключенные. Игнорирование здесь было бы той же бедой с другой стороны: человек написал порты, увидел, что спека применилась, и считает, что сузил канал. -lan_devices— устройства, с которых ядро забирает трафик клиентов. По умолчанию одинbr-lan. Каналы, гдеfromне задан, ловятся правиломiifname { … }по этому списку, и по нему же заворачивается DNS. Роутер бывает выходной точкой не только для своего моста: хостам из Tailscale или ZeroTier, для которых он шлюз, полагаются те же правила, что домашним устройствам.Именем устройства, а не подсетью, и это не стилистика. У
tailscale0адрес на роутере обычно/32, то есть подсеть пиров из него не выводится вовсе; у клиентов за вторым роутером в LAN адреса чужой подсети, а интерфейс тот же. Имя отвечает точно там, где адрес не отвечает.lan_device(одна строка) — та же вещь, записанная сокращённо: список из одного элемента, ровно какdeviceрядом сdevicesу выхода. Задать оба сразу — отказ загрузки: молча взять одно значило бы, что половина написанного не действует.Состав имени —
[A-Za-z0-9_.-], повтор отвергается. Устройства, которого нет на роутере, достаточно для загрузки спеки: правило написано черезiifname, то есть сверяется по имени при проходе пакета и начинает работать само, когда устройство поднимется, — иначе перезагрузка роутера оставляла бы человека без правил, пока не запустится демон Tailscale. Видимым это делаетsteer diagпроверкойlan_device(§5). -from_default— подсети клиентов для каналов, гдеfromне задан. Способ описать тех же клиентов АДРЕСОМ вместо устройства; поля нет — клиентов задаётlan_devices.Вместе с несколькими
lan_devicesотвергается. Это единственный способ не соврать. Взять только подсети — и человек, добавившийtailscale0, не увидит никакого эффекта, а причины не найдёт: отказа нет, правила есть, трафик идёт мимо. Взять и то, и другое вторым правилом поiifname— хуже:from_defaultпишут, чтобы клиентов ОГРАНИЧИТЬ (гостевая подсеть на том же мосту нарочно остаётся вне списка), и второе правило молча забрало бы её тоже. Отказ узкий:from_defaultрядом с ОДНИМ устройством законен и значит ровно то, что значил, — так написаны все спеки, сделанные до появления перечня. -traceroute_hops— необязательно. Делает узлы вtracerouteосмысленными. Требует правила в чужом firewall, которым ядро не владеет, поэтому по умолчанию выключено: читаемые, но неверные узлы хуже отсутствующих. Ядро предупреждает, чего именно не хватает.
Выходы (outputs)#
kind—direct,interface,vless,xsteer,zapret,tgwsилиawg. Неизвестный вид отвергается целиком, и это желаемое поведение: спека, записанная более новым интерфейсом, не должна применяться наполовину.-
devices/device— имя устройства или список. Список задаёт порядок предпочтения для failover. Задан один — второй выводится, чтобы дальше по коду не было двух путей.Проверка здоровья идёт по УСТРОЙСТВУ, а не по виду выхода, который его назвал. Если устройство создано выходом
kind: vlessилиkind: xsteer, то и в чужомdevicesоно проверяется так, как проверял бы его владелец (проба TCP у vless, а под демоном с--supervise— слово его клиента, который сам следит за узлом; наличие устройства у xsteer), и оживляется тоже по-владельцевски — ожиданием своего процесса (его поднимает супервизор), а неifdown/ifupпо устройству, которым netifd не управляет. По владельцу решается и то, нужен ли устройству masquerade: у пула, активное устройство которого создано выходомvless/xsteer,diagговорит «masquerade не нужен», а не вешает вечное «нет masquerade» — жёлтая метка на исправной настройке учит не смотреть на проверки вовсе. Проба по виду назвавшего была бы уkind: interfaceпробой ICMP, а через VLESS-туннель ICMP не проходит принципиально: живой туннель в таком списке объявлялся бы мёртвым на первом же проходе и приon_fail: dropуходил бы в blackhole.Так и собирается пул разнородных туннелей: выход
kind: interface, вdevicesкоторого названы устройства выходовvless/xsteerвперемешку с обычными интерфейсами, в порядке предпочтения. Отдельногоkind: poolв спеке v1 нет.Цена у неё одна, и о ней надо знать управляющему слою: такая спека дословно совпадает с законной старой, поэтому ядро постарше примет её молча и объявит живой туннель мёртвым (а при
on_fail: drop— уведёт канал в blackhole). Неизвестныйkindто же ядро отвергло бы громко и целиком; здесь громкого отказа нет и быть не может. Проверять поколение ядра поэтому обязан управляющий слой — например, по полюnodesу выходаkind: vlessвstatus(см. ниже): оно появилось тем же изменением. -kind: zapret— выход БЕЗ устройства и БЕЗ своей таблицы маршрутизации: трафик уходит обычным маршрутом, а по дороге его разбирает отдельный экземплярnfqwsсо своей стратегией обхода DPI. Так правило «эти домены — сюда» начинает значить «эти домены — через ЭТУ настройку обхода»: YouTube через свою стратегию, Discord через свою, остальное не трогается вовсе.Единственное поле такого выхода —
opts_file: абсолютный путь к файлу с ключамиnfqws, по строке на ключ. Не задан — выводится из имени выхода (/etc/steer/zapret/<имя>.opts). Сам файл пишет управляющий слой: ядро стратегий не знает и знать не должно, его дело — отдать помеченный трафик в очередь.Что при этом делает ядро и о чём должен знать управляющий слой:
statusпечатает у такого выходаmark,queue(номер очереди nfqueue),opts_file,upиon_fail. Номер очереди выводит ядро steer (из метки выхода), поэтому его надо ЧИТАТЬ, а не вычислять на своей стороне: второй расчёт разошёлся бы с первым, и процесс встал бы на очередь, в которую ядро ничего не отдаёт.steer zapret-instancesпечатает по строке на выход — имя, очередь, файл ключей, поля через табуляцию. Ей пользуется init-скрипт, поднимая по обработчику на выход.upу такого выхода означает «жив обработчик его очереди», а не «поднято устройство».- Обработчик обязан запускаться с
--dpi-desync-fwmark=0x60000000. Это не наше число:0x40000000— метка самого zapret, по которой его цепочкаpredefragснимает порождённые пакеты с учёта conntrack (без этого ядро отбрасывает их как INVALID);0x20000000выводит их из нашей очереди. Трафику выхода ядро steer ставит0x40000000сам — по нему системный обход, если он тоже работает, узнаёт «этот трафик не мой». - Нужен пакет
kmod-nft-queue. Без него ядро отвергает правилоqueue, аnft -fатомарен — то есть один такой выход снял бы маршрутизацию целиком. Ядро steer это проверяет до транзакции и отказывает, называя пакет. kind: tgws— выход БЕЗ устройства и БЕЗ таблицы маршрутизации, какzapret, но вмешивается он не в содержимое пакетов, а в адресата: соединение приложения с дата-центром Telegram ПЕРЕХВАТЫВАЕТСЯ правилом nat и уводится веб-сокетомwss://kwsN.<домен>/apiws: ДЦ2 и ДЦ4 — сначала к самомуweb.telegram.org, остальные — через домен за Cloudflare. Смысл в том, что в самом приложении при этом ничего настраивать не надо, — этим выход и отличается от прокси MTProto, которому в каждом клиенте прописывают адрес и секрет.
Единственное поле —
domain, и оно ОБЯЗАТЕЛЬНО. Это имя за Cloudflare, у которогоkwsN.<domain>ведёт на веб-точку Telegram: ядро соединяется сkwsN.<domain>(у ДЦ203 —kws203.<domain>), оно же SNI иHost. Умолчания нет нарочно. ТочкиkwsN.web.telegram.orgмост пробует и сам — по TLS 1.2 на адрес веб-клиента 149.154.167.220, — но там обслуживаются только ДЦ2 и ДЦ4; ДЦ1, 3, 5 и 203 работают только через домен, и подставить вместо негоweb.telegram.orgмолча значило бы завести выход, у которого эти дата-центры не работают никогда. Домен либо свой (аккаунт Cloudflare и запись с проксированием), либо общественный.Что делает ядро и о чём должен знать управляющий слой:
- Правило перехвата стоит в
nat preroutingс приоритетомdstnat + 1и ловит TCP на порты 443, 80 и 5222 — то есть только трафик КЛИЕНТОВ сети. Соединения самого роутера идут черезoutputи не перехватываются нарочно. - Номер дата-центра берётся из адреса назначения (
SO_ORIGINAL_DST), потому что в рукопожатии прямого соединения его нет вовсе (он есть только у MTProxy). Адреса встроены в ядро и дополняются файлом/etc/steer/tgws-dc.conf— строки<адрес> <номер> [media]. Неизвестный адрес не перехватывается: соединение переливается туда, куда шло. Увести его не в тот дата-центр значит сломать работавшее. - Поток MTProto идёт НАСКВОЗЬ, без расшифровки: ключ обфускации выводится из самих 64 байт
рукопожатия (без секрета), и точка
apiwsждёт ровно такой же пакет. Ядро правит в нём только восемь байт хвоста — вписывает номер дата-центра. steer tgws-instancesпечатаетимя<TAB>портпо строке на выход; ей пользуется init-скрипт, поднимая по мосту на выход. Порт выводит ядро (из метки), поэтому его надо ЧИТАТЬ, а не вычислять на своей стороне — тот же довод, что у очереди уzapret.on_failу такого выхода толькоdrop, и это не умолчание, а единственное выразимое: правило перехвата стоит в ядре всегда, и пока моста нет, ядро отвечает отказом. Обещатьdirectбыло бы обещанием, которого нечем выполнить.- Звонки через такой выход не идут. Голос Telegram — отдельный канал по UDP (P2P или через рефлекторы), MTProto участвует только в установке звонка; перехват здесь только TCP. Кому нужны звонки — тому туннель.
- Сертификат точки
apiwsне проверяется: в сборке нет X.509, а внутри едет MTProto со своей сквозной аутентификацией — посредник во внешнем TLS не прочитает и не подделает ни одного сообщения. Эталонные реализации поступают так же. kind: awg— туннель AmneziaWG/WireGuard в ядре Linux, которое ядро steer заводит само: создаёт устройство приapply, настраивает его через generic netlink модуля и снимает приsteer downи при удалении выхода из спеки. Процесса у выхода нет — пакеты шифрует ядро, и послеapplyядро steer в жизни туннеля не участвует (это ответ на требование батареи на телефоне: ни процесса, ни TUN, ни таймеров). Для остальной части ядра steer это выход с устройством, какinterface: метка, своя таблица,on_fail; masquerade ему нужен (на телефоне его ставит ядро steer правилом iptables, на роутере — зона firewall, как у любого интерфейса).
json
"nl": { "kind": "awg", "conf": "/data/misc/steer/awg/nl.conf", "on_fail": "drop" }
Поля:
- **`conf`** — абсолютный путь к файлу формата **awg-quick/wg-quick**. Не задан — выводится из
имени выхода: `<каталог настроек>/awg/<имя>.conf` (`/etc/steer/awg/…` на роутере,
`/data/misc/steer/awg/…` на телефоне). Ключи живут только в файле: спеку печатают `status`,
`diag` и резервная копия. Файл пишет управляющий слой.
- **`device`** — необязателен и обычно не нужен: **имя устройства выбирает ядро steer**. Это имя
выхода, если оно не длиннее 15 символов и не начинается с `tun`, `tap`, `utun`, `wg`, `awg`,
`ppp`, `pptp`, `ipsec`, `vpn`, `l2tp`, `wireguard`, `amnezia` (регистр не важен); иначе —
`if` и восемь шестнадцатеричных знаков хэша имени выхода. Туннель не должен выдавать себя
именем: приложения на телефоне видят имена интерфейсов с адресом. Названное явно `device`
подчиняется тому же правилу (иначе отказ); `devices` — одно устройство или ничего: пул
туннелей собирается выходом `kind: interface`, в `devices` которого названо это устройство.
- **`via`** — (ветка via) имя выхода, через который идёт UDP самого туннеля: метка сокета
туннеля (`WGDEVICE_A_FWMARK`) = метка выхода `via`. Без `via` метка — «мимо каналов ядра»:
на телефоне `STEER_SELF_MARK` (`0x0fc00000`, тем же метит свой запрос резолвер), на роутере 0.
Что поддержано в файле. `[Interface]`: `PrivateKey`, `Address` (несколько, v4 и v6, через
запятую и повторными строками), `MTU` (по умолчанию 1420), `ListenPort`, параметры AmneziaWG —
`Jc`, `Jmin`, `Jmax`, `S1`–`S4`, `H1`–`H4` (число или диапазон `lo-hi`), `I1`–`I5`,
`HeaderProtectionKey`, `ContentPaddingAddition`, `RekeyAfterTime`, `RekeyTimeout`,
`RejectAfterTime`, `KeepaliveTimeout`, `MaxHandshakeAttempts`, `RandomTrailers`,
`DisableCookies`. `[Peer]` (до восьми): `PublicKey`, `PresharedKey`, `Endpoint` (`адрес:порт`,
`имя:порт`, `[IPv6]:порт`; имя разрешается при `apply`), `AllowedIPs` (до 1024 на файл),
`PersistentKeepalive`, `AdvancedSecurity`. **`DNS`, `Table`, `FwMark`, `PreUp`/`PostUp`/
`PreDown`/`PostDown`, `SaveConfig` принимаются и НЕ исполняются** — `apply` говорит об этом
предупреждением: DNS ведёт резолвер ядра, таблицу и метку — само ядро. Незнакомый ключ — отказ.
Ошибка в файле называет строку и имя параметра, но никогда значение.
Модуль. Всегда `amneziawg`, когда он есть в ядре (он понимает и обычный WireGuard). Нет его, а
файл без параметров обфускации (не заданы или нулевые, `H1`–`H4` = 1–4) — модуль `wireguard`.
Нет AmneziaWG, а обфускация задана, — отказ с причиной в журнале, устройство не создаётся.
Параметр, который модуль этой версии не выражает (например, `HeaderProtectionKey` при
семействе версии 1 или 2), — тоже отказ: старое ядро молча отбросило бы атрибут, и туннель
встал бы без обфускации. `PersistentKeepalive` по умолчанию выключен — keepalive будит радио
телефона; включается только файлом.
Смена файла применяется `apply` **поверх** живого устройства: сессии пиров с прежними ключами
не рвутся, пиры, которых в файле больше нет, снимаются. Устройство пересоздаётся, только когда
поверх нельзя: файлу теперь нужен AmneziaWG, а устройство — `wireguard`, или из файла убран
параметр AmneziaWG (снять `Jc` или `I1` поверх модуль не умеет). `apply --dry-run` файлы
проверяет и говорит об ошибках предупреждением.
Здоровье для сторожа — **без проб**: рукопожатие моложе 180 с (или `RejectAfterTime`) — жив;
старое рукопожатие само по себе не приговор (по молчащему туннелю рукопожатий не бывает), а
приговор «мёртв» — когда с прошлого прохода сторожа что-то отправлено, а в ответ не пришло ни
байта. Починка молчащего туннеля — заново разрешить имя `Endpoint` и перенастроить (или
создать, если устройства нет), не чаще раза в пять минут. Замер прошлого прохода сторож демона
(`--watch`) и `failover --loop` держат в памяти, а не в файле: каталог состояния телефона — флеш,
и запись каждый проход шла бы круглые сутки; одиночный проход (`steer failover`) хранит его в
`<state>/awg-<устройство>.hs`. Выбор устройств (`<state>/active`) и
подпись устройства awg пишутся только при изменении.
-
on_fail— что делать, когда не работает ни одно устройство:drop(по умолчанию — блокировка, чтобы трафик не утёк в открытый интернет),direct,zapret.directиzapretпускают трафик как обычный трафик роутера — общий обход DPI, если он запущен, его разбирает (§ про бит0x40000000ниже);zapretвдобавок проверяет, что обход запущен, и говорит в журнале, если нет.У выхода с
dropв его таблице маршрутизации рядом с маршрутом в устройство всегда лежит запасной запрет —blackhole default metric 65535. Маршрут в устройство ядро вычищает само в миг, когда устройство исчезает (умер помощник с его TUN, пересоздаётся устройствоkind: awg, упал интерфейс), и до прохода сторожа таблица была бы пустой — а пустая таблица значит «ищи дальше», то есть напрямую. Запасной запрет остаётся, и помеченный трафик стоит, а не утекает. Уdirectиzapretего нет: там пустая таблица и есть обещанное «напрямую». Привязка таблицы к устройству идёт заменой маршрута (ip route replace), а стоящее правилоip ruleне снимается, так что ни смена устройства, ниapplyне открывают помеченному трафику путь мимо туннеля даже на миг.У
kind: zapretтот же выбор значит то же самое, но выражает его САМО ЯДРО, без сторожа и без опроса:drop— очередь без флагаbypass(нет процесса — пакет отбрасывается),direct— с флагом (нет процесса — пакет идёт как обычный). Значениеzapretу такого выхода отвергается: «при отказе обхода включить обход» — круг, который ничего не значит. -nodes/node— только дляkind: vless: какие узлы подписки взять и в каком порядке предпочтения. То же самое понятие, чтоdevicesуkind: interface, — список кандидатов, из которого первый живой забирает трафик, — только у подписки кандидаты называются номерами узлов.
json
"nl": { "kind": "vless", "sub_file": "/etc/steer/sub.txt", "nodes": [6, 7, 12] }
- **Номера — среди ПРИГОДНЫХ узлов**, то есть ровно те, что печатает `steer vless-nodes`.
Непригодные узлы номеров не занимают.
- **Порядок = предпочтение.** Ядро проверяет кандидатов сверху вниз (восемь секунд на узел) и
поднимает первого ответившего.
- **Пустой список и отсутствие поля** значат одно: «первый рабочий среди ВСЕХ пригодных». Это
умолчание, и оно же рекомендуется: номер узла меняется при обновлении подписки, а проверка
находит живой сама.
- **`node: N`** — сокращение для `nodes: [N]`, `node: -1` — для пустого списка. Обе формы сразу
(`node` и `nodes` у одного выхода) отвергаются: взять одну молча значило бы, что половина
написанного не действует, и узнать об этом было бы нечем.
- Не больше **16** номеров; повтор отвергается; отрицательный номер внутри `nodes` отвергается
(там он не значит «первый рабочий», а прочитанный молча дал бы пропущенного кандидата).
- **Номер, которого в подписке уже нет, пропускается**, а не роняет выход: подписка обновляется и
укорачивается, и устаревший номер не повод выключить локации, которые на месте. А вот когда за
пределы подписки уехали ВСЕ выбранные, выход не поднимается вовсе и говорит об этом. Перебирать
вместо выбранного что попало нельзя: это увело бы трафик в локацию, которую никто не выбирал.
**Как узнать ядро, которое `nodes` понимает.** Незнакомый ключ спеки ядро пропускает молча
(это и есть совместимость вперёд внутри мажора), поэтому `nodes`, записанные в ядро постарше,
дадут применённую спеку и трафик через узел, которого человек не выбирал. Признак — поле `nodes` у
выхода `kind: vless` в `steer status`: оно печатается всегда, в том числе пустым. Тем же приёмом
отличается ядро, понимающее `lan_devices`.
-
sub_file— только дляkind: vless: файл с узлами подписки. Обычно/etc/steer/sub.txt. Скачивает его управляющий слой, ядро только читает — поэтому ссылки подписки в спеке нет и быть не может.Рядом с этим файлом управляющий слой держит
<sub_file без .txt>.userinfo— остаток трафика, названный панелью в заголовкеsubscription-userinfoответа. Ядро его не читает и не пишет: чисел этих в подписке нет вовсе, узнать их можно только в момент запроса к панели, а запросы — не его дело. Файл назван здесь по одной причине: он перечислен в/lib/upgrade/keep.d/steer, то есть переживает обновление прошивки «с сохранением настроек» вместе с самой подпиской. Без этого обновление уносило бы остаток молча, и интерфейс говорил бы «панель не сообщает остаток» о панели, которая сообщает. -obfs— необязательно, только дляkind: interface. Несёт UDP-транспорт туннеля внутри поддельного потока TCP (WireGuard поверх TCP, совместимо на проводе с phantun). Добавляет 12 байт накладных (заголовок TCP 20 байт вместо UDP 8) и сохраняет семантику датаграмм: без ретрансмиссий, без управления потоком, одна датаграмма на сегмент. Есть и в базовом, и в расширенном пакете.
json
"vpn": {
"kind": "interface", "device": "wg0", "on_fail": "drop",
"obfs": { "mode": "wg-over-tcp",
"server": "203.0.113.10:4567",
"listen": "127.0.0.1:51820" }
}
- **`mode`** — необязательно. Сегодня существует только `wg-over-tcp`, и отсутствие значения
означает его. Незнакомое значение отвергается, а не додумывается.
- **`server`** — адрес и порт сервера обфускации (`steer obfs-server` или апстримный
`phantun_server`). Обязательно литерал IPv4, не имя: разрешение имени означало бы обращение к
DNS, а он сам может быть направлен в туннель, который этот сервер и поднимает. Разрешает имя
управляющий слой и записывает адрес.
- **`listen`** — локальный адрес и порт, которые слушает обфускатор. Обязан совпадать с
`Endpoint` пира в `/etc/config/network`. Это единственное место, где две настройки должны
сойтись, и вывести одну из другой ядро не может: ключи и пиры WireGuard — не его
собственность. Расхождение молчаливо: WireGuard отправляет в никуда.
- **MTU** — MTU интерфейса туннеля не должен превышать `MTU канала − 72` (внешний IP 20 +
поддельный TCP 20 + WireGuard 32): 1428 при обычных 1500, 1420 на PPPoE. На обеих сторонах
туннеля MTU обязан совпадать. `steer diag` считает предел от настоящего исходящего устройства и
предупреждает точным числом.
- Перечисляются `steer outputs --obfs`, запускаются `steer obfs <выход>` (ребёнок демона с
`--supervise`). Процесс ставит одно своё правило nft в таблице `inet steer_obfs`,
которое отбрасывает RST собственного ядра для своего потока: без него стек роутера сам рвёт
сессию. Правило живёт вне `inet steer` потому, что `apply` пересобирает ту таблицу целиком.
via— необязательно: имя другого выхода, через который идёт собственный трафик туннеля этого выхода — его соединение с сервером. Каналы решают, куда идёт трафик клиентов;viaрешает, куда идёт сам туннель. Так VLESS, сервер которого виден только из сети WireGuard, пускается через выход-интерфейс, а обфусцированный WireGuard — через туннель VLESS.
json
"wg": { "kind": "interface", "device": "wg0" },
"nl": { "kind": "vless", "sub_file": "/etc/steer/sub.txt", "via": "wg" }
- **Кому можно.** Только выходу со своим соединением с сервером, которое открывает или
настраивает ядро: `kind: vless`, `kind: xsteer`, `kind: awg` и `kind: interface` с блоком
`obfs` (сокет обфускатора к серверу обфускации). У обычного `kind: interface` соединение
открывает ядро WireGuard по настройке системы, и `via` у него отвергается, как и у `direct`,
`zapret`, `tgws`.
- **Куда можно.** Цель — выход с устройством и таблицей: `interface` (в том числе пул), `vless`,
`xsteer`, `awg`. `direct`, `zapret` и `tgws` отвергаются — метке там некуда вести.
- **Отказы спеки** (код 2, текст называет выход): цели нет в спеке; `via` на самого себя; круг
(`a → b → a`, путь печатается целиком); цепочка длиннее трёх переходов (`a → b → c → d` можно,
на четвёртом — отказ); круг через пул — цель `kind: interface`, среди устройств которой
устройство самого выхода или выхода, который сам идёт через него. Пустая строка значит то же,
что отсутствие поля.
- **Как работает.** Сокет туннеля получает метку выхода-цели (SO_MARK до `connect()`; у
`kind: awg` — метка устройства WireGuard, `WGDEVICE_A_FWMARK`), и ядро
ведёт его по уже стоящему ip rule цели в её таблицу — то есть в её активное устройство, с её
адресом источника. Новых правил ip rule `via` не добавляет; текст правил nft от него зависит в
одном месте — на телефоне (ниже). Трафик туннеля — собственный трафик роутера (хук output), каналы его не касаются: на
роутере они на prerouting, на телефоне — на output только для приложений (UID от 10000), а
помощники ядра работают под root. Без `via` сокет не метится на роутере вовсе, а на телефоне
получает значение «само ядро» (всё поле, `0x0fc00000`): маршрута на него нет, пакет идёт
обычной сетью netd. На телефоне при `via` к метке цели добавляется бит 28 (`0x10000000`,
«собственный трафик туннеля»; вне поля ядра steer, в reserved netd, маршрутизации не касается), и
заворот DNS приложений на output пропускает пакеты с ним: у спеки с `via` к правилам заворота
добавляется условие `meta mark and 0x10000000 == 0x00000000`, у спек без `via` этого условия нет.
Так туннель, чей сервер слушает 53-й порт (UDP или TCP), не забирается к резолверу ядра.
- **NAT.** Адрес источника туннеля выбирает `connect()` по таблице цели, то есть это адрес её
устройства. На телефоне правило masquerade у выхода-интерфейса (iptables, по метке выхода)
совпадает и с трафиком туннеля и ничего не меняет.
- **Отказ цели.** Пока цель не работает, сторож считает нерабочим и выход, который через неё идёт,
и ставит в его таблицу **его** `on_fail`; устройство такого выхода в этот проход не пробуется и
не перезапускается. Выходы обходятся в порядке зависимостей, поэтому ожившая цель возвращает
зависящий выход тем же проходом. Новых проб и таймеров нет. Сам транспорт внутреннего туннеля
при этом подчиняется `on_fail` цели: при `drop` его соединение не встанет, при `direct` уйдёт
обычным путём.
- **Помощники.** Метку процесс помощника читает один раз при старте, поэтому `via` входит в его
подпись — и имя цели, и её метка из реестра: смена цели, как и цель, пересозданная под тем же
именем с другой меткой, перезапускает помощника по `reload`; спека без `via` подписи не
меняет. `steer supervise --state-dir` читает реестр из того же каталога и передаёт его
помощникам. У `kind: awg` процесса нет — метку устройства (`WGDEVICE_A_FWMARK`) заново ставит
каждый `apply`.
Супервизор поднимает цели раньше зависящих. `steer outputs --via` печатает пары
`выход<TAB>цель`.
- **Ограничения.** Только IPv4: ip rule и маршрут по умолчанию у таблицы выхода — IPv4. Клиенты
vless, xsteer и обфускатор и так соединяются только по IPv4 (сокеты AF_INET, адрес сервера —
IPv4 или имя, разрешаемое в IPv4). У `kind: awg` с `via` адрес IPv6 в `Endpoint` — отказ
туннелю: `apply` его не поднимает (и не перенастраивает стоящий), `apply --dry-run`
предупреждает, причина в журнале; имя в `Endpoint` при `via` разрешается только в IPv4. Тихой
отправки туннеля мимо цели по IPv6 нет ни в одном случае. Имя сервера внутреннего выхода (узел VLESS, `Endpoint` xsteer и awg)
разрешается обычным резолвером системы при подъёме, **не через цель**: имя, которое
разрешается только внутри сети цели, не разрешится — пишите адрес.
Каналы (channels)#
Порядок массива задаёт приоритет: побеждает первый совпавший.
name— подпись канала. Уходит вstatus, чтобы у счётчика было имя.enabled— необязательно, по умолчаниюtrue. Приfalseканал остаётся в спеке, но не создаёт ни набора nft, ни правила в цепочке, ни доменного канала резолвера, и проверки его пропускают. «Выключено» значит «не действует», а не «действует тише». Резолвер выключенный канал пропускает наравне с компилятором: иначе выключенное доменное правило выдавало бы клиенту поддельный адрес, набора для которого в ядре нет, — имя переставало бы открываться СОВСЕМ, и снаружи это выглядело бы как сломанный выключатель.from— массив адресов или MAC-адресов источника. Все записи одного массива обязаны быть одного типа: смешивание отвергается с ошибкой, потому что nft не выражает «IP или MAC» внутри одного правила. Чтобы охватить и хост, и MAC, заведите отдельные каналы. Адреса бывают и IPv6 (fd00::5,fd00::/64), и смесь IPv4 с IPv6 в одномfromзаконна: правило IPv4 берёт записи IPv4, его двойник IPv6 — записи IPv6. MAC отличается от адреса IPv6 формой (шесть групп по 1-2 шестнадцатеричные цифры), а не двоеточием. Канал со своимfromиз одних адресов IPv4 IPv6 этих клиентов не узнаёт (diag:ipv6_clients) — для него нужен адрес IPv6 или MAC. То же вfrom_default; там из одних подсетей IPv4 IPv6 клиентов по умолчанию берётся поlan_devices.out— имя выхода.-
match— условия:domains_files/domains_file— пути к доменным спискам. Единственное число — сокращение для списка из одного элемента, обе формы равноправны.prefixes_files/prefixes_file— пути к адресным спискам. Канал может совмещать адреса и домены: набор один, просто заполняется с двух сторон.srs_files/srs_file— пути к наборам правил sing-box (.srs, версии формата 1-5). Набор — полноценный источник списка, его читает само ядро: имена — резолвер, подсети — набор nftables, потоком, без промежуточных файлов. Раскладывать набор в.lst(steer srs-read) для этого не нужно. Разрешён и вschema: 1: ядро постарше незнакомый ключ пропустит, и канал из одного набора оно отвергнет какmatches nothing, а рядом со списками — просто не получит элементов набора; шире от этого канал не становится. Подробно — ниже, «Наборы sing-box».mode— режим резолвера для доменных каналов:fakeip(выдаёт адреса из198.18.0.0/15, по умолчанию) илиrealip.anyиallow_all— оба обязательноtrue, чтобы забрать весь трафик указанных источников. Одинanyотвергается: это почти всегда описка, а её последствие — клиенты теряют и роутер, и DNS, то есть чинить придётся с провода. «Весь трафик» — оба семейства: IPv6 таких клиентов идёт в выход так же, как IPv4.proto— толькоschema: 2. Транспортный протокол канала:tcp,udpилиboth. Поля нет — протокол не критерий;bothзначит ровно то же и существует затем, чтобы у интерфейса с тремя пунктами в списке не было особого случая «третий пункт — не писать ключ».-
ports— толькоschema: 2. Порты назначения, массив строк:"443"или"50000-65535". Тире, а не двоеточие: так же, как записан диапазон адресов в списках (10.0.9.0-10.0.9.5). Не больше 16 записей; пересечения и повторы отвергаются (nftне принимает множество с накладывающимися интервалами и отверг бы весь набор правил).Порты не являются источником совпадения: канал из одних портов, без
prefixes_files,domains_filesилиany, отвергается тем жеmatches nothing, что канал без списков. «Канал ловит по портам» выразить нечем — правило безip daddrбезусловно, то естьudp 50000-65535ко всему интернету уехало бы в туннель.В правило это уходит как
meta l4proto … th dport …— одной строкой к тому же правилу канала. Один протокол ядро печатает обратно короче (udp dport …), это та же самая проверка.thникогда не стоит безmeta l4protoперед ним:th— смещение в транспортном заголовке, и у протокола без портов (icmp, esp, gre) по нему лежат чужие байты.Канал с сужением получает свой набор nftables (имя с суффиксом
_pN): два канала одного выхода с разными портами не сливаются в одну группу, иначе ограничение либо расползлось бы на чужие адреса, либо пропало бы вовсе. Каналы с одинаковым сужением сливаются, как сливались всегда.Пример — Discord: его голос описывается подсетями и портами, а не доменами:
json { "schema": 2, "channels": [ { "name": "discord", "out": "vpn", "match": { "prefixes_files": ["/etc/steer/lists/discord.lst"], "proto": "udp", "ports": ["50000-65535", "19000-20000"] } } ] }
Наборы sing-box (srs_files)#
Набор — это несколько правил, и у каждого своё назначение (имена и/или подсети), бывает — своё сужение по протоколу и портам, ограничение по клиенту или приложению и исключения. Ядро приводит их к клаузам (одно назначение на клаузу, src/model/srs.c) и раскладывает канал так:
- Сужение применяется само. У клаузы набора (
network,port,port_range) оно своё; если у канала заданыproto/ports, действует их пересечение. Пустое пересечение — отказ спеке (код 2) с именем канала, файла и обоих сужений: канал сужен так, что из набора не поймал бы ничего; почти всегда это сужение, перенесённое вручную изsrs-read --meta-out, — у набора оно и так своё. - Одно сужение у всего списка канала (или никакого) — обычная группа, как у канала с
proto/ports: набор адресов и одно правило. Имя набора — по тем же правилам, сужение из набора нумеруется после сужений каналов (_pN). - Смешанное сужение (у
discord.srs: имена без сужения, подсети —udp 50000-65535иudp 19000-20000) — один составной набор на канал:
set <выход>_<вид>_c<N>[r]_m {
type ipv4_addr . inet_proto . inet_service
flags interval[,timeout]
elements = { 104.16.0.0/12 . 17 . 19000-20000, 104.16.0.0/12 . 17 . 50000-65535, … }
}
… ip daddr . meta l4proto . th dport @<набор> … comment "steer:<набор>"
У каждого элемента свои протокол и порты; элемент без сужения — `0-255 . 0-65535`, и такое
правило ловит любой трафик к нему, включая ICMP (у протокола без портов `th dport` читает два
байта заголовка, они попадают в `0-65535`). Пересекающиеся элементы ядро в составной набор не
принимает, поэтому ядро steer раскладывает их без пересечений. Встречный путь — `ip saddr . meta
l4proto . th sport`. Резолвер кладёт имя в такой набор элементом `поддельный адрес . протокол .
порты` с сужением своей клаузы.
- Деление по группам вместо составного набора — на старой раскладке ядра (4.9), на ядре,
которое составной интервальный набор не принимает (до 5.6; проба
nft -c, переопределение —STEER_NFT_CONCAT=0|1), и когда элементов больше 16384 (составной набор такого размера в разы дороже обычных и по памяти, и по времениnft -f). Тогда у канала группа на каждый вариант сужения, ровно как у каналов с разнымиports. - Условия, которых у канала нет, — своей группой (
<выход>_<вид>_c<N>[r]_e<id>, id = номер канала × 100 + порядковый):source_ip_cidr— второеip saddr { … }в правиле рядом с «кому» канала;package_name— только у канала на сам телефон: «кому» группы — UID приложения из/data/system/packages.list(пересечение сfromканала), правило из одногоpackage_name— весь трафик приложения; исключения-подсети («x, но не эти адреса») — набор<группа>_xиip daddr != @<группа>_xперед поиском. Исключения-имена («x.com, но не y.x.com») применяет резолвер, в ядро они не идут. - Правила фильтров AdGuard (
adguard_domain— так их хранитsing-box rule-set convert --type adguard) читаются как имена:||x^— имя и поддомены,|x^— только имя, правило без якоря —*x, правило со*или без^— шаблоном (||ad*.x—ad*.x*и*.ad*.x*). Исключения@@становятся исключениями-именами клаузы, правила$important— своей клаузой. Правило с символом, которого в имени не бывает (всё, кроме букв, цифр,.,-,_и*), снимается элементом. В отличие от sing-box, правило с заглавными буквами совпадает без учёта регистра.steer srs-readтакой набор плоским списком не печатает — исключения списком не выражаются, — его подключают к правилу ключомsrs. - Снимается с предупреждением, файл принимается (
steer[warn] apply: srs: <файл>: снято правил: N (…)): правила с условиями, которые на платформе никогда не истинны или не видны (процессы, Wi-Fi, тип и признаки сети, адреса интерфейсов, вид DNS-запроса, порт источника, транспорт не tcp/udp), правило верхнего уровня сinvert(«всё, кроме …») и логическое сinvert, «и» с двумя назначениями, «или» внутри «и», исключение с двумя условиями, правило без назначения;package_nameу канала для клиентов раздачи,source_ip_cidrу канала на телефон. Подсети IPv6 не снимаются — идут в парный набор<группа>6; снимаются только подсети IPv6 правил с условиями, которых для IPv6 нет (source_ip_cidr,package_name, исключения-подсети), одной строкойподсети IPv6 правил с условиями … пропущены. Снимается правило целиком — набор от этого только сужается; снять одно условие внутри правила значило бы расширить его. Отказа (код 2) из-за содержимого набора нет: невыразимое правило всегда можно снять целиком. - Непрочитанный или испорченный файл — как непрочитанный адресный список: предупреждение с именем файла и причиной, остальные списки канала работают, канал без единого читаемого списка остаётся с пустым набором, а не забирает весь трафик.
statusсчитает набор одним списком (lists);explainнаходит адрес и в составном наборе и называет сужение совпавших элементов;diagищет публичные резолверы и в подсетях наборов.
Формы записей в списках#
Файлы списков — обычный текст, по записи на строку. Пустые строки и начинающиеся с # или ;
пропускаются.
Адресные списки:
| Форма | Пример |
|---|---|
| Хост | 1.2.3.4 (равно /32) |
| Префикс | 10.0.0.0/8 |
| Диапазон | 10.0.9.0-10.0.9.5 |
| Хост, префикс, диапазон IPv6 | 2001:db8::1, 2001:db8::/32, 2001:db8::1-2001:db8::9 |
Диапазон принимается наравне с префиксом, и это существенно: steer fit сам выдаёт диапазоны — два
соседних адреса, не складывающихся в выровненный префикс, объединяются именно так. Набор объявлен с
flags interval и auto-merge, поэтому nft принимает такой элемент как обычный.
IPv6 в списках. Строка IPv6 —
адрес: она идёт в парный набор группы <группа>6 (type ipv6_addr), у правила появляется
v6-двойник (раздел «IPv6 правил» ниже), резолвер её пропускает, fit выводит её как есть.
Набора <группа>6 нет, если в списках группы нет ни одной строки IPv6 (у доменного канала он
бывает и без них — «IPv6 правил» ниже).
Доменные списки:
| Форма | Смысл |
|---|---|
example.org |
Имя и все его поддомены |
*.example.org, foo?.example.org |
Шаблон (* и ?) |
=example.org |
Только это имя, без поддоменов |
re:^.*\.example\.org$ |
Регулярное выражение (POSIX extended, регистр не важен) |
Регистр букв не важен нигде. Негодное регулярное выражение пропускается как одна строка, а не роняет резолвер.
IPv6 правил#
Своих ключей для IPv6 у спеки v1 нет; по ней ядро делает так:
- Подсети IPv6 в списках идут в правила (см. «Формы записей в списках»): строки IPv6
файлов и подсети IPv6 наборов
.srsидут в парный набор<группа>6(ipv6_addr; у составной группы —ipv6_addr . inet_proto . inet_service), у правила — v6-двойник в той же цепочке, с той же меткой и тем же комментариемsteer:<группа>(объём канала вstatus— сумма обоих). - «Весь трафик» (
any) включает IPv6. - Выход с IPv6 —
kind: interface,kind: awgи пул устройств из них:ip -6 rule fwmark <метка>/<маска> table <таблица>с той же меткой и тем же номером таблицы, в таблице IPv6 —default dev <устройство>и всегда запаснойprohibit default metric 65535. Запрет в таблице IPv6 —prohibit, а неblackhole, и основной (приon_fail: dropи у устройства без IPv6), и запасной: ядро сразу отвечает клиенту ICMPv6 «administratively prohibited», и клиент с двумя стеками переходит на IPv4 немедленно, а не по таймауту соединения. У IPv4 запрет —blackhole.on_failдействует на оба семейства; сторож и страж правил сверяют и возвращают оба. Устройство с выключенным IPv6 — в таблице IPv6 запрет, IPv6 правил выхода стоит (apply:маршрут IPv6 в X не встал … IPv6 правил выхода остановлен). masquerade IPv6 на роутере — дело зоны fw4 (option masq6 '1'), на телефоне ядро steer ставит его само черезip6tables. - Выход без IPv6 —
kind: vless,kind: xsteer,kind: tgws(и пул, где такой выход — член): IPv6-трафик правила, ведущего в такой выход, отбрасывается, а не уходит напрямую — цепочкаforward_v6(reject with icmpx type admin-prohibitedпо метке выхода); клиент с двумя стеками сразу переходит на IPv4. На телефоне для каналов на само устройство —rejectвoutput_mark. diag:ipv6_output. kind: direct— IPv6 идёт напрямую, как IPv4 (двойник без метки, только ради «первое совпадение выигрывает»).kind: zapret— IPv6 правила уходит в очередь nfqws, как IPv4 (очередь выбирается по метке соединения, у семейств общей).- Доменные каналы: ответ на AAAA для имени под правилом пустой в
обоих режимах и в любой выход — клиент с двумя стеками сразу идёт по IPv4. Поддельный и
настоящий IPv6 на AAAA даёт только спека v2 (docs/spec-v2.md, «IPv6 доменных правил»); после
steer spec convertони появляются. Набор правил при этом тот же, что у спеки v2 с теми же каналами: у канала, который ведёт в выход с IPv6 (илиdirect) и чьи клиенты узнаются по IPv6 (устройства, MAC, адреса IPv6,from_defaultпо умолчанию), — набор<группа>6(ipv6_addr,flags interval,timeout), v6-двойник правила и, приfakeip, картаfakeip6с правиломip6 daddr fdfe:dcba:9876::/96 … dnat ip6 to ip6 daddr map @fakeip6вprerouting_dnat. Резолвер по спеке v1 в них ничего не кладёт; строки IPv6 из списков канала лежат в<группа>6, как у адресного. Ответы HTTPS и SVCB для имён под правилом пустые. В таблице «демон → резолвер» у каналов спеки v1 поле семейства «4», в подписиdnsd-sig— тоже (строканабор|режим|семейства|файлы…). steer fit— строки IPv6 выводятся как есть после строк IPv4 (не сливаются, в--budgetне входят); задевшая исключение IPv6 из--excludeне выводится. В отчёте при наличии строк IPv6 — поля"v6"(выведено) и"v6_excluded";source,keptиmalformed— только про IPv4.
2. Состояние (выход)#
Запрашивается steer status. Отдаёт применённую конфигурацию и живое состояние.
{
"schema": 1,
"features": ["lan_devices", "nodes", "pool", "active_device", "spec_schema2", "via"],
"lan_devices": ["br-lan", "tailscale0"],
"outputs": {
"vpn": { "kind": "interface", "device": "awg0", "up": true, "mark": "0x00100000",
"table": 300, "in_firewall": true, "nat": true }
},
"channels": [
{ "name": "vpn_dom", "out": "vpn", "kind": "domains", "live": true,
"packets": 5009, "bytes": 1799237, "down_packets": 4120, "down_bytes": 5981023,
"lists": 2, "channels": ["youtube", "google"] }
]
}
-
features— что это ядро УМЕЕТ, перечнем имён. Появилось в 1.3.0; поля нет вовсе — ядро старше, и тогда о поколении по-прежнему судят по косвенным признакам (наличиеlan_devicesздесь, полеnodesу выходаkind: vless).Зачем перечень нужен: незнакомый ключ спеки ядро пропускает молча — это и есть совместимость вперёд внутри мажора, — поэтому управляющий слой, записавший новое поле в ядро постарше, получает применённую спеку и трафик не туда, куда просил. Косвенных признаков для этого не хватает:
nodesвиден только там, где выход подписки уже есть, а смешанный пул нужнее всего там, где его нет вовсе (xsteer плюс wireguard).Не номер версии, и это принципиально. Версию в дерево проставляет релизный workflow, а не коммит, поэтому ядро из
mainчерез два коммита после релиза называет то же число, что и релиз; сравнение по нему однажды объявит умеющим ядро, которое не умеет.Набор может и расти, и сокращаться между версиями: потребитель обязан терпеть незнакомые имена и не должен требовать наличия какого-либо конкретного. Печатаются сейчас:
Имя Что обещано lan_devicesКлиенты описываются перечнем устройств (§1), и statusпечатает его всегда.nodesВыбор нескольких узлов подписки списком у выхода kind: vless(§1).poolСмешанный devices: здоровье устройства и нужда в masquerade судятся по ВЛАДЕЛЬЦУ устройства, а не по виду назвавшего его выхода. Без этого обещания пул с локацией подписки внутри объявляется мёртвым на первом же проходе сторожа.active_devicedeviceу выхода — устройство, которое несёт трафик сейчас, а не первое вdevices;applyпривязывает таблицу к нему же.awgВыход kind: awg(§1): туннель AmneziaWG/WireGuard в ядре Linux, который заводит ядро steer; у выхода вstatus— полеawg.failedВыход, который сторож признал неработающим (on_fail применён), отдаётся с up: falseиfailed: true, а не по состоянию устройства (см.up/failedниже).groupsГруппы спеки v2 ( kind: group):pickorder/latency/manual/balance, вложенные группы, командаselect; у выхода-группы вstatus— объектgroup(ниже).balance_byКлюч byу группыpick: balance(docs/spec-v2.md):connection,site,site_client— раздача новых соединений случайно или хешем адресов.excludeКлючи exclude(страны по флагу в имени узла) иexclude_name(куски имени) у выходаkind: tunnelспеки v2 (spec-v2.md); у узла в*-nodes— поляccиexcluded(§6).active_nodesПул узлов туннеля — ключи active,by,interval,silenceу выходаkind: tunnelспеки v2 (spec-v2.md, «Пул узлов туннеля»); у выхода вstatus— объектvless(proxy) с полемactive(ниже), вhelperдемона — полеactive.dns_groupsГруппа серверов DNS в dns.upstreamsспеки v2 —{ servers, mode: race \| failover }(spec-v2.md, разделdns); годится вdns.upstream,dns.otherиdnsправила; вdns-log—proto: "group"с членами.dns_otherКлюч dns.otherспеки v2: сервер или группа для имён вне правил, с запасным путём на DNS роутера; вdns-log— объектotherи полеdnsу имени.spec_schema2Спека schema: 2— сужение канала по протоколу и портам (§1).viaКлюч viaу выхода (§1) и полеviaвstatus(ниже).status_cachestatus --fastотдаёт запомненный ответ с"cached": true(§6, «Память состояния»).xslinkСсылка xs://принимается везде, где ждут настройку xsteer (docs/xsteer.md, «Ссылка xs://»).xsteer_stateСостояние туннеля xsteer видно снаружи: файл xsteer-<имя>.json(docs/xsteer.md, «Что видно снаружи: файл состояния»), у клиента под демоном — командаhelper(docs/ctl.md).- lan_devices— устройства, с которых ядро забирает трафик, СПИСКОМ и всегда, какой быформой они ни были записаны в спеке. Управляющему слою это нужно, чтобы показать, с чего идёт трафик, не читая спеку вторым источником: два источника однажды разойдутся, и разошедшийся интерфейс покажет не то, что применено. - channelsв выводе — это объединённые группы, а не исходные каналы спеки один к одному.Каналы, совпадающие по выходу, виду списка, клиентам и режиму резолвера, сливаются в одну группу с одним набором и одним правилом. Имена исходных каналов группы приходят в поле channels— это то,что стоит показывать человеку, а не имя набора. - name— имя набора nft. Формируется из выхода и вида:vpn_ip,vpn_dom. Когда группаотличается от умолчаний (свой список клиентов или realip), к имени добавляется различитель:vpn_ip_c1,vpn_dom_c0r. Разбирать это имя не нужно и не следует — оно служебное.- live—falseозначает, что правила нет в ядре.- awg— только у выходаkind: awg: состояние туннеля из ядра (WG_CMD_GET_DEVICE), однимобъектом рядом с device/up:
json
"awg": { "live": true, "impl": "amneziawg", "peers": 1, "handshake_ago": 12,
"rx": 104857, "tx": 20480, "endpoint": "198.51.100.7:51820" }
`impl` — модуль (`amneziawg` или `wireguard`); `handshake_ago` — секунд с самого свежего
рукопожатия, `null` — его не было; `rx`/`tx` — байты, суммой по пирам; `endpoint` — адрес
первого пира, как его видит ядро (после разрешения имени), или `null`. Устройства нет или ядро
не ответило — `{"live": false}`. Ключей в выводе нет никаких.
packets/bytes— счётчики ИСХОДЯЩЕГО (upload): пакеты, уходящие от роутера к выходу. Значение «исходящий» уbytesсохранено намеренно, ради совместимости с установленными управляющими слоями.down_packets/down_bytes— счётчики ВХОДЯЩЕГО (download), считаются в цепочкеpostrouting_down. Печатаются только когда по каналу шло скачивание. Не переиспользуйтеbytesпод download: существующие интерфейсы читают его как upload.lists— сколько файлов списков стоит за этой группой.device— устройство, которое несёт трафик выхода СЕЙЧАС, а не первое вdevices. Сторож переключает выход на запасное устройство, не трогая настройку, и о переключении рассказывает это поле;up,in_firewallиnatотносятся к нему же. Так о выходе говорят все три команды сразу:applyпривязывает таблицу к нему,statusего называет,diagвыносит приговор ему. До версии 1.3.0statusиdiagназывали первого кандидата, и работающий пул выглядел сломанным.-
up/failed— идёт ли трафик выхода черезdevice.up: false— устройства нет, оно не поднято или сторож признал выход неработающим: ни одно устройство не ответило на пробу, иon_failуже применён (запрет приdrop, прямой путь приdirect). В последнем случае рядом стоит"failed": true, аdeviceназывает устройство, которое есть, но не отвечает. Поляfailedнет вовсе, когда выход не в отказе. Умение объявлено вfeatures(failed).Ядро без умения
failedпечатает в этом состоянииup: true— по состоянию устройства, а не по трафику: отказ сторожа у него поstatusне виден. -devices/on_fail— список кандидатов и поведение при отказе, как в спеке. У группы спеки v2devices— листья её членов по порядку (у вложенной группы — её текущий). -group— только у выходаkind: groupспеки v2 (пулdevicesспеки v1 виден выходомkind: interfaceбез этого объекта). Остальные поля выхода у группы — те же, что у любого выхода:
json
"group": { "pick": "latency", "members": ["nl", "wg1"], "selected": "wg1", "alive": ["nl", "wg1"],
"url": "http://cp.cloudflare.com/generate_204", "latency": { "nl": 212, "wg1": 48 } }
- `pick` — `order` | `latency` | `manual` | `balance`; `members` — члены по порядку (имена
выходов, в том числе вложенных групп);
- `selected` — член, чей лист сейчас несёт трафик группы; `null` — группа в отказе (или сторож ещё
не проходил). У `balance` — член, в чьё устройство ведёт таблица самой группы (сокеты с меткой
группы, `over` на группу); соединения клиентов раздаёт ядро по `alive`;
- `alive` — живые члены по последнему проходу сторожа; у `balance` это и есть состав карты раздачи;
- `select` — только у `manual`: выбор человека (команда `select`) или `default`, пока выбора не
было. `select` без `selected` — выбранный член не работает, и группа по своему `on_fail`: другой
член вместо выбранного не берётся;
- `url`, `latency` — только у `latency`: адрес проверки urltest и задержки по членам в мс (время до
первого байта ответа 204/200 через члена; только измеренные — член без замера не назван). По
`latency` сторож и выбирает;
- `latency4`, `latency6` — только у группы `latency`, которую демон мерил по обоим семействам (все
живые члены несут IPv6): замеры по IPv4 и по IPv6 порознь, в мс, только измеренные. `latency`
тогда — худший из двух у каждого члена, а член, не ответивший по одному из семейств, в `latency`
не назван (выбыл); если по IPv6 не ответил никто — `latency` равен `latency4`. У остальных групп
этих полей нет;
- `weights` — только у `balance`: веса членов по порядку `members`.
nodes— только у выходаkind: vless: выбранные узлы подписки, как в спеке. Печатается всегда, в том числе пустым списком ("nodes": []— «первый рабочий среди всех пригодных»), и именно поэтому годится как признак ядра, понимающего выбор нескольких узлов. У выходов других видов поля нет вовсе: у них кандидаты — устройства, и пустойnodesрядом с ними читался бы как «узлы есть, но не выбраны».in_firewall/nat— диагностические признаки: виден ли туннель firewall'у OpenWrt и есть ли для него NAT.nat— подмена IPv4 (masq зоны fw4, правилоmeta nfproto ipv4 masqueradeили правило без семейства): зона с одним masq6 даётnat: false.nat6— подменяется ли IPv6 на устройстве, кем бы ни было: masq6 зоны fw4 (meta nfproto ipv6 masquerade, правило без семейства вinetили правило в таблицеip6) или цепочка ядраpostrouting_nat6— у выхода сipv6: nat, а у выхода без ключаipv6— у владельца устройства (группа, чей нынешний членipv6: nat). Только у выхода, который несёт IPv6 (kind: interface,kind: awg, группа из таких): у остальных IPv6 правил отвергается, и вопроса о подмене нет. diag приnat6: falseи глобальном адресе IPv6 на устройстве раздачи —output_nat6(warn).nat6_by— кем подменяется IPv6, только приnat6: true:"steer"— цепочкой ядра (она стоит всегда, когда ключnatесть, и включённый вдобавок masq6 зоны этого не меняет),"fw4"— зоной или правилом firewall.ipv6(спека v2) — ключipv6выхода, как записан:"routed","nat","off". Поля нет у выхода без ключа.ipv6_applied: false— записан, но на этой платформе не действует (телефон:routedиnatтам — как без ключа).prefix— только у донора (routed): префикс хоста, записанный или выведенный по ядру сейчас,null— не узнать (diag скажет, чего не хватает).via— только у выхода сviaв спеке: через какой выход идёт его туннель. Живость цели здесь не повторяется — она видна у самой цели в том же ответе. Умение объявлено вfeatures(via): ядро постарше поле спеки пропустило бы молча и пустил туннель напрямую.obfs— только у выхода, который несёт транспорт поверх поддельного TCP. Повторяет спеку. Признака живости здесь нет намеренно:statusопрашивают раз в несколько секунд, а живость процесса — это обход/procна каждый опрос ради поля, которое повторяло быdiag. Приговор о живости даётdiag, который спрашивают по нажатию.probe— ход подъёма выходаkind: vless. Появляется только когдаup: falseи только когда есть что сказать:
json
"probe": { "state": "probing", "node": 3, "total": 26 }
"probe": { "state": "failed", "total": 26 }
"probe": { "state": "no_such_node", "node": 31, "total": 29 }
Устройство туннеля vless создаётся **не сразу**: когда узел не назван одним номером, клиент
перебирает кандидатов с таймаутом восемь секунд на узел, и устройство появляется только
после выбора. `total` — это число КАНДИДАТОВ, а не узлов подписки: при `nodes: [6, 7, 12]` человек
ждёт «2 из 3», потому что перебираться будут три, и обещать ему двадцать шесть значило бы назвать
чужое ожидание. Пока перебор идёт, `up: false` — то же самое значение, что при настоящем отказе:
без `probe` на подписке из десятков нерабочих узлов исправная настройка минутами выглядела бы
сломанной, а настоящий отказ — медленной проверкой. Под демоном (`--supervise`) ход перебора
`status` берёт из памяти демона (события `node` клиента, docs/ctl.md), без демона — из файла
`<state_dir>/probe-<выход>`, который пишет клиент.
- `probing` — проверяется узел `node` из `total` (номер с единицы). Верно, пока жив клиент: у
файла читатель проверяет `/proc/<pid>` писавшего. Перебор, прерванный посередине, поэтому не
остаётся на экране навсегда.
- `no_such_node` — **выбранный НОМЕР узла за пределами подписки**: узлы в ней есть, а такого
номера нет. `node` — номер, который написал человек, `total` — сколько пригодных узлов в
подписке на самом деле.
Отдельное состояние, а не разновидность `failed`: `failed` с `total: 0` значит «в подписке
нет пригодных узлов», и по такому приговору человек пошёл бы перекачивать подписку и менять
поставщика, а поправить надо одно число. Управляющий слой обязан различать эти два
состояния: у первого лечение — «поправьте номер» (а лучше «первый рабочий»), у второго —
«проверьте ссылку и поставщика».
Сторож это состояние тоже читает и НЕ обещает подъём: ждать нечего, супервизор будет
поднимать клиента с тем же отказом, пока число не исправит человек.
- `failed` — перебор кончился: живого узла не нашлось (`total > 0`) либо пригодных узлов в
подписке нет вовсе (`total: 0`). Это состояние **переживает смерть клиента намеренно** — он
выходит с кодом 1 именно потому, что узла нет, а супервизор поднимает его заново, и приговор
нужен после выхода. Живёт две минуты; старше — значит никто больше не
пробует.
- **Поля нет** — «не знаем»: файла нет, он неразбираем, устарел или писавший мёртв. Отсутствие
обязано читаться как «не знаем», а не как «плохо»: иначе новый канал состояния сам станет
источником лжи.
node_down— только у выходаkind: vless(и у выхода, чьё текущее устройство создано таким выходом), чей клиент — ребёнок демона (--supervise), и только пока за устройством нет ни одного живого узла. Клиент следит за своими активными узлами сам (проверка раз вinterval, 60 с, и сразу после нескольких несостоявшихся подряд соединений или обрыва соединения по порогу молчания — docs/spec-v2.md, «Пул узлов туннеля»), мёртвый заменяет следующим кандидатом в том же процессе и, когда живых не осталось, сообщает об этом с причиной:
json
"node_down": { "why": "TCP не соединился", "since": 1790000000 }
`why` — причина словами клиента (последняя неудачная проверка узла), `since` — когда он о ней
сказал (секунды Unix). Устройство при этом на месте, поэтому это не `probe`; выход обычно и в
отказе (`up: false`, `failed: true`): сторож принимает слово клиента без своей пробы. Поле
пропадает, когда клиент снова нашёл живой узел — прежний или другой кандидат: процесс при этом не
перезапускается. **Поля нет** — живой узел есть, или сказать нечего: без демона событий
слушать некому.
vless(у протоколов прокси —proxy) — у туннеля по подписке, пока его клиент жив: какие узлы активны сейчас (пул узлов, ключactiveспеки v2):
json
"vless": { "pid": 2716, "up": true, "node": "NL-1", "want": 2, "slots": 2, "by": "connection",
"active": [ { "index": 3, "name": "NL-1" }, { "index": 7, "name": "DE-2" } ] }
`active` — живые активные узлы: `index` — номер среди пригодных (как у `vless-nodes`), `name` — имя
из подписки; `want` — сколько просит спека, `slots` — сколько клиент держит (не больше кандидатов);
`up` — есть хоть один живой узел; `node` — имя первого активного; у `proxy` ещё `protocol`. **Поля
нет** — клиент не запущен (или ядро старше пула узлов).
paths_down— только у выходаkind: tgws, чей мост — ребёнок демона (--supervise), и только в ответе демона: пути до дата-центров Telegram, которые мост сам отставил, потому что данные через них не шли, — пока срок не вышел.
json
"paths_down": [ { "dc": 2, "media": false, "domain": "kws2.web.telegram.org",
"at": 1790000000, "until": 1790000300 } ]
`dc` — номер ДЦ, `media` — медийный ДЦ, `domain` — домен пути, `at` и `until` — когда отставлен и
до какого времени (секунды Unix). Пустой массив — отставленных путей нет. **Поля нет** — «не
знаем»: мост поднят не демоном, или ответ дала подкоманда без демона. С новым процессом моста
список пуст: отставку помнит сам мост.
3. Ограничения ввода и проверки#
Спека — строгий JSON. Нарушение перечисленного означает отказ ядра (ненулевой код), а не работу с тихо испорченным состоянием.
- Строгий JSON, без висящей запятой. Массивы вида
[...,]отвергаются громким отказом. - Размер файла — не больше 16 МиБ. Больший отвергается (
spec too large (max 16 MiB)), а не обрезается. Телоcheckиapplyпо управляющему сокету — не больше 1 МиБ (docs/ctl.md). - Размеры массивов. Чисел «не больше N» у списков нет: выходы, каналы, файлы списков
(
domains_files/prefixes_files/srs_files), клиенты (from,from_default),lan_devices,outputs.*.devices,outputs.*.nodes, узлы подписки и апстримы растут по мере надобности, а предел им — память. Повтор имени устройства и номера узла отвергается. Пределы, у которых есть причина, — и в отказе названо число:match.ports— не больше 16 диапазонов на канал (L4_PORTS_MAX): каждый диапазон размножает правила nft и ящики составных наборов; пересечение и повтор отвергаются;- выходы с меткой — число мест в поле метки (
steer_mark_slots,src/model/marks.h): 211 на роутере, 43 на телефоне (Android), 1 у моста Telegram отдельной сборки. Поле — восемь бит с двадцатого, и значения, которые чужая перезапись бит 16-23 не превратит в метку другого выхода, кончаются; отказ загрузки называет и число выходов с меткой, и число мест. Выходdirectметки не берёт и в счёт не идёт; pick: balance— не больше 120 членов: столько мест в карте ядра, по которой пакет выбирает члена (GROUP_BAL_SLOTS); остальные виды групп предела не имеют;- запросов DNS в полёте у резолвера — 1024 (номер запроса в DNS — шестнадцать бит, а слот держит поколение; сверх этого запрос отбрасывается, клиент повторит).
- Длина строк — каждая запись
from/devices/пути обрезается по своей ширине (64 / 32 / 256 байт). Это обрезание, а не отказ. - Незавершённая спека отвергается громко. Файл, записанный не до конца (например, пропало
питание посреди записи), даёт
channels.<имя>: match: expected a keyи код 2.
Память под спеку выделяется по её размеру, а не по числу мест, заложенному заранее: спека на триста правил, клиентов и списков разбирается тем же кодом, что и на три.
4. Диагностика (выход)#
Запрашивается steer diag [--spec ФАЙЛ].
{
"schema": 1,
"checks": [
{ "id": "table", "verdict": "ok", "what": "nft-таблица steer на месте", "why": "" }
],
"warn": 0,
"fail": 0
}
У каждой проверки есть id, verdict, what, why.
Вердикты — одно из четырёх значений:
| Вердикт | Смысл |
|---|---|
ok |
Проверено, всё хорошо. |
note |
Совет, а не находка: работает и будет работать, но знать полезно. note намеренно исключён из счётчиков warn/fail и не должен трактоваться как «не ок». Иначе вечно верный совет — например, про браузер с DoH, обходящий DNS роутера, — навсегда красил бы здоровый роутер красным. |
warn |
Работает, но есть то, что объясняет вероятную будущую жалобу. |
fail |
Сломано, трафик идёт не туда. |
Идентификаторы проверок, которые печатаются сейчас: table, down_chain, set,
lan_device, bridge_nf, dns_redirect, dnsd, doh, ipv6, resolver, output, obfs,
zapret, ipv6_output, ipv6_clients, output_nat6, ipv6_host. Набор может и расти, и сокращаться между версиями;
потребитель обязан терпеть незнакомые id и не должен требовать наличия какого-либо конкретного.
ipv6— толькоok(«IPv6 наружу работает, правила его разбирают»), если у роутера есть маршрут IPv6 по умолчанию; без маршрута проверки нет.ipv6_output(note) — на каждый выход без IPv6 (vless, xsteer, tgws), в который ведёт хоть одно правило: IPv6 его правил отбрасывается, а не идёт напрямую. Совет, а не находка: клиент с двумя стеками уходит на IPv4, и ничего не ломается.ipv6_clients(warn) — на каждый канал со своимfromиз одних адресов IPv4: IPv6 этих клиентов правилом не узнаётся.output_nat6(warn) — выход несёт IPv6, устройство в зоне, у клиентов есть IPv6 наружу (глобальный адрес на устройстве раздачи), а подмены IPv6 на устройстве нет (nat6: falseвstatus): IPv6 клиентов уйдёт в туннель с их адресами. Проверкаoutputсудит только IPv4: зона с одним masq6 даёт тамwarn«нет masquerade», а не «NAT есть». У выхода с ключомipv6не печатается: уnatподмену ставит ядро steer, уroutedона не нужна; у выхода без ключа судит ключ владельца устройства (группа, чей нынешний членipv6: nat, — не печатается).ipv6_host— выходыipv6: routedиipv6: nat, «чего не хватает», по ядру и по файлам/etc/config/networkи/etc/config/dhcp(только чтение):fail«у <устройства> MTU N — IPv6 на нём не работает» (MTU < 1280);- routed:
ok«префикс хоста P, у LAN адрес из него»; у LAN адреса из префикса нет — по нуль-маршруту netifd (unreachable Pв main) одно из трёх:warn«префикс … не раздаётся — нет ip6prefix» — «задайте ip6prefix у интерфейса <устройство>» (при выведенном префиксе ещё «или prefix: у выхода»);warn«у LAN нет адреса из префикса … — нет ip6assign» — «задайте ip6assign у LAN»;warn«у LAN нет адреса из префикса …, хотя ip6assign N задан» — «проверьте ip6class у LAN»; маршруты не прочитать —warn«у LAN нет адреса из префикса …» с обоими советами. Устаревший адрес (preferred_lft 0 — префикс сняли, адрес ещё доживает) адресом из префикса не считается.warn«префикс хоста не узнать» — несколько кандидатов, нуженprefix:;warn«ip6assign N у <сети> больше префикса хоста /L»;warn«у зоны <зона fw4> (в ней <устройство>) включён masq6» — адреса префикса подменяются (совет — снять masq6, если зона названа по устройству, иначе перенести устройство в свою зону; подмена правилом на самом устройстве — «IPv6 на <устройство> подменяется (masquerade)»);note«у LAN есть ULA рядом с префиксом хоста» — при правилах fake-IP в донора; - nat:
fail«у <устройства> нет адреса IPv6»;warn«у LAN только ULA, а ra_default не задан» — «задайте ra_default=1 в dhcp.<секция>» (устаревший глобальный адрес на LAN за глобальный не считается); - проба (эхо ICMPv6 через выход, у routed — с адреса LAN из префикса, до 800 мс):
ok«IPv6 через <устройство> отвечает»;warn«IPv6 в <устройство> не уходит» (у пира нет::/0в AllowedIPs);warn«хост не отвечает по IPv6» — сервер не пускает источник. note«выход X: ipv6: routed|nat здесь не действует» — телефон.
-
ipv6_outputу выхода с IPv6, снятым спекой, говорит причину: «IPv6 выключен (ipv6: off)» или «IPv6 идёт только через <донор> (ipv6: routed)». -
lan_device— печатается на каждое устройство изlan_devices:ok, если оно есть в/sys/class/net, иwarnс его именем, если нет. Отсутствие устройства не мешает применить спеку: правило написано черезiifname, то есть сверяется по имени в момент прохода пакета и начинает работать само, когда устройство поднимется. Приговорwarn, а неfail, потому что остальные перечисленные устройства при этом работают. -
bridge_nf— загружен лиbr_netfilterи включён лиnet.bridge.bridge-nf-call-iptables. Когда включён —warn: кадры, ходящие внутри моста (клиент↔клиент по LAN), начинают проходить через ip-хуки netfilter, а наше перенаправление порта 53 условия на получателя не имеет — значит запрос клиента к DNS-серверу на той же LAN (Pi-hole, второй роутер) тоже заворачивается на резолвер ядра steer. Выглядит это как «Pi-hole перестал получать запросы» при исправной настройке steer.Ядро steer эту настройку не переключает: она общесистемная и чужая (её ставят docker и libvirt), и снять её значило бы сломать соседа. Приговор
warn, а неnote, потому что это не свойство мира, как совет про DoH, а переключаемая настройка этой системы с наблюдаемым следствием. Модуль не загружен или sysctl равен нулю —ok. В файле что-то третье — проверка молчит: приговор наугад хуже молчания.Разметка каналов эти кадры тоже видит, но в туннель они от неё не уезжают: мостовой кадр форвардится на L2, маршрутного поиска для него нет, а политика по fwmark спрашивается только при маршрутном поиске. Поэтому приговор говорит про DNS и не обещает большего.
-
output— у выходаkind: vlessбез устройства приговоров три. Пока клиент перебирает узлы подписки, печатаетсяnote«выход vpn: проверяю узлы, 3 из 26» — это штатная работа, и красить ею состояние нельзя. Когда перебор кончился без результата —fail«ни один узел подписки не ответил (проверено 26)» либо «в подписке нет пригодных узлов». И только когда сказать нечего (см.probeв §2), —fail«устройства нет». Различать эти случаи по тексту нельзя: приговор иid— контракт, текст — нет.У выхода в отказе (в
status—failed: true) приговорfail«выход vpn: wg0 не отвечает, трафик канала остановлен» (или «идёт напрямую» приon_fail: direct) вместо проверок зоны и NAT: они ничего не объясняют, пока трафик через устройство не идёт.У выхода vless, чей клиент под демоном потерял узел (в
status—node_down), приговорfailпро узел, а не про устройство: «выход vl: узел перестал отвечать, трафик канала идёт напрямую» (без хвоста о трафике, если выход не в отказе), и вwhy— причина клиента и «выход вернётся сам, когда узел ответит». -udp— не печатается. Туннель несёт UDP (команда VLESS 2), поэтому QUIC, WireGuard и игровой трафик черезkind: vlessработают. Заметкуudp«выход VLESS несёт только TCP» печатали ядра, чей туннель UDP не нёс; отличать ядра по её отсутствию нельзя — для этого есть версия. -obfs— печатается на каждый выход с блокомobfs, до четырёх раз: процесс обфускатора работает (fail, если нет); его правило против RST установлено (warn, если нет — без него ядро роутера само рвёт сессию); маршрут к серверу обфускации не идёт через туннель, который этот сервер поднимает (fail— такую петлю нельзя разорвать изнутри); MTU туннеля влезает в поддельный конверт TCP (warn, с точным числом). -resolver— печатается какnote, когда в адресном списке канала сkind: vlessнайден адрес публичного резолвера (например,8.8.8.0/24внутри категории Google или YouTube). Такие запросы DNS до резолвера доходят, но каждый запрос — отдельный поток UDP и, значит, отдельное рукопожатие к узлу: имена разрешаются, просто медленнее. Никогда неwarn— ничего не сломано.
Код возврата. steer diag отвечает 1 только когда хотя бы одна проверка — fail; note и
warn ненулевого кода не дают. JSON при этом полный и валидный.
Счётчики верхнего уровня. warn — число вердиктов warn, fail — число fail. Счётчика для
note нет.
5. Префиксы журнала#
Ядро steer пишет в журнал (stderr) с постоянным префиксом уровня. Классифицировать строки следует по префиксу, а не по тексту: формулировки меняются между версиями.
steer[warn]— настоящая забота: что-то сломано или трафик идёт не туда.steer[info]— информационное состояние, не тревога.
За префиксом идёт метка подсистемы — apply, failover, dnsd, obfs, tunnel, xsteer,
hub, tgws, sub, srs, spec, supervise, awg, via, — например,
steer[warn] failover: .... (via — отказ поставить метку цели на сокет туннеля: соединение тогда
не открывается.)
Что уровня не несёт намеренно. Отказ, адресованный тому, кто позвал ядро — негодная спека,
плохой аргумент, отсутствующий выход, команда, которой нет в этой сборке, — печатается как обычное
steer: ... и завершается кодом 2. Это ответ вызывающему, а не запись в журнал: он приходит на
stderr вызывающего, и управляющий слой уже знает об отказе по коду возврата. Всё, что ядро пишет
во время работы, префикс уровня несёт.
6. Командная строка#
Ядро зовут как steer <команда> [аргументы] [флаги]. Флаги всегда идут после команды; флаг на
месте команды отвергается, а не додумывается.
Коды возврата. 0 — сделано; 2 — ядро отказалось: плохие аргументы, нечитаемая или негодная
спека, отсутствующий выход. 1 зависит от команды и не означает одно и то же везде:
| Команда | Что означает 1 |
Это провал выполнения? |
|---|---|---|
diag |
Хотя бы одна проверка — fail |
Нет: JSON на stdout полный и валидный |
needs-dnsd |
В спеке нет доменных каналов, резолвер не нужен | Нет: это и есть ответ |
fit |
Список не влез в бюджет | Нет: список и отчёт всё равно напечатаны |
apply |
nft отверг набор правил; ничего не применилось |
Да |
explain |
Резолвер не ответил | Да, для этого запроса |
vless, obfs, obfs-server, dnsd |
Процесс завершился | Да |
Не классифицируйте 1 обобщённо. Трактовка 1 у apply как «отрицательного ответа» превращает
неудавшееся применение в успех.
Потоки. Запрошенная справка (steer help, steer help <команда>, steer <команда> --help,
steer --version) идёт в stdout и завершается нулём. Всё, от чего ядро отказывается, идёт в
stderr с кодом 2. Машиночитаемый вывод (status, diag, vless-nodes, vless-probe,
outputs, fit, sub-fetch, sub-quota, sub-hwid) — на stdout, не перемешанный с
диагностикой.
Разбор аргументов строгий. Неизвестный флаг, флаг, которого команда не понимает, флаг с
потерянным или съеденным следующим флагом значением, лишний позиционный аргумент и не-число там, где
требуется число, — всё это ошибки. Нераспознанное не поглощается молча: вызывающий, ошибшийся в
--dry-run, получит отказ, а не настоящее применение правил.
Общие флаги. --spec ФАЙЛ (по умолчанию /etc/steer/spec.json) и --state-dir КАТАЛОГ (по
умолчанию /var/lib/steer) принимает каждая команда, читающая спеку. vless-probe --node -1
означает «как поднимется выход», это же и умолчание: проверяются кандидаты выхода в порядке
предпочтения — весь список пригодных либо выбранное подмножество nodes, — до первого ответившего.
steer vless-nodes перечисляет пригодные узлы подписки полем nodes (по узлу на запись), а
ВЫБРАННЫЕ номера отдаёт полем chosen — списком, в порядке предпочтения, пустым, когда выбора
нет. Именем nodes этот список назвать было нельзя: оно здесь занято сами́м перечнем узлов ещё до
появления выбора, и переопределение молча показало бы старому потребителю не то. Прежнее поле
node осталось: один выбранный номер либо -1; выбор из нескольких узлов оно выразить не может и
печатает -1, то есть ровно то, что старый потребитель и понимает — «узел не назначен, ищем
рабочий».
Вместо имени выхода vless-nodes принимает путь к файлу подписки (аргумент, начинающийся с
/): тогда спека не читается, output печатается пустым, chosen — пустым списком, а sub_file
— названным путём. Спутать формы нельзя: имена выходов состоят из [A-Za-z0-9_.-] (§6), косой
черты в них не бывает. Форма нужна управляющему слою ради выхода, которого ещё нет: локации
подписки надо показать до того, как на неё заведён хоть один выход, — иначе выход собирается из
подписки вслепую.
Ту же форму принимает vless-probe: steer vless-probe /путь/к/подписке --node N проверяет узел
подписки без выхода, а --node -1 в этой форме значит «все узлы по порядку подписки» — порядка
предпочтения без выхода нет. В ответе output пуст, sub_file — названный путь. Нужно тому же
месту: узел выбирают там, где выход собирают, и «какой из них живой» надо знать до выхода.
--insecure в форме с файлом. Узел security=tls с allowInsecure (insecure=1,
skip-cert-verify) пригоден только у выхода с insecure: true, а номера считаются среди пригодных,
поэтому у такого выхода номера бывают сдвинуты относительно перечня по файлу. Флаг --insecure у
vless-nodes, vless-probe, proxy-nodes и proxy-probe с путём к файлу разбирает подписку так,
как её разбирает выход с insecure: true: такие узлы входят в перечень, и номер узла совпадает с
номером у выхода (у proxy-* по файлу номера сквозные по пяти протоколам, а порядок внутри протокола
тот же, что у выхода). Без флага — прежний разбор, как у выхода без insecure. С именем выхода флаг —
отказ разбора кодом 2 («только с файлом подписки»): у выхода решает его собственный ключ. У
hysteria2-nodes и hysteria2-probe флаг принимается ради единого вызова и ничего не меняет:
insecure=1 там — параметр ссылки узла, и такие узлы пригодны всегда.
Поля узла в nodes (vless-nodes, hysteria2-nodes, proxy-nodes). Общие: index, name,
host, port, type, security. Остальные зависят от протокола, и необязательные печатаются
только там, где заданы, — у узла без них вывод прежний:
| Протокол | Поле | Значение |
|---|---|---|
| vless | vision |
всегда: true — flow=xtls-rprx-vision |
| vless | mode |
режим grpc/xhttp, если задан |
| vless | encryption |
режим постквантового шифрования VLESS (первые три части строки, без ключей) |
| vless | pqv |
true — у Reality есть проверка подписи ML-DSA-65 |
| vless | fp |
отпечаток браузера в ClientHello (chrome, firefox…); только у tls и reality |
| vless | insecure |
true — узел tls с allowInsecure: сертификат не проверяется, в перечне только при insecure выхода или --insecure |
| hysteria2 | obfs |
всегда: "", "salamander" или "gecko" |
| hysteria2 | pinned, insecure, hop |
всегда: закреплён ли сертификат узла, insecure=1 у узла, есть ли смена портов |
| hysteria2 | up_bps, down_bps |
всегда, байт/с; up_bps больше нуля — соединение открывается с Brutal на этой скорости, ноль — BBR (сервер может перевести Brutal в BBR ответом auto, docs/hysteria2.md) |
| прокси | transport |
всегда: транспорт узла (tcp, grpc, xhttp, ws, httpupgrade); без параметра в ссылке — tcp |
| shadowsocks | method |
всегда: aes-128-gcm, aes-256-gcm, chacha20-ietf-poly1305, 2022-blake3-aes-128-gcm, 2022-blake3-aes-256-gcm, 2022-blake3-chacha20-poly1305 или none |
| vmess | cipher |
всегда: шифр тела auto, aes-128-gcm или chacha20-poly1305 |
| trojan, vmess, http | fp |
как у vless; только у tls и reality |
| trojan, http | insecure |
как у vless |
| все | cc |
страна по флагу-эмодзи в имени узла (первая пара символов regional indicator), две заглавные буквы; нет флага — поля нет |
| все | excluded |
true — перечень по выходу, и его exclude/exclude_name этот узел не берут в кандидаты; номер узла при этом прежний (spec-v2.md) |
TLS у прокси называет общее поле security: tls или reality у trojan, tls у https:// и у vmess
с tls, none у http://, shadowsocks и socks. Признака allowInsecure у ссылки vmess ядро не
читает, поэтому insecure у узла vmess не печатается: сертификат у него не проверяется только при
insecure выхода.
Карта подмены переживает apply. Резолвер раздаёт поддельные адреса и хранит раздачу в
<состояние>/fakeip.state (строки «домен, поддельный, настоящий»). apply пересобирает таблицу
целиком, и карта подмены fake→real исчезала бы вместе с ней до перезапуска резолвера — а запрос,
пришедший в это окно, получал бы настоящий адрес (fail-open) и уводил домен мимо туннеля на весь
TTL записи. Поэтому apply засевает карту прямо в наборе правил из файла состояния: строки без
настоящего адреса, повторы поддельного и адреса вне 198.18.0.0/15 пропускаются. Файл резолвер
переписывает не чаще раза в минуту, но перед засевом apply просит живой резолвер записать его
сразу, а при выходе (SIGTERM, остановка службы) резолвер дописывает его сам; получив новую
таблицу, резолвер сверяет карту в ядре со своей памятью и заменяет разошедшиеся адреса. На
роутере каталог состояния — /var/lib/steer, то есть tmpfs: после перезагрузки файла нет, и
карта пуста до первого вопроса каждого имени. Если ядро не приняло подмену при живом netlink в
окне, когда таблицы с картой ещё нет (резолвер поднят раньше первой загрузки набора правил),
резолвер отвечает клиенту SERVFAIL, а не настоящим адресом: клиент повторит запрос через секунду и
получит поддельный адрес с работающей подменой. Стойкий отказ ядра вне этого окна — настоящий
ответ (fail-open, §7).
В строке бывает четвёртое поле (его пишет только резолвер по спеке v2: по спеке v1 на
AAAA пустой ответ) — настоящий IPv6 для fake-IP v6: «домен, поддельный,
настоящий, настоящий IPv6», где на месте настоящего IPv4 — «-», если его нет. Поддельный IPv6 не
пишется: это пара поддельного IPv4 (fdfe:dcba:9876:: и адрес IPv4 в младших 32 битах). Карта
fakeip6 засевается из этих строк тем же способом; строки из трёх полей читаются так же.
Неудачная запись подмены IPv6 даёт клиенту пустой ответ на AAAA, а не настоящий адрес и не
SERVFAIL: клиент идёт по IPv4, где у имени свой путь.
Память состояния. status печатает поле at — unix-время сборки ответа. Каждый полный вызов
запоминает свой ответ в <состояние>/status.json, а status --fast отдаёт запомненное немедленно и
добавляет "cached": true; у живого ответа этого поля нет вовсе. Возраст ответа потребитель считает
сам по at — ядро устаревший снимок не отвергает: решение «это слишком старо, чтобы показывать»
принимает тот, кто спрашивает. Снимка нет, он не читается или это обрубок — --fast считает всё
честно, то есть пустотой не отвечает никогда. apply снимок снимает. Умение названо в features
как status_cache.
Подписка. sub-fetch <ссылка> --out ФАЙЛ [--info ФАЙЛ] скачивает подписку, кладёт её по
указанному пути и печатает {ok, url, path, bytes, usable, skipped, foreign, title, hwid,
hwid_sent, warn?, quota?}. url — та ссылка, по которой обновлять дальше: она отличается от
запрошенной, когда подписка не дала ни одного пригодного узла и была перезапрошена с суффиксом
/json (панели этого семейства выбирают формат ответа по клиенту). Отказ — код 1 и {ok:false,
error}; прежний файл подписки при отказе не тронут.
sub-quota <ссылка> --info ФАЙЛ спрашивает у панели только остаток трафика и печатает
{ok, asked, why?, quota?}. Код возврата 0 и при молчании панели: молчание — не отказ команды.
--info по умолчанию выводится из --out правилом «.txt → .userinfo». Файл остатка — это
ключ=значение по строке: upload, download, total, expire, at, at0, used0. Пустое
значение значит «панель этого не сообщала» и не равно нулю. at0/used0 — первое наблюдение
периода: панель сообщает только конец срока и накопленный расход, а средний расход в сутки без
начала посчитать не из чего. Период считается новым, когда сменился срок, сменился объём или расход
уменьшился. Формат файла — часть контракта: его читает и управляющий слой.
sub-hwid печатает {hwid, os, model}. hwid — идентификатор устройства для панели, вида
splify2-<20 шестнадцатеричных знаков>: первые двадцать знаков SHA-256 от строки splify2:<mac>.
MAC берётся у первого физического порта в порядке eth*, lan*, wan*, остальное — по алфавиту
внутри группы, минуя виртуальные интерфейсы и локально назначенные адреса. Пустая строка значит «не
из чего считать». Значение обязано быть постоянным: панели привязывают подписку к устройствам, и
изменение рецептуры отбирает у человека его слот.
steer help перечисляет команды, steer help <команда> документирует одну. Для всех команд, кроме
fit и dnsd, список порождается из той же таблицы, которая проверяет аргументы, поэтому разойтись
им негде. Эти две разбирают свои флаги сами и печатают свой список, который steer help дописывает
дословно: один источник на команду, но два механизма.
Определение сборки. steer --version печатает версию, вариант и ревизию:
steer X.Y.Z (базовая сборка, ревизия vX.Y.Z-11-gf6eef25). Вариант — «базовая сборка»,
«расширенная сборка, VLESS/Reality» или «серверная сборка, хаб xsteer». Ревизия — git describe
той сборки, которой собран бинарник; она отвечает на вопрос, которого номер версии не различает:
релиз это или сборка из main после него (версия между релизами не меняется). Когда git при сборке
недоступен, там честно стоит «неизвестна». Разбирать ревизию потребителю незачем и не следует:
формат её задаёт git, а не контракт, — она для человека и для сравнения двух сборок глазами.
Стабильно в строке первое: слово steer, пробел, версия.
Отдельно: команды VLESS без модуля steer-vless (в базовой сборке — без клиента вообще) отвечают
отказом, содержащим подстроку steer-extended — по ней управляющий слой отличает варианты
пакета, и эта подстрока является частью контракта. С выпуска 1.10 рядом с ней стоит имя пакета
модуля («нужен пакет steer-vless (входит в steer-extended)»); то же у kind: vless и
kind: xsteer в спеке. Слово steer-extended остаётся, пока управляющий слой не научится читать
имена модулей.
Состав идентификаторов в спеке ограничен. Имена выходов, имена устройств и записи lan_devices
обязаны
состоять из [A-Za-z0-9_.-]: они подставляются в командные строки и в имена наборов и цепочек
nftables, и парсер отвергает всё остальное при загрузке. Имена каналов — подписи, не идентификаторы:
в них разрешён любой UTF-8 (русские имена — норма), запрещены кавычка, обратная косая и управляющие
символы, потому что они ломают JSON у status и текст генерируемого набора правил.
7. Архитектурные инварианты#
- Побеждает первое совпадение. Каналы проверяются сверху вниз. Внутри своей цепочки это
выражено
return, а не проверкой «метка ещё нулевая»: первое совпавшее правило побеждает по построению. - Одно имя может принадлежать нескольким каналам, и решает это ЦЕПОЧКА, а не резолвер. Домен,
названный в двух правилах (скажем, «YouTube на телевизоре — в один выход» и «YouTube для всей
сети — в другой»), попадает в наборы ОБОИХ, а кто заберёт пакет, определяет порядок правил —
то есть предыдущий инвариант. Резолвер при этом строит ответ по первому совпавшему каналу:
ответ клиенту один, а режим (
fakeip/realip) у каналов может быть разный. Клади резолвер поддельный адрес в набор одного канала, клиенты остальных получали бы адрес, которого нет ни в одном их правиле, и имя не открывалось бы у них вовсе. - Своя таблица, а не include в fw4. Перезагрузка fw4 ЗАМЕНЯЕТ
table inet fw4и вымывает каждый набор, живущий внутри неё; отдельнаяtable inet steerэто просто переживает. Одинnft -fатомарен: применяется либо весь набор каналов, либо ничего.applyзаменяет таблицу тем же одним запуском — файл начинается сtable inet steerиdelete table inet steer, за ними новая таблица, — поэтому момента без таблицы (и без перенаправления DNS в резолвер) нет, а отвергнутый набор оставляет в ядре прежний. Удаление сводится кnft delete table inet steer, и ничем нашим нельзя испортить чужой firewall. - Ядро не трогает firewall — с одним явным исключением. У выхода с
ipv6: nat(спека v2) masquerade IPv6 на его устройство ставит само ядро: цепочкаpostrouting_nat6(type nat hook postrouting priority srcnat) вtable inet steer, правилоoifname "<устройство>" meta nfproto ipv6 counter masquerade comment "steer-nat6:<выход>", только по этому ключу. Зоны fw4 не меняются; включённый вдобавок masq6 зоны не мешает (подмену соединения выбирает первая цепочка nat).nat6вstatusучитывает и эту цепочку (nat6_by: "steer"), а проверкиoutput_nat6у diag и предупреждение apply «нет masquerade IPv6» такому выходу не пишутся — и у группы без ключаipv6, чей нынешний членipv6: nat. - Адрес из префикса хоста уходит только в донора. У спеки с
ipv6: routedвtable inet steer— наборv6donor(type ipv6_addr; flags interval: префикс хоста — изprefix:или выведенный; элементы переписывает сторож, сверкаapplyих не считает) и три правила: последнее правило разметкиprerouting_mark(иingress_mark) —ip6 saddr @v6donor ip6 daddr != @v6donor ip6 daddr != { fc00::/7, fe80::/10, ff00::/8 } meta mark set … return comment "steer-v6donor:<донор>"(всё несовпавшее из префикса — в донора); вforward_v6—ip6 saddr @v6donor ip6 daddr != @v6donor oifname != { "<устройство донора>", <lan_devices> } counter reject with icmpx type admin-prohibited comment "steer-v6src:<донор>"(fail-closed: правилоdirect, упавший донор сon_fail: direct, снятоеip rule, чужой маршрут — пакет с адресом из префикса отвергается, а не уходит в WAN или в другой выход) иoifname "<устройство донора>" ip6 saddr fc00::/7 ip6 saddr != @v6donor counter reject … comment "steer-v6ula:<донор>"(ULA-источник в донора — хост его не пропустит). У выходов рядом с донором (кромеipv6: nat) IPv6 снят: нетip -6 rule, IPv6 их правил отвергаетsteer-v6drop:<выход>, AAAA имён под ними пуст. - Совпадение по файлам. Условия ссылаются на внешние файлы, а не держат списки в спеке, — ради памяти и ради того, чтобы обновление списка не было правкой настройки.
- Статичный набор правил. Динамические изменения (failover) правят таблицы маршрутизации, не трогая набор правил nftables.
- Маршрутизация выхода самовосстанавливается. Каждый проход
failoverсверяет фактическое состояние — есть ли правилоfwmark, что лежит в таблице выхода, ведёт лиdefaultна живое устройство, — и приводит его в порядок, а не полагается на память о прошлом событии. Иначе любое расхождение (оставшийсяblackhole, снятое правило, вычищенный ядром маршрут исчезнувшего TUN) жило бы до перезапуска ядра steer, причём незаметно: пинг с роутера в таблицу выхода не заглядывает, поэтому «устройство отвечает» и «трафик каналов мёртв» совместимы. Перезапуск ядра steer лечением не считается. - Помеченный пакет уходит только в устройства своего выхода — или никуда. Правило
fwmarkживёт вне нашей таблицы, и его снимают чужие: netifd на каждом своём старте (network restart), netd на телефоне,ip rule flush. Пока его нет, пакет с меткой выхода шёл бы по таблицеmainв WAN. Поэтому цепочкаpostrouting_guard(type filter hook postrouting priority filter, то есть доsrcnat) отбрасывает пакет, у которого в поле ядра steer метка выхода с устройством, аoifname— не одно из устройств этого выхода (у группы — любое из устройств её членов, вглубь):meta mark and <маска> == <метка> oifname != { … } counter drop comment "steer-guard:<выход>". Мимо неё идут пустое поле (первое правило,return) и метки из набораfailopen(«пущен напрямую», второе правило) — трафик выхода, который сторож приon_fail: direct/zapretотпустил вmainнарочно. Меткиkind: zapretиtgws(без таблицы маршрутизации) иdirect(без метки) правил не имеют. Семейство — оба сразу. Для соседей это значит: пакет с нашей меткой выхода, уведённый чужим правилом маршрутизации в другое устройство (правило с меньшим приоритетом, чем нашеfwmark, таблица mwan3), дальшеpostroutingне уйдёт — это ровно то «мимо туннеля», от которого метка и ставится. Счётчикиsteer-guard:<выход>видны вnft list table inet steer. -
Метки — на хуке ingress устройств раздачи, а где его нет — в prerouting mangle, до того как решение о маршруте принято окончательно. Правила каналов раздачи стоят в цепочке
ingress_mark(type filter hook ingress, устройства — те изlan_devices, что есть приapply, приоритетfilter + 10, то есть после flowtable fw4 на том же хуке): пакет получает метку выхода до conntrack и до чужих цепочекprerouting. Метку соединения (ct mark) и выбор члена группыbalanceна ingress не поставить — записи conntrack там ещё нет, — и их ставит первое правилоprerouting_mark(mangle + 1) по готовой метке пакета. Остальные правилаprerouting_mark— те же правила каналов, запасные: ими разбирается пакет, который ingress не видел (устройство раздачи, которого не было приapplyили которое пересоздали, клиент с другого устройства) или чью метку между ingress и нами переписали. Пересозданное устройство раздачи ядро из цепочки вынимает, а на новое она сама не вешается; демон со--watchзамечает новое устройство по событию rtnetlink и ставит набор правил заново сразу, не дожидаясь прохода сторожа.Пока пакет идёт от ingress до
prerouting_mark, в поле ядра steer лежит метка выхода или значение0x0f000000(«разобран, выхода нет»: правилоdirectили ни одно).prerouting_markэто значение снимает, и дальше пакет идёт с пустым полем, как без ingress. Для соседей, которые смотрят на метку вpreroutingраньшеmangle + 1(mwan3 и pbr наmangle), это значит: поле ядра steer на пакетах раздачи уже заполнено. Значение0x0f000000не задевает биты 16-23 Tailscale и pbr. Их перезаписьand 0xff00ffffметки выходов 1-15 обнуляет, а 16 и0x0f000000не трогает, так что другой нашей меткой ни одно значение не становится; незнакомое значениеprerouting_markне узнаёт и разбирает пакет заново.Ingress не ставится на старом ядре (до 5.10 и раскладка 4.9), на телефоне (устройства раздачи появляются по требованию), и в мини-сборке tgws (в её однобитовом поле нет места под
0x0f000000).STEER_NFT_INGRESS=0в окруженииapplyоставляет разметку вprerouting. - Ядру steer принадлежат ВОСЕМЬ БИТ метки, а не всё слово. Диапазон —0x0ff00000, то есть восемь бит начиная с двадцатого (STEER_MARK_MASKвsrc/model/spec.h). В поле лежит НОМЕР выхода, а не его бит: метка выхода — это0x00100000 * (место + 1), и помеченных выходов поэтому столько же, сколько их вообще разрешено объявить, — шестнадцать. Диапазон измениться не может — слева от него бит 28 у мини-сборки, 29 и 30 у zapret, справа 16-23 у Tailscale и pbr. Старые сборки давали выходу бит, а не номер: потребитель, вычислявший номер выхода как позицию одинокого бита, обязан считать его делением; метки, уже выданные прежней сборкой, ядро из реестра берёт как есть и не перетасовывает. Правило ставится чтением-модификацией (meta mark set mark and 0xf00fffff or <метка>), а маршрутное правило ищет метку с маской (ip rule add fwmark <метка>/0x0ff00000). Это часть контракта, а не деталь: слово метки — общий ресурс системы, в нём работают mwan3 (маска0x3F00), pbr и sqm. Перезапись слова целиком и поиск точным совпадением ломали бы в обе стороны и молча — чужая политика выключалась бы на нашем трафике, а чужая перезапись уводила бы наш помеченный пакет в таблицуmain, то есть напрямую, минуя запретon_fail: drop. Правило прежней формы (без маски), оставшееся от старой сборки, ядро снимает само при первой же привязке — отдельного шага обновления не нужно.Предупреждение: на битах 20-23 поле пересекается с Tailscale и pbr. Их маска —
0x00ff0000(биты 16-23), наша —0x0ff00000(биты 20-27), общие биты — 20, 21, 22 и 23. Раскладка от этого не меняется (это контракт), но последствия надо знать: - Tailscale в хукеforwardпереписывает метку каждого пакета сtailscale0(meta mark set mark and 0xff00ffff xor 0x40000, снято с роутера) и тем стирает наши биты 20-23. Маршрут выхода это не ломает —ip ruleрешается раньше, вprerouting, — но метка ПАКЕТА послеforwardу всех выходов, кроме шестнадцатого (0x01000000), уже не наша. Всё, что узнаёт выход послеforward, обязано смотреть на метку СОЕДИНЕНИЯ (ct mark): так сделана очередьkind: zapret, и так обязан делать потребитель, читающий метку вpostrouting. - Чужое правило, которое метит ТОТ ЖЕ пакет маской0x00ff0000вpreroutingпосле нашей разметки, стирает биты 20-23 нашей метки — пакет уходит по таблицеmain, мимо выхода и мимо запретаon_fail: drop. Правило, которое метит его ДО нас, теряет свои биты 20-23 от нашей разметки, и егоfwmark X/0x00ff0000на этом пакете больше не совпадает. - Пакет, который метит только один из двух, не страдает, пока значения соседа умещаются в биты 16-19: наши значения кратны0x00100000и с ними не совпадают. Пересекаются маски, а не метки. Сколько значений занимает pbr, зависит от его настройки и на роутере не сверялось.applyсмотрит в набор правил и, если видит в чужой таблице выражение с меткой и маской0x00ff0000(или её дополнением0xff00ffff), пишет в журнал предупреждение с именем таблицы. Это не отказ: с Tailscale рядом ядро работает, и отказ снял бы маршрутизацию там, где всё в порядке.На платформе Android (
src/platform/android.c) поле ВЫШЕ битов netd: база0x00400000, шесть бит (22-27), маска0x0fc00000. netd кладёт в метку каждого сокета свою раскладку (system/netd/include/Fwmark.h, android-16.0.0_r4): номер сети — биты 0-15, флаги — 16-20 (последний —uidBillingDone), резерв — 21-28, биты производителя — 29-30,ingress_cpu_wakeup— 31; егоip ruleсравнивают эти поля масками. Роутерное поле с бита 20 задевало быuidBillingDone, поэтому поле сдвинуто внутрь резерва. Метка выхода там —0x00400000 * (место + 1), выходов не больше шестнадцати (63 ненулевых значения в шести битах), правило ставитсяmeta mark set mark and 0xf03fffff or <метка>, маршрутное —ip rule add pref 9000 fwmark <метка>/0x0fc00000: явный приоритет 9000 ставит наши правила до лестницы netd, которая начинается с 10000; вip ruleэто выглядит как9000: from all fwmark 0x400000/0xfc00000 lookup 300. Верх поля тот же, что на роутере (бит 28 у мини-сборки, 29 и 30 у zapret), поэтому раскладка отличается только нижней границей. Всё, что выводится из метки (номер очереди, порт моста), выводится из этих чисел так же. zapret в сборке под Android нет: биты 29-30 совпадают там с битами производителя в раскладке netd, и бит пропуска0x40000000на телефоне не ставится, аkind: zapretиon_fail: zapret— отказ спеки.Мини-сборка микропакета tgws (профиль
tgws,src/profile/tgws.c) живёт в СВОЁМ бите —0x10000000, бит 28. Она ставится рядом с полным ядром, и оба метят пакеты в prerouting на одном приоритете; с общей маской тот, чья цепочка идёт второй, стирал бы метку первого (and ~маска), а порядок цепочек одного приоритета — порядок их загрузки, то есть менялся при каждом apply любого из двух. У мини-сборки поэтому своя маска, свой ряд номеров таблиц (316+, у полного —300..315), свой ряд портов моста (8490+, у полного —8480+) и свой файл имён таблиц (stgws.conf). Запись реестра с меткой вне своей маски любая сборка не берёт, а выдаёт метку заново. - К метке добавляется ЧУЖОЙ бит0x40000000— у всех выходов, кромеkind: direct. Бит не наш: им системный обход DPI (zapret) узнаёт «этот пакет не мой» — его цепочка входит по условиюmeta mark & 0x40000000 == 0. Значит трафик, который ядро увело в туннель, в мост Telegram или в свой экземплярnfqws, общий обход не трогает.Не только бесполезно, но и вредно было бы иначе: снаружи DPI видит не полезную нагрузку, а сам туннель, разбирать там нечего — а
nfqwsпри этом рассинхронизирует и режет внешние пакеты, то есть трогает транспорт туннеля, и рукопожатие может не сойтись. Плюс лишний проход через очередь на каждый пакет.kind: direct— единственное исключение, и оно же смысл прямого канала: там общий обход обязан работать. Бит ставится вместе с меткой (на ingress или вprerouting), потому что цепочкиzapretвисят наpostrouting. Маска нашего слова от этого не меняется:ip ruleищет метку как<метка>/0x0ff00000, и лишний бит в неё не попадает.Выход упал и пущен напрямую — бит снимается. При
on_fail: directиon_fail: zapretсторож, не найдя живого устройства, снимает правилоfwmarkвыхода, и его трафик уходит по таблицеmain, то есть открытым путём. Такой трафик — обычный трафик роутера, и общий обход обязан его видеть: иначе «напрямую» значило бы «напрямую и мимо zapret», а уon_fail: zapretэто прямо противоречило бы названию. Поэтому метка упавшего выхода лежит в набореfailopenнашей таблицы (типmark), а цепочкаprerouting_failopen(приоритетmangle + 2, сразу после разметки) снимает с пакетов этих выходов0x40000000. Набор ведут те же, кто меняет маршрут выхода: сторож кладёт метку при отказе сdirect/zapretи вынимает при привязке к живому устройству или приdrop;applyкладёт её, если устройства для выхода нет вовсе.applyпересоздаёт таблицу с пустым набором и сразу после загрузки возвращает отметки по таблицам выходов: у выхода сon_failнеdropи без маршрута по умолчанию в его таблице отметка ставится снова. Касается это только выходов с устройством (interface,vless,xsteer); набор и цепочка ставятся, лишь если в спеке есть такой выход сon_failнеdrop. Наборfailopenчитает ещё иpostrouting_guard(выше), поэтому на телефоне, где бита0x40000000нет, набор тоже есть (при выходе с устройством иon_failнеdrop), а цепочкиprerouting_failopenнет. Уkind: zapretотказ выражает флагbypassочереди (см. выше), и при мёртвомnfqwsего трафик идёт без всякого обхода: цепочка общего обхода (srcnat + 1) к этому моменту уже пройдена. - Каталог/etc/steerпринадлежит ядру, но не только ядру. Всё, что лежит в нём, объявлено системе через/lib/upgrade/keep.d/steer— иначе обновление прошивки «с сохранением настроек» и «Создать архив» в LuCI об этих файлах не знают и теряют их молча. Список закрытый (files/lib/upgrade/keep.d/steer): спека (spec.json,spec.yaml), выбор группpick: manual(select), подписка (sub.txt) и остаток её трафика (sub.userinfo, пишет управляющий слой — см. §1,sub_file), каталог подписокsubs/, свои спискиlists/custom/, файлы выходовkind: awg(awg/), ключиxsteer/и стратегииzapret/. Файл, добавленный в каталог мимо этого списка, существует до первого sysupgrade. - Состояние минимально, но не отсутствует. Спека статична, а вот резолвер держит явное постоянное состояние — таблицу fake-IP (fakeip.state), переживающую перезапуск, — иapplyхранит реестр меток и таблиц (<каталог состояния>/registry). Оба файла переживают перезапуск ядра; на роутере каталог состояния —/var/lib/steer, то есть tmpfs, и после перезагрузки они заводятся заново (на телефоне каталог состояния на флеше). Это не кэш, а контракт: клиентский кэш fake-IP обязан остаться верным после перезапуска демона, а неустойчивая метка молча уводила бы трафик чужим путём через устаревшееip rule. - Fail-open в DNS. Резолвер не блокирует транзакцию DNS: при ошибке или исчерпании пула fake-IP наверх уходит настоящий ответ. Сломанный резолвер не должен означать «интернета нет». Исключение одно — окно до первой загрузки набора правил, когда карты подмены в ядре ещё нет: там клиент получает SERVFAIL и через секунду спрашивает снова (§6, «Карта подмены переживает apply»). - Имя набора считает одна функция. Компилятор и резолвер вычисляют его одной и той же функцией: разойдись они, резолвер наполнял бы набор, которого нет, и доменная маршрутизация молча перестала бы работать.