
Протокол MPTCP (Multipath TCP) устроен довольно сложно. Главным образом это так из-за того, что он должен нормально работать в интернете, где промежуточные устройства (middlebox), такие как NAT, файрволы, IDS или прокси, способны модифицировать части TCP-пакетов. Если что-то помешает нормальной работе MPTCP, то, в худшем случае, MPTCP-соединение должно иметь возможность «откатиться» к резервному варианту — к «обычному» TCP. В наши дни подобные «откаты» случаются реже, чем прежде. Вероятно — это так из-за того, что MPTCP используется во всём мире с 2013 года на миллионах смартфонах Apple. Но проблемы с передачей MPTCP-трафика всё ещё возможны. Например — в некоторых мобильных сетях, в которых применяются PEP (Performance Enhancing Proxy, прокси-сервер, предназначенный для улучшения производительности сетевых соединений), где MPTCP-соединения не могут обойти эти прокси-серверы, не подвергнувшись их воздействию. В подобных случаях можно продолжать пользоваться MPTCP и его полезными возможностями, прибегнув к туннелированию MPTCP-соединений. Тут существуют разные решения, но обычно они добавляют в систему дополнительные уровни абстракции и требуют настройки VPN (Virtual Private Network, виртуальная частная сеть) с применением частных IP-адресов между клиентом и сервером.
Здесь вашему вниманию предлагается решение этой проблемы, которое устроено проще, чем остальные: TCP-in-UDP (встраивание TCP-пакетов в UDP-датаграммы). Это решение основано на eBPF, оно не добавляет к пакетам дополнительных данных и не требует использования VPN.
Сразу отмечу, что если в используемой вами сети блокируются TCP-расширения вроде MPTCP, или другие протоколы — лучшее, что можно сделать — связаться с оператором. Возможно, он просто не знает о существовании проблемы и способен легко её исправить.
TCP-in-UDP
Существует множество решений для туннелирования трафика, но все они направлены на удовлетворение других потребностей, отличающихся от тех, на которые ориентирована технология TCP-in-UDP. Например это — обеспечение доступа к частным сетям, в ряде случаев — с шифрованием. Среди таких решений можно отметить OpenVPN, IPSec, WireGuard. Ещё туннели используются для добавления в пакеты дополнительной информации, позволяющей решать задачи маршрутизации. Эти задачи решают, кроме прочих, такие протоколы как GRE и GENEVE. Ядро Linux поддерживает многие из этих туннелей. В нашем случае цель заключается не в том, чтобы работать с частными сетями, и не в том, чтобы оснастить соединение дополнительным уровнем шифрования. Наша цель — сделать так, чтобы пакеты не модифицировались бы при прохождении по сети.
Получается, что нам достаточно «конвертировать TCP-пакеты в UDP». Именно этим и занимается технология TCP-in-UDP. Причём, эта идея не нова, она навеяна одним старым черновиком стандарта IETF. Суть её работы можно описать так: элементы заголовка TCP перегруппируются таким образом, чтобы в начало заголовка попали бы элементы, характерные для заголовка UDP.
Преобразование заголовка TCP в заголовок UDP
Для того чтобы лучше разобраться в том, как осуществляется вышеописанное преобразование — посмотрим на то, как выглядят разные заголовки.
UDP:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length | Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
TCP:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |C|E|U|A|P|R|S|F| |
| Offset| Reser |R|C|R|C|S|S|Y|I| Window |
| | |W|E|G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (Optional) Options |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length | Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |C|E| |A|P|R|S|F| |
| Offset| Reser |R|C|0|C|S|S|Y|I| Window |
| | |W|E| |K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (Optional) Options |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Первые восемь байтов заголовка TCP-in-UDP, как описано здесь, соответствуют классическому UDP-заголовку. Затем в заголовке идёт поле Data Offset
с флагами и поле Window
. Размещение поля Data Offset
после поля Checksum
обеспечивает то, что там будет значение, превышающее 0x5
, а это необходимо для обхода STUN. Дальше идут поля Sequence Number
и Acknowledgment Number
. После такого преобразования в заголовке TCP меняется порядок следования полей, он начинается с полей заголовка UDP, но при этом его длина не меняется. Читатель, ориентирующийся в этой теме, вероятно, заметит исчезновение флага URG
и поля Urgent Pointer
. Дело в том, что это поле используется редко, и некоторые промежуточные устройства его сбрасывают. Для большинства TCP-приложений это — несущественная потеря.
Другими словами, если сравнивать пакеты TCP и TCP-in-UDP, то они, помимо другого порядка полей, отличаются следующими двумя модификациями:
В заголовке пакета протокола 3 уровня (IPv4/IPv6) теперь указывается новый протокол 4 уровня (UDP вместо TCP).
Поле
Urgent Pointer
меняется наLength
(и наоборот).
Эти две модификации, конечно, повлияют на поле контрольной суммы (Checksum
), содержимое которого надо привести к актуальному состоянию.
Разбираемся с оптимизациями сетевого стека
На бумаге необходимые модификации выглядят просто: поменять название протокола, 16-битное слово и пересчитать контрольную сумму. Всё это должно легко делаться с помощью eBPF-программ, связанных с входными (ingress) и выходными (egress) TC-хуками. Правда, реализация этого всего в высокооптимизированном сетевом стеке — это уже задача, сложность которой несколько выше ожидаемой.
Доступ ко всем необходимым данным
В Linux все данные пакетов хранятся в буфере сокетов — в так называемом SKB. В нашем случае eBPF-коду нужно обращаться к заголовку пакета, который обычно находится между skb->data
и skb->data_end
. Однако же skb->data_end
не всегда указывает на конец пакета. Обычно этот указатель соответствует адресу конца заголовка пакета. Это — одна из оптимизаций сетевого стека, так как ядро часто выполняет различные операции, результат которых зависит от заголовка пакета. Ядро не особо интересуется данными, которые имеются в пакете, так как они, чаще всего, обрабатываются в пространстве пользователя или перенаправляются на другой сетевой интерфейс.
В нашем случае, при обработке исходящего трафика — при переходе от TCP к UDP — это выглядит вполне нормальным. Нам доступен весь заголовок TCP, а именно его и нужно модифицировать. При обработке входящего трафика — при переходе от UDP к TCP — всё уже немного иначе. Некоторые сетевые драйверы выравнивают данные только до конца заголовка протокола 4-го уровня — до 8 байтов заголовка UDP. Этого недостаточно для осуществления перехода от UDP к TCP, так как для этого нужен доступ ещё к 12 байтам. Но эту проблему несложно решить: уже давно существуют вспомогательные функции eBPF, позволяющие получать данные из буферов, разбитых на фрагменты. Например — это bpf_skb_pull_data или bpf_skb_load_bytes.
GRO и TSO/GSO
Размер пакетов в интернете обычно не превышает 1500 байтов. При этом в каждом пакете должны содержаться некие заголовочные данные, указывающие на адреса источника и приёмника данных. Так же в пакетах имеется и сопутствующая информация — вроде номера последовательности данных. Необходимость работы с «маленькими» пакетами означает дополнительную нагрузку на системы, которая может оказаться довольно высокой на больших скоростях передачи данных. Для того чтобы это компенсировать, сетевой стек Linux предпочитает работать с более крупными блоками данных, используя «внутренние» пакеты, размеры которых составляют десятки килобайт, позже разделяя их на более мелкие пакеты с очень похожими заголовками. Некоторые сетевые устройства даже способны выполнять такую вот сегментацию или агрегацию данных на аппаратном уровне. Именно для этого существуют такие технологии, как GRO (Generic Receive Offload) и TSO (TCP Segmentation Offload) / GSO (Generic Segmentation Offload).
При использовании технологии TCP-in-UDP необходимо работать на уровне каждого отдельного пакета. А именно — каждый TCP-пакет преобразуется в UDP-пакет, который будет содержать UDP-заголовок (8 байтов) и остальные заголовки TCP (12 байтов + опции TCP). После этого идут данные, передаваемые в TCP-пакете. Другими словами — данные каждого UDP-пакета будут содержать часть заголовка TCP, уникальную для каждого пакета. А значит — традиционные механизмы GRO и TSO тут использовать не получится, так как данные одного пакета нельзя, как в обычных условиях, просто «объединить» с данными другого пакета.
Эрудированный читатель способен заметить, что вышеописанные возможности сетевых устройств можно легко отключить, воспользовавшись ethtool
. Например — так:
ethtool -K "${IFACE}" gro off gso off tso off
Это правильная идея, но даже если отключить все аппаратные возможности, связанные с сегментацией пакетов, при обработке исходящего трафика сетевой стек Linux, всё равно, заинтересован в работе с более крупными пакетами, поэтому сегментация будет выполняться программно. С помощью eBPF не так уж и просто повлиять на то, как именно будет выполняться сегментация. Поэтому необходимо сообщить стеку о том, чтобы он не применял бы вышеописанную оптимизацию. Сделать это, например, можно так:
ip link set "${IFACE}" gso_max_segs 1
Контрольная сумма
Это, определённо — самая неприятная проблема из всех, которые мы тут решаем!
Учитывая то, как именно вычисляется контрольная сумма, перемещение 16-битных или более крупных фрагментов данных её не меняет. Но при этом для вычисления корректной контрольной суммы нужно использовать новые значения некоторых полей. А именно — выше, в разделе «Преобразование заголовка TCP в заголовок UDP», мы говорили о полях, которые повлияют на вычисление актуальной контрольной суммы.
Нет необходимости пересчитывать всю контрольную сумму. Это можно сделать инкрементально, и некоторые вспомогательные функции eBPF могут нам в этом помочь. Это, например — bpf_l3_csum_replace и bpf_l4_csum_replace.
При тестировании TCP-in-UDP с использованием сетевых пространств имён (netns
), когда один хост выделили для трансляции при перенаправлении пакетов, всё работало как надо. А именно — в каждом пакете можно было видеть правильную контрольную сумму. Но при тестировании на реальном «железе», когдаeBPF-хуки TCP-in-UDP размещались прямо на клиенте и на сервере, ситуация изменилась. Контрольные суммы в исходящем трафике на большинстве сетевых интерфейсов были некорректными, причём — даже в тех случаях, когда аппаратное вычисление контрольной суммы (TX Checksum Offload) на интерфейсе было отключено.
После довольно серьёзного исследования этой проблемы оказалось, что eBPF-хуки правильно обновляли контрольные суммы протоколов 3 и 4 уровня, но либо сетевой адаптер, либо сетевой стек модифицировали контрольную сумму протокола 4 уровня в неправильном месте. Этот факт нуждается в пояснениях.
При обработке исходящего трафика сетевой стек TCP в Linux-системе, отправляющей данные, обычно устанавливает kb->ip_summed
в значение CHECKSUM_PARTIAL
. Смысл тут в том, что при таких настройках стек TCP/IP будет вычислять лишь часть контрольной суммы, затрагивающую псевдозаголовок: в него входят IP-адреса, номер протокола и длина. А оставшаяся часть контрольной суммы будет вычисляться позже, в идеале — делать это будет сетевое устройство. На этой вот последней стадии работы устройству нужно знать лишь о том, где именно в пакете начинаются данные, касающиеся протокола 4 уровня, и о том, где, относительно начала этих данных, находится поле контрольной суммы. Эти сведения регистрируются во внутреннем поле skb->csum_offset
, и они, при работе с заголовками TCP и UDP, отличаются друг от друга, так как поле контрольной суммы в этих заголовках расположено в разных местах.
В результате получается, что при переходе от UDP к TCP недостаточно изменить номер протокола в материалах протокола 3 уровня. Нужно обновить и внутреннее значение смещения поля контрольной суммы. Если я не ошибаюсь — в сегодняшних условиях это значение с помощью eBPF изменить нельзя. Адекватное решение этой проблемы, естественно, заключается в создании новой вспомогательной функции eBPF и в добавлении её в ядро. Но работать это будет только на новых ядрах Linux, или, в конце концов, это заработает с использованием кастомного модуля. Вместо всего этого был найден обходной путь. Он заключается в объединении eBPF-хука и действия ACT_SUM
при обработке исходящего трафика, когда осуществляется преобразование пакетов из TCP в UDP. Это действие csum вызывает программный процесс повторного расчёта контрольной суммы для заданных заголовков пакета. Другими словами — в нашем случае этот механизм используется для вычисления оставшейся части контрольной суммы данных заданного протокола (UDP) и для установки отметки о том, что контрольная сумма вычислена (CHECKSUM_NONE
). Это последний шаг весьма важен. Даже если технически возможно вычислить полную контрольную сумму с помощью eBPF-кода, как мы и делали, вычислять её нет смысла в том случае, если нельзя изменить флаг CHECKSUM_PARTIAL
. При его неправильной установке окажется, что мы ожидаем, что контрольная сумма будет пересчитана позже, с использованием остальных данных, но сделано это будет с применением некорректного смещения.
В результате, прибегнув к комбинации TC ACT_CSUM
и eBPF можно получить правильную контрольную сумму после изменения протокола 4 уровня.
MTU/MSS
MTU (Maximum Transmission Unit, Максимальная единица передачи) и MSS (Maximum Segment Size, Максимальный размер сегмента) не связаны с высокооптимизированным сетевым стеком Linux, но в настоящей сети пакеты — это именно UDP-пакеты, а не TCP-пакеты. Это значит, что здесь не будут работать некоторые механизмы, вроде динамического согласования MSS (TCP Maximum Segment Size), известного как MSS Clamping (принудительное ограничение размера сегмента). Во многих мобильных сетях используется инкапсуляция без джамбо-фреймов, то есть — максимальный размер фрейма не превышает 1500 байтов. Из соображений, касающихся производительности, и для того, чтобы с этим не сталкиваться, важно избегать IP-фрагментации. Другими словами — может понадобиться настроить на интерфейсе параметр MTU, или параметры MTU/MSS, соответствующие особенностям конкретных получателей данных.
Итоги
В итоге можно сказать, что описанную здесь новую eBPF-программу легко можно развернуть на клиенте и на сервере, воспользовавшись ей для обхода промежуточных устройств, которые всё ещё блокируют MPTCP или другие протоколы. Для того, чтобы её попробовать, вам достаточно будет модифицировать порт назначения, так как в настоящий момент он жёстко задан в коде.
О, а приходите к нам работать? ? ?
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
V-King
Всё бы ничего, но провайдеры не любят UDP трафик. Чаще приходилось решать обратную задачу - упаковывать UDP в TCP, чтобы протолкнуться через прова.