SQUADER
Войти
Блог разработкиБлог разработки

Шумодав внутри форка libwebrtc, GraphQL за восемь часов и аудит API из 402 методов

Разработка Squader, проблемы и помощь AI-агента. А стоит ли?

Несколько последних месяцев я делаю Squader - приложение для поиска тиммейтов в онлайн-игры. Если коротко: анкеты "ищу тиму в CS2/Dota/Valorant" с фильтрами по рангу и роли, кланы с текстовыми и голосовыми каналами, события с календарём, личные чаты и звонки, шеринг экрана на Windows.

Проектов внутри несколько, и все в одном репозитории: мобильное приложение и десктоп на Flutter (Android и Windows из одного кода), бэкенд на .NET с Postgres и Redis, админка на React и публичный сайт для поисковиков. Голос - LiveKit на отдельной машине, события в реальном времени - WebSocket через Centrifugo.

И почти всё это время я пишу код не один, а с ИИ-агентами. Это не статья про "ИИ заменит программистов". Это три с половиной истории о том, где агент реально выручил: что было за проблема, как решили, что из этого вышло. С кодом. В конце - советы и мысли о том, кем теперь становится разработчик.

Кейс 1. Шумоподавление: RNNoise внутри форка libwebrtc

Голосовой чат завёлся быстро - livekit_client на клиенте, свой LiveKit на сервере. А потом посыпались жалобы: "клавиатура барабанит", "слышно соседа по комнате". Шумодав, встроенный в WebRTC, - спектральный: постоянный фон он давит нормально (вентилятор, дорога за окном), но клавиши и голос за стеной - нет. Платный Krisp у LiveKit отпал сразу: он только в их облаке (а сервер у меня свой), и во Flutter-пакете нет Windows.

Ответ, в общем-то, известный - RNNoise от Xiph. Маленькая рекуррентная сеть, обученная отличать речь от всего остального, BSD-лицензия, нативный C. Не "купить и подключить", а именно библиотека. Чтобы впаять её в звонок на Flutter, надо:

  1. сделать форк flutter_webrtc и собрать его под Windows - со всеми зависимостями libwebrtc;
  2. врезаться в аудиоконвейер. libwebrtc пускает стороннюю обработку микрофона ровно в одно место - RTCAudioProcessing::SetCapturePostProcessing - и добраться туда можно только изнутри плагина;
  3. разобраться с частотой. RNNoise работает строго на 48 кГц и строго с кадром в 480 сэмплов. А конвейер WebRTC вызывает обработку на своей рабочей частоте - на живом звонке мне встречалась и 16 кГц, то есть 160 сэмплов в кадре.

Пункт третий - это как раз то место, где такое обычно умирает. Что получилось в итоге (упрощённо, из native/rnnoise_denoiser.h):

// Ядро шумоподавления: RNNoise плюс запирание пауз. Ни одного include из
// libwebrtc - сознательно: ядро собирается и для Windows- и Android-плагинов.
//
// Частота - главная сложность. APM зовёт обработку кадрами по 10 мс, но на своей
// рабочей частоте, и она не обязана быть 48 кГц. Поэтому звук приводится к 48 кГц
// и обратно (RationalResampler) - так же поступает всякий, кто встраивает rnnoise.
class RnnoiseDenoiser {
public:
    // Поверх сети - запирание пауз по вероятности голоса, которую сеть и так
    // возвращает. Насколько жёстко - решает ступень: 0 - только сеть, 1 - до -30 дБ,
    // 2 - до -60 дБ.
    void SetLevel(int level);
    void ProcessFrame(float* samples, size_t count);
};

Пара решений, которые агент внёс, пока я читал диффы - оба мне самому в голову не пришли бы:

  • Шкала сэмплов определяется по первым секундам звука: внешний код не документирует, отдаёт он нормализованные float или "как int16". Ядро различает это само - ошибка тут беззвучна и верить на слово нельзя.
  • Запирание пауз поверх сети. RNNoise обучали "не навредить речи", поэтому она осторожничает - и остаточный шум слышен как раз в тишине между словами. Специальная логика доводит эти куски до нуля по вероятности голоса, которую сеть и так возвращает.

На Windows ядро оборачивается в RTCAudioProcessing::CustomProcessing, на Android JNI-мост регистрируется в AudioProcessingController.capturePostProcessing. Со стороны Dart это два метода: setNoiseSuppressor и getNoiseSuppressorStats. Второй возвращает счётчик обработанных кадров - чтобы отличать "включено" от "работает". Счётчик я вывел прямо на экран настроек: пусть человек видит, что фильтр живой.

И бонус из того же форка, который я бы искал сам очень долго. Симптом был странный: "игра тихнет ровно на те минуты, когда в канале появляется второй человек". Оказалось, WebRTC на Windows открывает вывод звука с коммуникационной ролью - и Windows, пока играет "звонок", приглушает все остальные звуки машины. Лечится открытием устройства вывода по индексу, без роли.

Что я из этого вынес: ИИ сильно удешевил мне вход в чужие C++-кодбейсы. Форк libwebrtc раньше был автоматическим "нет, спасибо". Теперь это задача на несколько вечеров - при том что на C++ я не пишу.

Кейс 2. GraphQL поверх REST за восемь часов

Сначала клиент общался с бэкендом по REST: около 400 клиентских методов на 48 контроллерах. Методы я изначально делал мелкими и атомарными - чтобы под одни и те же эндпоинты легко адаптировать и мобилку, и десктоп, и сайт. Работало нормально, но в какой-то момент я начал замечать цепочки запросов друг за другом: экран клана - это 6–8 запросов подряд, и клиент ждёт каждый. Решил переехать на GraphQL не ради моды: пусть запросы выполняются параллельно на сервере внутри кластера, где задержки между сервисами - доли миллисекунды, а не друг за другом с телефона через мобильный интернет.

Переписывать 402 метода на полноценный GraphQL с нуля не хотелось: API уже работает у пользователей. Поэтому схема такая: тонкий прокси только на чтение поверх существующего REST, схема строится автоматически из OpenAPI. Идея моя, реализовывал агент - на бэкенде это заняло около трёх часов, перевод Flutter-клиента ещё около пяти.

Как это устроено (services/graphql, .NET + graphql-dotnet):

  1. На старте прокси скачивает клиентский OpenAPI-документ бэкенда и по SHA-256 хэшу решает, надо ли перестраивать схему - правки REST не требуют правок прокси. Схема также перестраивается по таймеру и по сигналу.
  2. Из документа строится схема: тег операции (ASP.NET ставит его по имени контроллера) становится корневым полем, маршрут - листовым внутри. /api/games/{id} → games.byId. Набралось 186 полей только на чтение.
  3. Резолвер у всех полей один: подставить аргументы GraphQL в шаблон REST-пути, позвать бэкенд и разобрать стандартную обёртку ответа:
public sealed class RestProxyResolver(
    string fieldName,
    string pathTemplate,
    IReadOnlyList<string> pathParameters) : IFieldResolver
{
    private async Task<object?> ResolveCoreAsync(IResolveFieldContext context)
    {
        var caller = services.GetRequiredService<IRestCaller>();
        var headers = ForwardedHeaders.Collect(httpContext);   // allowlist: Authorization, Accept-Language, X-Device-Id, X-Real-IP
        var pathAndQuery = RequestUriBuilder.BuildPathAndQuery(
            pathTemplate, pathParameters, CollectArgumentValues(context));

        var result = await caller.GetAsync(pathAndQuery, headers, context.CancellationToken);
        // 401 -> ошибка с кодом "пора обновить токен"; 429 -> retryAfterSeconds наружу
        ...
    }
}
  1. Мутаций нет - специально. Вся запись (~240 мест в клиенте), загрузка файлов, обновление токена и realtime остались на REST. Это снимает почти всю сложность GraphQL - кэширование, вложенные мутации, права на поля, - а выгоды миграции даёт целиком.

