- PVSM.RU - https://www.pvsm.ru -

Ограничение скорости скачивания пользователей в Xray-core

Кто тут любит коммерческие прокси-сервисы? Я не вижу ваши руки? Да, я тоже не особый фанат. Но приходится признавать, что как только домашний Xray [1] с вручную вписанными в JSON ключами выходит за пределы узкого круга непосредственных родственников, приходится разбираться в том же спектре проблем: гео-блоки со стороны сервисов, маршрутизация, слив и слишком обширное использование подписки, из-за чего пользователи начинают мешать друг-другу, а машины блокируются по превышению квоты.

Торопитесь? Вот код [2]

Ограничение скорости скачивания пользователей в Xray-core - 1

HWID — театр безопасности

Думаю, вы уже знаете, что такое подписка. Если нет — это обычный HTTP-эндпоинт, который по GET-запросу выдаёт набор URI или JSON-объектов. Эти объекты и ссылки представляют собой параметры и идентификаторы для подключения к определённому Xray-серверу, и клиенты рисуют их как отдельные локации. Учитывая это, классическое решение против излишнего распространения ключей, поощряемое крупными панелями и отдельными клиентами это Device Info Headers Specification [3] — набор заголовков, которые клиенты высылают серверу в GET-запросе на получение подписки. Там модель, операционная система, версия, а также HWID — псевдослучайный набор символов, генерируемый при установке приложения и призванный идентифицировать конкретное устройство. Нет HWID — панель не выдаёт подписку. Ну и далее подсчёт HWID, ограничение на количество HWID на одну подписку, статистика и иже с ними. Популярные клиенты также поддерживают дополнительные серверные заголовки для симметричного шифрования и ограничения копирования отдельных объектов внутри подписки.

> curl -s --header "User-Agent: Happ" -L https://foo.example.com/sub/IygXmxsMTrV0 | base64 -d

vless://db4ce498-527c-4801-9311-95a7726e96cf@bar.example.com:8443?mode=stream-one&path=%2F&security=tls&alpn=h2&encryption=none&flow=xtls-rprx-vision&fp=chrome&type=tcp&sni=dsapi-v2-6ab3b2eb.durov.ru#Test1
vless://db4ce498-527c-4801-9311-95a7726e96cf@baz.example.com:8443?mode=stream-one&path=%2F&security=tls&alpn=h2&encryption=none&flow=xtls-rprx-vision&fp=chrome&type=tcp&sni=example.durov.ru#Test2

# Вот так выглядит URI формат

У такого подхода есть довольно много недостатков. Правда в том, что аутентификация и авторизация enterprise-уровня никогда не были приоритетом v2ray и его поздних форков, рассчитанных на маленькие сервера для пары человек. Никто не думал про сессионные ключи, SSO, аутентификацию по сертификатам. Да что там — аутентификация по паре логин-пароль, позволяющая отделить стабильный идентификатор пользователя (логин) от его токена (пароль), появилась сильно позже, в ядре Hysteria, а типичный VLESS или Shadowsocks использует один UUID для обеих задач по сей день.

Сценарий Device Info Headers как-то помогает только от неумышленного и массового распространения подписки, а во всех умышленных случаях ничего абсолютно не мешает пользователю, который делится подпиской, также поделиться своим HWID или непосредственными объектами внутри своей подписки. Шифрование симметричное, и ключ тривиально вытаскивается из клиента, ограничения копирования обходятся средствами дебага на ОС или перехватом трафика, вся затея в целом рассчитана работать против технически некомпетентного пользователя. Старый добрый snake oil. Я даже возиться не стал и вам не рекомендую.

Дёгтя в бочку также добавляет стойкое нежелание разработчиков Xray-core исправлять ситуацию: пулл-реквесты проекта пестрят разнообразными попытками разобраться с проблемой, включая ограничения по IP, переписывание валидатора на разные лады, модульную аутентификацию — все такие PR закрываются отмашкой о том, что Xray-core не рассчитан на «коммерческое использование». Разумеется, ни о каком конфликте интересов не может быть и речи, а то, что проект спонсируется самой крупной коммерческой панелью для нод Xray, никого не смущает.

При этом проект действительно обширный и быстроразвивающийся, из-за чего у меня тоже не возникало стойкого желания поддерживать форк с моими изменениями единолично до скончания веков. Пришлось призадуматься.

Почему я за шейпинг

Концептуально, прокси-сервера в нынешней реальности — это те же интернет-провайдеры, но на один уровень виртуализации выше. И многое из того, что решает проблемы интернет-провайдеров, применимо и для прокси-серверов. Но давайте побрейнстормим, покидаем идей.

Наивный подход: сделать белый список IP-адресов, которые могут подключаться к серверу. Но тогда придётся содержать его руками и закидывать адреса целыми CGNAT-подсетями провайдеров, что спустя некоторое время сделает этот белый список достаточно большим, чтобы нивелировать его ограничивающий эффект. Игра не стоит свеч.

Можно считать количество запросов за единицу времени. Звучит разумно, особенно с учётом того, что в случае Xray-core основную нагрузку на процессор создаёт не сам процесс шифрования, который, как правило, вполне неплохо ускоряется железом, а церемониал установления и разрыва подключений. Меньше RPS — меньше нагрузки. Очевидное решение — читать лог-файл, считать запросы на голову и потом… а что потом, банить? Я пробовал.

Во-первых, самый распространённый и живучий протокол на сегодня — это VLESS, и у него есть очень интересная модель аутентификации — в классическом режиме raw (в бытность tcp) аутентифицируется TCP-соединение, а не каждый отдельный поступающий запрос/сегмент/пакет. В момент хэндшейка клиент один раз показывает свой UUID, и, если он находится в MemoryMap инбаунда, то соединению даётся ход, и дальнейшие запросы и ответы, выходящие в логе, заворачиваются уже внутрь этого соединения.

Как следствие, если резко убрать пользователя из инбаунда, то это предотвратит установление новых соединений, но не разорвёт существующие — активное соединение, в котором постоянно шурует трафик продолжит ничтоже сумняшеся жить ещё некоторое время, в чём вы лично убедитесь, когда обнаружите на одном UUID 75 тысяч IP, геолоцируемых как Иранская Исламская Республика. Костыли от панелей существуют [4], но не исправляют ситуацию в целом.

Во-вторых, всех легитимных паттернов использования вы не завайтлистите. Есть ресурсы и машины, которые просто делают кучу коротких запросов. Телеметрия, реклама, проверка обновлений и прочая, и прочая.

В-третьих, даже если игнорировать предыдущие накладки, какой смысл резать по соединениям, если одна вполне легитимная закачка из Steam может забить собой весь канал и попросить добавки? Допустим, на интерфейсе на сервере есть fq_codel, но, реалистично, сервер «для себя и родных» живёт на виртуалке за 500 рублей с ненулевым cpu steal, который вырастет в несколько раз от резкой нагрузки. Или вы прислонитесь лбом в квоту по трафику.

Лимиты на общее количество трафика а-ля мобильный оператор — это уже нормальный, рабочий подход, но ставит пользователя с утёкшей ссылкой в неудобное положение, т.к. злоумышленнику ничего не стоит за пару минут выжать весь трафик, пользователь уходит в блок и теперь даже отписать вам не сможет, ну разве что в мессенджере Макс.

И, наконец, можно резать скорость. Плюсы очевидны — и с точки зрения сервера, и с точки зрения клиента канал становится считаемым и предсказуемым в определённой степени, а при умышленных и неумышленных нарушениях, доступ не отключается полностью — с правильно настроенным QoS даже при забитой полосе пропускания можно достучаться до мессенджера и дать знать о проблеме или ротировать ссылку доступа.

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

Никакой помощи в этом доме

Я не первый, кто до этого додумался — раз [5], два [6], три [7], четыре [8], пять [9]. Увы, в основном безрезультатно. Да, на некоторых UDP-транспортах вроде mkcp или hysteria можно ставить лимиты на буфер приёма-передачи, через congestion control можно попытаться резать скорость со стороны клиента, но тут очень много нюансов (к которым мы вернёмся), а клиентские ограничения не работают против злого умысла, это мы уже выяснили.

Более перспективно выглядит вариант с HTB в качестве qdisc, чтобы резать скорость на каждый IP, однако это не решит проблему 75 тысяч граждан с разными IP на одном ключе, но добавит горя пользователям под CGNAT либо же вашим коллегам/соседям, если вы все работаете в одном офисе и сидите за одним белым IP. Также при наивном использовании можно порезать скорость не только в направлении клиента, но и от Xray в сторону интернета, что обернётся весёлыми последствиями вроде сломанного каскада или тормозящих ютубов/тиктоков — надо либо ставить fwmark и отфильтровывать, либо же брать второй внешний айпи и фильтровать по target ip или target port на файрволле. Нюансов очень много и риск напороться на них велик — я уже говорил, что qdisc HTB поддерживает лишь 65 тысяч горизонтальных бакетов трафика, а всё, что не влезет, пойдёт в общую бочку? А IPv6? Вот то-то.

