MAATRIX / Блог / Убрали промежуточный сертификат — и мобильное приложение отвалилось

Убрали промежуточный сертификат — и мобильное приложение отвалилось

MAATRIX

В конце августа 2026 года у одной команды, с которой мы разбирали этот случай, после обычного, ничем не примечательного обновления SSL-сертификата на API-сервере перестало работать мобильное приложение — и только оно. Сайт в браузере открывался, curl с сервера деплоя тоже отрабатывал без ошибок, а приложение на телефонах у части пользователей падало на этапе логина с невнятной сетевой ошибкой. Разбор этого инцидента — хороший повод разобраться, почему «сертификат обновился и всё работает» не значит «TLS-цепочка настроена правильно», и чем прод-браузер отличается от TLS-стека внутри мобильного приложения.

Что сломалось

Схема у команды была стандартная: nginx как reverse proxy перед бэкендом, сертификат Let's Encrypt, продление через certbot по cron. В рамках небольшой уборки конфигов админ вынес блок server в отдельный файл и, копируя пути к сертификату, вместо

ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

указал

ssl_certificate     /etc/letsencrypt/live/api.example.com/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

Разница на первый взгляд косметическая: cert.pem — это только листовой (leaf) сертификат домена, а fullchain.pem — тот же листовой сертификат плюс промежуточный (intermediate) сертификат удостоверяющего центра, склеенные в один файл. nginx -t конфиг проверил и признал валидным — синтаксис файла с одним сертификатом ничем не хуже синтаксиса файла с двумя. Reload прошёл штатно, мониторинг аптайма зелёный, sitemap и главная страница открываются. Проблема обнаружилась не сразу, а через несколько часов, когда в поддержку начали приходить жалобы от пользователей мобильного приложения: экран логина зависает, потом ошибка вида «не удалось установить защищённое соединение».

Важная деталь, которая в итоге и стала ключом к разгадке: жаловались не все пользователи, а заметная, но не стопроцентная доля — причём воспроизвести проблему на рабочем ноутбуке разработчика через Postman или curl не получалось вообще.

Что показывали логи и метрики

Первым делом посмотрели туда, где обычно смотрят при проблемах с доступностью:

  • Логи доступа nginx (access.log) — пусто в смысле ошибок 5xx для мобильных запросов, потому что до уровня HTTP запросы просто не доходили: соединение рвалось на этапе TLS-рукопожатия, раньше, чем nginx успевал что-то залогировать как HTTP-запрос.
  • Метрики бэкенда — количество успешных запросов от мобильных клиентов за последний час просело, а с десктоп-версии сайта и панели администратора трафик был в норме.
  • journalctl -u nginx и error-лог nginx — тоже ничего показательного: nginx не считает неполную отправку цепочки ошибкой, он честно отдаёт клиенту то, что указано в конфиге.

Дальше подключили сетевые метрики на уровне TLS. Если у вас есть access-лог nginx с полем $ssl_protocol и $ssl_cipher, а ещё лучше — метрики хендшейков через ssl_early_data и логирование ошибок верификации на стороне клиента (если у мобильного приложения есть свой crash-reporting с деталями сетевой ошибки), именно там нашлась зацепка: в отчётах о крэшах на Android регулярно повторялась ошибка

javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException:
Trust anchor for certification path not found

а на части iOS-устройств — похожая по смыслу ошибка от NSURLErrorDomain о невозможности проверить сертификат сервера. При этом ни один отчёт не жаловался на просроченный сертификат или на неверное имя хоста — сама ошибка была не про «сертификат плохой», а про «не могу построить цепочку доверия до корневого центра».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые не подтвердились

Прежде чем дойти до реальной причины, отбросили несколько версий, каждая из которых звучала правдоподобно:

  • Сертификат просрочен. Проверили openssl x509 -enddate -noout -in cert.pem — срок действия в порядке, до истечения оставались месяцы. Эта гипотеза отпала быстро.
  • OCSP-ответчик недоступен и клиенты не могут проверить статус отзыва. Проверили через openssl s_client -connect api.example.com:443 -status — OCSP stapling либо не был включён, либо отвечал корректно; к тому же большинство мобильных TLS-стеков по умолчанию не блокируют соединение при недоступности OCSP (soft-fail), так что это не объясняло стабильно воспроизводимый процент отказов.
  • Проблема на стороне DNS или балансировщика — оба домена (сайт и API) резолвились в один и тот же IP, curl с любого хоста подключался нормально, значит маршрутизация ни при чём.
  • CDN или WAF режет мобильный User-Agent. Проверили логи CDN — фильтрации не было, запросы вообще не доходили до CDN в проблемных случаях, обрывались раньше.
  • Проблема в самом приложении, битая сборка. Откатили клиента на предыдущую версию у нескольких тестировщиков — ошибка воспроизводилась и на старой версии приложения, значит дело не в коде клиента, а во внешнем окружении, которое изменилось независимо от релизов приложения.

