splify2
Ядро steer

Контракт 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_device device у выхода — устройство, которое несёт трафик сейчас, а не первое в 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): pick order/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_cache status --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.0 status и diag называли первого кандидата, и работающий пул выглядел сломанным.
  • up / failed — идёт ли трафик выхода через device. up: false — устройства нет, оно не поднято или сторож признал выход неработающим: ни одно устройство не ответило на пробу, и on_fail уже применён (запрет при drop, прямой путь при direct). В последнем случае рядом стоит "failed": true, а device называет устройство, которое есть, но не отвечает. Поля failed нет вовсе, когда выход не в отказе. Умение объявлено в features (failed).

    Ядро без умения failed печатает в этом состоянии up: true — по состоянию устройства, а не по трафику: отказ сторожа у него по status не виден. - devices / on_fail — список кандидатов и поведение при отказе, как в спеке. У группы спеки v2 devices — листья её членов по порядку (у вложенной группы — её текущий). - 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»). - Имя набора считает одна функция. Компилятор и резолвер вычисляют его одной и той же функцией: разойдись они, резолвер наполнял бы набор, которого нет, и доменная маршрутизация молча перестала бы работать.