Транспорт
Любой подсистеме узла — DHT, оверлеям, обмену блоками — рано или поздно нужно обратиться к другому узлу по сети: отправить запрос и дождаться ответа либо просто передать данные, не дожидаясь ничего в ответ. Для этого нужен канал, который сам находит или устанавливает соединение с нужным узлом, надёжно подтверждает, что на другом конце именно тот, кому обращение адресовано, и одинаково хорошо умеет дозваниваться и принимать входящие соединения. Эту роль в Tycho играет транспорт — общий для всех подсистем сетевой слой поверх QUIC.
Точка входа в сетевой слой
Сетевой слой узла собирается из трёх вещей: приватного ключа длиной 32 байта (можно сгенерировать случайный), адреса, на котором открывается UDP-сокет, и одного сервиса-обработчика для всех входящих обращений. Собранный объект владеет всем состоянием транспорта: QUIC-объектом на этом сокете, фоновой задачей, которая ведёт установление и разрыв соединений, и двумя реестрами — активных соединений и известных узлов. Наружу он отдаёт идентификатор и адреса самого узла, операции подключения, отключения, поиска пира и подписки на события пиров, а также подпись данных собственным ключом узла. Он клонируется дёшево: это разделяемая ссылка на общее внутреннее состояние, а не копия.
Единственный принимаемый при сборке сервис — корень маршрутизации входящих обращений. На практике это Router, который дальше разводит запросы по DHT, оверлеям и прочим подсистемам узла. Публичный адрес, который сетевой слой сообщает наружу, используется в анонсах контактной информации узла. Если его не задали явно, используется адрес, на котором фактически слушает сокет.
QUIC-соединение на одном сокете
Один QUIC-объект на весь узел стоит за всеми соединениями. Он создаётся на этапе сборки сетевого слоя, привязывается к одному UDP-сокету и с этого момента принимает входящие соединения и устанавливает исходящие: отдельных портов или сокетов под роль клиента и под роль сервера не заводится. Вызов, которым создаётся этот объект, принимает только серверную конфигурацию. Клиентская конфигурация в этот момент ещё не нужна — она собирается заново при каждом исходящем соединении, под конкретного ожидаемого пира.
Датаграммы QUIC отключены: весь прикладной обмен идёт только через потоки. Размеры буферов отправки и приёма можно задать явно, а если этого не сделать, на Linux берётся максимум, разрешённый настройками ядра. Управление перегрузкой можно переключить между тремя алгоритмами: Cubic, BBR и NewReno. Обнаружение оптимального размера пакета и Generic Segmentation Offload — тоже настройки этого QUIC-объекта.
Идентификация пира по сертификату
Соединение аутентифицируется на уровне TLS: вместо цепочки сертификатов от доверенного центра каждая сторона предъявляет самоподписанный сертификат, который целиком сводится к её публичному ключу ed25519. Это «сырой» публичный ключ, а не X.509-цепочка, и идентификатор узла восстанавливается прямо из него. Аутентификация взаимная: сервер обязательно запрашивает клиентский сертификат и принимает любой корректно закодированный ключ как удостоверение личности. Клиент при исходящем соединении дополнительно сверяет полученный сертификат с идентификатором пира, к которому он подключался, и отклоняет соединение при несовпадении.
Разрешён только TLS 1.3, подпись и её проверка выполняются по ed25519, а обмен ключами построен на X25519. Шифронабор — один из трёх вариантов: AES-256-GCM, AES-128-GCM или CHACHA20-POLY1305. Цепочка сертификатов не может содержать промежуточных звеньев: она состоит ровно из одного сертификата.
Значит, адресу доверять не нужно: личность пира привязана к его ключу, а не к тому, откуда пришло соединение. Идентификатор узла в Tycho — это и есть его публичный ключ ed25519: те же 32 байта, скопированные побайтово. Восстановление ключа обратно из идентификатора выполняется тем же разбором байтов, но может не удаться: не всякий набор из 32 байт задаёт корректную точку кривой, и тогда идентификатор непригоден для проверки подписи.
Рукопожатие и версия протокола
После того как QUIC-соединение установлено, стороны обмениваются версией протокола, прежде чем передать хоть один запрос. Сторона, принявшая соединение, открывает отдельный однонаправленный поток и пишет в него восьмибайтовый заголовок: пять байт магической последовательности tycho, два байта номера версии и один нулевой байт. Сторона, инициировавшая соединение, читает тот же поток и ожидает такой же заголовок. Несовпадение магической последовательности, ненулевой последний байт или незнакомая версия — ошибка рукопожатия, и соединение не считается установленным. В версии 0.3.11 действует единственная версия протокола.
Если для входящего соединения включён режим 0-RTT, рукопожатие проводится уже после того, как принятые по нему данные подтверждены: версия не сверяется на данных, которые ещё могут быть отброшены. Тот же восьмибайтовый заголовок версии предшествует и каждому отдельному запросу и ответу внутри потоков обмена — версия проверяется не только один раз при рукопожатии, но и на каждом сообщении.
Формат кадров
Один запрос или ответ в сетевом представлении выглядит одинаково: сначала уже знакомый восьмибайтовый заголовок версии, затем тело, закадрированное четырёхбайтным префиксом длины в сетевом порядке байт. Кадр длиннее заранее заданного предела отвергается кодеком: предел настраивается параметром max_frame_size. Тело кадра — это сырые байты, обычно результат TL-сериализации, и восемь байт заголовка версии в него не входят: они кодируются отдельно.
Первые четыре байта тела читаются как идентификатор TL-конструктора. По нему выбирается сервис, которому адресовано обращение, но это происходит уже на стороне получателя, после того как обращение принято.
Запрос и сообщение
Запрос с ответом идёт по двунаправленному потоку: отправитель пишет тело и ждёт ответа на этом же потоке. Сообщение без ответа идёт по однонаправленному потоку: отправитель пишет тело и не ждёт ничего в ответ. У сетевого слоя для обоих случаев есть удобные методы, которые сами находят уже открытое соединение с пиром или устанавливают новое, а затем выбирают нужный вид потока.
На приёме различие симметрично: двунаправленный поток обрабатывается как запрос, который может получить ответ, однонаправленный — как сообщение, ответа не предполагающее. Обращение к пиру, которого нет среди известных узлов, до отправки не доходит: оно завершается ошибкой ещё на стороне отправителя.
Обработка входящих обращений
Отдельная фоновая задача поднимается на каждое установленное соединение и принимает потоки этого пира. Она построена вокруг единого цикла ожидания: сначала убирает завершившиеся обработчики предыдущих потоков, затем принимает однонаправленные потоки (сообщениям отдан приоритет), и только потом двунаправленные. Каждый принятый поток обрабатывается в своей отдельной задаче: тело читается целиком, дальше запрос идёт в сервис-обработчик, и, если тот вернул ответ, ответ уходит обратно по тому же потоку. Сообщение идёт в сервис без ожидания результата. Число одновременных запросов от одного пира ограничено параметром max_concurrent_requests_per_peer: при превышении новый поток отклоняется с кодом ошибки 0xdead, а счётчик отклонённых запросов растёт. Если задача-обработчик завершается из-за ошибки соединения, пир убирается из реестра активных соединений.
Сервис-обработчик получает тело запроса вместе с метаданными: кто его прислал, кто из двоих устанавливал само соединение, и с какого сетевого адреса оно пришло. Идентификатор отправителя в этих метаданных берётся не из тела запроса, а из сертификата уже аутентифицированного соединения, поэтому подделать его на уровне транспорта нельзя. Эти метаданные общие для всех запросов одного соединения: то, кто установил соединение, не меняется от запроса к запросу, а вот направление конкретного обращения может быть любым — по одному соединению запросы идут в обе стороны.
Сервис и Router
Всё, что принимает входящие обращения в сетевом слое (DHT, оверлеи и прочие подсистемы), реализует один и тот же контракт: сервис. Обработчик запроса возвращает необязательный ответ: если он решает ничего не отвечать, запрос считается отменённым, и ответа отправлено не будет. Обработчик сообщения ответа не возвращает вовсе. Реализовать нужно только ту половину контракта, которая нужна конкретному сервису, для другой есть готовые заглушки.
Сетевой слой при сборке принимает произвольный сервис, но на практике используется единственный — Router, и он разводит входящие обращения между всеми остальными. Каждый зарегистрированный в нём сервис заранее объявляет, идентификаторы каких TL-конструкторов он обрабатывает, и Router складывает их в две отдельные таблицы «идентификатор → сервис»: одну для запросов, другую для сообщений. На приёме Router читает первые четыре байта тела как этот идентификатор и по нужной таблице, выбранной в зависимости от типа принятого потока, находит нужный сервис: сам он при этом устроен как ещё один сервис, только не отвечает сам, а передаёт обращение дальше. Если идентификатор не найден в таблице или тело короче четырёх байт, обращение молча отбрасывается, без ответа об ошибке. Если же два сервиса регистрируются на один и тот же идентификатор, это ошибка, которая всплывает уже при сборке Router, а не во время работы узла.
Маршрутизацию входящих обращений между сервисами не стоит путать с таблицей маршрутизации DHT: несмотря на похожее название, это разные структуры, решающие разные задачи.
Менеджер соединений
Одна фоновая задача ведёт все соединения узла. Она построена вокруг цикла ожидания сразу нескольких источников: команд от сетевого слоя (подключиться к пиру или завершить работу), новых входящих соединений на QUIC-объекте, завершений задач, которые устанавливают соединения или обрабатывают уже установленные, и таймера проверки связности. Установление соединения, что для входящего, что для исходящего, выполняется в отдельной задаче с ограничением по времени connect_timeout. Успешный результат добавляется в реестр активных соединений, и для него поднимается уже описанная задача-обработчик входящих потоков.
Сетевой слой обращается к менеджеру только через почтовый ящик: запрос на подключение к пиру ждёт результата отдельно, пока менеджер его не обработает. Неудачная попытка исходящего соединения получает экспоненциально растущую задержку перед повтором — от connection_backoff до max_connection_backoff. Ошибку уже закрытого соединения менеджер может отложить на connection_error_delay, в расчёте на то, что пир как раз в этот момент устанавливает встречное соединение сам. Момент очередной проверки связности каждый раз сдвигается случайной задержкой. Так узлы сети не дозваниваются друг до друга синхронным залпом.
Реестр активных соединений
Реестр активных соединений следит за тем, чтобы на каждого пира приходилось не больше одного соединения: добавление и удаление в нём поддерживают этот инвариант и рассылают подписчикам событие о появлении или потере пира. Сетевой слой отдаёт подписку на поток этих событий, а также читает реестр напрямую, чтобы узнать, активно ли соединение с конкретным пиром прямо сейчас.
Причины потери соединения различаются: явный запрос на отключение, таймаут, закрытие на уровне приложения, несовпадение версии протокола и другие — большинство из них отражают то, что сообщила о разрыве сама QUIC-библиотека. Удаление из реестра адресовано не пиру вообще, а конкретному соединению. Если задача-обработчик завершается по ошибке уже устаревшего соединения, а к этому времени с тем же пиром успело подняться новое, из реестра уходит именно старое, а не свежее.
Эта осторожность неслучайна: два узла могут дозвониться друг до друга одновременно, и тогда решение, какое из двух получившихся соединений оставить, принимается детерминированно. Если оба соединения одного направления (оба входящих или оба исходящих), остаётся более новое. Если направления разные, сравнение идентификаторов узлов решает исход: оно устроено так, что оба узла независимо приходят к одному и тому же выбору. Проигравшее соединение закрывается. Проверка выполняется дважды: при обработке ожидающих подключений по адресу и ещё раз при вставке соединения в реестр. Так оба узла гарантированно сходятся на одном и том же уцелевшем соединении, а не оставляют по ошибке два параллельных.
Реестр известных узлов и аффинити пира
Отдельно от активных соединений сетевой слой ведёт реестр известных узлов: для каждого хранится подписанная контактная информация и уровень аффинити, отражающий заинтересованность узла в поддержании связи с этим пиром. Уровней три: высокий (узел сам дозванивается до пира и держит соединение), допустимый (соединение разрешено, но не устанавливается автоматически) и запрещающий (соединения с пиром отклоняются, в том числе если пир забанен). При периодической проверке связности менеджер соединений сам дозванивается до всех пиров с высоким аффинити, которые ещё не подключены и не находятся в задержке перед повтором.
Уровень аффинити задают сами подсистемы, которым нужна связь с конкретным пиром: они удерживают запись подпиской, а удержание реализовано счётчиком. Пока счётчик пуст, аффинити допустимое. Как только на запись оформлена хотя бы одна подписка, аффинити становится высоким. Когда отпущена последняя подписка на не забаненного пира, его запись удаляется из реестра вовсе. Бан задаётся особым значением того же счётчика удержаний, а не отдельным уровнем аффинити: вызванный им запрещающий уровень переживает такие обновления записи. Контактная информация в записи обновляется только на более свежую по времени публикации.
Контактная информация узла
Контактная информация узла — это подписанная запись из идентификатора узла, списка его адресов, окна действия по времени и подписи ed25519. Список адресов содержит от одного до четырёх адресов. Проверка записи целиком включает четыре условия: время публикации не в будущем, с секундной погрешностью на рассинхронизацию часов, срок действия ещё не истёк, список адресов непуст и подпись верна для указанного идентификатора. Ключ для проверки подписи здесь брать неоткуда, кроме самого идентификатора: идентификатор узла и есть его публичный ключ, так что запись проверяется без обращения к какому-либо внешнему реестру.
Это единая форма, в которой узлы предъявляют свои контакты через DHT и при прямом подключении к уже известному пиру. Она сериализуется для передачи по сети и в JSON для человекочитаемого вывода.
Каждый адрес в списке — это либо IPv4- или IPv6-адрес с портом, либо DNS-имя с портом. DNS-имя проверяется по стандартному формату доменных имён и не может само быть IP-адресом. При исходящем подключении используется только первый адрес из списка: в версии 0.3.11 перебор остальных при неудаче не реализован.
Конфигурация
Часть настроек сетевого слоя не относится ни к DHT, ни к резолверу узлов, ни к оверлеям, а управляет именно транспортом: соединениями, кадрами и самим QUIC-объектом. Эти настройки живут в секции network локального конфига узла, рядом с dht, peer_resolver и overlay, и тоже целиком опциональны — при отсутствии секции или отдельного поля применяется значение по умолчанию.
| Параметр | По умолчанию | Что задаёт |
|---|---|---|
quic | null | Настройки QUIC-транспорта (таблица ниже) либо null — тогда действуют значения по умолчанию для всех вложенных полей |
connection_manager_channel_capacity | 128 | Глубина очереди команд менеджеру соединений: подключиться к пиру, завершить работу |
connectivity_check_interval | "5s" | Период проверки связности — дозвона до пиров с высоким аффинити |
max_frame_size | "8 MiB" | Максимальный размер одного кадра запроса или ответа |
connect_timeout | "10s" | Таймаут установления одного соединения, входящего и исходящего |
connection_backoff | "10s" | Начальная задержка перед повтором неудачного исходящего соединения |
max_connection_backoff | "1m" | Верхняя граница задержки повтора при экспоненциальном росте |
connection_error_delay | "3s" | Отсрочка перед обработкой ошибки уже закрытого соединения |
max_concurrent_outstanding_connections | 100 | Лимит одновременных проактивных дозвонов при проверке связности; на входящие соединения и явные запросы подключения не действует |
max_concurrent_connections | null | Порог общего числа активных пиров, при достижении которого новые входящие соединения от пиров, не известных узлу, отклоняются; известных пиров и исходящие соединения не ограничивает, null — порога нет |
active_peers_event_channel_capacity | 128 | Глубина очереди событий о появлении и потере пира в реестре активных соединений |
max_concurrent_requests_per_peer | 128 | Сколько запросов от одного пира обрабатывается одновременно |
shutdown_idle_timeout | "1m" | Таймаут последнего шага остановки узла — ожидания, что уже закрытые соединения освободят QUIC-эндпоинт; истечение не прерывает остановку, только пишет предупреждение |
enable_0rtt | false | Включает приём данных по режиму 0-RTT для входящих соединений |
connection_metrics | null | Уровень детализации метрик по отдельным соединениям — "Brief" или "Detailed", null — не собираются |
Узел печатает max_frame_size как "8.4 MB" — при сериализации он использует десятичные приставки. Обратно эта строка читается как 8 400 000 байт, а не как исходные 8 388 608, поэтому в таблице стоит другая запись, "8 MiB" (то же самое можно записать как 8388608): именно она при обратном чтении восстанавливает настоящий дефолт.
Поле quic — не отдельная секция локального конфига узла наравне с network, dht, peer_resolver и overlay, а вложенный объект внутри network. Все двенадцать полей QUIC-транспорта живут под ключом network.quic.
| Параметр | По умолчанию | Что задаёт |
|---|---|---|
max_concurrent_bidi_streams | 100 | Сколько двунаправленных потоков (запросов) может быть открыто одновременно в одном соединении |
max_concurrent_uni_streams | 100 | Сколько однонаправленных потоков (сообщений) может быть открыто одновременно в одном соединении |
stream_receive_window | null | Окно приёма для одного потока, null — авто |
receive_window | null | Окно приёма для соединения целиком, null — авто |
send_window | null | Окно отправки для соединения целиком, null — авто |
send_fairness | true | Честная очередь потоков равного приоритета при отправке |
enable_segmentation_offload | true | Generic Segmentation Offload (GSO) при отправке |
socket_send_buffer_size | null | Размер буфера отправки UDP-сокета, null — авто (на Linux берётся максимум из настроек ядра) |
socket_recv_buffer_size | null | Размер буфера приёма UDP-сокета, null — авто (на Linux берётся максимум из настроек ядра) |
use_pmtu | true | Обнаружение оптимального размера пакета (PMTU) |
initial_mtu | null | Начальный размер MTU, null — авто |
congestion_algorithm | "Bbr" | Алгоритм управления перегрузкой: "Cubic", "Bbr" или "NewReno" |
Оверлеи
Публичные и приватные оверлеи Tycho — логические подсети поверх общего транспорта, их членство, обмен и обнаружение участников через DHT.
Клиент оверлея
Клиент публичного оверлея Tycho — как он подбирает и оценивает соседей для запросов, наказывает их за нарушения и отдельно отслеживает валидаторов как цели широковещания.