Ни firewall, ни DNS, ни само приложение под подозрение не подходили — все факты указывали на TLS-хендшейк именно с этим конкретным сервером, и именно с этого момента по времени, который совпал с последним reload nginx.

Реальная причина: где потерялся промежуточный сертификат

Финальную проверку сделали командой, которая должна быть в закладках у любого, кто администрирует TLS на сервере:

openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null 2>/dev/null | openssl x509 -noout -text | grep -A2 "Issuer\|Subject"

а нагляднее — так:

echo | openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts 2>/dev/null | grep -c "BEGIN CERTIFICATE"

Результат — 1, вместо ожидаемых двух (листовой сертификат плюс промежуточный). Сервер действительно отправлял в TLS-хендшейке только один сертификат, без цепочки до промежуточного CA. Дальше проверка самого файла на диске всё подтвердила:

grep -c "BEGIN CERTIFICATE" /etc/letsencrypt/live/api.example.com/cert.pem     # 1
grep -c "BEGIN CERTIFICATE" /etc/letsencrypt/live/api.example.com/fullchain.pem  # 2

Вот и разгадка: в конфиге nginx стоял путь на cert.pem, а не на fullchain.pem. Certbot кладёт в директорию /etc/letsencrypt/live/<domain>/ сразу четыре файла — cert.pem (только сертификат домена), chain.pem (только промежуточный сертификат CA), fullchain.pem (склейка домена и промежуточного) и privkey.pem (приватный ключ). Использовать в ssl_certificate нужно именно fullchain.pem — это задокументировано, но при ручной правке конфига или копировании из другого шаблона легко перепутать похожие имена файлов, особенно если раньше в проекте использовался другой ACME-клиент с другой раскладкой файлов.

Почему браузеры выжили, а мобильное приложение — нет

Самый неочевидный момент во всей истории — почему сайт в браузере открывался нормально при абсолютно той же неполной цепочке на сервере. Дело в том, что современные десктопные браузеры (через TLS-стек операционной системы) при построении цепочки сертификатов умеют дозагружать недостающий промежуточный сертификат самостоятельно — по расширению Authority Information Access (AIA) в самом листовом сертификате, где указан URL, откуда можно скачать сертификат вышестоящего CA. Кроме того, ОС и браузеры кешируют промежуточные сертификаты, которые уже видели при заходе на другие сайты, использующие тот же удостоверяющий центр — а Let's Encrypt используется настолько массово, что шанс встретить его промежуточный сертификат «в кеше» у активного пользователя браузера очень высок.

Мобильные приложения работают иначе. Многие мобильные HTTP-стеки — как встроенный TLS в Android-рантайме, так и сторонние библиотеки, которыми пользуются приложения, — по умолчанию не делают такую дозагрузку промежуточных сертификатов через AIA и полагаются строго на то, что прислал сервер в хендшейке, плюс на то, что уже есть в системном хранилище доверенных корневых и промежуточных сертификатов устройства. Если промежуточный сертификат не был предварительно установлен в системе и сервер не прислал его сам — цепочка не строится, и клиент считает соединение недоверенным. Именно поэтому проблема проявлялась не у всех: у части пользователей нужный промежуточный сертификат мог оказаться в системном хранилище по другим причинам (например, они заходили в браузере на другой сайт с тем же CA на этом устройстве), а у остальных — нет.

Отсюда практическое правило: сервер обязан сам присылать полную цепочку сертификатов, не полагаясь на то, что клиент её дозагрузит откуда-то ещё. Это касается не только Let's Encrypt — любой сертификат от любого коммерческого CA поставляется вместе с одним или несколькими промежуточными сертификатами, и все их нужно отдавать в TLS-хендшейке.

Что изменили после инцидента

