WebSocket не подключается в Android: как я дошёл от пустого DevTools до mTLS и VPN

История расследования ошибки, которая выглядела как проблема 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):

1
adb devices -l

Сначала устройство было видно USB-подсистеме Linux, но ADB отвечал no permissions (missing udev rules?). Исправление правил доступа USB в Linux позволило получить состояние device и подтвердить авторизацию на телефоне.

Далее в десктопном Brave открыл:

1
brave://inspect/#devices

После разблокировки телефона и открытия нужной вкладки на 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 появились события примерно такого вида:

1
2
3
Network.webSocketCreated
Network.webSocketFrameError
Network.webSocketClosed

Но не было подтверждённой пары:

1
2
Network.webSocketWillSendHandshakeRequest
Network.webSocketHandshakeResponseReceived

Это не доказывает, что пакеты не отправлялись: события DevTools — не сетевой дамп. Зато показывает, что не состоялся полноценный WebSocket-handshake, который DevTools смог бы отобразить.

Иными словами, искать ответ 101 Switching Protocols было преждевременно.

NetLog: настоящая причина отказа

Для диагностики ниже уровня HTTP я использовал сетевой журнал Chromium:

1
brave://net-export

Включил запись, воспроизвёл проблему и остановил её. В полученном NetLog удалось связать события TLS с одной попыткой wss://.

Ниже — обезличенная, сокращённая последовательность, а не фрагмент исходного журнала:

1
2
3
4
5
6
7
8
9
URL_REQUEST_START_JOB
TCP_CONNECT OK
SSL_CONNECT BEGIN
SSL_CLIENT_CERT_REQUESTED
SSL_CONNECT net_error: -110
URL_REQUEST_DELEGATE_CERTIFICATE_REQUESTED
CANCELLED
URL_REQUEST_START_JOB net_error: -3
WEBSOCKET_STATE_CHANGED CONNECTING → CLOSED

Значения ошибок 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

Ниже — минимальная модель исходной логики, не копия действующей системы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# Фрагменты внутри http {}. Адреса — документационные примеры.
geo $trusted_source {
default 0;
192.0.2.0/24 1;
198.51.100.0/24 1;
}
map "$trusted_source:$ssl_client_verify" $deny_request {
default 1;
~^1: 0;
0:SUCCESS 0;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream example_backend {
server 127.0.0.1:8080; # Вымышленный backend
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/nginx/example/server.crt;
ssl_certificate_key /etc/nginx/example/server.key;
ssl_client_certificate /etc/nginx/example/client-ca.crt;
ssl_verify_client optional;
if ($deny_request) {
return 403;
}
location /ws/ {
proxy_pass http://example_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
}
}

Это иллюстрация принципа, а не готовый production-конфиг. В том числе не показаны остальные маршруты, ограничения доступа, управление сертификатами и альтернативные протоколы.

Здесь два принципиально разных этапа:

  1. TLS. ssl_verify_client optional означает: nginx запрашивает клиентский сертификат, но разрешает завершить handshake и без него. Запрос происходит до определения URL и проверки доверенного IP.
  2. HTTP. Уже для сформированного запроса nginx проверяет IP и значение $ssl_client_verify: доступ открыт, если выполнено хотя бы одно условие.

Следовательно, «IP или сертификат» относится к решению об авторизации HTTP-запроса, а не к тому, будет ли TLS-сертификат запрошен. Это и создаёт несовместимость с некоторыми клиентами WebSocket. Если клиент добровольно предъявит невалидный сертификат, nginx в режиме optional может отклонить его ещё до пользовательского правила авторизации.

Документация: ssl_verify_client, nginx WebSocket proxying.

Обходной путь: специально включил VPN, дождался запроса сертификата и выключил VPN

Важно правильно восстановить хронологию: VPN не был причиной исходной поломки. WebSocket отказывал, когда VPN ещё не был включён. Я включил его специально, чтобы заставить браузер пройти другой путь авторизации.

Эксперимент выглядел так:

  1. В обычном сетевом режиме Brave на Android открывал страницу, но WebSocket завершался ошибкой; диалога выбора клиентского сертификата не было.
  2. Я включил VPN. Сетевой маршрут изменился: запрос пришёл как от внешнего клиента, не проходящего проверку доверенного IP.
  3. Браузер показал запрос клиентского сертификата. Важен сам наблюдаемый факт появления диалога; по имеющимся данным нельзя утверждать, что именно было выбрано или сохранено в этот момент.
  4. Я отключил 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 была обойдена. Это отдельная тема для проверки сервера.

Что я вынес из этой отладки

  1. WebSocket failed не равно «сервер WebSocket отказал». Ошибка может возникнуть ещё до HTTP, и тогда у сервера приложения не будет ни запроса, ни кода ответа.
  2. Пустые заголовки — повод опуститься ниже уровнем. Network и Console не всегда показывают причину. Protocol Monitor помогает определить стадию, NetLog — увидеть TLS и сетевое решение.
  3. ERR_SSL_CLIENT_AUTH_CERT_NEEDED не следует читать как окончательный отказ. Важна последующая последовательность: выбор сертификата, повторное соединение или CANCELLED / ERR_ABORTED.
  4. Опциональный клиентский сертификат всё равно запрашивается. Если авторизация на уровне nginx устроена как «IP или mTLS», браузер может столкнуться с CertificateRequest даже для доверенного IP.
  5. Диалог выбора сертификата для WebSocket-подресурса может не появиться. Обход состоял в том, чтобы специально включить VPN и вызвать запрос сертификата, затем вернуть исходное подключение. Сохранение или повторное использование решения о сертификате — возможное объяснение, а не доказанный результат.
  6. Серверные логи — важная часть диагностики. Неудачных WebSocket-попыток не было в просмотренных логах nginx. В сочетании с NetLog это согласуется с отказом до HTTP. Но отсутствие строки при ещё открытом успешном сокете было бы нормальным: запись обычно появляется после завершения HTTP-запроса/закрытия соединения.
  7. Не стоит «лечить» это снятием защиты. Если менять схему аутентификации, ей нужна эквивалентная защита каждого HTTP- и WebSocket-маршрута. Возможная архитектурная альтернатива — разделить браузерный и mTLS-трафик по виртуальным хостам, но это требует отдельного проектирования, а не простого удаления ssl_verify_client.

Итог оказался неожиданным: я отлаживал WebSocket, затем TLS, а для восстановления соединения специально включил VPN, дождался запроса клиентского сертификата и вернулся к обычному подключению. Самый ценный результат — понимание, почему бессодержательная ошибка в DevTools способна скрывать поведение браузера на уровне TLS, а обычная HTTPS-навигация иногда помогает выполнить действие, недоступное WebSocket-подресурсу.

Источники

Материал основан на реальном расследовании, но все примеры и схема публикационно обезличены. Исходный NetLog не входит в статью.