Корень проблемы в том, что Linux не знает, что то или иное соединение принадлежит тому или иному пользователю VLESS — модуль ядра conntrack видит только то, что кто-то постучался по TCP на порт 443 и процесс Xray согласился его принять, а UUID и пользователи как абстракция появляются уже внутри TLS-туннеля, на два уровня OSI выше. Наивная интуиция подсказывает, что в промежутке можно поставить веб-сервер, который расшифрует TLS и использует UUID как ключ для троттла, но VLESS в режиме RAW на то и RAW, что у него есть очень тонкий wire-format [10], и это не HTTP-трафик, с которым привыкли работать веб-серверы. Даже если получится (и да: с XHTTP так можно) — так невыгодно делать из-за splice.

Лирическое отступление про splice

VLESS — это прокси-протокол. Вы посылаете данные серверу, а он, от своего имени, дальше. От маршрутизации это фундаментально отличается тем, что входящий и исходящий пакеты неизбежно разные, как минимум за счёт того, что target ip разный. Минимально возможный способ добиться от сервера отправки пакета дальше от своего имени это NAT: берётся входящий пакет, переписываются source ip, source port, target ip и target port, отправляется весело дальше — и типичный гайд по настройке какого-нибудь WireGuard в определённый момент научит вас именно этому.

Но это означает, что полезная нагрузка IP-пакета для нас закрыта — мы лишь переделываем заголовки, никакие иные действия с трафиком для нас недоступны — нельзя аутентифицировать пакеты, нельзя роутить трафик для разных сайтов по-разному и абсолютно точно нельзя убедительно маскировать трафик как что-то иное. Это всё требует от нас взаимодействия на уровнях выше. Поэтому обычный прокси берёт входящий трафик, анализирует его, а на выход выпускает уже 100% свои пакеты от своего имени и со своего адреса, копируя, где нужно, содержимое.

Почему это важно? Потому что сокеты живут в ядре Linux, а гошный код Xray живёт в юзерспейсе — и каждый раз, как мы пересекаем эту границу, надо копировать содержимое из кернелспейса в юзерспейс, а потом, на выходе, наоборот. Наивный прокси (не путать с naiveproxy) так и выглядит: read(fdA, buf, N) копирует из ядра в юзерспейс, дальше какая-то логика, а в конце write(fdB, buf, N) записывает из буфера в юзерспейсе в сокет, и Linux отправляет данные в путь.

Во-первых, это много системных вызовов. Во-вторых, надо резервировать память под операции копирования. И, разумеется, делать это надо дважды, т.к. ответ, полученный от веб-ресурса, надо отправить обратно пользователю.

Ограничение скорости скачивания пользователей в Xray-core - 2

Системный вызов splice [11] элегантно решает эту проблему:

splice(fd_A, NULL, pipe_write, NULL, N, flags)   // сокет → пайп буфер в ядре
splice(pipe_read, NULL, fd_B, NULL, N, flags)    // наоборот

Из-за того, что в обоих случаях данные не пересекают границу между юзер- и кернелспейсом, а пайп — это просто указатель на страницы в ядре, никакого физического перемещения байтов не происходит (cм. man, есть нюансы), и memcpy нам становится не нужен. Замечу, что сам Xray явно splice не вызывает — его вызывает стандартная библиотека Go, но у Xray есть ручка управления:

    // CanSpliceCopy is a property for this connection
    // 1 = can, 2 = after processing protocol info should be able to, 3 = cannot
    CanSpliceCopy int

3 нужен для большинства не-VLESS протоколов, где без копирования байтов в юзерспейс не обойтись — vmess, shadowsocks, wireguard, даже tun, а вот 2 интереснее — он нужен для "flow": "xtls-rprx-vision" — внутренний VLESS-хэндшейк обрабатывается копированием, а потом CanSpliceCopy переключается на 1, и дальше трафик идёт через сплайс. Разумеется, всё это подразумевает транспорт в значении "raw".

Да, область применения специфичная, но VLESS RAW Reality/TLS всё ещё очень популярен, и правильно настроенный сервер на моей практике может потянуть до 25 тысяч параллельных клиентов, поэтому грамотное использование сплайса важно для больших или ограниченных по ресурсам серверов.

SO_MAX_PACING_RATE

Итак, ломать splice — дорого, влезать каким-нибудь userspace софтом между линуксовым сокетом и инбаундом точно так же означает несколько операций копирования, плюс у нас всё ещё есть желание не менять код Xray без надобности и ограничение — мы хотим лимиты именно на одного пользователя. Выглядит сложно.

Ограничение скорости скачивания пользователей в Xray-core - 3

Для начала, оставим идентификацию трафика того или иного пользователя в сторону и займёмся торможением. Фундаментально, «порезать скорость» можно реализовать одним из трёх способов:

  • тормозим отправителя по достижении предела

  • буферизируем то, что вылезло за предел

  • выбрасываем то, что вылезло за предел

SO_MAX_PACING_RATE это первый вариант — он ничего не выбрасывает, а лишь растягивает во времени отправку. Он отрабатывает глубоко внутри ядра, задолго до драйвера и сетевой карты. Проследим путь данных, чтобы понять механизм:

  1. Xray получает в распоряжение сокет и хочет отправить данные через conn.Write, для чего вызывает системный вызов send()

  2. TCP нарезает данные на сегменты

  3. Надо решить, какие сегменты когда отправить — и тут в дело вступает pacing — если на сокете есть SO_MAX_PACING_RATE, то сегментам проставляется Earliest Departure Time (EDT), потолок которого зависит от SO_MAX_PACING_RATE

  4. Сегмент отправляется дальше и в какой-то момент энфорсится EDT — если qdisc умеет (а fq умеет), то им, а если нет, то внутренним hrtimer таймером TCP — да, эта часть TCP-специфична, если на интефрейсе не fq, то TCP запейсится, а UDP — нет. Впрочем, эта статья фокусируется на TCP, UDP это отдельная история.

Хорош этот механизм за счёт того, что по превышении лимита давление в qdisc в конечном итоге отдаёт выше, на Xray: TCP Small Queues не даёт одному сокету забить очередь и буфер драйвера, поэтому лишнее копится в буфере отправки сокета, который с другой стороны опустошают по SO_MAX_PACING_RATE. И по заполнении conn.Write начинает висеть. Это бы было проблемой для однопоточного синхронного приложения, но в Xray это горутина, поэтому зависание там идёт в netpoller и не мешает остальным сокетам работать. В какой-то момент в буфере появляется место, send() отрабатывает и данные снова идут дальше.

Короче говоря, SO_MAX_PACING_RATE обеспечивает равномерную отправку данных и контрит резкие всплески, переполняющие буфер в узком месте. Переполнение означает потерю сегментов, а потеря — сигнал для congestion control срезать окно передачи (скинуть скорость): на 30% у CUBIC, на 50% Reno, что ощущается как подлагивание. Ни драйвер, ни сетевая карта при этом не в курсе, что троттлинг вообще происходит: до них доходят уже ровненько разложенные во времени байты.

И добивающий: SO_MAX_PACING_RATE можно навесить, не меняя код Xray вообще. Это очень легко, на самом деле:

{
  "inbound": {
    "port": 443,
    "protocol": "vless",
    "settings": {...},
    "streamSettings": {
      "network": "raw",
      "sockopt": {
        "customSockopt": [
          {
            "level": "1",           // SOL_SOCKET
            "opt": "47",            // SO_MAX_PACING_RATE
            "type": "int",
            "value": "10485760",    // 10 мегабит
            "network": "tcp"        // Применяем только к TCP
          }
        ]
      }
    }
  }
}

Бинго — теперь каждый входящий клиентский коннект троттлится по 10 мбит, так как дочерние сокеты, созданные через системный вызов accept(), копируют структуру родительского, включая опции сокета. Статья окончена, всем спасибо, расходимся.

Почему этого недостаточно

Потому что все плюсы SO_MAX_PACING_RATE упираются в один неприятный факт — он наследуется всеми дочерними сокетами основного. Соответственно, лимит, навешанный как в примере выше, применяется ко всем входящим на инбаунд соединениям и троттлит каждое по 10 мбит/с. И это бы идеально нам подходило, если бы мы могли гарантировать, что на клиента у нас будет ровно одно долгоживущее соединение. А мы не можем.

Во-первых, «ровно одно долгоживущее соединение» — это один из маркеров прокси. Поэтому Xray в обычном режиме raw без Mux.Cool открывает по TCP-соединению на каждое новое подключение со стороны клиента.

Во-вторых, подписочная модель раздачи конфигов означает, что к ней могут и будут подключаться с разных устройств — мы всё это подробно обсуждали выше. Если даже мы замуксим все соединения клиента в одно, чтобы резать каждое по 10 мбит/с, а потом кто-то решит распространить свою подписку на несколько сотен тысяч пользователей, у каждого из них будет отдельное соединение, и общий лимит всё равно перевалит все разумные и неразумные значения. Почти все минусы подхода «резать скорость по входным айпи» применимы также тут. Нам нужно троттлить UUID-ключ — и все клиенты, авторизирующиеся по нему, должны попадать в одно и то же ведро. Деваться некуда, придётся возиться с qdics.

qdisc живёт в конце пайплайна обработки данных: в цепочке он стоит между маршрутизацией и драйвером. К этому моменту мы уже вышли из Xray, прошли сокет, прошли TCP, прошли все хуки файрволла (POSTROUTING отрабатывает до постановки в очередь) — данные уже на платформе и готовы к отправке.

qdisc решает, как эта отправка будет происходить: в каком порядке выпускать пакеты и с какой задержкой. Инструмента у него ровно два — придержать пакет в очереди или выбросить его. Фидбэк до Xray при этом доходит тем же образом, что и с SO_MAX_PACING_RATE: пакеты в очереди числятся за сокетом, упираются в TSQ, дальше заполняется буфер сокета, дальше виснет conn.Write. Разница с pacing в другом: SO_MAX_PACING_RATE растягивает отправку ещё до того, как сегмент ушёл вниз по стеку, и сигнала о перегрузке не создаёт вовсе, а qdisc работает с пакетами, которые уже созданы и уже здесь, — и при переполнении очереди просто дропает, а дроп TCP не отличит от реальной потери в сети.

Но главный минус в том, что Xray и qdics не знают друг про друга — qdisc вообще не в курсе, кто там прислал байты, для него данные это данные, а значит, если мы хотим, чтобы он различал трафик разных пользователей, нам придётся как-то самим ставить метки, а потом настроить qdics по ним работать.

Как вытащить контекст соединения из Xray

План таков: клиентское соединение приходит на инбаунд, случается хэндшейк, узнаём UUID, дальше Xray помечает сокет этого соединения, а потом мы на qdisc ставим общее ограничение на метку. Пять, десять, двадцать соединений — нас не волнует: троттлится по метке, а значит, канал фиксирован — чем дальше ты распространяешь подписку, тем меньший объём из общей трубы достаётся каждому.

Ответом на вопрос «как пометить» является fwmark. Это 32-битное число, которое живёт только внутри ядра: оно не записывается ни в заголовок, ни в тело пакета, по проводу не уезжает и на той стороне просто не существует. Такая внутренняя бирка, по которой потом можно маршрутизировать, фильтровать на файрволле и — что нам и нужно — классифицировать трафик в tc. Если вы когда-то заворачивали WireGuard или AmneziaWG в Xray вручную, то вы уже работали с fwmark, чтобы отделить только что вошедший WG-трафик от выходящего из аутбаунда Xray и избежать закрута данных.

Тут кратко следует сказать, что под капотом `fwmark` это три разные сущности

Метка

Как живёт

Чем ставится

Кто её читает

Пакета (skb->mark)

Ровно столько, сколько сам пакет в ядре

iptables -j MARK, nft meta mark set, tc ... action skbedit mark

Маршрутизация (ip rule fwmark), файрвол и tc filter ... fw

Сокета (sk->sk_mark)

Вместе с сокетом

setsockopt(SO_MARK), в Xray — sockopt.mark

Ядро само копирует её в skb->mark каждого пакета, который породил этот сокет

Соединения (ct mark)

Вместе с записью в conntrack: переживает отдельные пакеты, общая для обоих направлений потока

-j CONNMARK --set-mark / --save-mark, ct mark set в nftables

Напрямую — никто. Пока её явно не перенесли на пакет (-j CONNMARK --restore-mark, ct mark → meta mark), ни маршрутизация, ни tc её не видят

P.S.: что такое skb читать здесь [12]