Что это дало против чистого REST:

  • Один запрос вместо шести. Через алиасы можно собрать данные экрана клана - профиль, участники, каналы, события, роли - за один заход. На телефоне в метро это разница между "открылось" и "крутилка".
  • Не появилось "версии 2.0" API. Прокси - отдельный сервис, REST не тронут ни строчкой. Если что-то пойдёт не так - переключаю клиента обратно, час работы.
  • Самодокументируемость: GraphiQL в проде служит документацией, которая генерируется из того же кода и потому не может устареть.
  • Предсказуемые ошибки. Три кода (REST_ERROR, REST_HTTP_ERROR, TRANSPORT_ERROR), при 429 - retryAfterSeconds, при 401 - сигнал обновить токен. Обработка ошибок стала одинаковой для всех запросов.

На стороне Flutter таблица "REST-маршрут → GraphQL-поле" сгенерирована из контроллеров, так что клиент переехал механически:

/// Ключ - шаблон REST-маршрута, в точности как в OpenAPI-документе,
/// из которого прокси строит схему. Генерируется, правки руками не вносятся.
const Map<String, GqlRoute> gqlRoutes = {
  '/anketas/chats/mine':        GqlRoute('anketas', 'chatsMine'),
  '/anketas/{id}':              GqlRoute('anketas', 'byId'),
  '/calendar/clans/{id}':       GqlRoute('calendar', 'clansById'),
  ...
};

Главное, что я понял: агент не придумал за меня архитектуру - но её реализация стала стоить копейки. От идеи до рабочих сборок - один рабочий день. И ещё: перед кодом агент сам свёл полный инвентарь всех GET-вызовов клиента в таблицу "экран → запросы → будущие поля". Такой инвентарь на сотни строк обычно никто не делает, потому что "некогда, надо кодить" - а тут он стал почти бесплатным.

Кейс 3. Аудит безопасности: 402 эндпоинта под проверкой

Когда API разрастается до 400+ методов, вопрос "а везде ли стоит авторизация?" превращается в лотерею. Агент прогнал аудит так:

Шаг 0 - сначала документ, а не код. Все 402 клиентских метода выгружены в один документ: контроллер, маршрут, ожидаемый доступ. Дальше аудит шёл строго по списку, каждый эндпоинт отмечается по мере проверки - ничего не теряется, и в конце видно, что покрыто всё.

Шаг 1 - авторизация. Живые запросы во все эндпоинты без токена: 373 вернули 401, 29 - легитимно публичные (логин, публичные профили, версии приложения). Пропущенных проверок не нашлось. По коду контроллеров это глазами проверяется за час-два; живые запросы дольше, но ловят то, чего в коде не видно - когда проверка вроде стоит, но общий код обработки запроса её случайно отключает.

Шаг 2 - каждый эндпоинт проверялся с тремя наборами данных: валидными (должно отработать), невалидными (мусор в параметрах - вместо внутренней ошибки 500 должен прийти аккуратный 400) и неразрешёнными (валидные данные, но чужие: имеет ли право этот пользователь читать или менять этот объект). Последний набор - охота на IDOR: когда по честному, но чужому идентификатору эндпоинт отдаёт то, чего трогать нельзя.

Шаг 3 - ревью всех write-методов на то же самое, но по коду: где проверяется владение объектом, а где просто подразумевается.

Главная находка вылезла на "неразрешённых" данных, и она серьёзнее опечаток в валидаторах. Отдача файлов проверяла только "человек залогинен", а не "этому человеку можно этот файл": эндпоинт после проверки логина просто отдавал файл по идентификатору (GUID). Любой залогиненный мог скачать чужой файл, узнав или перебрав ссылку. А GUID утекает легко - он в url предпросмотра ссылок в чатах. Личные файлы, черновики - всё было доступно любому пользователю приложения.

Фикс - области видимости прямо в модели файла (CloudFiles.ScopeType/ScopeId) и одна проверка перед отдачей:

public async Task<bool> CanReadAsync(CloudFile file, long? userId, ...)
{
    // Публичные читаются без сессии: сборки приложения по ссылке и клановая
    // графика в карточке приглашения, которую разбирает чужой сервер предпросмотра.
    if (file.IsPublic) return true;
    if (userId is not { } callerId) return false;

    // Свой файл отдаётся всегда: человека, выгнанного из клана, авторство не отбирает.
    if (file.OwnerUserId == callerId) return true;

    return file.ScopeType switch
    {
        // Не прикреплён ни к чему и не свой - отказ
        FileScopeType.Unbound        => false,
        FileScopeType.Authenticated  => true,
        FileScopeType.Clan    => await CanReadClanAsync(file.ScopeId!.Value, callerId, ...),
        FileScopeType.Chat    => await CanReadChatAsync(file.ScopeId!.Value, callerId, ...),
        FileScopeType.Team    => await _teamService.IsActiveMemberAsync(...),
        // Область из будущей версии, которую этот экземпляр не умеет читать. Отказ,
        // а не разрешение: неизвестное правило доступа - это не отсутствие правила.
        _ => false,
    };
}

Посмотрите на последнюю ветку: _ => false. Это не забытый дефолт. Если новая версия кода добавит новый тип области, а старый экземпляр его не знает - будет отказ, а не дыра. В ручном коде такое легко остаётся в категории "ну добавим потом".

Были и мелочи: опросы кланов без валидатора на обновление (вопрос неограниченной длины ронял эндпоинт), поиск людей без потолка на длину запроса. Всё закрыто валидаторами.

После аудита тем же агентом добавлен rate limiting: счётчики на Redis, для запросов без логина (логин, почта) - по IP, для остальных - по действию; при превышении (429) клиенту отдаётся retryAfterSeconds.

Отдельно про Centrifugo. У него есть канальные прокси: перед подпиской пользователя на канал Centrifugo спрашивает у бэкенда, можно ли. Пока этого не было, тихо слушать канал клана мог фактически любой пользователь. Для голосовых каналов кланов и команд включён subscribe_proxy: право "состоит ли пользователь в клане" проверяется на каждом подключении, а не один раз при выдаче:

{
  "name": "clan",
  "allow_subscribe_for_client": true,
  "presence": true,
  "subscribe_proxy_enabled": true,
  "subscribe_proxy_name": "backend"
}

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

Что я вынес. Полный аудит API стал реалистичен для проекта одного человека - и это не "агент считает, что всё хорошо", а проверяемые факты: реальные запросы, реальные коды ответов, отчёт по каждому эндпоинту. Есть старое правило безопасности: стоимость защиты должна быть соразмерна стоимости защищаемых данных. Раньше оно работало против инди-проектов - нормальный аудит стоил дороже, чем один разработчик мог себе позволить. С ИИ аудит дешевеет на порядок, и это правило начинает работать в обе стороны.

Кейс 4. Защита и оптимизация мобильного приложения

Короткая история, но показательная - про знание платформ, которое у обычного Flutter-разработчика размытое.

Скрытие адреса бэкенда в бинарнике. Я был уверен, что флаг --obfuscate прячет константы. Агент показал, что нет: obfuscate переименовывает символы Dart, а строковые литералы не трогает. strings libapp.so на релизной сборке печатает адрес бэкенда как есть. Решение - пакет envied: значение хранится как массив байтов, XOR-ённый с ключом, который перегенерируется на каждой сборке:

@Envied(path: '.env', requireEnvFile: false)
abstract final class Env {
  @EnviedField(
    varName: 'SQUAD_API_DOMAIN',
    obfuscate: true,           // XOR с перегенерируемым ключом вместо строкового литерала
    defaultValue: 'squader.pro',
  )
  static final String apiDomain = _Env.apiDomain;
}

В комментарии к классу агент честно записал: это поднимает цену чтения бинарника, но не делает значение секретом - ключ едет в том же бинарнике. Такая честность ("вот что это защищает, а вот что нет") - возможно, самое ценное в работе с агентом.

Привязка сертификата. Пиннинг - это когда приложение проверяет не просто "сертификат сервера валиден", а "выдан конкретным центром сертификации". На Android это делает система через network_security_config.xml, но на Windows его никто не читает, а шифрование в десктопном Flutter делает BoringSSL со своим хранилищем корней. Решение - SecurityContext с пустым хранилищем доверия и ровно тремя корнями Let's Encrypt:

/// withTrustedRoots: false - доверяем только корням ISRG, зашитым ниже.
/// Корням, а не листовому сертификату: лист перевыпускается каждые ~90 дней,
/// пиннинг листа окирпичил бы все установленные копии при следующем обновлении.
/// И предохранитель: после даты _pinsExpire возвращаем null и валидируем как все -
/// если бэкенд уедет с Let's Encrypt, а этот файл забудут поправить,
/// приложения восстановятся сами, а не останутся навсегда офлайн.

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

Разбивка по архитектурам. Android-сборка бьётся на отдельные нативные библиотеки: arm64-v8a, armeabi-v7a и x86_64 - устройству достаётся только его архитектура. Про x86_64 скажу отдельно: телефонов на нём почти нет, живут эти библиотеки в основном в эмуляторах. Так что вырезание x86_64 из релиза - не только минус десяток мегабайт, но и защита: гонять приложение в эмуляторе, чтобы перехватывать трафик и ковырять его в отладчике, становится заметно неудобнее.

Советы: что реально работает

  1. Пусть агент сначала пишет документы, а не код. Самые полезные артефакты у меня - не коммиты, а инвентаризации: таблица всех REST-вызовов перед миграцией, список всех эндпоинтов перед аудитом, правила транзакций ("ничего внешнего - пуши, S3, голосовой сервер - внутри транзакции базы"). Документ на 300 строк стоит копейки, а решение на его основе - уже осознанное.
  2. Комментируйте код - агенты по комментариям понимают его в разы лучше. Один комментарий "зачем эта ветка switch" держит контекст всего решения; без него агент через неделю предложит "оптимизацию", которая ломает замысел.
  3. Каждый вывод агента должен быть проверяемым действием. "Кажется, авторизация везде" - это ничто. 402 живых запроса без токена - это факт. Просите агента проверять, а не рассуждать.
  4. Архитектуру решаете вы. Чем точнее постановка, тем меньше шанс получить уверенно выглядящую ерунду. Своего решения нет - просите альтернативы с минусами каждой.
  5. ИИ удешевляет "правильное, но скучное": аудит, форки, тесты, документацию - всё то, что обычно съедают дедлайны. Тратьте сэкономленное именно туда.
  6. Читайте диффы. Всегда. Ревью никуда не делось - оно стало важнее, потому что чужого кода в сутки теперь в разы больше.

Что стало лучше. Скорость на рутине и на исследовании (форк C++-библиотеки за вечера!). Исчез паралич чистого листа - "новый экран" стартует за час. Бесплатный второй взгляд на безопасность. И, неожиданно, код стал читабельнее: агент пишет комментарии, объясняющие почему, а не что, - я бы сам часто поленился.

Что стало хуже. Агент уверенно выдумывает API библиотек, особенно свежих версий: livekit_client, .NET 10 - галлюцинации приходится ловить компилятором. Без контроля расползается объём: просишь одно - получаешь "заодно улучшил" три файла. И скорость без дисциплины - это скорость накопления долга, просто долга теперь больше в единицу времени.

Кем стал разработчик и что дальше

Сейчас руками я пишу, может, 10–20% кода. Остальное время - постановка задач, архитектурные решения, чтение диффов и проверка на живом проде. Фактически я стал техлидом маленькой команды, где остальные члены - агенты: они не устают проверять 402 эндпоинта и работают ночью.

Что дальше, как мне видится:

  • Аудит станет непрерывным: проверки авторизации и мусорными данными будут гоняться на каждый пул-реквест, а не раз в год.
  • Умение читать код станет дороже умения писать. Проверить чужое решение - последний навык, который нельзя делегировать.
  • Джунов как класса станет меньше, но путь до сеньора - короче для тех, кто научится управлять агентами. Хуже всех сейчас "середине": достаточно опытному, чтобы доверять своим знаниям, и недостаточно, чтобы ловить агента на ошибках.
  • Цена ошибки постановки вырастет. Когда исполнение дешёвое, вся стоимость ошибки переезжает в стадию "что именно мы делаем".

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

Расскажите в комментариях, где ИИ вас выручил (или подставил) - сравним шрамы.

Комментарии (0)

Комментариев пока нет.