splify2
Ядро steer

Спека v2 — справочник формата#

Спека v2 — формат, который ядро читает наряду с JSON со schema: 1|2 (спека v1, docs/contract-v1.md). Во что спека разбирается — разделы 3 и 4в docs/architecture.md; здесь — все ключи, умолчания и отказы. Разбор — src/model/v2.c, печать (steer spec convert) — src/model/v2print.c, стенд — tests/v2match.sh.

Формат и файл#

  • YAML (читается libyaml из дерева, src/third_party/libyaml) или JSON: JSON — это тоже YAML, и спеку v2 можно записать JSON-объектом с "version": 2. Алиасы и якоря (*имя, &имя), повтор ключа и несколько документов в одном файле — отказ (src/lib/ynode.h).
  • version: 2 наверху обязателен. Формат узнаётся по содержимому, а не по имени файла:
    • текст начинается с { — это JSON: ключ version наверху — спека v2, иначе — спека v1 с отказами v1 слово в слово; и version, и schema сразу — отказ;
    • всё остальное — YAML, то есть спека v2; YAML без version — отказ «нет version: 2», YAML со schema — отказ «спека v1 пишется JSON-объектом».
  • Файл по умолчанию — {etc}/spec.json (роутер: /etc/steer, телефон: /data/misc/steer) или {etc}/spec.yaml рядом. Лежит одна — читается она; нет ни одной — spec.json (туда пишет управляющий слой). Лежат обе — отказ «две спеки», а не правило старшинства: какая из двух настоящая, знает только тот, кто их положил, и молча читать одну значило бы, что правки во второй не действуют. Путь, названный --spec, читается как есть — кроме самих spec.json и spec.yaml по умолчанию: названный так путь значит «какая из двух лежит» (init-скрипт роутера всегда передаёт --spec /etc/steer/spec.json). Пакет роутера знает оба имени: init-скрипт (reapply), hotplug и список sysupgrade (/lib/upgrade/keep.d/steer).
  • steer apply --spec, клиент steer (тело apply демону) и ctl apply (тело спеки в сокет) — всё идёт через один разбор (load_spec), поэтому тело может быть и v1 JSON, и v2 YAML/JSON. ctl apply кладёт тело в тот файл, который сейчас спека (spec.json или spec.yaml), какого бы формата тело ни было.

Общие правила разбора#

  • Неизвестный ключ — отказ, на любом уровне, с подсказкой, какие ключи там есть. v1 незнакомые ключи пропускал (совместимость вперёд внутри мажора), и опечатка в ключе молча не действовала; у v2 совместимость вперёд держит version.
  • Ключ чужого вида — отказ («ключ strategy есть только у kind: zapret»).
  • Каждый отказ — с местом: файл:строка:столбец: что не так.
  • Перечень — список [a, b] или одно значение: to: youtube и to: [youtube] значат одно.
  • Строка — скаляр в любом стиле записи (wg0, "wg0"). Число и true/false — только без кавычек ("5" — строка, как в JSON), по базовой схеме YAML 1.2: yes/no/on/off — строки, а не логические значения.
  • Имена клиентов, списков, выходов и апстримов — буквы, цифры, _ - ., до 31 байта. У каждого раздела своё пространство имён. Занятые слова: клиент lan, список all.
  • Ссылки по именам (for, to, out, members, default, over, dns) проверяются: несуществующее имя — отказ; круг в группах и в over — отказ.
  • Ещё не поддерживается. То, что формат уже описывает, а ядро ещё не умеет, разбирается и проверяется как всё остальное (и хранится в модели там, где у модели есть место), но спека отвергается отказом ещё не поддерживается в этой версии ядра steer: … с местом в тексте. Этот отказ звучит ПОСЛЕ всех настоящих проверок: ошибка в спеке называется раньше, чем то, чего ядро ещё не умеет. Молча не игнорируется ничего. Перечень — в конце файла.

Пример#

То, что ядро принимает сегодня (стенд tests/v2match.sh проверяет, что оно разбирается):

version: 2

lan: { devices: [br-lan] }

clients:
  kids: { mac: [aa:bb:cc:dd:ee:01, aa:bb:cc:dd:ee:02] }
  tv:   { addr: [192.168.1.50] }

lists:
  work:  { prefixes_file: lists/work.lst, domains_file: lists/work.dom }
  voice: { prefixes_file: lists/dc.lst, proto: udp, ports: [50000-65535] }

outputs:
  wg0:  { kind: interface, device: wg0 }
  wg1:  { kind: interface, device: wg1 }
  nl:   { kind: tunnel, protocol: vless, subscription: sub/nl, nodes: [3, 5], over: wg0 }
  res:  { kind: group, pick: order, members: [wg0, wg1], on_fail: drop }
  fast: { kind: group, pick: latency, members: [nl, wg1], tolerance: 50 }
  eu:   { kind: group, pick: manual, members: [nl, res], default: nl }
  bal:  { kind: group, pick: balance, members: [nl, wg1], weights: [2, 1] }
  dpi:  { kind: zapret }

dns:
  mode: fakeip

rules:
  - { name: kids,  for: [kids], to: all, out: res, scope: device }
  - { name: work,  to: [work], out: fast, resolve: realip }
  - { name: voice, for: tv, to: voice, out: res }
  - { name: bulk,  for: tv, to: all, out: bal }

Разделы#

Все разделы необязательны, кроме version. Спека из одного version: 2 законна: ядро настроено и ничего не направляет (так же, как пустая спека v1).

version#

2, числом. Другое число — отказ «version N не поддерживается».

lan — клиенты по умолчанию#

for правила без клиентов значит «клиенты по умолчанию».

Ключ Значение Умолчание
devices перечень устройств, с которых ядро забирает трафик клиентов [br-lan]
addr перечень адресов, подсетей или диапазонов клиентов, IPv4 и IPv6 (в v1 — from_default); из одних IPv4 — IPv6 клиентов берётся по devices —

Отказы: пустой devices; имя устройства негодного состава; устройство дважды; addr рядом с несколькими devices (клиенты описаны дважды — «уберите addr»); выход, ведущий в устройство из devices (петля).

На роутере с Linux 5.10 и новее правила раздачи ставятся на хук ingress устройств из devices (docs/contract-v1.md, §7). Устройство, которого нет при apply, не отказ: его трафик размечается в prerouting, как на старом ядре, а в цепочку ingress оно попадает при следующем apply или reload. Клиент, заданный адресом, чей трафик приходит не с устройства из devices, тоже размечается в prerouting.

clients — именованные группы клиентов#

имя: { ключ: перечень }.

Ключ Значение
addr адреса, подсети или диапазоны IPv4 и IPv6 (смесь законна; клиент из одних IPv4 по IPv6 не узнаётся — нужен адрес IPv6 или mac)
mac MAC-адреса aa:bb:cc:dd:ee:ff
uid только на телефоне: UID приложения 10123 или диапазон 10000-10099
self только на телефоне: true — трафик всех приложений устройства
app только на телефоне: имена пакетов приложений — ещё не поддерживается (пишите uid)

Отказы: пустой клиент; addr и mac в одном клиенте (nft не умеет «или» внутри правила — два клиента); телефон (uid, self) вместе с клиентами раздачи; self рядом с uid; uid 0 (root); uid/self/app на роутере.

lists — назначения#

имя: { ключ: значение }.

Ключ Значение
srs перечень наборов sing-box (.srs): имена, подсети и сужение в одном файле
prefixes_file перечень файлов адресов и подсетей (строка списка — как у v1; строки IPv6 идут в парный набор <группа>6)
domains_file перечень файлов доменов
proto tcp, udp или both (записанное умолчание — сужения нет)
ports перечень портов 443 и диапазонов 50000-65535
all true — весь трафик (с сужением proto/ports, если оно нужно)
override_port подмена порта назначения, 1..65535: соединение TCP и UDP к поддельному адресу имени из списка уходит на настоящий адрес с этим портом (как override_port у route-options sing-box). Только у списка имён (domains_file без prefixes_file, srs и all) и только в режиме fakeip: подмена идёт по карте fake-IP. У списка с подменой свой набор (<выход>_dom_c<N>_p<M>), соседние имена того же выхода порт не теряют
domains, prefixes встроенные записи — ещё не поддерживается (вместо них — файлы)

Путь файла — до 255 байт; относительный путь — от каталога файла спеки (lists/dc.lst рядом с /etc/steer/spec.yaml — это /etc/steer/lists/dc.lst), спека со стандартного ввода — от рабочего каталога. Так же читаются subscription, conf и strategy у выходов. У v1 путь относительный от рабочего каталога ядра, поэтому steer spec convert печатает пути абсолютными. Отказы: у списка нет источника (порты источником не являются — правило без набора адресов забрало бы весь трафик с этими портами); all рядом с файлами; негодный порт или диапазон (1..65535, начало не больше конца); пересекающиеся диапазоны.

outputs — куда#

имя: { kind: вид, … }. Общие ключи:

Ключ У кого Значение Умолчание
kind у всех вид выхода (ниже) обязателен
on_fail у выхода с устройством, группы, zapret, tgws drop, direct, zapret (не на телефоне) drop
over у выхода со своим соединением с сервером выход-подложка, через который идёт трафик самого туннеля (в v1 — via) напрямую
device interface, tunnel, xsteer, awg устройство у interface обязательно; у остальных выводится из имени
ipv6 routed, nat — interface, awg; off — ещё zapret и группа IPv6 от хоста на том конце туннеля (ниже, «IPv6 от хоста») ключа нет — выход несёт IPv6, если его несёт вид
prefix выход с ipv6: routed префикс хоста, 2001:db8:1::/56 (длина 1..64, без битов хоста) выводится из адресов LAN

Виды и их ключи (проверяет ключи сам вид — kind_ops.parse, один разбор на оба формата):

kind Ключи Что это
direct — не маршрутизировать: трафик идёт обычным путём
interface device, obfs: { mode, server, listen } устройство системы (wireguard, pppoe…); obfs — WireGuard поверх поддельного TCP: server и listen — адрес:порт литералами, mode — wg-over-tcp (умолчание)
tunnel protocol: vless, hysteria2, trojan, shadowsocks, socks, http или vmess; subscription, nodes, device, transport, insecure, exclude, exclude_name, active, by, interval, silence туннель по подписке; subscription — файл подписки (v1: sub_file), nodes — номера пригодных узлов по предпочтению (пусто — первый рабочий); transport — какими транспортами узлов ходить (ниже; только у vless); exclude, exclude_name — какие узлы не брать (ниже; у всех протоколов); active, by, interval, silence — сколько узлов работают сразу, как делятся соединения и как клиент следит за узлами (ниже, «Пул узлов туннеля»)
xsteer conf, stream, stream_port, device xsteer; conf — файл в стиле wg (умолчание {etc}/xsteer/<имя>.conf)
awg conf, device AmneziaWG, устройство заводит ядро; conf — умолчание {etc}/awg/<имя>.conf
zapret strategy обход DPI через nfqws; strategy — файл ключей nfqws (v1: opts_file, умолчание {etc}/zapret/<имя>.opts); не на телефоне
tgws domain мост Telegram; domain обязателен; on_fail — только drop
group members, pick, default, tolerance, interval, url, idle_timeout, weights группа выходов (ниже)

Почему VLESS пишется kind: tunnel, protocol: vless, а не kind: vless: туннель поверх потоков — общий стек, а протокол — его дайлер (docs/architecture.md, «Туннели: стек, дайлер, транспорт»). kind: vless в v2 — отказ с подсказкой. xsteer туннелем в этом смысле не является (везёт пакеты, а не потоки) и остаётся своим видом.

protocol: hysteria2 — клиент hysteria2 (QUIC), только с пакетом steer-hysteria2 (в steer-extended он не входит; без него спека отвергается словами «kind hysteria2 требует пакет steer-hysteria2»). Ключи те же, что у vless, кроме transport: у hysteria2 транспорта нет, и ключ — ошибка разбора. Узлы подписки — ссылки hysteria2:// / hy2:// либо конфиг Xray-core (docs/hysteria2.md). В v1 тот же выход пишется kind: hysteria2.

protocol: trojan, shadowsocks, socks, http, vmess — клиенты протоколов прокси (podkop/forkop поверх steer через коннектор sing-box), только с пакетом steer-proxy (в steer-extended он не входит; без него — «kind … требует пакет steer-proxy»). Узлы из подписки: стандартные share-ссылки trojan://, ss://, socks*://, http(s)://, vmess:// (docs/proxy.md). Ключа transport нет (транспорт берётся из ссылки узла); insecure есть у trojan, vmess и http (есть TLS), у ss и socks его нет.

transport туннеля — tcp, grpc, xhttp, ws, httpupgrade, одно имя или список (transport: ws, transport: [ws, httpupgrade]); ключа нет — любые. Это фильтр узлов подписки, а не замена их транспорта: транспорт — свойство входа на сервере, и узлы с ним приходят из подписки (type= ссылки, network конфига Xray). Нужен тому, у кого панель отдаёт один сервер в нескольких видах: transport: ws держит туннель на узлах через CDN и после обновления подписки, когда номера уехали. Вместе с nodes — пересечение, номера при этом те же (среди всех пригодных, как печатает steer vless-nodes); не осталось ни одного узла — туннель не поднимается и говорит «среди выбранных узлов нет ни одного с транспортом из transport», а не уходит на узел другого транспорта. steer spec convert печатает одно имя строкой, несколько — списком в порядке имён выше. Отказы: незнакомое имя, имя дважды, пустой список, ключ у другого вида. Параметры самих транспортов (path, host, заголовки) — у узла в подписке (docs/vless.md, «ws и httpupgrade»). Постквантовые поля узла — encryption (шифрование VLESS) и pqv (ключ проверки подписи ML-DSA-65 у Reality) — тоже приходят из подписки (docs/vless.md, «Постквантовая часть»); в спеке для них ключей нет, а помощник перезапускается при их изменении, потому что подпись помощника включает содержимое файла подписки.

insecure туннеля vless — true: не проверять сертификат узлов security=tls этого выхода (цепочку, имя, срок, закрепления; подпись CertificateVerify остаётся). По умолчанию выключен; узлы подписки с allowInsecure без этого ключа пропускаются с причиной. Пока ключ включён, steer diag предупреждает, а steer status печатает "insecure":true. У hysteria2 ключа нет (там insecure — параметр ссылки узла), у других видов — ошибка разбора. Закрепление сертификата (pcs) и имена проверки (vcn) — поля узла в подписке (docs/vless.md, «Проверка сертификата узла»).

exclude и exclude_name туннеля — какие узлы подписки не брать в кандидаты, у всех протоколов туннеля. exclude — страны: код из двух заглавных латинских букв (ISO 3166-1), одно значение или список (exclude: RU, exclude: [RU, US]). Страна узла — флаг-эмодзи в его имени: первая пара подряд идущих символов regional indicator где угодно в имени («🇳🇱 Амстердам» → NL), так же, как страну по имени понимает интерфейс splify2. Узел без флага страны не имеет, и exclude его не исключает; названия стран словами ядро не читает. exclude_name — куски имени узла без учёта регистра латиницы и кириллицы (exclude_name: [Мобильный, LTE]): узел, в имени которого есть любой из них, не берётся. Исключение называет свойство узла, а не номер, поэтому переживает обновление подписки: новые узлы исключённой страны тоже не берутся, в том числе при пустом nodes («первый рабочий»). Это только отбор: номера узлов от него не сдвигаются (они по-прежнему среди всех пригодных, как печатает steer vless-nodes), вместе с nodes и transport — пересечение. Не осталось ни одного кандидата — туннель не поднимается и говорит «все … узлов-кандидатов исключены exclude или exclude_name», а не перебирает исключённые. Узел, названный явно (*-probe --node N), проверяется и исключённым. В перечне узлов (*-nodes) у узла — cc (страна по флагу) и, у выхода, "excluded":true у исключённых (contract-v1.md, раздел 6). steer spec convert печатает оба ключа списками в порядке записи. Отказы: код не из двух заглавных латинских букв («ru», «RUS»), код или кусок дважды, пустая строка, ключ у другого вида. Умение названо в features status (exclude).

Пул узлов туннеля — active, by, interval, silence у kind: tunnel:

outputs:
  nl: { kind: tunnel, protocol: vless, subscription: sub/nl.txt, active: 3, by: site }
Ключ Значение Умолчание
active сколько узлов из кандидатов (nodes, transport — порядок предпочтения) работают сразу, 1..65536; больше, чем узлов в nodes, — отказ; кандидатов в подписке меньше — активны все 1
by как новые соединения делятся между активными узлами: connection — каждое на случайный живой узел (поровну), site — по адресу назначения (сайт на одном узле), site_client — по паре «клиент, адрес»; только при active больше 1 connection
interval период проверки каждого активного узла, секунды, 5..86400 60
silence порог молчания узла на живом соединении, секунды: 0 — порога нет, иначе 5..32767 20 (у hysteria2 — 30)

Клиент держит active живых узлов сразу; соединение получает узел при открытии и остаётся на нём до конца. Имена значений by — те же, что у by группы balance, а адрес назначения — тот, что в пакете (при fake-IP — уже настоящий адрес сайта). Каждый активный узел проверяется раз в interval той же проверкой, по которой выбран при подъёме; две неудачи подряд (после обрыва соединения по silence — одна: узел и так молчал дольше порога) — узел мёртв: соединения через него сбрасываются (RST клиенту — приложение переподключается сразу, на живой узел), а на его место берётся следующий свободный кандидат по порядку предпочтения — в том же процессе, без перезапуска и не трогая соединения живых узлов. Замены нет — круг через 15 с (сперва прежний узел, потом свободные кандидаты), с каждым пустым кругом вдвое дольше, до 5 минут. Демону (--supervise) клиент говорит down, только когда живых узлов не осталось, и up, когда живой нашёлся (docs/ctl.md, «Сторож выходов»).

silence — молчание узла под живым соединением: отправленное узлу не подтверждено им дольше порога (узел умер под загрузкой, звонком, ТСПУ режет поток после N КБ) или от узла ничего нет дольше порога и он не отвечает на проверку TCP keepalive (умер под закачкой). Такое соединение ядро обрывает, клиенту уходит RST, а слежке — повод проверить узел сразу, не дожидаясь interval. Долгий опрос и простаивающее соединение не страдают: живой узел подтверждает принятое и отвечает на проверку сам, даже когда приложению отвечать ещё нечего. Плата — одна пустая проверка TCP за порог простоя на соединение. У hysteria2 silence — срок простоя QUIC (PING — втрое чаще срока, но не реже 10 с), а silence: 0 оставляет прежние 30 с: соединению QUIC срок простоя нужен всегда.

Где что есть: у vless и протоколов прокси (trojan, shadowsocks, socks, http, vmess) — все четыре ключа; у hysteria2 — только interval и silence (соединение с узлом у него одно QUIC на все потоки, и active больше 1 или by — отказ). Какие узлы активны сейчас — steer status (объект vless или proxy у выхода: active — номера и имена) и helper демона (поле active, docs/ctl.md); steer diag предупреждает, пока активных меньше active. steer spec convert печатает только отличное от умолчания. Отказы: active вне 1..65536 или больше узлов в nodes; by не из трёх имён или при active: 1; interval вне 5..86400; silence 1..4 («меньше 5 с: столько длится повторная передача на медленной линии, и живые соединения обрывались бы») или больше 32767 (предел ядра для keepalive); ключ у вида не tunnel. Умение названо в features status (active_nodes).

Группа (kind: group) — выход, у которого вместо устройства члены:

Ключ Значение Умолчание
members перечень выходов с устройством (в том числе других групп), по предпочтению обязателен
pick order — первый живой; latency — самый быстрый с допуском (urltest); manual — выбор человека командой select; balance — ядро раскидывает новые соединения по живым членам order
tolerance pick: latency: допуск в мс, 0..60000 умолчание сторожа (50)
interval pick: latency: интервал замера в секундах, 5..86400 (у демона — свой таймер группы, и интервал может быть короче периода сторожа) умолчание сторожа (180)
url pick: latency: адрес проверки — http://… или https://… (https — в пакете steer-core; в статической сборке без TLS, микропакете tgws, — отказ разбора) http://cp.cloudflare.com/generate_204
idle_timeout pick: latency: сколько секунд без трафика через группу замер не делается, 0..86400; 0 — мерить всегда телефон — 1800, роутер — 0
default pick: manual: член до первой команды select первый
weights pick: balance: вес каждого члена по порядку members, 1..100 — доля новых соединений все по 1
by pick: balance: как раздаются новые соединения — connection (каждое само по себе, случайно по весам), site (один сайт — один член), site_client (сайт у одного устройства — на одном члене) connection

Как выбирает каждый pick:

  • order — первый живой член; возврат на более предпочтительный — после нескольких проходов подряд, когда он отвечает (гистерезис сторожа).
  • latency — задержка члена меряется как urltest sing-box: запрос GET к url через члена (сокет с меткой члена — его правило и таблица, тот же путь, что у трафика группы, включая туннель VLESS и цепочки over), время до первого байта ответа 204 или 200. Мерятся живые члены раз в interval; выбирается самый быстрый, а из тех, кто хуже него не больше чем на tolerance, — самый предпочтительный по порядку; с живого текущего группа уходит только за выигрыш больше tolerance. Замер идёт своим таймером группы раз в interval, а не с проходом сторожа, и сторож переключает группу сразу, как только замер меняет выбор (docs/ctl.md, «Сторож выходов»). Пока через группу нет трафика дольше idle_timeout (по счётчикам её правил), проверочных запросов нет вовсе. Имя из url разрешает системный резолвер. Выбор члена один на оба семейства: если все живые члены несут IPv6, замер идёт и по IPv4 (запись A), и по IPv6 (AAAA, сокет IPv6 с меткой члена), и у члена берётся худший из двух — член, быстрый по IPv4, но медленный или глухой по IPv6, не выигрывает; если по IPv6 не ответил никто (у адреса нет AAAA, туннели не выпускают IPv6), — по IPv4. У группы, где хоть один член IPv6 не несёт (VLESS), и у пула devices спеки v1 — только по IPv4. Литерал IPv6 в url не принимается.
  • manual — член, выбранный командой select <группа> <член> (docs/ctl.md), без apply; выбор переживает перезапуск и перезагрузку (файл select рядом со спекой). Не работает выбранный член — группа применяет свой on_fail, а не переходит на другой: manual — выбор человека. При запуске это действует сразу, до первого прохода сторожа: таблица группы — на выбранном члене или, если его устройства нет, on_fail (docs/ctl.md, «Сторож выходов», «До первого прохода»).
  • balance — ядро раздаёт НОВЫЕ соединения по живым членам по весам (карта из 120 слотов), член соединения запоминается в метке соединения, и соединение остаётся на своём члене. Упавший член сторож убирает из карты (только карту, без перезагрузки набора правил); живых нет — on_fail группы. IPv6 раздаётся так же, если его несут все члены; если хоть один не несёт (VLESS), IPv6 правил группы отвергается, как у выхода без IPv6. Для правил на сам телефон (uid, self) — ещё не поддерживается. Слот нового соединения выбирает by (режимы load-balance у Clash):

    • connection — случайно (numgen random): каждое новое соединение само по себе, и соединения одного сайта расходятся по членам;
    • site — хеш адреса назначения (jhash ip daddr, у IPv6 — ip6 daddr; consistent-hashing): все соединения к сайту — через один член, у любого клиента. Сайт — адрес назначения до подмены fake-IP: у канала с fake-IP это имя (у каждого имени свой поддельный адрес), без fake-IP — адрес, и имя на нескольких адресах может попасть на разные члены;
    • site_client — хеш пары «адрес клиента, адрес назначения» (sticky-sessions): сайт у одного устройства — через один член, у другого устройства — возможно, через другой.

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

Вложенность. order, latency и manual разворачивают выбор до листа: трафик группы идёт в устройство, которое сейчас несёт трафик выбранного члена (у вложенной группы — её текущий лист). У balance вложенная группа — один член со своим весом: её собственный выбор (order, manual) сохраняется внутри её доли, а вложенная balance делит свою долю дальше по своим весам и своему by: сайт остаётся на одном выходе до самого листа, только если by: site (или site_client) у всех balance на пути; хеш у каждой группы свой, поэтому вложенная группа делит сайты своей доли по всем своим членам.

Отказы: члена нет; группа в себе самой; член дважды; член без устройства (direct, zapret, tgws); круг групп; default не из членов или не у manual; tolerance/interval/url/idle_timeout не у latency; url не http(s)://хост[:порт][/путь], https:// в сборке без TLS; weights не у balance или не по числу членов; by не у balance или не connection/site/site_client; группа balance членом группы order/latency/manual (у такой группы трафик идёт в одно устройство, а у balance устройство на каждое соединение своё); device у группы.

Отказы вида (текст — вида, место — выхода): у interface нет device; у tunnel нет subscription; путь conf/strategy с кавычкой; stream_port без stream; имя устройства awg длиннее 15 символов или выдаёт туннель; у tgws нет domain. Сквозные (как у v1): выход в локальное устройство; устройство дважды в группе; over у вида без своего соединения с сервером, на выход без устройства, на себя, круг (в том числе через устройства членов группы), цепочка длиннее трёх.

IPv6 от хоста (ipv6 у выхода). Хост — сервер на том конце туннеля WireGuard, у которого IPv6 есть; роутер без своего IPv6 у провайдера получает IPv6 через него, внутри самого туннеля. Сторону хоста человек настраивает сам (обычный сервер WireGuard с IPv6), раздачу IPv6 в LAN — тоже сам, в netifd и odhcpd; steer маршрутизирует и в diag говорит, чего не хватает (id ipv6_host).

outputs:
  host:  { kind: interface, device: wg1, ipv6: routed, prefix: "2001:db8:1::/56" }
  other: { kind: interface, device: wg2 }
  • routed — хост маршрутизует на пира префикс (или отвечает за адреса из него NDP proxy), и у клиентов LAN адреса из него. Роутер: option ip6prefix '2001:db8:1::/56' у интерфейса туннеля и option ip6assign '64' у LAN в /etc/config/network — netifd выдаст LAN кусок префикса, odhcpd раздаст его клиентам. IPv6 клиентов с адресом из префикса идёт по правилам: правила в этот выход («донор») ведут IPv6 в него; у правил в любые другие выходы (и в direct) IPv6 отвергается сразу (ICMPv6 «administratively prohibited» — клиент переходит на IPv4), а имена под ними получают пустой AAAA; всё несовпавшее IPv6 из префикса — в донора. Адрес из префикса не уходит ни в другой выход, ни в WAN никогда (другой сервер отбросил бы его по AllowedIPs, провайдер — по BCP38): такой пакет отвергается, в каком бы направлении его ни увёл маршрут. Донор лёг — IPv6 отвергается сразу (запрет prohibit в его таблице), а не уходит напрямую. Донор в спеке один. prefix — необязателен: без него префикс выводится из адресов LAN — нуль-маршрут unreachable P, который netifd ставит на каждый раздаваемый префикс, накрывающий глобальный адрес устройства раздачи; префикс провайдера (с маршрутом default from P через WAN) не берётся; двух кандидатов ядро не угадывает — тогда нужен prefix. Выведенный префикс сторож переписывает сам, когда netifd его выдаёт или меняет. ULA-префикс хоста (хост сам делает NAT66 для ULA) — только prefix явно. Клиенты с ULA рядом с префиксом ходят к поддельным адресам fake-IP (fdfe:…) со своего ULA, и такой IPv6 в донора отвергается (хост его не пропустит) — доменные правила fake-IP в донора для них идут по IPv4; resolve: realip или LAN только с префиксом хоста (ip6class) это снимают.
  • nat — у пира один адрес IPv6, клиенты LAN на ULA: masquerade IPv6 на устройство выхода ставит само ядро steer, в своей таблице (зоны fw4 не трогаются; masq6 зоны для этого не нужен), и IPv6 идёт по правилам, как IPv4. Роутер: у интерфейса туннеля — адрес IPv6, выданный хостом; если у LAN только ULA — option ra_default '1' в секции dhcp LAN, иначе odhcpd не объявит маршрут по умолчанию.
  • off — выход IPv6 не несёт, хотя вид умеет: IPv6 его правил отвергается, AAAA имён под ними пуст.
  • Ключа нет — выход несёт IPv6, если его несёт вид. Рядом с донором выход без ключа IPv6 не несёт (как off); nat рядом с донором несёт IPv6 клиентов с ULA, а адрес из префикса и туда не уходит.
  • Телефон. Раздачей IPv6 там владеет Android (Tethering): routed и nat разбираются, но действуют как отсутствие ключа — apply и diag говорят об этом; off действует.

Отказы: неизвестный режим; routed/nat у группы («задайте у члена») или у вида без туннеля; ipv6 у вида без IPv6 (tunnel, xsteer, tgws, direct); prefix без routed; префикс с битами хоста, длиннее /64, служебный (link-local, multicast, пул fake-IP); второй routed в спеке.

dns#

Ключ Значение Умолчание
mode fakeip или realip — режим доменных правил без своего resolve; оба семейства: на AAAA имени под правилом — поддельный IPv6 из пула fdfe:dcba:9876::/96 (fakeip) или настоящий адрес в наборе IPv6 правила (realip), если правило ведёт в выход с IPv6 (interface, awg, direct, группа из таких) и его клиенты узнаются по IPv6 (см. ниже) fakeip
traceroute_hops true — трассировка через fake-IP видит настоящие узлы (в v1 — traceroute_hops верхнего уровня) false
cache записей в кэше ответов на имена под правилами (и на имена вне правил, когда задан other); 0 — кэша нет 0
cache_ttl { min, max, negative } — секунд: ответ живёт не меньше min и не дольше max, отрицательный (NXDOMAIN, пустой) — negative. TTL в ответе клиенту зажат теми же пределами и уменьшается на возраст записи { min: 10, max: 3600, negative: 30 }
upstreams имя: { url, out, ips, bootstrap } — серверы DNS, или имя: { servers, mode } — группа серверов, см. ниже —
upstream имя из upstreams (сервер или группа) — общий сервер для имён под правилами, у которых своего нет нет: прежний путь наверх
other имя из upstreams (сервер или группа) — сервер для имён, не попавших ни под одно правило, см. ниже нет: прежний путь наверх
bootstrap до четырёх адресов серверов обычного DNS, которыми разрешается имя сервера DoT/DoH/DoQ —

Апстримы. url выбирает транспорт: https://имя[:порт][/путь] — DoH (RFC 8484, POST application/dns-message, HTTP/1.1 с keep-alive, путь по умолчанию /dns-query); tls://имя[:порт] — DoT (853); quic://имя[:порт] — DoQ (RFC 9250, UDP, порт 853, ALPN doq); udp://адрес[:порт] и tcp://адрес[:порт] — обычный DNS (у udp усечённый ответ повторяется по TCP); адрес IPv6 пишется в скобках. Для udp:// и tcp:// нужен адрес, не имя. Другой записи DoQ (doq://) нет.

DoQ — одно долгоживущее соединение QUIC на апстрим, каждый вопрос идёт своим потоком, поэтому потеря пакета задерживает только тот вопрос, чей ответ в нём. Простаивающее соединение закрывается через 5 минут кодом DOQ_NO_ERROR; соединение, молчащее 1,5 с после отправленного вопроса, пересоздаётся, а вопрос уходит на новом. Билеты сессии хранятся в памяти процесса резолвера (по одному на сервер — имя и порт, не больше восьми серверов), на диск не пишутся: со второго соединения к тому же серверу рукопожатие идёт по билету, а первый вопрос уходит в 0-RTT (early data, RFC 9250 разрешает — вопрос DNS идемпотентен), не дожидаясь конца рукопожатия. Если сервер 0-RTT не принял, вопросы уходят заново на новом потоке того же соединения. STEER_DOQ_EARLY_DATA=0 в окружении резолвера выключает билеты и 0-RTT.

  • out — выход, через который уходит запрос к серверу (выход со своим устройством: interface, awg, tunnel любого протокола, xsteer; группа, zapret и tgws не годятся); нет ключа, или выход direct, или слово direct — напрямую. Запрос уходит с меткой выхода, так что может идти по отдельному стабильному выходу, не тому, что у трафика правила. Выход лёг — запрос не уходит мимо него (набор правил не выпускает помеченный пакет в другое устройство), а клиент получает SERVFAIL, не прежний путь наверх.
  • Имя сервера DoT/DoH/DoQ разрешается не системным резолвером: ips (адреса сервера, имя тогда не разрешается вовсе) либо bootstrap апстрима, либо общий dns.bootstrap. Bootstrap — обычный DNS по UDP, с той же меткой пути, что и апстрим; результат живёт срок TTL ответа (60-3600 с), при молчании bootstrap берутся прежние адреса. Ни ips, ни bootstrap — отказ.
  • Сертификат сервера проверяется до корней (на роутере — /etc/ssl/certs/ca-certificates.crt, на телефоне — системное хранилище), имя — по SNI (у DoQ — имя из url, то же и при адресе, найденном через ips или bootstrap). Без TLS в сборке (мини-пакет tgws, статический steerd без пакета TLS) DoT и DoH не работают, без обёртки QUIC (статическая сборка без src/proto/quic) не работает DoQ: апстрим показывается в dns-log со состоянием no-tls, вопросы к нему получают SERVFAIL.

Апстрим задаётся у правила (dns: имя или сразу dns: { url: …, out: … } — те же ключи) или общий (dns.upstream). Правила с одним выходом, клиентами и режимом, но разными апстримами остаются отдельными каналами резолвера. Имена вне правил спрашиваются, как раньше (dnsmasq на роутере, исходный сервер запроса на телефоне), если не задан dns.other.

Группа серверов — запись upstreams с ключами servers (имена серверов из upstreams, по порядку; число не ограничено) и mode вместо url. Группа годится везде, где имя апстрима: dns.upstream, dns.other, dns правила (в том числе сразу в правиле: dns: { servers: [a, b], mode: race }). Годный ответ — любой, кроме SERVFAIL и REFUSED (NXDOMAIN и пустой ответ годны: это ответ об имени, а не отказ сервера).

  • mode: race — вопрос уходит всем серверам группы сразу, клиенту — первый годный ответ; отказ (SERVFAIL клиенту), только если годного не дал никто.
  • mode: failover (умолчание) — по очереди: вопрос уходит первому; он ответил SERVFAIL/REFUSED или не принял вопрос (нет соединения, пауза после неудачи соединения) — сразу следующему; не ответил за 1,5 с — вопрос уходит и следующему, а годный ответ любого из спрошенных — ответ группы. Сервер, который отказал (SERVFAIL, REFUSED, нет ответа за 4 с, не принял вопрос), уходит на паузу 5 с, 10 с, 20 с … до 300 с: пока она идёт, он спрашивается последним, после всех серверов без паузы. Пауза кончилась — сервер снова первый в своём порядке, и следующий вопрос проверяет его; годный ответ снимает паузу, отказ удлиняет вдвое. Медленный, но отвечающий сервер паузы не получает.

У каждого сервера группы — свой путь (out), свои адреса и соединения. Отказы: url и servers вместе; пустой servers; имя, которого нет в upstreams; имя дважды; группа в группе («перечислите её серверы»); mode не race и не failover.

Сервер для остальных имён (dns.other). Имена, не попавшие ни под одно правило, резолвер спрашивает этим сервером или группой, а не прежним путём. Имена своей сети остаются у DNS роутера: имя без точки (имя устройства из DHCP), домены lan, local, home.arpa, internal, localhost, localdomain, home и обратные зоны in-addr.arpa, ip6.arpa. Сервер не ответил (нет ответа за 4 с, нет соединения) — вопрос уходит прежним путём, клиент получает ответ DNS роутера, а следующие имена вне правил идут прежним путём сразу, пока длится пауза (5 с, 10 с … до 300 с, как у группы); после паузы сервер снова спрашивается первым. Ответ SERVFAIL или REFUSED — этот вопрос тоже уходит прежним путём, но без паузы. Так интернет не пропадает вместе с сервером. Кэш (dns.cache) держит и ответы other. Без other — как раньше. На телефоне прежний путь — сервер, к которому шёл запрос приложения.

dns:
  cache: 2048
  bootstrap: [1.1.1.1, 8.8.8.8]
  upstream: doh3
  other: g
  upstreams:
    g:    { url: https://dns.google/dns-query, out: dnsvpn }
    dot:  { url: tls://one.one.one.one, ips: [1.1.1.1, 1.0.0.1] }
    q9:   { url: https://dns.quad9.net/dns-query, ips: [9.9.9.9] }
    cf:   { url: https://cloudflare-dns.com/dns-query, ips: [1.1.1.1] }
    doh3: { servers: [q9, cf, g], mode: failover }
rules:
  - { name: work, to: [work], out: eu, dns: dot }
  - { name: yt,   to: [youtube], out: dpi }                              # спрашивает doh3
  - { name: ai,   to: [ai], out: eu, dns: { servers: [q9, cf], mode: race } }
# имена вне правил — g; router, nas.lan — DNS роутера

steer explain <имя> называет под ответом строкой DNS:, каким сервером или группой ответил резолвер; dns-log — то же у каждого имени (поле dns), состояние групп и other (docs/ctl.md).

IPv6 доменных правил. Клиенты правила узнаются по IPv6, когда они — lan (клиенты по умолчанию: IPv6 берётся по devices), устройства, MAC, адреса IPv6 или приложения телефона; клиент из одних адресов IPv4 по IPv6 не узнаётся. У правила без IPv6 (выход tunnel любого протокола, xsteer, tgws, группа с таким членом, или клиент из одних IPv4) на AAAA его имён — пустой ответ, и клиент с двумя стеками сразу идёт по IPv4. Имя, совпавшее с несколькими правилами, получает AAAA, только если IPv6 есть у всех них: иначе поддельный или настоящий IPv6 у клиентов правила без IPv6 ушёл бы мимо его выхода. fakeip на старом ядре без nat для IPv6 (4.9 у части телефонов) — только IPv4, realip — оба семейства. На HTTPS и SVCB для имён под правилом ответ пустой. Поддельный IPv6 — пара поддельного IPv4 того же имени (fdfe:dcba:9876::c612:5 — 198.18.0.5); steer explain по такому адресу называет правило и имя.

rules — сверху вниз, выше — сильнее#

Список отображений.

Ключ Значение Умолчание
name подпись: в status и журнале резолвера; по-русски можно, кавычку нельзя; до 31 байта rule-<номер>
for клиенты из clients; lan — клиенты по умолчанию lan
to списки из lists; all — весь трафик обязателен
out выход или группа обязателен
resolve fakeip или realip dns.mode
dns имя апстрима (сервера или группы) из dns.upstreams или отображение { url, out, ips, bootstrap } либо { servers, mode } dns.upstream
enabled false — правило лежит в спеке, но не действует true
scope device — правило на одно устройство, старше глобальных по построению (в v1 — scope канала); global global

to: all — явное «весь трафик»; согласия allow_all, как у v1, не нужно: слово all пишут нарочно, опечаткой оно не получается.

Сменили правилу out (или сняли, выключили правило) — после применения его установленные соединения прежнего выхода снимаются, и приложения соединяются заново уже через новый выход: иначе они жили бы на прежнем до своего конца (у группы balance, где прежний выход — член, — держались бы на нём). Правило узнаётся по name, поэтому переименование вместе со сменой выхода читается как новое правило и снятое прежнее. Подробно — docs/ctl.md, apply, «Смена выхода правила».

Несколько клиентов или списков у правила компилятор пока ведёт одним клиентом и одним списком, поэтому разбор сводит их, когда это не меняет смысла: клиенты одного вида (все адреса, все MAC, все UID) — в одного клиента, списки с одним сужением и без all — в один список, ровно как v1 пишет несколько файлов в одном канале. Не сводится (адреса с MAC, списки с разными proto/ports) — ещё не поддерживается: разнесите по правилам.

Отказы: нет out или to; ссылка на несуществующее имя; имя дважды в for/to; lan или all рядом с другими; у включённого правила на устройство — нет клиентов или у клиента подсеть, диапазон или диапазон UID (приоритет достался бы не одному устройству); правило на сам телефон в выход только для клиентов раздачи (tgws). Выключенное правило по смыслу не проверяется (иначе сломанное правило нельзя выключить, только удалить), но имена в нём обязаны существовать.

steer spec convert#

steer spec convert [--spec ФАЙЛ] читает спеку (v1 или v2) и печатает её спекой v2 в стиле примера раздела 3. Файл спеки не меняется. Напечатанное даёт тот же набор правил, что исходная спека v1, до байта — это проверяет tests/v2match.sh на всех спеках v1 из стендов генератора.

Ответы резолвера после перевода меняются в одном: у спеки v1 на AAAA имени под доменным правилом ответ всегда пустой, а у напечатанной спеки v2 — поддельный или настоящий IPv6 там, где это разрешают выход и клиенты правила (см. «IPv6 доменных правил» выше). Если туннель на той стороне IPv6 не везёт, после перевода клиент с двумя стеками сперва пробует IPv6.

Что придумывается при переводе — у v1 этого нет:

  • клиент и список канала получают имя канала, если оно годится в идентификатор (иначе rule<номер>); список «весь трафик» без сужения — to: all, с сужением — список all: true;
  • выход v1 с devices (пул) — группа pick: order (или latency при prefer: latency, с tolerance/interval) из выходов kind: interface с именами <пул>.<устройство>. Они печатаются после всех выходов спеки: реестр раздаёт метки по порядку выходов, и прежние выходы сохраняют прежние метки. В v2 это обычные выходы — со своей меткой и таблицей;
  • via — over; sub_file — subscription; opts_file — strategy; node: N — nodes: [N]; from_default — lan.addr; lan_device(s) — lan.devices; mode: realip — resolve: realip; traceroute_hops — dns.traceroute_hops;
  • умолчания, которые вид выводит из имени выхода (путь conf/strategy, имя устройства tunnel, xsteer и awg), не печатаются.

Ещё не поддерживается#

Спека с этим отвергается ещё не поддерживается в этой версии ядра steer: …:

Что Чем выразить сейчас
pick: balance для правил на сам телефон (uid, self) группой order, latency или manual
встроенные domains/prefixes списка файлами: domains_file, prefixes_file, srs
app клиента uid
клиенты разных видов или списки с разными сужениями в одном правиле разнести по правилам