Нужна, в конечном итоге, только первая опция skb->mark — две другие метки лишь способы её туда проставить: одна автоматически, на каждом пакете сокета, другая вручную, по явному правилу.

Далее очевидным решением является опция для инбаунда, которая берёт на хэндшейке клиентский UUID, записывает в список и потом лепит индекс элемента в качестве fwmark на соединение. И было бы замечательно, если бы в инбаунде была какая-то опция, которая бы реализовывала этот функционал — но её нет и, как я понимаю, не будет. Самое время для костыля.

V2ray и производные известны очень гибкой маршрутизацией: можно фильтровать по доменам, по названию процесса, vlessRoute, айпи на входе, айпи на выходе и другим параметрам. Клиентский UUID тоже видно, по нему можно маршрутизировать, но нельзя ни назначить fwmark, ни вызвать произвольный код — всё, что происходит в маршрутизации, остаётся в маршрутизации. Мы можем только фасовать по аутбаундам и балансировщикам.

В свете чего очевидным костылём является генерация заранее отдельного аутбаунда для каждого пользователя, назначение на них fwmark-ов… что, несмотря на героизм, не решит проблему, ибо fwmark вы поставите не между клиентом и Xray, а между Xray и веб-ресурсом, который клиент запрашивал. Ну, то есть, так можно порезать разве что скорость выгрузки, а не скачивания.

Но недавно лёд тронулся неожиданным образом: в Xray-core замержили пулл-реквест #5722 [13]. Замысел был простой и понятный — когда в маршрутизации обнаруживается bittorrent-трафик, мы можем ключом webhook заставить ядро выдать вебхук, с расчётом на то, что панель обработает его и забанит клиента красиво, с полным уведомлением о нарушении, временем, адресом и так далее. Метаданных действительно немало:

{
  "email": "2",                        // string | null
  "level": null,                       // number | null
  "protocol": "tls",                   // string | null
  "network": "tcp",                    // string
  "source": "tcp:127.0.0.1:54203",     // string | null
  "destination": "tcp:dns.google:443", // string
  "routeTarget": null,                 // string | null
  "originalTarget": "tcp:8.8.8.8:443", // string | null
  "inboundTag": "VLESS_TCP",           // string | null
  "inboundName": "vless",              // string | null
  "inboundLocal": "tcp:192.168.108.1:443", // string | null
  "outboundTag": "NTFY_BLOCK",         // string | null
  "ts": 1771886901                     // number
}

Признаюсь честно, до меня не сразу дошло, насколько это перспективное изменение — я несколько месяцев искал способ сайдэффектнуться из маршрутизации и выдать наружу контекст подключения, пока не понял, что webhook именно этим и занимается — выдаёт контекст проходящего через него подключения.

Соединения в conntrack идентифицируются уникально через пятёрку protocol, source_ip, source_port, target_ip и target_port — и у нас здесь есть вся информация, которая нужна. Пазл сходится таким образом:

Ограничение скорости скачивания пользователей в Xray-core - 4
  1. Ставим правило маршрутизации, которое захватывает весь TCP-трафик и выдаёт webhook. Разумеется, абстрактный — Xray умеет.

  2. Далее находим в conntrack нужный флоу по комбинации 5 параметров и ставим на него mark.

  3. Тут важный момент — conntrack mark и fwmark — это немножечко разные вещи. Оба про контекст, но коннтрековский маркирует соединение, а fwmark маркирует пакеты. К моменту, как мы дошли до qdisc, коннтрековская метка для нас бесполезна, поэтому нам надо переписать коннтрековскую метку на пакет: nft add rule inet speedlimit postrouting meta mark set ct mark.

  4. Параллельно создаём класс в htb и назначаем на него ограничение в сколько нужно мегабит.

  5. Вешаем на класс фильтр по fwmark, и htb начинает его троттлить.

В итоге, путь пакета от сервера к клиенту получается примерно таким:

Ограничение скорости скачивания пользователей в Xray-core - 5

Ограничения подхода

Да, на руках есть код [2], его можно потыкать, он работает, но это скорее PoC, у которого есть и недостатки, о которых следует рассказать.

Подход TCP-специфичный: для управления потоком нам нужно, чтобы L4-протокол понимал концепт соединения. Да, линуксовый коннтрек показывает UDP-флоу тоже, но он, грубо говоря, угадывает по таймаутам, и нам такое не годится. Вопрос с WireGuard отдельно заслуживает исследования — я не знаю наверняка, можно ли надеяться на эвристику conntrack применительно к его трафику.

Есть риск коллизии, если слушать на 0.0.0.0 — вот это обнаружилось уже после деплоя. Условно, если на интерфейсе eth0 забиндены два айпи, например 128.1.1.1 и 128.1.1.2, а инбаунд слушает на 0.0.0.0 или [::], то есть весьма небольшая вероятность, что комбинация из 5 параметров не будет уникальной — например, CGNAT может занатить два разных подключения с одного и того же source-порта и айпи, так как у одного из них destination ip 128.1.1.1, а у другого 128.1.1.2. Из-за того, что слушающему на 0.0.0.0 не видно, с какого айпи на локальном интерфейсе поступил пакет, они будут неотличимы по коннтреку:

source ip (клиент)

source port

destination ip

destination port

188.8.8.8

56111

0.0.0.0

443

188.8.8.8

56111

0.0.0.0

443

Строго говоря, это не баг — делайте отдельные инбаунды, и будет вам счастье. Но алгоритм на всякий случай положит их оба в один и тот же бакет, т.к. такое поведение можно затриггерить со стороны клиента, и лучше перебдеть, чем недобдеть.

tc тормозит только egress: тормозить в обратную сторону можно, механизмы вроде ifb существуют, но я пока не вижу в этом существенной необходимости. Если просто хочется обеспечить какие-то разумные рамки — воспользуйтесь SO_MAX_PACING_RATE на аутбаунде.

Количество классов htb: по умолчанию на одной горизонтальной плоскости иерархии может быть максимум 65 535 классов-соседей, поэтому будем предполагать, что онлайн на конкретной машине не превышает это значение (пожалуйста, покажите в комментах, если да, я не уверен, вывезет ли это Xray вообще). Тоже излечимо созданием иерархии с фильтрами-пустышками на верхнем уровне и по 65 535 реальных субклассов на каждый класс.

htb не такой фичастый и умный из коробки, как его соседи по палате fq_codel и cake, поэтому нужно будет твёрдо понимать, что вы хотите от него получить, как работает «заимствование» между классами и burst. Заимствование я отключил сразу, а вот burst в разумных пределах — вещь замечательная, но во избежание кары со стороны congestion control не советую переходить 1000 миллисекунд. Оговорюсь: сам tc/HTB понимает burst в байтах, а не во времени — миллисекунды это надстройка конкретно моего инструмента (флаг -htb-burst-ms, см. htbBurstBytes в tcshape.go), который сам переводит «столько-то мс трафика на скорости этого класса» в байты. Полезете руками в tc class add ... htb burst — там ожидается число байт, а не миллисекунды. Если вы не знаете, зачем вообще нужен burst, значит он вам, скорее всего, не нужен.

Я написал первичную реализацию руками на Python, а потом попросил Claude переписать его на Go — есть грех. Глазами отсмотрел, алгоритм незамысловатый и выглядит нормально, но отметить стоит.

За исключением вышесказанного, я не смог выявить каких-либо существенных недостатков схемы в рамках моих скромных тестов, но тут я надеюсь на силу опенсорса. PRs are welcome, как говорится.

Автор: polyfusion

Источник [14]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/qos/458762

Ссылки в тексте:

[1] Xray: https://github.com/XTLS/Xray-core

[2] Вот код: https://github.com/p0lyfusion/xray-speedlimit

[3] Device Info Headers Specification: https://github.com/XTLS/Xray-core/discussions/4877

[4] существуют: https://github.com/kastov/sockdestroy

[5] раз: https://github.com/XTLS/Xray-core/pull/6050

[6] два: https://github.com/XTLS/Xray-core/pull/6496

[7] три: https://github.com/XTLS/Xray-core/issues/6150

[8] четыре: https://github.com/XTLS/Xray-core/issues/4643

[9] пять: https://github.com/XTLS/Xray-core/issues/415

[10] очень тонкий wire-format: https://xtls.github.io/ru/development/protocols/vless.html

[11] Системный вызов splice: https://www.man7.org/linux/man-pages/man2/splice.2.html

[12] читать здесь: http://oldvger.kernel.org/~davem/skb.html

[13] #5722: https://github.com/XTLS/Xray-core/pull/5722

[14] Источник: https://habr.com/ru/articles/1086654/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1086654