Подробное описание внутренней механики telemt-shaper.
┌───────────────────┐
│ Главный цикл │
│ каждые 5 секунд │
└─────────┬─────────┘
│
┌────────────────────┴────────────────────┐
│ │
┌───────▼────────┐ ┌────────▼────────┐
│ ss -tnip │ │ Решения по │
│ → парсинг │ │ каждому IP │
│ → дельты │ │ (state machine)│
│ → скорость │ └────────┬────────┘
│ bytes×8 → bps │ │
└───────┬────────┘ │
│ │
└─────────────► Скорости по IP ◄──────────┘
│
┌───────────┴────────────┐
│ │
┌──────▼──────┐ ┌───────▼──────┐
│ add_shape │ │ change_level │
│ remove_ │ │ (апгрейд │
│ shape │ │ без разрыва)│
└──────┬──────┘ └───────┬──────┘
│ │
└──────────┬─────────────┘
│
┌──────▼──────┐
│ tc class │
│ tc qdisc │
│ tc filter │
│ (HTB+cake) │
└─────────────┘
Каждые CHECK_INTERVAL секунд (по умолчанию 5) скрипт вызывает ss -tnip state established. Из вывода берутся только строки, в которых упоминается процесс telemt — то есть отслеживаются только клиенты MTProto-прокси.
Для каждого сокета извлекаются:
local_addr(IP:port сервера)remote_addr(IP:port клиента)bytes_sentиbytes_receivedиз TCP-инфо
bytes_sent/received — счётчики конкретного сокета, а не суммарно по IP. Если клиент держит несколько подключений к прокси (что нормально), нужно агрегировать.
Сначала считаем дельту по каждому сокету ((local, remote) → разница байт от прошлого тика), а потом суммируем дельты по remote_ip. Это устойчиво к переоткрытию соединений: если клиент закрыл сокет и открыл новый — старый счётчик пропадает, новый стартует с baseline, дельты не «прыгают».
(сумма дельт байт по IP) × 8 / dt = биты/сек
Делим на 1_000_000 — получаем Мбит/с.
IP, попадающие в SKIP_NETWORKS, никогда не шейпятся:
- Все официальные CIDR Telegram (по списку самого Telegram)
- RFC1918 (приватные сети)
- Loopback и link-local
Это нужно чтобы сам трафик прокси «к Telegram» не зарезался — иначе мы бы ломали то, для чего прокси и работает.
У каждого IP в каждый момент одно из двух состояний: не шейпится или шейпится на уровне N.
Условие: скорость ≥ SHAPE_LEVELS[0].threshold_mbps в течение SHAPE_LEVELS[0].exceed_ticks тиков подряд.
Накапливается счётчик exceed_count[ip]. Если на каком-то тике скорость упала ниже порога — счётчик сбрасывается в 0, копить надо заново. Это защита от ложных срабатываний на коротких всплесках.
Когда счётчик достиг порога → add_shape(ip, level=0):
- Аллоцируем уникальный class_id (берём из пула освобождённых, если есть; иначе инкрементим)
- Создаём HTB-класс с
rate = ceil = limit_mbps - Внутрь класса вешаем cake qdisc с
bandwidth = limit_mbps - Добавляем фильтр u32 по
dst ip→ flowid класса
Условие: скорость ≥ SHAPE_LEVELS[N+1].threshold_mbps в течение SHAPE_LEVELS[N+1].exceed_ticks тиков подряд.
Логика та же, что для входа на L0, только счётчик хранится в состоянии шейпленного IP (upgrade_count).
Когда накопилось → change_shape_level(ip, N+1):
tc class change— меняет rate/ceil/burst у существующего классаtc qdisc change— меняет bandwidth у cake
Важно: фильтр НЕ пересоздаётся. Это значит, нет даже миллисекундного окна, в котором трафик уходил бы нешейпленным. Один и тот же class_id, один и тот же фильтр — меняется только лимит внутри.
Условие «успокоился»: скорость < CALM_RATIO × текущий_лимит_уровня (по умолчанию 0.5, т.е. ниже половины лимита).
При выполнении этого условия запускается отсчёт COOLDOWN_SECS (по умолчанию 120 секунд). Если за это время скорость снова поднимется выше calm-порога — отсчёт сбрасывается. Если выдержал тишину все 120 секунд — remove_shape(ip):
tc filter deltc qdisc deltc class del- class_id возвращается в пул свободных
Снимается шейп сразу до полного отсутствия (а не пошаговое понижение L2 → L1 → L0). Логика: если клиент действительно перестал качать — отпускаем целиком, не мучаем. Если потом снова разгонится — пройдёт весь путь L0 → L1 → L2 заново.
Базовая структура qdisc на интерфейсе:
1: htb (root)
├── 1:10 (default class, rate=10gbit, qdisc=fq)
│ └── весь обычный трафик идёт сюда
├── 1:100 (htb, rate=40mbit) — шейпленный IP A
│ └── cake (bandwidth=40mbit)
├── 1:101 (htb, rate=24mbit) — шейпленный IP B
│ └── cake (bandwidth=24mbit)
└── ...
При старте, если корневого HTB ещё нет, скрипт его создаёт (setup_htb()). Если есть — оставляет как есть и добавляет свои классы рядом.
cake умеет шейпить сам, без HTB-обёртки. Но нам нужна изоляция per-IP: чтобы один шейпленный клиент не мешал шейпу другого. HTB даёт древовидную структуру с независимыми классами, cake внутри каждого класса делает грамотное управление очередью (fairness между потоками одного клиента, борьба с буферблоутом).
cake шейпит на link layer с учётом ethernet/IP/TCP overhead. На уровне ss (TCP payload) реальная скорость получается на 5-10% меньше заявленной cake. Например, при cake bandwidth 40mbit реально на ss видно ~38 Мбит/с.
Это учтено в дефолтных порогах: L1.threshold=36 при L0.limit=40 (запас 10%). При старте скрипт валидирует конфиг и ругается, если порог следующего уровня выше 90% от лимита предыдущего.
Раз в минуту (gc_state()) удаляются:
- Записи
ip_last_seen, которым большеSTATE_TTL_SECS(по умолчанию 600 секунд) и которые сейчас не шейпятся - Соответствующие им
exceed_count
Это защита от бесконечного роста словарей при большом потоке клиентов.
При получении SIGTERM/SIGINT (shutdown()):
- По всем активным шейпам вызывается
remove_shape()— все tc-классы и фильтры удаляются - Снимается flock на PID-файле, файл удаляется
- Выход с кодом 0
systemd ждёт graceful shutdown до TimeoutStopSec=30s, дальше SIGKILL. На практике укладывается в пару секунд даже при сотнях активных шейпов.
- IPv6. Сейчас только v4. Если нужен v6 — нужно дописать отдельную ветку с
inet6иprotocol ipv6фильтрами. - Прометей-метрики. Если кому-то надо — экспорт можно прикрутить отдельным процессом, читающим лог.
- Глобальный лимит. Шейпер работает per-IP, не считает суммарную нагрузку. Если 100 клиентов одновременно качают по 50 Мбит/с — шейпер может не успеть всех зарезать сразу. На практике одновременных «качков» обычно единицы.
- Поддержка других прокси. Хардкод фильтра
"telemt"в выводе ss. Для xray/sing-box/etc нужно править исходник.