Устройство steer#
Документ для тех, кто правит ядро steer: как устроен код и как он работает. Внешние обещания описаны
отдельно: спека v1, вывод status и diag, командная строка и инварианты набора правил —
docs/contract-v1.md; управляющий сокет демона — docs/ctl.md; формат
спеки v2 — docs/spec-v2.md; клиент VLESS — docs/vless.md; протокол
xsteer — docs/xsteer.md.
1. Сборки#
Одно дерево на C, несколько сборок. Какие файлы входят в сборку, решает профиль в
build/sources.mk — единственном списке исходников: Makefile его включает, build.sh и
build/build-ext.sh читают через build/sources.sh, а список в Android.bp с ним сверяет
tests/buildmatch.sh.
| Профиль | Состав | Что в сборке |
|---|---|---|
base |
CORE_SRC |
ядро: модель спеки, компилятор, демон, резолвер, виды direct, interface, zapret, tgws (правила перехвата), awg и группы, обфускатор; криптографии нет |
extended |
ядро, XS_COMMON_SRC, EXT_ROUTER_SRC, KINDS_EXT_SRC |
статическая полная сборка (телефон, стенды): ещё клиент VLESS/Reality со стеком туннеля, клиент xsteer, мост tgws, подписка, TLS на wolfSSL; на роутере те же файлы разложены по пакетам (ниже) |
server |
ядро, XS_COMMON_SRC, EXT_SERVER_SRC |
хаб xsteer |
tgws |
ядро, EXT_TGWS_SRC |
мини-сборка моста Telegram: своё поле метки, свой ряд таблиц и портов моста (docs/contract-v1.md, §7), без резолвера |
android |
как extended |
та же сборка с умолчанием платформы «телефон» (-DSTEER_DEFAULT_PLATFORM=android) |
Код обеих платформ, роутера и телефона, есть в каждой сборке: платформа выбирается при запуске (раздел 2, правило 2).
Пакет роутера — разделяемая раскладка, а не один статический файл. Профили выше остаются для
статических сборок (телефон, стенды, микропакет tgws, хаб на VPS); роутер получает те же файлы,
разложенные по бинарникам и библиотекам (списки — те же build/sources.mk, рецепт —
build/build-libs.sh):
| Файл | Список | Что в нём |
|---|---|---|
libsteer-wolfssl.so.<версия wolfSSL> |
build/wolfssl/build.sh |
наша сборка wolfSSL (build/wolfssl/user_settings.h, QUIC включён) — только достижимое от экспорта; в libsteer.so — ещё ngtcp2 с патчем Brutal и обёртка src/proto/quic |
libsteer.so.<версия ядра steer> |
LIBSTEER_SRC |
модель спеки с libyaml, платформа, реестр и файлы видов, разбор командной строки, линия событий, обфускатор (obfs.c), слой примитивов, TLS 1.3, REALITY, h2, транспорты, стек TUN и tun.c |
steerd |
STEERD_DYN_SRC = DAEMON_SRC + urltls.c |
демон, компилятор правил, apply, сторож, супервизор, резолвер, заглушки команд модулей (src/cli/modcmd.c) |
steer-vless |
VLESS_MODULE_SRC |
клиент VLESS: vlmain, vldial, client, vless_proto, vision, разбор и скачивание подписки, проба TLS; пул узлов и слежка (src/tunnel/pool.c) — в libsteer, со стеком |
steer-xsteer |
XSTEER_MODULE_SRC |
клиент звезды: xsclient и общая часть формата (xswire, xsconf, xslink, xsroute, xsconn, xsstream, xsepoch, xshake), служебные команды xsadmin |
steer-obfs |
OBFS_MODULE_SRC |
точка входа обфускатора (obfsmain.c); сам obfs.c — в libsteer, его зовёт и xsteer |
steer-tgws |
TGWS_MODULE_SRC |
мост Telegram (tgws.c); правила перехвата пишет ядро (kinds/tgws.c) |
steer-hysteria2 |
HY2_MODULE_SRC |
клиент hysteria2 (docs/hysteria2.md): провод (hy2wire), узлы и подписка (hy2sub), соединение QUIC и мультиплексор потоков (hy2conn), дайлер стека (hy2dial), команды и слежка (hy2main); запись вида — kinds/hysteria2.c в libsteer (KINDS_HY2_SRC); в статические профили и в телефон не входит |
steer-proxy |
PROXY_MODULE_SRC |
клиенты прокси (docs/proxy.md): trojan, shadowsocks, socks, http, vmess одним бинарником — провод и вывод ключей (pxwire), узлы и подписка (pxsub), общее дайлеров (pxdial), по файлу на протокол (pxtrojan, pxss, pxsocks, pxhttp, pxvmess), команды (pxmain), слежка — пул узлов src/tunnel/pool.c; дайлеры — поверх стека src/tunnel и транспорта src/proto/transport, как vless; записи пяти видов — kinds/proxy.c в libsteer (KINDS_PROXY_SRC); в статические профили и в телефон не входит (как hysteria2) |
Каждый модуль — свой main (src/modules/main_<имя>.c) и src/cli/modcmd.c; линкуется он с
libsteer.so, thread-local таблицы туннеля живут в куче потока (раздел «Туннели»). В модуле нет
failover.c и модели: маршрут выхода ставит демон по событию up (раздел 4а). Списки
непересекающиеся, modcmd.c — общий; это сверяет tests/buildmatch.sh.
Экспорт libsteer.so — version-script build/libsteer.map (только то, что берут steerd и
модули; порождён из кода build/libs-exports.sh, проверка — make libs-test), SONAME
libsteer.so.<версия ядра steer>; ABI между версиями не обещается, поэтому модуль той же версии, что
ядро (зависимость пакета steer-core (= версия); демон дополнительно проверяет версию из hello
модуля, docs/ctl.md). Экспорт libsteer-wolfssl.so — build/wolfssl/libsteer-wolfssl.map:
символы wolfSSL, которые зовёт слой примитивов, и отпечаток сборки. Библиотеки собираются -fPIC и
-ftls-model=initial-exec; видимость задаёт version-script, а не -fvisibility=hidden (пометка
visibility("default") на каждом экспортируемом определении — второй список в исходниках рядом с
первым). Загрузчик musl задаётся на каждую архитектуру (-Wl,--dynamic-linker, interp_of в
build.sh): ld-musl-mipsel-sf.so.1, ld-musl-mips-sf.so.1, ld-musl-aarch64.so.1,
ld-musl-armhf.so.1, ld-musl-arm.so.1, ld-musl-x86_64.so.1; RPATH нет — библиотеки в
/usr/lib.
Пакеты (build.sh, функция pack; оба формата — .apk и .ipk — из одного дерева файлов):
steer-core (steerd, steer, steer-tools — ссылка на steerd, steer-nfqws, обе библиотеки
в /usr/lib, init-скрипт, hotplug, keep.d; зависит только от чужих nftables, ip-full,
conntrack, kmod-nft-queue), steer-vless, steer-xsteer, steer-obfs, steer-tgws,
steer-hysteria2, steer-proxy (по одному бинарнику usr/sbin/steer-<имя>; зависят от steer-core (= версия),
модули с собственным TUN — ещё от kmod-tun) и мета-пакет steer-extended (устаревший: ядро и
первые четыре модуля; steer-hysteria2 и steer-proxy в него не входят). Библиотеки лежат внутри steer-core, а
не в своих пакетах: steerd сам ходит по HTTPS (замер групп, urltls.c) и по DoH, DoT и DoQ (резолвер),
поэтому криптография нужна ядру и без модулей. Модули файлов ядра не повторяют — у каждого файла
один владелец.
steer-core заменяет пакеты прежней раскладки — steer, libsteer, libsteer-wolfssl: он
объявляет provides steer, replaces и конфликт с ними (apk: provides, replaces и !имя в
depends; opkg: поля Provides, Replaces, Conflicts), поэтому установка и обновление снимают
старые пакеты, а /usr/sbin/steerd принадлежит одному пакету. Проверка на настоящем менеджере apk —
tests/pkglayout.sh; для opkg проверяются только поля метаданных.
Модуль, которого нет в системе, — не отсутствие команды. Вид выхода vless или xsteer при
разборе спеки отвечает «kind vless требует пакет steer-vless (входит в steer-extended)» —
единственное место текста, src/kinds/kind.c; команда модуля (steer vless …, sub-fetch,
tls-probe, xsteer-key…) без модуля отвечает так же, с кодом 2, а при установленном модуле
steerd запускает его с той же командной строкой (src/cli/modcmd.c). Есть ли модуль, решает
файл steer-<имя> рядом с исполняемым файлом (src/lib/module.c; каталог подменяет
STEER_MODULE_DIR).
2. Устройство#
Ядро — компилятор. Спека превращается в дерево набора правил (src/lib/ir.h), дерево — в
текст, а текст применяется одной транзакцией nft -f. Поэтому генератор проверяется без роутера:
tests/gen.sh и снимок генератора (раздел 4).
Ядерная форма. Выход выбирают метки nftables, policy routing и наборы адресов; доменные правила идут через свой резолвер (fake-IP или real-ip); туннели — устройства TUN или устройства ядра. tproxy, перехвата потоков в пользовательский процесс и сниффинга нет. Пользовательский процесс стоит только там, где без него нельзя: туннели с протоколами и DNS как плоскость управления.
Чем ближе к L2, тем лучше — когда это даёт пользу. Пакет классифицируется и получает выход как можно ниже по стеку и как можно раньше на своём пути, если это даёт хотя бы одно из следующего: - меньше обработки трафика; - выше скорость, меньше задержки и расход ресурсов; - меньше возможности другому софту вмешаться в наш трафик без нашего ведома; - удобнее.
Перенос ниже, который ничего из этого не даёт или стоит памяти (например, вторая копия наборов в
таблице netdev), не делается. По этому правилу правила каналов раздачи стоят на хуке ingress
(«Набор правил» ниже).
Чужие пакеты — настройка человека. dnsmasq, https-dns-proxy, netifd, odhcpd и зоны
fw4 ядро не настраивает: оно маршрутизирует само и говорит в diag, чего не хватает. Исключение
одно — masquerade IPv6 по ключу ipv6: nat у выхода (раздел 4б), и то в своей таблице.
Один разбор спеки. Спеку читает одна функция load_spec (src/model/parse.c), а таблицу
доменных каналов для резолвера строит код модели (dch_build, src/dnsd/table.c); имя набора
у компилятора и резолвера считает одна функция (docs/contract-v1.md, §7).
Правила устройства#
- Вид выхода — модуль. Всё, что знает о виде, лежит в
src/kinds/<вид>.cи отдаётся одной таблицейstruct kind_ops(«Вид выхода» ниже). Общий код спрашивает свойства и функции вида, а не сравнивает, какой он: сравнение вида внеsrc/kindsловитtests/buildmatch.sh. - Платформа — модуль, выбираемый при запуске. Отличия телефона от роутера лежат в
src/platform/openwrt.cиsrc/platform/android.cза таблицейstruct platform_ops(platform.h): пути, поле метки, NAT, цепочки на сам телефон. Порядок выбора (platform.c):--platformилиSTEER_PLATFORM, затем умолчание сборки (-DSTEER_DEFAULT_PLATFORM), затем признаки среды, последним — роутер. Условной компиляции поSTEER_ANDROIDвнеsrc/platformнет (buildmatch). - Сборка — набор модулей, а не набор макросов. Профиль в
build/sources.mk— это список файлов, и больше ничего. Команды модулей, которых в сборке может не быть (клиенты VLESS и xsteer, подписка, мост, хаб), — слабые ссылки вsrc/daemon/main.c, виды — слабые ссылки реестраsrc/kinds/kind.c: файла нет в профиле — на его месте штатный отказ «нужен пакет …». То, что файлом модуля не выражается, лежит в файле профиляsrc/profile/<профиль>.c(profile.h): имя варианта сборки, признак полного пакета, у мини-сборки tgws — поле метки, ряд таблиц, файл имён таблиц, порты моста и «без резолвера». Уbaseсвоего файла нет, действуют умолчанияsrc/profile/profile.c. МакросовSTEER_EXTENDED,STEER_SERVER,STEER_TGWSнет ни вsrc, ни в путях сборки (buildmatch). - Слои зависят только вниз. buildmatch проверяет это частями: сборочные списки замкнуты по
#include, ядро не включает заголовков протоколов, аsrc/tunnelиsrc/protoне зовут маршрутизацию демона. - Ошибки возвращаются. Модели и компилятору
die()не нужен: они возвращают код и заполняютstruct err(src/lib/err.h), а процесс завершает только точка входа бинарника (err_die). Вsrc/model,src/compileиsrc/lib, кромеerr.c, нет ниdie(), ниexit()(buildmatch). - Спека — значение, а не глобалы. Всё прочитанное лежит в
struct spec(src/model/spec.h), и функции получают её параметром. Прежних глобалов спеки (g_out,g_ch,g_lan_devи соседних) вsrcнет (buildmatch).
Процессы#
На роутере:
procd
└─ steerd daemon --watch --supervise --apply один экземпляр, respawn
├─ сокет управления, протокол v1 <каталог спеки>/steer.sock
├─ apply-сверка дети apply-plan, apply-commit на команду
├─ сторож на цикле событий, без fork на проход
├─ супервизор детей
├─ steer dnsd --table-fd 3 ребёнок: файл steerd, argv[0] «…/steer»
├─ steer vless|xsteer|obfs|tgws <выход> ребёнок: бинарник модуля (steer-vless,
│ steer-xsteer, steer-obfs, steer-tgws),
│ argv[0] «…/steer»
└─ steer-nfqws <очередь> <файл ключей> ребёнок: обёртка nfqws
steer <команда> клиент: команды демона — в сокет; остальное и всё без демона — execv steerd
steer-tools <команда> ссылка на steerd: отвечает только на инструменты
- Три имени в пакете.
steerd— всё ядро одним файлом: демон, компилятор, apply, помощники выходов, резолвер, инструменты;steerd <подкоманда>— то же, что подкоманда ядра.steer— отдельный маленький клиент сокета (src/client/main.c).steer-tools— ссылка наsteerd, под этим именем ядро отвечает только на инструменты (раздел 4а, «Бинарники»). - Помощники: резолвер — подкоманда
steerd, туннели, обфускатор и мост — модули. Резолвер демон запускает тем же файломsteerd, помощников выходов — бинарником модуля из каталога ядра (kind_helper.prog,helpers_planвsrc/daemon/helpers.c):steer-vless,steer-xsteer,steer-obfs,steer-tgws,steer-hysteria2. Слова у них те же, что у подкоманды (<команда> <выход> --spec … [--state-dir …]), окружение —STEER_EVENT_FDиSTEER_SUPD, как у прежних помощников. argv[0] у всех «…/steer» (helper_argv0): в списке процессов они выглядят какsteer dnsd,steer vless <выход>, и поиск по командной строке (diag — обходом /proc) их находит. В статической сборке (телефон, стенды) модули слинкованы вsteerd(modcmd_builtin), и помощник — подкоманда, как раньше. Обработчик zapret — своя программа рядом с ядром (files/usr/sbin/steer-nfqws). - Первое сообщение модуля —
helloс версией его сборки (docs/ctl.md). Демон сверяет её со своей: модуль другой версии, как и модуль, начавший не сhello, он гасит (SIGTERM) и не верит ни одному его событию; причина — в журнале и вlast_downответаhelper(полеrejected). Бинарника модуля нет — в журнале один раз «модуля нет: нужен пакет steer-<имя>», вlast_downто же, повтор запуска по обычной паузе. - Службу держит
/etc/init.d/steer: один экземпляр procd с respawn.reload,reload_dnsd,reload_zapretиreapply— запросreloadдемону клиентом;stopждёт выхода демона и зовётsteerd down; hotplug (files/etc/hotplug.d/iface/95-steer) шлёт экземпляру SIGHUP. На телефоне демона держит init (init/steerd.rcв дереве прошивкиvendor/der, вне этого репозитория).
Слои и каталоги#
Каталоги слоёв — INC_DIRS в build/sources.mk; их же ровно перечисляет Android.bp, и сверяет
это tests/buildmatch.sh. Имена заголовков в дереве уникальны: заголовки подключаются по имени из
любого слоя.
src/
lib/ общие кирпичи: run.c (run, run_quiet), err.c, tmpfile.c (шаблон временного файла),
jsonw.c, jsonr.c, ynode.c (обёртка libyaml), nlbuf.h, nftnl.c (nf_tables по
netlink), nftdump.c, nftvmap.c, ctnl.c (conntrack), rtnl.c, procscan.c, sindex.c,
puff.c, ir.c (дерево набора правил: его строят и compile, и виды через emit),
evline.c (линия событий помощник → демон, hello), ctlcall.c (вызов сокета демона),
module.c (какая команда чья, установлен ли модуль),
scrypto.c (слой криптографических примитивов — только в сборках с TLS)
model/ spec.h (struct spec, struct output с union видов, правила, клиенты, списки),
parse.c (выбор формата), v2.c и v2print.c (спека v2), v1.c (перевод спеки v1),
check.c (сквозные проверки), registry.c (реестр меток и таблиц), marks.h, probe.c,
srs.c, srsplan.c (наборы sing-box)
kinds/ kind.h и kind.c (struct kind_ops, биты свойств, реестр),
direct.c interface.c awg.c vless.c xsteer.c zapret.c tgws.c group.c grpurl.c
hysteria2.c proxy.c (записи видов модулей steer-hysteria2 и steer-proxy)
platform/ platform.h, platform.c, openwrt.c, android.c
profile/ profile.h, profile.c (умолчания), extended.c, server.c, tgws.c — данные профиля
compile/ groups.c, balance.c, generate.c, print.c, legacy.c (раскладка ядра 4.9),
nftcompat.c (что умеет nft этого ядра)
daemon/ main.c, ctl.c (сокет, протокол), apply.c, recon.c (сверка), watch.c и watchd.c
(сторож), supervise.c и supd.c (супервизор), helpers.c, status.c, diag.c,
explain.c, fwcheck.c, failover.c, fogroup.c, folat.c, foprobe.c, urltest.c, gaiw.c,
conns.c, loop.c, state.c, rulewd.c, nftquery.c
dnsd/ main.c, proxy.c, wire.c, rules.c, fakeip.c, realip.c, origdst.c, table.c, tabfmt.c,
fpseed.c, adopt.c, dlog.c
client/ main.c — steer, клиент сокета
cli/ cli.c — таблица команд и разбор командной строки; modcmd.c — команды модулей
(заглушки в steerd, настоящие ветки в модуле) и main модуля (steer_module_main)
modules/ main_vless.c, main_xsteer.c, main_obfs.c, main_tgws.c, main_hysteria2.c, main_proxy.c
— main бинарников модулей (только разделяемая раскладка)
tools/ aggregate.c (fit), srsread.c, hwid.c
tunnel/ стек туннеля без протокола: tun.c (TUN: очереди, разгрузка, запись пакетов),
rtx.c (кольцо повтора), stack.c и stack.h (TCP/UDP ↔ потоки к узлу, таблица
соединений, пул установщиков, запасные сессии), dialer.h (struct dialer_ops),
pool.c и pool.h (пул узлов выхода: N активных узлов, раздача соединений, слежка
за каждым и замена мёртвого без перезапуска — обёртка дайлера)
proto/ tls/ (tls13, certverify, reality, chello, h2, roots — корни проверки сертификата,
tlsprobe, urltls)
transport/ (transport.h — struct transport_ops и security_ops; transport.c —
сборка ярусов и транспорт tcp; trdial.c — сокет до узла; trsec.c — none, tls,
reality; trgrpc.c; trxhttp.c; trupgrade.c — запрос Upgrade по HTTP/1.1 и
httpupgrade; trws.c — кадры WebSocket; trpath.c — путь запроса Upgrade)
vless/ (vlmain.c — подкоманды vless*, vldial.c — дайлер стека, client.c —
vless_connect и проверка узла, vless_proto, vision, sub,
subfetch; sublink.c — ссылка узла и поля транспорта, общие с модулем steer-proxy)
proxy/ (trojan, shadowsocks, socks, http, vmess: pxwire, pxsub, pxdial, pxtrojan,
pxss, pxsocks, pxhttp, pxvmess, pxmain — модуль steer-proxy, docs/proxy.md)
hysteria2/ (клиент hysteria2 на QUIC — модуль steer-hysteria2, docs/hysteria2.md)
xsteer/ (xswire, xshake, xsepoch, xsconf, xslink, xsconn, xsroute, xsstream,
xsclient, xshub, xsadmin) tgws/ obfs/ (WG поверх поддельного TCP: помощник и
obfs-server)
third_party/libyaml (разбор YAML; файлы не правятся — суммы в UPSTREAM сверяет buildmatch)
Отдельной точки входа туннеля в src/tunnel нет: она у модуля протокола
(src/proto/vless/vlmain.c), а стек — библиотека, которую модуль зовёт (stack_run).
Куда файлы уходят в разделяемой раскладке (раздел 1): src/tunnel, src/proto/tls (кроме
tlsprobe.c и urltls.c), src/proto/transport, src/proto/obfs/obfs.c, src/lib, src/model,
src/kinds, src/platform, cli/cli.c, tools/hwid.c — в libsteer.so; src/daemon, src/dnsd,
src/compile, tools/aggregate.c, tools/srsread.c, tls/urltls.c — в steerd; proto/vless
(с tls/tlsprobe.c), proto/xsteer (без xshub.c), proto/tgws, proto/obfs/obfsmain.c —
в модули; xshub.c — только в хаб на VPS.
Файлы reality.c, tls13.c, vision.c, vless_proto.c, xswire.c, xshake.c, xsepoch.c,
certverify.c держат байты на проводе: ошибка в них не ломается явно, поэтому их правка
проверяется make ext-test и стендами протокола (раздел 4).
Вид выхода#
Вид выхода — файл src/kinds/<вид>.c с записью struct kind_ops (src/kinds/kind.h, полностью
и с доводами — там же):
enum kind_cap {
KC_DEVICE = 1 << 0, KC_MARK = 1 << 1, KC_CTMARK = 1 << 2, KC_ENGINE_OWNED = 1 << 3,
KC_SELF_NAT = 1 << 4, KC_OVER = 1 << 5, KC_SKIP_ZAPRET = 1 << 6,
KC_TCP_PROBE = 1 << 7, KC_FLOW_UDP = 1 << 8, KC_IPV6 = 1 << 9,
};
struct kind_ops {
const char *name; /* как пишется в спеке */
unsigned caps; /* enum kind_cap */
unsigned keys; /* enum kind_key: чьи ключи спеки */
const char *absent; /* не NULL — вида в этой сборке нет, строка отказа */
unsigned (*caps_of)(const struct output *); /* свойства, зависящие от настройки */
const char *novia, *selfnat_why, *lan_only; /* тексты о виде для общего кода */
int (*parse)(struct output *, const struct out_keys *, struct err *); /* один разбор на v1 и v2 */
void (*keys_of)(const struct output *, struct out_keys *); /* обратное parse */
int (*check)(const struct spec *, const struct output *, struct err *);
void (*emit)(struct nft_rs *, const struct spec *, const struct output *);
int (*health)(const struct spec *, const struct output *, const char *dev);
int (*revive)(const struct spec *, const struct output *, const char *dev,
const struct kind_name *names, size_t n);
size_t (*revive_names)(const struct spec *, const struct output *, struct kind_name *, size_t);
int (*latency)(const struct spec *, const struct output *, const char *dev);
void (*status)(FILE *, const struct spec *, const struct output *);
void (*diag)(kind_diag_fn *, const struct spec *, const struct output *);
int (*helper)(const struct spec *, const struct output *, struct kind_helper *);
};
- Свойства — биты
caps. Предикатыout_*вspec.h(out_has_device,out_needs_mark,out_engine_managed,out_self_nattingи соседи) — тонкие обёртки над битами, и у каждого записано, чем он отличается от соседнего. - Любая функция может быть
NULL: общий код проверяет это явно и идёт общим путём (общая проба ICMP или TCP поKC_TCP_PROBE, общий замер соединением TCP, правила по свойствам). - Настройки видов —
unionвнутриstruct output: у каждого вида своя структура (struct vless_cfg,struct xsteer_cfg, …), и заполняет её только разбор его модуля. Помощники спрашивают свою настройку у вида (out_vless,out_xsteer,out_tgws). - Реестр (
kind.c):direct,interface,vless,xsteer,zapret,tgws,awg— в этом порядке идут правила видов в дереве (kind_emit_all) и проверки diag. На записи вида, файла которого в сборке нет, стоит запись отказа сabsent; её текст о VLESS и xsteer содержит подстрокуsteer-extended— её читает splify2. - Группа (
kind: group,src/kinds/group.c) — вид модели v2 и в реестр не входит: спека v1 её не знает, разбор v2 находит её черезkind_by_name_v2, а пулdevicesспеки v1 собирает в группу перевод v1 (раздел 4в).
Набор правил#
Всё, что ставит ядро steer, лежит в своих таблицах (table inet steer, в раскладке старого ядра Linux — ещё
ip steer и ip6 steer), и каждая замена идёт одной транзакцией: файл начинается со снятия
таблицы, за ним новая, поэтому момента без таблицы нет (docs/contract-v1.md, §7). Раскладку под
ядро выбирает src/compile/nftcompat.c; печать под ядро 4.9 — src/compile/legacy.c. Основные
цепочки (src/compile/generate.c):
| Цепочка | Хук | Что делает |
|---|---|---|
ingress_mark |
ingress устройств раздачи | правила каналов раздачи: метка выхода пакету до conntrack и до чужих цепочек prerouting |
prerouting_mark |
prerouting, mangle + 1 |
метка соединения и выбор члена balance по готовой метке; те же правила каналов — запасные, для пакета, который ingress не разобрал |
prerouting_failopen |
prerouting, mangle + 2 |
снимает бит обхода DPI с трафика выхода, пущенного сторожем напрямую |
prerouting_dns |
nat prerouting | заворот DNS клиентов на порт резолвера |
prerouting_dnat |
nat prerouting | подмена поддельного адреса fake-IP на настоящий по карте; поддельный адрес без подмены отбрасывает правило steer-fakeip-nomap |
forward_v6 |
forward | отвергает IPv6 правил, ведущих в выход без IPv6, и адрес из префикса хоста мимо донора (раздел 4б) |
postrouting_guard |
postrouting, filter |
пакет с меткой выхода с устройством уходит только в устройства этого выхода — или отбрасывается |
postrouting_down |
postrouting | счётчики входящего трафика каналов |
postrouting_nat6 |
nat postrouting | masquerade IPv6 у выхода с ipv6: nat |
output_mark, output_dns |
output | каналы на сам телефон и заворот его DNS; в раскладке 4.9 — ещё output_reroute и output_nat |
zapret_*, tgws_redirect |
— | правила видов zapret и tgws (kind_ops.emit) |
Разметка на ingress. Цепочка ingress_mark стоит на тех устройствах из lan_devices, что есть
при apply, с приоритетом filter + 10 — после flowtable fw4 на том же хуке. Первое правило даёт
пакету с пустым полем метки значение «разобран, выхода нет» (STEER_INGRESS_SEEN, 0x0f000000,
src/model/marks.h), дальше идут правила каналов с меткой пакета. Записи conntrack на ingress ещё
нет, поэтому метку соединения и выбор члена balance ставит первое правило prerouting_mark
(цепочки ingress_seen и ingress_ct). Остальные правила prerouting_mark ловят пакет, который
ingress не разбирал: устройство без хука, клиент с другого устройства, метка, переписанная чужой
цепочкой между ingress и нами. Цепочку ingress ядро принимает только на существующие устройства и
вынимает из неё исчезнувшее; устройство, созданное заново, возвращает в неё следующая замена набора
правил (демон со --watch ставит её сам, раздел 4а). Разметка остаётся целиком в prerouting на
ядре без inet ingress (проба nft -c), в раскладке 4.9, на телефоне, в мини-сборке tgws, у спеки
без правил раздачи и при STEER_NFT_INGRESS=0 в окружении apply.
Порядок применения (docs/ctl.md, «Порядок применения»): устройства awg и привязка выходов —
раньше набора правил, чтобы метка ни мгновения не жила без своего правила fwmark; набор правил —
одной транзакцией, с картой fake-IP и наборами каналов fake-IP, засеянными из файла состояния
резолвера (src/dnsd/fpseed.c, print_elements в src/compile/print.c); сразу после загрузки —
возврат элементов real-ip резолвером, отметки «пущен напрямую», карта balance к живым членам;
снятие прежних меток — только после удачной загрузки.
Туннели: стек, дайлер, транспорт#
Туннель с протоколом поверх потоков — три слоя (раскладка по файлам — «Слои и каталоги»):
- стек (
src/tunnel): TUN ↔ потоки TCP/UDP клиента — SYN-ACK сразу, окно и кольцо повтора, ранние данные, сборка фрагментов, таблица соединений, пул установщиков, запасные сессии. Про протокол он не знает ничего: на каждое соединение у него непрозрачная сессия дайлера; вход —stack_run(выход, дайлер, ready, arg)(stack.h), а обратный вызовreadyполучает имя поднятого устройства; - дайлер — протокол поверх транспорта: заголовок запроса, обёртки, разбор ответа, обрамление
датаграмм. Дайлеры — VLESS (
src/proto/vless/vldial.c: заголовок VLESS, Vision, UDP командой 2) и протоколы прокси (src/proto/proxy: trojan, shadowsocks, socks, http, vmess — по файлу на протокол; docs/proxy.md), а hysteria2 ходит своим путём (QUIC,src/proto/hysteria2). Подкомандыsteer vless*/steer proxy*и выбор первого узла — в модуле протокола (vlmain.c/pxmain.c), а N активных узлов, раздача соединений и слежка — у пула узлов (src/tunnel/pool.c): он обёртка дайлера, ctx протокола у него по-прежнему узел, только узел свой у каждого соединения; - транспорт (
src/proto/transport): как поток дайлера едет до узла — сокет по всем адресам имени с меткойover(trdial.c), безопасностьsecurity=— none, tls, reality (trsec.c) — и транспортtype=— tcp, grpc, xhttp, ws, httpupgrade (transport.c,trgrpc.c,trxhttp.c,trws.c,trupgrade.c).
Интерфейсы — сокращённо, полностью с доводами в заголовках:
struct dialer_ops { /* src/tunnel/dialer.h */
const char *name;
unsigned caps; /* DC_PRECONNECT: связь открывается до адреса — пул запасных */
size_t sess_size; /* сессия на соединение клиента; память выделяет стек */
int (*connect)(const void *ctx, void *sess, int timeout_s); /* в потоке установщика */
void (*take)(void *dst, void *src); /* связь из запасной сессии — в сессию соединения */
int (*flow_open)(const void *ctx, void *sess, const struct flow_key *k, int udp);
int (*send)(const void *ctx, void *sess, const struct flow_key *k, int udp,
const unsigned char *d, size_t n);
size_t (*dgram_frame)(const unsigned char *p, size_t n, unsigned char *out, size_t cap);
int (*read)(void *sess, unsigned char *buf, size_t cap,
const unsigned char **data, size_t *got);
int (*deliver)(const void *ctx, void *sess, int udp, const unsigned char *d, size_t n,
dialer_emit_fn emit, void *arg); /* кусками потока или датаграммами */
/* служебные: peer, describe, strerror, close, clear, fd, has_data */
/* узлов несколько (пул, pool.c; NULL — узел один): peer_of, match — годится ли запасная,
* stale — узел соединения больше не активен (RST), lost — связь оборвана ядром или узлом */
};
struct transport_ops { /* src/proto/transport/transport.h — tcp, grpc, xhttp, ws, httpupgrade */
const char *name;
const char *alpn; /* что просить в ALPN; NULL у tcp, http/1.1 у ws и httpupgrade */
int zc; /* данные лежат в записях TLS как есть — чтение без копии */
int (*open)(struct transport *, const struct tr_node *, int timeout_s);
int (*write)(struct transport *, const unsigned char *, size_t);
int (*read)(struct transport *, unsigned char *, size_t cap, size_t *got);
void (*moved)(struct transport *); /* структура переехала — поправить самоуказатели */
void (*close)(struct transport *); /* своё сверх основной связи: вторая связь xhttp */
int (*pending)(const struct transport *); /* своё непрочитанное: остаток за ответом 101 */
};
struct security_ops { /* none, tls, reality */
const char *name;
int (*handshake)(struct tr_link *, const struct tr_node *, const char *alpn);
};
- Безопасность и транспорт — две таблицы, а не одна цепочка. В ссылке узла это два независимых
поля,
security=иtype=, и сочетаются они любые. Слои безопасности различаются только рукопожатием — после него у tls и reality одни и те же записи TLS 1.3, — поэтому уsecurity_opsодна функция, а поток после рукопожатия общий. Вторая связь xhttp (stream-up, packet-up) поднимается тем жеtr_link_open, что и основная. - У дайлера нет своих
open_tcp/open_udp. Узел у протокола один на соединение (у пула узлов — свой у каждого соединения, без пула — один на процесс), связь — одна на поток клиента, и открывает её установщик стека (connect); TCP и UDP различаются флагом вflow_open,sendиdeliver— у VLESS это одна связь с другой командой в заголовке. Датаграммы едут байтами потока (dgram_frame). - Сессия одна на соединение клиента — и связь, и состояние потока. Граница между ними — дело
дайлера:
takeпереселяет из запасной сессии только связь, не затирая UUID и Vision потока. - Таблицы потока — в куче, а не в
__thread. У потока цикла одно отображение (mmap) под таблицу соединений, списки, корзины и сессии; страницы берутся по факту обращения, а установщики и поток слежки этого адресного пространства не получают. Статический TLS разделяемой библиотеки заводился бы каждому потоку каждого слинкованного с ней процесса. - Буферы на поток в
libsteer.soостаются__thread..tbssобъектов библиотеки (mipsel,size -A):tls13.c— три по 40 КБ (рукопожатие, сертификаты) и 16 КБ записей,stack.c— 18 КБ и три по 4 КБ,trgrpc.cиtrxhttp.c— по 16–32 КБ,h2.c— около 40 КБ,tun.cиreality.c— по 4 КБ; в заголовке TLS-сегментаlibsteer.soитого около 282 КБ, уsteerd— 16 КБ (urltls.c), уsteer-vless— около 52 КБ (дайлер, проверка узла). Библиотека загружается при запуске процесса (DT_NEEDED, неdlopen) и собирается с-ftls-model=initial-exec, поэтому обращение — одна загрузка из GOT. На musl TLS потока лежит в его же отображении, нулевые страницы не трогаются, пока буфер не использован; резидентной остаётся только используемая часть.
Новый протокол поверх потоков — это файл дайлера, стек не трогается (так добавлены trojan, vmess и
http модуля steer-proxy). UDP самим протоколом по датаграммам (shadowsocks, socks5 UDP ASSOCIATE)
ложится в ту же таблицу без правки стека: бит DC_UDP_OWN говорит, что у потока UDP своя связь —
сокет UDP к узлу, а не поток, и стек не берёт для него запасную связь пула (dialer.h). Новый
транспорт — таблица transport_ops без правки дайлера и стека (так устроены ws и httpupgrade:
tr_ws в trws.c, tr_httpupgrade в trupgrade.c).
xsteer на этот стек не ложится: он везёт IP-пакеты, а не потоки — TUN ↔ записи своего
протокола (Noise IK в облике TLS, xshake.c) поверх UDP или своего TCP (xsstream.c), без
терминатора TCP/UDP, окон и повтора. Транспорты src/proto/transport ему тоже ни к чему: его
рукопожатие — не TLS-клиент к серверу, а своё. Общее со стеком у него — слой TUN (tun.c:
очереди, чтение, склейка сегментов при записи); подъём устройства и потоки — свои.
Криптография#
Криптобиблиотека — wolfSSL, собранная из исходников выпуска со своими опциями: версия и сумма
архива — build/wolfssl/fetch.sh, опции — build/wolfssl/user_settings.h, список файлов —
build/wolfssl/build.sh (с ним сверяется build/wolfssl/Android.bp). Код протоколов и транспортов
библиотеку не зовёт: между ними тонкий слой примитивов src/lib/scrypto.h — хэши, HMAC, HKDF,
AES-GCM, ChaCha20-Poly1305, AES-CTR, X25519, проверка подписей и цепочки X.509. В заголовке слоя нет
ни одного типа wolfSSL, контексты — непрозрачные буферы фиксированного размера, а заголовки
wolfSSL включает один src/lib/scrypto.c (buildmatch). Свои TLS 1.3 и REALITY (src/proto/tls),
рукопожатие и ратчет xsteer и мост tgws стоят на этом слое. В статической базовой сборке
криптографии нет.
Постквантовая часть. Слой отдаёт ML-KEM-768 (sc_mlkem768_*: ключ и закрытый ключ — байтами, не
контекстами; случайность даёт вызывающий), проверку ML-DSA-65 (sc_mldsa65_verify, пустой контекст
FIPS 204) и BLAKE3 (sc_blake3_*). Первые два стоят на wolfSSL (WOLFSSL_WC_MLKEM, WOLFSSL_WC_MLDSA в
режиме VERIFY_ONLY, SHA-3 — все три в user_settings.h; ключи wolfSSL создаются в куче на время
вызова, поэтому их размер не входит в ABI между libsteer и libsteer-wolfssl). BLAKE3 в wolfSSL нет:
это src/lib/blake3.h, переносимый C без библиотеки, включаемый в scrypto.c. Потребители: гибрид
X25519MLKEM768 в TLS 1.3 и REALITY (src/proto/tls), подпись ML-DSA-65 у REALITY
(certverify.c) и VLESS encryption (src/proto/transport/trvenc.c), см. vless.md.
В пакете роутера библиотека — libsteer-wolfssl.so.<версия wolfSSL>, слой scrypto.c — в
libsteer.so, и libsteer.so зависит от неё (DT_NEEDED). Наружу из libsteer-wolfssl.so выходят
символы wc_* и wolfSSL_*, которые зовёт слой (build/wolfssl/libsteer-wolfssl.map), и
steer_wolfssl_abi; остальное код wolfSSL — включая TLS-стек и QUIC, включённые опциями, — в
файл попадает, только если достижим от экспорта (--gc-sections). Размеры SC_HASH_CTX_SIZE,
SC_AEAD_CTX_SIZE, SC_AESCTR_CTX_SIZE слоя — часть ABI между двумя файлами: контексты
размещает libsteer, а заполняет libsteer-wolfssl. Их держат две проверки: при сборке библиотеки
(build/wolfssl/abi.c: те же _Static_assert, что в scrypto.c) и при загрузке (sc_abi_check
в scrypto.c сверяет версию wolfSSL, размеры структур и смещение поля cm хранилища корней с
массивом steer_wolfssl_abi загруженной библиотеки; расхождение — строка в stderr и выход с кодом
3). На роутере wolfSSL пакета libwolfssl не используется: в нём нет QUIC, а его SONAME несёт
хеш опций.
QUIC как слой. QUIC-соединение для DoQ и hysteria2 даёт src/proto/quic — тонкая обёртка над
ngtcp2, целиком в libsteer.so. Сама ngtcp2 — выпуск с закреплёнными версией и суммой
(build/ngtcp2/fetch.sh, лицензия MIT), в дереве не лежит: скрипт скачивает архив, сверяет sha256 и
накладывает наши патчи (build/ngtcp2/patches). Собирается она своим рецептом build/ngtcp2/build.sh
(все файлы lib/ и криптобэкенд crypto/wolfssl, build/ngtcp2/config.h вместо порождаемого
configure) в статический архив с -fPIC; nghttp3 не берётся. Криптобэкенд зовёт TLS-стек и
wolfSSL_quic_* из libsteer-wolfssl.so, поэтому опции wolfSSL включают QUIC, слой EVP и AES-ECB
(защита заголовка пакета). Заголовки wolfSSL видят два файла: scrypto.c и src/proto/quic/qcssl.c
(ngtcp2 нужен сам TLS-стек, а не примитивы); quic.c держит сокет, потоки, датаграммы RFC 9221 и
таймер и wolfSSL не видит. Модель выполнения — внешняя линия событий: соединение отдаёт дескриптор
UDP-сокета и срок таймера (qc_fd, qc_timeout_ms), потребитель зовёт qc_on_readable и
qc_on_timer. Перегрузка по умолчанию — CUBIC; при bbr — BBR; при brutal_bps — Brutal из
hysteria2, патч к ngtcp2: заданная скорость в байтах в секунду, окно bps × RTT × 2 / доля
подтверждённых пакетов, без снижения при потерях. Второй патч (0002) даёт смену перегрузки на
установленном соединении (qc_set_cc: Brutal с другой скоростью или BBR).
Швы для hysteria2, не меняющие остальное поведение слоя: фильтр датаграмм сокета (qc_filter,
Salamander; tx_multi — один пакет в несколько датаграмм, Gecko), прыжки по портам сервера (hop_*: отправка на порт диапазона, приём с любого порта
диапазона, для ngtcp2 путь остаётся один), метка сокета (sock_mark), проверка отпечатка
сертификата (pin_sha256, вместо цепочки), однонаправленный поток (qc_stream_open_uni — управляющий
поток HTTP/3), PING по молчанию (keepalive_ms) и ручное продление окон приёма (flow_manual,
qc_stream_consumed) — обратное давление на сервер. Длина идентификатора соединения — четыре байта.
Потребители — модуль steer-hysteria2 и стенд tests/qcbench.c (build/libs-exports.sh).
Клиент hysteria2 в стеке туннеля. Стек (stack.c) держит на каждое соединение клиента
дескриптор, а QUIC-соединение у hysteria2 одно. Между ними — поток-мультиплексор модуля
(hy2conn.c): владеет QUIC и для каждого соединения клиента держит пару сокетов SOCK_SEQPACKET,
один конец которой стек опрашивает как обычный дескриптор (hy2dial.c, caps = 0, без пула
запасных сессий). Запись в SEQPACKET принимается целиком либо не принимается — то, что требует
dialer_ops.send; для UDP границы датаграмм сохраняются сокетом.
DNS#
Резолвер steer dnsd (src/dnsd) — ребёнок демона. Он слушает порт 5300 (DNS_PORT), и набор
правил заворачивает на него DNS клиентов (prerouting_dns; в мини-сборке tgws резолвера нет).
Спеку резолвер под демоном не читает: таблицу доменных каналов — набор, режим, семейства, файлы
списков (src/dnsd/tabfmt.h) — демон пишет ему в трубу (--table-fd) при старте и после каждого
изменения, и резолвер меняет её без перезапуска. Без трубы резолвер читает спеку сам.
- Имя вне правил уходит к апстриму как есть: на роутере —
127.0.0.1:53(dnsmasq), на телефоне — туда, куда шёл запрос клиента (адрес из conntrack,--upstream-origdst). Сdns.other— к его серверу или группе тем жеdup_ask, что у канала; имена своей сети (name_localвsrc/dnsd/proxy.c: без точки,lan,local,home.arpa, обратные зоны …) остаются на прежнем пути. Нет ответа или SERVFAIL/REFUSED — вопрос уходит прежним путём (forward_old, тот же код, что у обычного имени вне правил); отказ без ответа ставит паузу (struct dpause,src/dnsd/dup.h), и на её время имена вне правил идут прежним путём сразу. - Имя под правилом в режиме fake-IP получает поддельный адрес из
198.18.0.0/15(IPv6 —fdfe:dcba:9876::/96, раздел 4б); подмена ставится в карту ядра раньше ответа клиенту, а адрес — в набор канала. Раздача хранится в<каталог состояния>/fakeip.state. В режиме real-ip клиент получает настоящий ответ, а адрес ложится в набор канала со сроком ответа; эти элементы резолвер помнит в памяти (src/dnsd/realip.c) и возвращает после каждой замены набора правил. - Сокеты резолвера в каталоге состояния:
dnsd.sock— журнал имён дляdns-log;dnsd-ctl.sock— просьбы демона и загрузчика набора правил: подхват нового демона,down,reassert(вернуть элементы real-ip),flush(записатьfakeip.stateперед засевом) (src/dnsd/adopt.c). - Апстрим канала (
dns.upstreams,dnsу правила,dns.upstream,dns.other; ключи — docs/spec-v2.md). Канал — это набор nft, режим и апстрим; демон кладёт апстримы, которыми пользуются каналы иdns.other, в ту же таблицу: заголовокN U кэш min max neg [other], после строк каналов U строкимя|адрес|выход|метка|адреса|bootstrap, у канала последнее полеdns:<номер>; седьмое число заголовка — номер строки апстримаdns.other(только когда он задан). Группа серверов — строкаимя|group:race|-|0|1,2,3|-(илиgroup:failover): члены — номера строк апстримов, они стоят в таблице раньше группы. Без апстримов и кэша таблица — прежняя,Nи строки каналов, байт в байт. - Группа серверов (
src/dnsd/dupgrp.c) — для резолвера один апстрим:dup_askпо её номеру даёт ровно один обратный вызов — первый годный ответ члена (не SERVFAIL и не REFUSED) или отказ. race спрашивает всех сразу; failover — по порядку, с паузой отказавшего члена и вопросом следующему по отказу или через 1,5 с молчания. Следующий член спрашивается изdup_tick, а не из обратного вызова прежнего: тот приходит изнутри разбора соединения. Вопрос группы живёт в куче, пока не вернутся все принятые вопросы членов. Имя под правилом с апстримом уходит только туда (src/dnsd/dup.c): UDP, TCP, DoT (кадры с длиной в два байта по одному соединению, вопросы вперемешку, номер транзакции свой на апстрим), DoH (POSTapplication/dns-message; в ALPN «h2, http/1.1», протокол выбирает сервер. HTTP/2: одно соединение на апстрим, вопрос — поток, число одновременных потоков — то, что объявил сервер (SETTINGS_MAX_CONCURRENT_STREAMS, до его SETTINGS — 100), ответ на GOAWAY — потоки заlast_stream_idуходят на новое соединение; кадры и HPACK —src/dnsd/doh2.c, таблица заголовков у сервера нулевая. HTTP/1.1: keep-alive, соединение на каждый ожидающий вопрос, пока хватает дескрипторов — четверть RLIMIT_NOFILE, на каждом один вопрос за раз; ALPS из ClientHello DoH убран) и DoQ (RFC 9250,quic://: одно соединение QUIC на апстрим, вопрос — свой двунаправленный поток с кадром «длина, сообщение с номером 0» и FIN, ответ сопоставляется по потоку; кадры и коды —src/dnsd/doq.c). Сокеты — в epoll резолвера, неблокирующие, срок вопроса 4 с, простаивающее соединение закрывается через 5 минут; обрыв соединения под вопросом — один повтор на новом, дальше SERVFAIL; после неудачи следующая попытка — через 1, 2, 4 … 30 с, всё это время вопросы получают SERVFAIL сразу. Рукопожатие TLS блокирующее, поэтому соединение устанавливает короткий поток (dupdial.c: bootstrap, connect,tls13_handshake_auth) и отдаёт циклу готовый сокет; поток заводится на соединение, не на вопрос. Рукопожатие QUIC неблокирующее: потокdupdial.cтолько находит адреса сервера, аqc_open, события сокета и таймер обёрткиsrc/proto/quicведёт цикл резолвера (dup_wait_msучитываетqc_timeout_ms); контекст TLS с корнями — один на процесс. Соединение DoQ, не подавшее ни пакета за 1,5 с после отправленного вопроса, считается мёртвым: оно пересоздаётся, вопрос уходит на новом. Билеты сессии (кэшqc_tlsвsrc/proto/quic/quic.c, по записи на сервер, память процесса) дают рукопожатие по PSK и 0-RTT: пока рукопожатие идёт,pick_connберёт соединение сdupq_early_readyи вопрос уходит в early data; отвергнутый 0-RTT (on_early_rejected) возвращает вопросы в очередь, попытка не тратится; счётчикиearlyиearly_rejected— вdns-log. TLS и обёртка QUIC берутся из библиотек ядра слабыми ссылками: сборка без них работает, а DoT/DoH (без TLS) и DoQ (без QUIC) в ней отказывают. - Путь запроса. Сокет апстрима «через выход» метится меткой выхода (та же, что у сокета туннеля
over,marks.h, плюс на телефоне бит собственного трафика туннеля), «напрямую» — без метки на роутере и меткой «само ядро steer» на телефоне. Метку в таблицу кладёт демон из реестра; у резолвера без демона её нет, и апстрим «через выход» не используется. Правило ip rule ведёт помеченный пакет в таблицу выхода,postrouting_guard(в него попадают и выходы, названные вdns) не пускает его в другое устройство. Bootstrap идёт с той же меткой. - Кэш (
dns.cache,src/dnsd/dcache.c): только ответы на имена под правилами и, сdns.other, на имена вне правил (ответы его сервера), NOERROR и NXDOMAIN, ключ — апстрим, имя без регистра, тип. Срок — наименьший TTL, зажатый пределамиcache_ttl; TTL в самом ответе зажат так же и уменьшается на возраст, поэтому адрес real-ip в наборе и у клиента кончается одновременно. Ответ из кэша идёт через тот жеupstream_answer, что ответ по сети: подмена fake-IP ставится в ядро раньше ответа клиенту, ответа «из пула сразу» нет. Кэш сбрасывается при каждой новой таблице. steer dns-logкроме имён (у каждого — сервер, которым оно спрашивается,dns) отдаётupstreams(состояние, счётчики, последняя ошибка; у группы — режим, члены и их паузы),cacheиother(docs/ctl.md).
Команды и файлы состояния#
Команда описана строкой таблицы CMDS[] в src/cli/cli.c (флаги, справка, проверка аргументов), а
вызывается цепочкой сравнений в main() (src/daemon/main.c). Команды модулей, которых в сборке
может не быть, объявлены там же слабыми ссылками и отвечают отказом «нужен пакет …» (правило 3).
Инструменты, на которые отвечает steer-tools, перечислены в TOOLS[] того же файла.
Файлы ядра (пути платформы — src/platform/openwrt.c и android.c):
| Где | Файл | Что |
|---|---|---|
каталог спеки (/etc/steer, телефон — /data/misc/steer) |
spec.json или spec.yaml |
спека |
select |
выбор групп pick: manual; пишется только при смене выбора |
|
steer.sock |
управляющий сокет демона | |
каталог состояния (/var/lib/steer, на роутере tmpfs; телефон — /data/misc/steer/state) |
registry |
реестр меток и таблиц выходов |
fakeip.state |
раздача fake-IP резолвера | |
active, latency, restart-* |
память сторожа между проходами у steer failover; демон держит её в памяти, active пишет при изменении |
|
status.json |
снимок status для status --fast |
|
probe-<выход>, xsteer-<имя>.json |
ход перебора узлов и состояние клиента xsteer — только у помощника без трубы событий демона | |
awg-devices, awg-<устройство>.hs, awg-<устройство>.sig, dnsd.sig |
устройства awg, которые завело ядро; замер awg одиночного прохода; подписи, по которым решается, перенастроить ли устройство awg и хватит ли резолверу SIGHUP | |
dnsd.sock, dnsd-ctl.sock |
сокеты резолвера |
Что из каталога спеки переживает sysupgrade, перечисляет files/lib/upgrade/keep.d/steer.
3. Спека v2#
Свой формат под ядерную модель steer, а не разделы sing-box. YAML читается через libyaml из
дерева (src/third_party/libyaml); JSON — тоже YAML, и спеку v2 можно записать JSON-объектом с
"version": 2. Ключ version: 2 отличает формат от спеки v1 — JSON со schema: 1 или
schema: 2 (docs/contract-v1.md), которую переводит src/model/v1.c.
Полная форма формата — ниже. Всё, что в ней есть, разбор принимает и проверяет; то, чего ядро
ещё не выполняет (здесь — app клиента, встроенные domains/prefixes списка), отвергается отказом «ещё не поддерживается в этой версии
ядра steer: …» после всех настоящих проверок. Что ядро принимает целиком, показывает пример
docs/spec-v2.md; там же все ключи, умолчания и отказы.
version: 2
lan: { devices: [br-lan] } # кто наши клиенты по умолчанию
clients: # именованные группы клиентов
kids: { mac: [aa:bb:cc:dd:ee:01] }
tv: { addr: [192.168.1.50] }
tg: { app: [org.telegram.messenger] } # только на телефоне
lists: # что: назначения
youtube: { srs: lists/youtube.srs }
work: { domains: [corp.example], prefixes: [10.20.0.0/16] }
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],
transport: ws, over: wg0 }
dpi: { kind: zapret, strategy: zapret/yt.opts }
res: { kind: group, pick: order, members: [wg0, wg1], on_fail: drop }
eu: { kind: group, pick: manual, members: [nl, res], default: nl }
bal: { kind: group, pick: balance, members: [nl, wg1] }
dns:
mode: fakeip # умолчание
cache: 2048
upstreams:
nl-doh: { url: https://1.1.1.1/dns-query, out: nl }
rules: # сверху вниз, выше — сильнее
- { name: yt, for: [kids, tv], to: [youtube], out: dpi, resolve: realip }
- { name: work, to: [work, voice], out: eu, dns: nl-doh }
- Правило только ссылается на имена:
for— клиенты (по умолчаниюlan),to— списки,out— выход или группа. При пересечении правил побеждает то, что выше, а нижнее не отбрасывается. over— подложка туннеля, выход, через который идёт трафик самого туннеля (в спеке v1 —via).- У
interfaceв v2 одно устройство: резерв из нескольких устройств — группаpick: order. - Формат — по содержимому, а не по имени файла. Текст с
{— JSON:versionнаверху — v2, иначе v1 с отказами v1; остальное — YAML, то есть v2. Файл по умолчанию —{etc}/spec.jsonили{etc}/spec.yaml; лежат оба — отказ «две спеки». - Неизвестный ключ и ключ чужого вида — отказ, каждый отказ —
файл:строка:столбец. Ссылки по именам проверяются, круг в группах и вover— отказ. - Ключи видов разбирает сам вид (
kind_ops.parseнадstruct out_keys— один разбор на оба формата):kind: tunnel, protocol: vless(kind: vless— отказ с подсказкой) сsubscription(v1sub_file),nodesиtransport;strategyу zapret (v1opts_file);conf,stream,stream_portу xsteer;confу awg;obfs: { mode, server, listen }у interface;domainу tgws;ipv6иprefixу выходов с IPv6 (раздел 4б). - Относительный путь — от каталога файла спеки (
lists/youtube.srsрядом со спекой в{etc}), а не от рабочего каталога процесса. steer spec convertпечатает спеку v2 из спеки v1 (src/model/v2print.c); напечатанное даёт тот же набор правил до байта.
Во что спека разбирается — раздел 4в.
4. Проверка изменений#
- Юнит-стенды. Модуль линкуется со стендом сам, без
#includeчужого.cиsetjmp; общий каркас —tests/unit.h. Стенды не включают.cизsrc/model,src/compile,src/lib,src/daemon,src/dnsd; число стендов, которые включают исходники изsrc(протоколы, туннель,awgmatch), не растёт — это держит храповикtests/buildmatch.sh. - Снимок генератора (
tests/snapshot.sh,tests/golden/ruleset, входит вmake test) — выводapply --dry-runсо stderr и кодом выхода по всем спекамtests/gen.sh, для обеих раскладок nft и обеих платформ. Изменение, которое не должно менять набор правил, обязано совпасть с ним байт в байт; перезапись —make snapshot-record, только вместе с намеренным изменением набора правил. Спека v1 и её перевод в v2 дают один набор правил (tests/v2match.sh). - Интеграция в сетевых пространствах (стенды
*.shс root): демон, резолвер, туннель и сторож на хосте, пакет клиента реально уходит в нужный выход, упавший выход переключается. make ext-test— стенды расширенной части на настоящем wolfSSL той же версии и с теми же опциями, что в сборке (tests/ext-test.sh).tests/buildmatch.sh— согласие сборочных списков, правила раздела 2, контракт журнала, упаковка,keep.dи идентификаторы проверок diag противdocs/contract-v1.md.- Стенд роутера в QEMU — поведение на настоящих procd, fw4 и netifd.
4а. Демон steerd#
Демон — хозяин состояния ядра: спека и группы в памяти, сторож выходов, дети-помощники и
резолвер, сокет управления с событиями. Флаги: --watch (сторож), --supervise (дети),
--apply (применить спеку при старте). Устройство сокета, команд и событий — docs/ctl.md.
Сокет и протокол. Протокол управляющего сокета v1: строка запроса с телом по длине, ответ —
один объект JSON одной строкой; subscribe оставляет соединение открытым и шлёт события строками
JSON (src/daemon/ctl.c). status, diag, explain, conns, dns-log демон отвечает из памяти
и по ядру в своём процессе: nf_tables и маршруты — по netlink (src/lib/nftdump.c,
src/lib/rtnl.c), резолвер и обфускатор — обходом /proc (src/lib/procscan.c). Изменяющие команды
(apply, check, reload, rm-file) идут по одной, в очереди; проверка и компиляция — в детях
демона, поэтому остальные команды отвечают и во время apply.
Цикл событий (src/daemon/loop.c). Один epoll: слушающий сокет и соединения, signalfd
(SIGCHLD, SIGHUP, SIGTERM), таймеры (timerfd, CLOCK_MONOTONIC), сокет rtnetlink событий сети
(ссылки и адреса) — они будят сторожа, сокет стража правил и трубы событий от детей. Опроса по кругу
нет.
Проход без fork. Проход сторожа (src/daemon/failover.c) — конечный автомат на цикле событий:
пробы (неблокирующий TCP connect и эхо ICMP, привязанные к устройству), ожидание подъёма устройства
(таймер шага и события netlink), команды оживления (ifdown/ifup, ubus — ребёнок на действие) и
разрешение имён (рабочий поток src/daemon/gaiw.c) ждутся шагами автомата, а не синхронно. Тот же
автомат крутят демон (watchd.c), steer failover --loop и одиночный steer failover. Сверка
маршрутизации читает ядро по rtnetlink, поэтому проход по исправной спеке не запускает ни одного
процесса; процесс появляется только на действие. Во время прохода демон отвечает на запросы.
Состояние в памяти (src/daemon/state.c, watchd.c): спека и группы, отпечатки применённого,
по выходу — активное устройство, серия, задержка, здоровье, время оживления; дети; подписчики. Где
та же память лежит файлами (у steer failover и помощников без демона), — «Команды и файлы
состояния» в разделе 2; шов между файлами и памятью — src/daemon/fostate.h.
Дети и здоровье (src/daemon/supd.c, helpers.c). Помощник получает дескриптор трубы
(STEER_EVENT_FD) и пишет в неё события строками JSON (src/lib/evline.h): up (с полем dev —
устройство, которое поднял этот процесс), down с причиной, node, health. Без дескриптора
(ручной запуск, стенды) помощник работает сам по себе и пишет свои файлы состояния. Порядок подъёма
по over, перезапуск с растущей паузой, подпись параметров «надо ли перезапускать» — одним кодом
для демона и steer supervise. Маршрут выхода к устройству помощника ставит демон: по первому up
с dev за жизнь процесса он привязывает таблицу выхода к устройству (bind_device) в своём
процессе; следующие up маршрут не трогают, после down и выхода процесса решает сторож
(on_fail). Без демона маршрут выхода к устройству помощника не привязывает никто. Клиент VLESS (и
протоколов прокси) сам следит за своими активными узлами (src/tunnel/pool.c), и сторож принимает
его up и down без своей пробы.
Apply-сверка (src/daemon/recon.c). apply и reload строят план новой спеки в ребёнке
(apply-plan: проверки dry-run и отпечатки частей) и применяют только изменившиеся части
(apply-commit): набор правил, маршрутизацию выходов, помощников, таблицу резолвера. Отпечаток
набора правил считается по тексту без засева из файла состояния резолвера. Неизменная спека не
запускает ни nft, ни ip.
Сверка с ядром. То же apply и reload сверяют с ядром и применённое: номер и отпечаток наших
таблиц (nfd_table_fp: цепочки, правила по порядку, заголовки наборов — без счётчиков и элементов),
сводку элементов статических наборов (сумма перемешанных хэшей, снимает ребёнок-план), правило
fwmark и таблицу каждого выхода. Ожидаемое снимает сам ребёнок apply-commit сразу после своего
nft -f. Демон с --watch сверяет номер и отпечаток таблиц ещё и перед каждым проходом сторожа —
без процессов и без элементов; расхождение ставит в очередь reload, не больше трёх за пять минут
(docs/ctl.md, «Сверка с ядром»).
Страж правил (src/daemon/rulewd.c, с --watch). Правила fwmark выходов живут вне нашей
таблицы, и их снимают чужие (netifd на своём старте, netd на телефоне, ip rule flush). Страж слушает
снятие правил IPv4 и IPv6 и запасного запрета в таблицах IPv6 выходов и возвращает их сам, одним
сообщением rtnetlink; своё снятие он отличает по таблице IPv4 выхода. Сторож ещё и замечает
пересозданное устройство раздачи и ставит сверку набора правил, возвращая на него цепочку
ingress_mark (docs/ctl.md, «Страж правил выходов»). Пока правила нет, помеченный пакет не уйдёт в
чужое устройство: его отбросит postrouting_guard.
dnsd — ребёнок демона на таблице от него (раздел 2, «DNS»). Демона резолвер переживает:
закрытая труба — «демона нет», он отвечает по последней таблице и ждёт нового демона
(--orphan-timeout, по умолчанию 60 с); новый демон на том же каталоге состояния забирает его через
dnsd-ctl.sock новой трубой (src/dnsd/adopt.c). steerd down без демона просит его выйти тем же
сокетом. Если stderr резолвера сломан (читателя нет), строки журнала уходят в /dev/log с
заголовком syslog.
Бинарники. steerd — всё ядро одним файлом. steer (src/client/main.c, CLIENT_SRC в
build/sources.mk) — клиент: команды демона (status, diag, explain, conns, dns-log,
select, apply без --dry-run, reload, subscribe) шлёт в сокет и печатает stdout, stderr и
код из ответа; всё остальное — и любую команду, когда демона нет или он обслуживает другую спеку
(сверка по version), — отдаёт steerd через execv с теми же аргументами. steerd клиент берёт
из STEER_ENGINE или рядом с собой. steer-tools — ссылка на steerd: под этим именем (argv[0])
ядро отвечает только на инструменты. Подробно — docs/ctl.md, «Клиент steer».
Выключенное ядро (телефон, свойство persist.der.steer.enabled): ни таймеров сторожа и стража
правил, ни снимка status, ни сокетов событий сети — демон не просыпается сам. Положение
выключателя ему сообщают apply, reload и SIGHUP. wakelock демон не берёт.
4б. IPv6#
Семейство — свойство строки списка и адреса клиента; у выхода и группы — бит KC_IPV6. Его несут
interface, awg и zapret (и группа, все члены которой его несут); у vless, xsteer и tgws
его нет.
- Списки. Строка списка классифицируется по семейству (
spec_line_family: 4, 6 или 0 — имя). Строки IPv6 из файлов и подсети IPv6 из.srsидут в парный набор группы<имя>6(ipv6_addr; у составной группы —ipv6_addr . inet_proto . inet_service). Набор заводится, только когда у группы есть элементы IPv6 или у доменной группы есть половина IPv6 (dom6).steer fitпропускает строки IPv6 как есть: не сливает и в бюджет не считает. - Правила. У правила группы — v6-двойник в той же цепочке: то же «кто», сужение, поиск в
<имя>6(или «весь трафик»), та же метка и тот же комментарий, поэтому счётчик канала остаётся одним числом (counters_loadскладывает правила с одним именем). «Кто» по устройству и по MAC — одно выражение на оба семейства; адреса —ip saddrдля записей IPv4 иip6 saddrдля записей IPv6. Клиенты по умолчанию из одних подсетей IPv4 узнаются для IPv6 по устройствам. Клиент правила из одних адресов IPv4 по IPv6 не узнаётся (diag:ipv6_clients). - Маршрутизация. Выход с устройством и
KC_IPV6получаетip -6 rule fwmark <метка>/<маска> table <таблица>с той же меткой и тем же номером таблицы и маршрут в устройство в таблице IPv6. В таблице IPv6 всегда лежит запасной запретprohibit(table_bh_type,src/daemon/failover.c): ядро сразу отвечает клиенту «administratively prohibited», и клиент переходит на IPv4, а не ждёт таймаута; у IPv4 запрет —blackhole. Сторож и страж правил сверяют и возвращают оба семейства. masquerade IPv6 на роутере — дело зоны fw4 (masq6), на телефоне его ставит ядро steer (ip6tables). - Проба сторожа — по IPv4: здоровье туннеля — здоровье его транспорта и пира, у семейств оно общее.
- Выход без IPv6. IPv6 его правил метится, но правила
ip -6 ruleу метки нет, и цепочкаforward_v6отвергает такой пакет (reject with icmpx type admin-prohibited): клиент с двумя стеками сразу переходит на IPv4. В prerouting reject ядро не принимает, а drop задел бы трафик к самому роутеру. На телефоне для каналов на само устройство — reject вoutput_mark. diag:ipv6_output. - Старое ядро (4.9). Разметка IPv6 остаётся в
inet; nat IPv6 — только приNFTC_IP6NAT.
Резолвер.
- Есть ли у доменного правила IPv6 — одно решение на компилятор и резолвер, dom6_ok
(src/model/parse.c): выход несёт IPv6 или метки не ставит (direct), «кто» выражается для IPv6, а
для fake-IP ещё и есть nat в ip6. Резолвер получает решение полем семейства в таблице каналов.
- Ответ на AAAA. Имя под правилом получает адрес, только если IPv6 есть у всех совпавших каналов
(dch_all_v6); иначе — пустой ответ. У спеки v1 ответ на AAAA имени под правилом пустой всегда:
перевод v1 ставит модели dns.names_v4, и таблица даёт её каналам семейство «4». Набор правил
спеки v1 от этого не меняется. HTTPS и SVCB для имён под правилом гасятся.
- fake-IP v6. Пул fdfe:dcba:9876::/96 (FAKEIP6_NET); поддельный IPv6 — пара поддельного IPv4
той же записи в младших 32 битах (fakeip6_of), поэтому выдача одна на оба семейства и хранить
поддельный IPv6 не нужно. Карта fakeip6 и правило dnat ip6 to ip6 daddr map @fakeip6 в
prerouting_dnat; отказ карты — пустой AAAA.
- real-ip v6. Настоящие AAAA — в <имя>6 каналов real-ip со сроком ответа.
- fakeip.state — строка «домен, поддельный, настоящий, настоящий IPv6»; на месте настоящего
IPv4 — «-», если его нет; запись без IPv6 пишется формой из трёх полей.
- explain принимает адрес IPv6 и у поддельного адреса обоих семейств называет имя.
IPv6 от хоста (ключ ipv6: routed | nat | off у выхода спеки v2, docs/spec-v2.md). Хост —
сервер на том конце туннеля WireGuard, у которого IPv6 есть; роутер получает IPv6 внутри туннеля.
- Модель. enum out_ipv6, struct v6pfx и v6_denied у struct output (spec.h). Кому снять
IPv6, решает spec_v6_resolve один раз после разбора: off и все выходы рядом с донором, кроме
донора и nat; у них снимается KC_IPV6, и всё, что спрашивает «несёт ли выход IPv6», видит один
ответ. На телефоне routed и nat действуют как отсутствие ключа (out_ipv6_mode), off
действует.
- Набор правил. Набор v6donor с префиксом хоста; последнее правило разметки — всё несовпавшее из
префикса, кроме назначений в самом префиксе, ULA, link-local и multicast, — в донора; в
forward_v6 — запрет источника из префикса мимо устройств донора и раздачи и ULA-источника в
донора; донор — и в postrouting_guard. nat — цепочка postrouting_nat6 с masquerade IPv6 на
устройство выхода.
- Префикс без prefix: (v6donor_derive, failover.c) выводится по ядру: нуль-маршрут
unreachable P, который netifd ставит на раздаваемый префикс, над глобальным адресом устройства
раздачи; префикс провайдера отличает маршрут default from P через чужое устройство. Кандидат
ровно один — префикс донора, иначе не угадывается. Сторож в конце каждого прохода сверяет набор
v6donor с ядром и переписывает элементы одной транзакцией (nfv_ranges6_write,
src/lib/nftvmap.c).
- diag (ipv6_host): чего не хватает в /etc/config/network и /etc/config/dhcp (только
чтение) и проба эхом ICMPv6 через выход.
4в. Модель v2 и группы#
Одна внутренняя модель — модель v2 (src/model/spec.h). Компилятор, резолвер, сторож и status
работают с ней. Спека v2 разбирается в неё напрямую (src/model/v2.c), спека v1 — переводчиком
src/model/v1.c; load_spec (parse.c) выбирает формат по содержимому, сквозные проверки обоих
форматов — src/model/check.c. Сущности:
- struct spec_client — кто: адреса, MAC, self/uid на телефоне; sp->lan — клиенты по
умолчанию;
- struct spec_list — что: файлы префиксов и доменов, .srs, сужение proto/ports, all — весь
трафик;
- выходы — struct output с видами из src/kinds, в том числе group;
- struct spec_rule — «кто → что → куда»: клиенты и списки номерами, выход по номеру, режим DNS,
область device, выключенность. Порядок правил — приоритет.
Несколько клиентов или списков у правила разбор сводит в одного клиента или один список, когда смысл не меняется: компилятор берёт у правила одного клиента и один список.
Перевод v1. Канал становится правилом с безымянными клиентом и списком; выход interface с
devices из нескольких устройств — группой pick: order (или latency) из безымянных
членов-интерфейсов по одному на устройство. Безымянные члены лежат в sp->out за именованными,
поэтому реестр, status, помощники и правила видов их не видят; группа видна снаружи прежним видом и
именем, с прежними меткой и таблицей. via становится over. У steer spec convert члены пула
получают имена <пул>.<устройство> и становятся обычными выходами со своими метками и таблицами.
Группы (src/kinds/group.c, struct group_cfg): у группы свои метка и таблица, члены — любые
выходы с устройством, в том числе группы. Ключи и умолчания — docs/spec-v2.md.
- Члены спеки v2 — выходы со своим приговором. Группа своих устройств не пробует: жив ли член —
приговор его прохода в том же обходе, лист — выбранное им устройство. Обход — по зависимостям
(over и члены раньше группы, fog_order в src/daemon/fogroup.c). Безымянные члены пула v1
пробуются, как устройства пула.
- До первого прохода (после старта и перезагрузки) apply и status решают за группу так же, как
решил бы проход, только без проб: fog_pick_known — последний приговор члена и наличие устройства.
- order — первый живой член; возврат на предпочтительный — после нескольких проходов подряд.
- latency — замер как urltest sing-box (src/daemon/urltest.c): GET к url через члена, время
до первого байта ответа 204 или 200. Через именованного члена — сокет с его меткой (SO_MARK),
то есть ровно путь трафика группы; безымянному члену пула — SO_BINDTODEVICE. HTTP — неблокирующий
сокет в цикле демона, HTTPS — рабочим потоком src/proto/tls/urltls.c, только в полном пакете
(профили extended и android; в остальных https:// отвергает разбор). Замер — по IPv4, а у
группы спеки v2, все живые члены которой несут IPv6, ещё и по IPv6; выбор один на оба
семейства — по худшему из двух (src/daemon/folat.c). У демона замер идёт своим таймером группы на
её interval и зовёт внеочередной проход, только если выбор меняется. Без трафика через группу
дольше idle_timeout (по счётчикам правил каналов) запросов нет.
- manual — член, выбранный командой select <группа> <член> без apply: маршрут таблицы группы
сразу на лист члена. Выбор хранится в файле select рядом со спекой (steer_keep_dir). Выбранный
член не работает — on_fail группы, другой член не берётся.
- balance (src/compile/balance.c) — правило канала переходит в цепочку bal_<таблица>: там
восстановление по метке соединения (соединение остаётся на своём члене), затем
numgen random mod 120 vmap @balmap_<таблица> и запасной переход в метку самой группы. 120 слотов
(GROUP_BAL_SLOTS), а не mod <живых>: уход члена меняет только элементы карты, которые сторож
переписывает одной транзакцией nf_tables (src/lib/nftvmap.c); веса — доли слотов. numgen
random, а не jhash по кортежу: постоянство соединения держит метка соединения, а хэш по
кортежу при смене карты переносил бы установленные соединения. Если хоть один член не несёт IPv6,
IPv6 группы уходит в метку самой группы, и forward_v6 его отвергает.
- balance с by: site / site_client — вместо numgen по правилу на семейство:
meta nfproto ipv4 jhash ip daddr mod 120 seed 0x<таблица> vmap @balmap_<таблица> и то же с
ip6 daddr (у site_client — ip saddr . ip daddr). Карта, метка соединения и сторож — те же;
хеш выбирает слот, то есть член, для сайта, а не для соединения. Семя — номер таблицы группы:
без семени ядро берёт случайное на каждое правило (сайты тасовались бы с каждым apply), и у каждой
группы оно своё (вложенная balance с тем же семенем получала бы только «свои» слоты внешней и
отдавала бы их одному члену). Раскладка слотов (group_balance_slots) — основная при всех живых;
когда член лёг, живые сохраняют свои слоты основной раскладки и добирают долю только слотами
ушедших, поэтому сайты живых не переезжают от чужого отказа, а вернувшийся получает свои назад.
- Вложенность. order, latency и manual разворачивают выбор до листа; у balance вложенная
группа — один член со своей меткой и весом, а вложенная balance — переход в её цепочку.
balance членом группы order, latency или manual — отказ разбора.
- status — объект group у выхода-группы спеки v2 и умение groups (docs/contract-v1.md, §2);
события switched у групп — с полем member (docs/ctl.md).