История расследования ошибки, которая выглядела как проблема WebSocket, но происходила ещё до HTTP-запроса.
Кратко. Веб-приложение работало на десктопе, но мобильный Brave постоянно обрывал wss://-соединение. Обычные HTTPS-запросы выполнялись, а DevTools не показывал ни HTTP-статуса, ни ответа сервера. В просмотренных логах nginx не было записей именно о неудачных WebSocket-попытках. NetLog вывел расследование на запрос клиентского TLS-сертификата: Chromium отменял WebSocket до HTTP Upgrade. Обход оказался неожиданным: я намеренно включил VPN → браузер запросил клиентский сертификат → я отключил VPN → WebSocket заработал. Это не было лечением «выключением мешавшего VPN»: исходная проблема существовала и без него. Механизм восстановления после появления запроса сертификата требует отдельного подтверждения.
Все адреса и имена в примерах вымышлены. Схема упрощена; исходные конфигурации, внутренние маршруты, сертификаты и журналы не публикуются.
Симптомы: всё работает, кроме WebSocket
У меня есть небольшое веб-приложение с REST API и подключением к серверу по WebSocket. Доступ к нему организован через nginx: клиент допускается, если он пришёл из доверенной сети или предъявил действительный клиентский TLS-сертификат.
На десктопном Brave соединение в какой-то момент заработало после очистки данных сайта. На Android картина была другой:
- HTML, JavaScript и часть API-запросов загружались;
- WebSocket повторно пытался подключиться и сразу отключался;
- в просматриваемых логах nginx не появлялась запись об этих неудачных WebSocket-попытках;
- в Brave DevTools у WebSocket были пустые
Response headers, отсутствовал HTTP-статус и отображалосьProvisional headers are shown; - Console ограничивалась сообщением
WebSocket connection to 'wss://app.example.com/ws/state' failed; - Chrome на том же телефоне также не смог нормально открыть сайт;
- Firefox предложил выбрать клиентский сертификат и после выбора заработал.
Первое подозрение было направлено на код приложения, проксирование WebSocket, кэш или Brave Shields. Но один вопрос оказался важнее остальных: завершился ли TLS-handshake вообще?
Удалённая отладка Android с Linux
Для начала понадобился доступ к настоящим DevTools мобильного Brave. Подключил телефон по USB, включил отладку и проверил Android Debug Bridge (ADB — интерфейс управления и отладки Android):
|
|
Сначала устройство было видно USB-подсистеме Linux, но ADB отвечал no permissions (missing udev rules?). Исправление правил доступа USB в Linux позволило получить состояние device и подтвердить авторизацию на телефоне.
Далее в десктопном Brave открыл:
|
|
После разблокировки телефона и открытия нужной вкладки на Android стал доступен Inspect, а с ним полноценные удалённые DevTools.
В Network → WS обнаружились повторяющиеся неудачные подключения. Нажатие на них не помогало: HTTP-ответа не было. Сравнение с серверной стороной усилило подозрение: в просмотренных логах nginx не было именно попыток подключения WebSocket. Это важная деталь, но сама по себе ещё не доказательство сбоя TLS: успешное, долго открытое WebSocket-соединение nginx обычно записывает лишь после закрытия, а вывод журнала может зависеть от настроек access_log. В данном случае WebSocket немедленно завершался ошибкой, и NetLog позднее дал независимое объяснение отсутствию HTTP-записи. Отключение Shields, очистка данных сайта и обычные проверки кэша не устранили ошибку. На этом этапе даже нашёлся баг Brave #53888 об ошибочном отображении заблокированных WebSocket в DevTools, но он оказался лишь похожим симптомом: Shields на проблемном сайте уже был отключён.
Protocol Monitor: браузер создаёт сокет, но не делает Upgrade
Следующим инструментом стал Protocol Monitor. Его можно включить в DevTools через Settings → Experiments → Protocol Monitor, а затем открыть командой Show Protocol monitor через Ctrl+Shift+P.
Для проблемного WebSocket появились события примерно такого вида:
|
|
Но не было подтверждённой пары:
|
|
Это не доказывает, что пакеты не отправлялись: события DevTools — не сетевой дамп. Зато показывает, что не состоялся полноценный WebSocket-handshake, который DevTools смог бы отобразить.
Иными словами, искать ответ 101 Switching Protocols было преждевременно.
NetLog: настоящая причина отказа
Для диагностики ниже уровня HTTP я использовал сетевой журнал Chromium:
|
|
Включил запись, воспроизвёл проблему и остановил её. В полученном NetLog удалось связать события TLS с одной попыткой wss://.
Ниже — обезличенная, сокращённая последовательность, а не фрагмент исходного журнала:
|
|
Значения ошибок Chromium:
-110,ERR_SSL_CLIENT_AUTH_CERT_NEEDED— сетевой слой встретил запрос клиентского сертификата и передал выбор на уровень браузера. Само по себе это промежуточное состояние, а не доказательство ошибки.-3,ERR_ABORTED— конкретный запрос в результате был отменён.
Это важное различие. Разработчик Chromium объяснял механизм: браузер может прервать первоначальный TLS-handshake, запросить у системы сертификаты и решение пользователя, а затем повторить соединение. В нашем случае успешного продолжения для WebSocket не произошло.
Отсюда и пустые заголовки: TLS ещё не завершён, поэтому HTTP-запрос с Upgrade: websocket не был успешно отправлен. Сервер приложения не получал возможности ответить ни 101, ни 403. Этим же объясняется отсутствие WebSocket в HTTP access log nginx: на уровне HTTP запрос не был обработан. Эта интерпретация согласуется с наблюдением о пустых серверных логах, но конкретный перечень просмотренных журналов не зафиксирован. TLS-событие при соответствующем уровне логирования могло бы появиться в error.log, но записи там в этом расследовании отдельно не подтверждались. При этом обычные HTTP-запросы могли проходить независимо.
Что было настроено в nginx
Ниже — минимальная модель исходной логики, не копия действующей системы:
|
|
Это иллюстрация принципа, а не готовый production-конфиг. В том числе не показаны остальные маршруты, ограничения доступа, управление сертификатами и альтернативные протоколы.
Здесь два принципиально разных этапа:
- TLS.
ssl_verify_client optionalозначает: nginx запрашивает клиентский сертификат, но разрешает завершить handshake и без него. Запрос происходит до определения URL и проверки доверенного IP. - HTTP. Уже для сформированного запроса nginx проверяет IP и значение
$ssl_client_verify: доступ открыт, если выполнено хотя бы одно условие.
Следовательно, «IP или сертификат» относится к решению об авторизации HTTP-запроса, а не к тому, будет ли TLS-сертификат запрошен. Это и создаёт несовместимость с некоторыми клиентами WebSocket. Если клиент добровольно предъявит невалидный сертификат, nginx в режиме optional может отклонить его ещё до пользовательского правила авторизации.
Документация: ssl_verify_client, nginx WebSocket proxying.
Обходной путь: специально включил VPN, дождался запроса сертификата и выключил VPN
Важно правильно восстановить хронологию: VPN не был причиной исходной поломки. WebSocket отказывал, когда VPN ещё не был включён. Я включил его специально, чтобы заставить браузер пройти другой путь авторизации.
Эксперимент выглядел так:
- В обычном сетевом режиме Brave на Android открывал страницу, но WebSocket завершался ошибкой; диалога выбора клиентского сертификата не было.
- Я включил VPN. Сетевой маршрут изменился: запрос пришёл как от внешнего клиента, не проходящего проверку доверенного IP.
- Браузер показал запрос клиентского сертификата. Важен сам наблюдаемый факт появления диалога; по имеющимся данным нельзя утверждать, что именно было выбрано или сохранено в этот момент.
- Я отключил VPN, вернулся к исходному сетевому подключению — и WebSocket сразу заработал.
Значит, точная формулировка результата — не «VPN ломал WebSocket, а его отключение вылечило», а «исходный WebSocket не работал; я намеренно спровоцировал появление запроса сертификата через VPN, вернулся к обычной сети, после чего сокет заработал».
Вероятное объяснение — изменение состояния браузера, связанного с выбором клиентского сертификата для этого сервера: Chromium способен повторно использовать ранее известное решение и для WebSocket, хотя сам WebSocket не открывает интерактивный выбор. Это соответствует комментарию в исходниках Chromium, но в этом инциденте повторное использование решения не доказано. Имеется только NetLog неудачных попыток; успешный NetLog и результат контрольного эксперимента без смены сетевого пути отсутствуют.
Особенно важно не путать два уровня: ssl_verify_client optional заставляет nginx запрашивать сертификат на TLS-уровне и для доверенного IP. Проверка «IP или сертификат» происходит только после TLS, когда существует HTTP-запрос. Поэтому VPN не является буквальным переключателем CertificateRequest: он меняет сетевой контекст и мог повлиять на то, как Brave инициировал выбор сертификата.
Оказалось, что это известное поведение Chromium
Поиск в первичных источниках дал почти дословное совпадение:
- Chromium #40244178 — Client certificates not supported with WebSockets (2022): при запросе клиентского сертификата Chrome обрывает WebSocket с
ERR_SSL_CLIENT_AUTH_CERT_NEEDED, тогда как Firefox работает. Тикет отмечен дубликатом #40640440. - Исходный код Chromium,
net/websockets/websocket_stream.cc: если сертификат ещё не выбран, WebSocket рассматривается как подресурс, для которого не открывают интерактивный диалог выбора. В обработчикеOnCertificateRequested()вызываетсяrequest->Cancel(). - Laravel WebSockets #266 (2019): другое приложение, практически тот же симптом — Firefox работает, Chrome сообщает о прерванном WebSocket-handshake. Участники расследования пришли к запросу клиентского сертификата и к той же ветке кода Chromium.
Таким образом, речь не просто о «Brave не любит WebSocket». У такого поведения давняя история в самом Chromium. Оно проявляется, когда WebSocket встречает запрос сертификата, для которого браузеру нужно интерактивное решение и нет подходящего ранее выбранного варианта. Если решение уже кэшировано или сертификат выбран другим способом, поведение может отличаться.
А что насчёт HTTP/3?
В NetLog также было видно, что часть обычных HTTPS-запросов успешно использовала HTTP/3 поверх QUIC, а проблемный WebSocket открывал отдельное TCP+TLS-соединение и пытался выполнить HTTP/1.1 Upgrade.
Это важно для диагностики, но не означает, что HTTP/3 сломал WebSocket: в зафиксированной попытке WebSocket не использовал QUIC. HTTP/3 лишь усложнял картину, потому что разные запросы к одному хосту проходили разными транспортами.
Существуют близкие, но отдельные обсуждения: nginx #2626, nginx GitHub #817 и Firefox #2027559 о взаимодействии клиентских сертификатов с HTTP/3. Они не доказывают, что причиной нашего инцидента был QUIC, и не дают оснований утверждать, что авторизация по HTTP/3 была обойдена. Это отдельная тема для проверки сервера.
Что я вынес из этой отладки
WebSocket failedне равно «сервер WebSocket отказал». Ошибка может возникнуть ещё до HTTP, и тогда у сервера приложения не будет ни запроса, ни кода ответа.- Пустые заголовки — повод опуститься ниже уровнем. Network и Console не всегда показывают причину. Protocol Monitor помогает определить стадию, NetLog — увидеть TLS и сетевое решение.
ERR_SSL_CLIENT_AUTH_CERT_NEEDEDне следует читать как окончательный отказ. Важна последующая последовательность: выбор сертификата, повторное соединение илиCANCELLED/ERR_ABORTED.- Опциональный клиентский сертификат всё равно запрашивается. Если авторизация на уровне nginx устроена как «IP или mTLS», браузер может столкнуться с
CertificateRequestдаже для доверенного IP. - Диалог выбора сертификата для WebSocket-подресурса может не появиться. Обход состоял в том, чтобы специально включить VPN и вызвать запрос сертификата, затем вернуть исходное подключение. Сохранение или повторное использование решения о сертификате — возможное объяснение, а не доказанный результат.
- Серверные логи — важная часть диагностики. Неудачных WebSocket-попыток не было в просмотренных логах nginx. В сочетании с NetLog это согласуется с отказом до HTTP. Но отсутствие строки при ещё открытом успешном сокете было бы нормальным: запись обычно появляется после завершения HTTP-запроса/закрытия соединения.
- Не стоит «лечить» это снятием защиты. Если менять схему аутентификации, ей нужна эквивалентная защита каждого HTTP- и WebSocket-маршрута. Возможная архитектурная альтернатива — разделить браузерный и mTLS-трафик по виртуальным хостам, но это требует отдельного проектирования, а не простого удаления
ssl_verify_client.
Итог оказался неожиданным: я отлаживал WebSocket, затем TLS, а для восстановления соединения специально включил VPN, дождался запроса клиентского сертификата и вернулся к обычному подключению. Самый ценный результат — понимание, почему бессодержательная ошибка в DevTools способна скрывать поведение браузера на уровне TLS, а обычная HTTPS-навигация иногда помогает выполнить действие, недоступное WebSocket-подресурсу.
Источники
- Chromium issue #40244178: Client certificates not supported with WebSockets
- Chromium issue #40640440: WebSocket cancels connections requiring Client Certs
- Chromium
net/websockets/websocket_stream.cc - Chromium net-dev: Trying to understand netlogs with
- nginx:
ssl_verify_client - nginx: WebSocket proxying
- nginx: Configuring Logging
- Laravel WebSockets issue #266
- Brave issue #53888: Shields WebSocket blocking not reported correctly
- nginx HTTP/3 ticket #2626
- nginx/nginx issue #817
- Firefox Bugzilla #2027559
Материал основан на реальном расследовании, но все примеры и схема публикационно обезличены. Исходный NetLog не входит в статью.