6.5 KiB
План: управление потоком в прямом туннеле (tcp-проброс)
Статус: отложено - сделать, если на практике упрёмся. Записано 2026-10-16.
Что сейчас
Прямой туннель (:8446, режим проброса tcp) - одно TCP-соединение на агента, по нему
мультиплексируются все проброшенные соединения этого агента кадрами
type(1) | stream(4) | length(4) | payload (internal/tunnel/frame.go).
Ёмкости хватает с запасом: на агента - одно соединение и одна горутина чтения, на проброшенное соединение - горутина и буфер 32 КБ; шифрования в туннеле нет. 20 агентов и тысячи соединений - десятки МБ памяти и почти ноль CPU. Потолок - сеть хоста (1 Гбит/с), через которую весь трафик идёт дважды (пользователь ↔ хост ↔ агент).
Проблема: блокировка всего туннеля агента одним соединением
Управления потоком (окон, как в HTTP/2 или yamux) нет, поэтому внутри одного агента медленное соединение тормозит остальные:
- Хост (
internal/server/tunnelserver.go,agentTunnel.readLoop): кадрTypeDataпишется пользователю синхронно,c.Write(f.Payload), без дедлайна. Клиент, который медленно читает или завис, останавливает чтение туннеля - встают все пробросы этого агента, пока клиент не отвалится. - Агент (
internal/agent/tunnel.go,tunClient.readLoop): данные кладутся в канал потокаin(256 кусков, до ~8 МБ). Если локальный сервис не читает и канал полон,readLoopблокируется наs.in <- f.Payload- встаёт весь туннель агента.
Между агентами влияния нет: у каждого свой readLoop.
Когда проявится: через одного агента одновременно большая отдача медленному клиенту и что-то интерактивное (SSH, веб) - интерактивное подвисает. Худший случай - зависший клиент держит туннель агента до разрыва TCP.
Предлагаемое решение
- Своя очередь записи у каждого потока - на хосте и на агенте.
readLoopтолько кладёт кадр в очередь потока и никогда не пишет в сокет сам; пишет отдельная горутина потока. - Окно (кредиты) на поток. Новый тип кадра
TypeWindow(stream, uint32 байт): получатель сообщает, сколько ещё байт готов принять. Отправитель не шлётTypeDataсверх окна. Начальное окно - 256 КБ; получатель возвращает кредит по мере записи в сокет (например, когда записана половина окна). Так очередь потока ограничена окном, а медленный поток тормозит только себя. - Дедлайны записи. Запись в пользовательский или локальный сокет - с
SetWriteDeadline(например, 60 с без прогресса). Не успел - поток закрывается (TypeClose), туннель живёт. - Справедливость отправки. Отправка в туннель уже сериализована (
wmu); кадры данных ограничить 32 КБ (сейчас так и есть), чтобы большой поток не занимал канал надолго.
Совместимость со старыми агентами
Рукопожатие LPRA-TUN1 не меняем: старый агент отвергает другое приветствие, а старый сервер -
ответ не из двух полей. Вместо этого:
- сервер знает версию агента (из БД,
versionAtLeast) и, если агент новый, сразу после рукопожатия шлёт кадрTypeHello(флаги:flowControl); - агент включает окна, только получив
TypeHello. Старый сервер его не шлёт - новый агент работает с ним по-старому. Старому агенту серверTypeHelloне шлёт и окна не включает (а пришедший неизвестный кадр старый агент и так пропустил бы: в егоreadLoopнетdefault).
Новые типы кадров: TypeHello = 5, TypeWindow = 6.
Тесты
- Медленный читатель на одном потоке не задерживает другой поток того же агента (замер задержки эха на втором потоке при забитом первом).
- Зависший клиент закрывается по дедлайну, туннель и остальные потоки живы.
- Новый сервер + старый агент и старый сервер + новый агент - проброс работает, как раньше.
- Пропускная способность одного потока не падает заметно (окно 256 КБ на RTT до 50 мс даёт ~40 Мбит/с на поток; если мало - окно больше или автоподстройка).
Объём
Сервер: tunnelserver.go (~150 строк), агент: tunnel.go (~150 строк), frame.go (типы), тесты.
Нужна новая версия агента; панель после обновления помечает старых как устаревших.