Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.
1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
Проверено на проде: в Redis лежал единственный ключ
`rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
действовал на весь инстанс разом, а не на клиента.
2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
открывший ссылку на конференцию в течение минуты, получал 429 — и видел
«Не удалось найти конференцию» для существующей и активной конференции.
Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.
Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
`X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
конференция не найдена или пароль неверен. Именно так выглядит перебор
номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
правдоподобного числа участников одной конференции.
Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).
Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.
Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.
Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).
Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.