Помимо очевидного немедленного фикса — поправить путь в конфиге на fullchain.pem и перезагрузить nginx — команда добавила несколько защитных механизмов, чтобы такая ошибка не повторилась незаметно:

  1. Проверка полноты цепочки как часть деплоя. В скрипт, который выполняется после каждого nginx -s reload на проде, добавили простую проверку:
#!/bin/bash
DOMAIN="api.example.com"
CHAIN_COUNT=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" -showcerts 2>/dev/null | grep -c "BEGIN CERTIFICATE")
if [ "$CHAIN_COUNT" -lt 2 ]; then
  echo "ВНИМАНИЕ: сервер $DOMAIN отдаёт неполную цепочку сертификатов ($CHAIN_COUNT шт.)"
  exit 1
fi

Скрипт встроили в тот же деплой-пайплайн, где раньше был только nginx -t — синтаксической проверки конфига оказалось недостаточно, нужна была ещё и проверка того, что реально уходит в TLS-хендшейк.

  1. Регулярный внешний мониторинг сертификата, а не только его срока действия — отдельно описали это в материале про мониторинг сертификатов и доменов: помимо даты истечения полезно проверять именно комплектность цепочки, потому что «сертификат ещё не просрочен» и «сертификат правильно настроен» — это разные утверждения.
  1. Тестирование на реальных мобильных устройствах перед раскаткой конфигурационных изменений на прод, а не только curl с сервера деплоя. curl по умолчанию тоже способен подхватывать системные сертификаты и вести себя иначе, чем строгий мобильный TLS-стек, поэтому «curl отработал» не гарантия того, что мобильное приложение подключится.
  1. Документированный чек-лист по TLS для новых людей в команде, куда добавили короткое объяснение, чем cert.pem отличается от fullchain.pem, и почему в ssl_certificate нужен именно второй файл. Отдельно зафиксировали, что при переходе на другой ACME-клиент (certbot, acme.sh, lego) нужно заново сверять раскладку файлов — она отличается между клиентами.
  1. Проверка через публичный SSL-тестер (например, SSL Labs) как ручной шаг после значимых изменений в TLS-конфигурации — такие сервисы прямо помечают неполную цепочку статусом вроде «Chain issues: Incomplete», и это самый быстрый способ поймать проблему до того, как она дойдёт до пользователей.

Если вы держите бэкенд для мобильного приложения на собственном сервере, стоит один раз собрать такой набор проверок и включить его в пайплайн — тестовый и прод-стенды для этого удобно разносить на разные VPS, чтобы прогонять проверки цепочки сертификатов на изолированном окружении перед выкаткой на боевой сервер.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, что сервер отдаёт полную цепочку сертификатов?

Выполните echo | openssl s_client -connect ваш-домен:443 -servername ваш-домен -showcerts 2>/dev/null | grep -c "BEGIN CERTIFICATE". Если результат меньше двух (при условии, что ваш CA использует один промежуточный сертификат — уточните у конкретного удостоверяющего центра, сколько их в цепочке), сервер отдаёт неполную цепочку.

Почему curl и браузер не показывали проблему, а мобильное приложение — показывало?

Десктопные браузеры и многие консольные утилиты используют TLS-стек операционной системы, который умеет дозагружать недостающие промежуточные сертификаты по расширению AIA в сертификате и кеширует ранее увиденные промежуточные сертификаты. Многие мобильные приложения так не делают и полагаются строго на то, что сервер передал в хендшейке.

В чём разница между cert.pem, chain.pem и fullchain.pem у certbot?

cert.pem — только сертификат вашего домена, chain.pem — только промежуточный сертификат CA, fullchain.pem — их склейка в правильном порядке. В директиве ssl_certificate в nginx всегда должен использоваться fullchain.pem.

Может ли такая проблема появиться сама по себе, без правки конфига вручную?

Да, если вы переезжаете с одного ACME-клиента на другой, меняете панель управления сертификатами, восстанавливаете конфиг из старого бэкапа или используете шаблон конфигурации, написанный для другого CA с другой раскладкой файлов — в каждом из этих случаев легко подставить не тот файл.

Помогает ли OCSP stapling от этой проблемы?

Нет, это разные механизмы: OCSP stapling подтверждает, что сертификат не отозван, а полнота цепочки — это про то, может ли клиент вообще построить путь доверия от вашего сертификата до корневого CA. Одно не заменяет другое, оба нужно настраивать и проверять отдельно.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →