Russian Trusted Root CA в Node и Docker: как пережить смену гос-API без простоя

Веб Штурм
  • Node.js
  • Docker
  • TLS
  • сертификаты
  • Russian Trusted CA
  • Минцифры
  • 152-ФЗ
  • zero-downtime
  • интеграции
  • DevOps

Когда российская нац-платформа переезжает на новый API-домен с сертификатом доверенного УЦ Минцифры (Russian Trusted Root CA), интеграция на Node/Docker внезапно перестаёт ходить к этому домену — соединение рвётся на проверке TLS-цепочки. Причина не в коде, а в том, что этого корневого сертификата нет в дефолтном trust store рантайма. Решение — добавить корень через NODE_EXTRA_CA_CERTS (additive, не ломая остальной TLS), вынести адрес API в переменную окружения и провести переключение обратимо: серт заранее → smoke на новом endpoint → флип → быстрый откат. Ниже — конкретика на примере перехода мессенджера MAX на новый домен Bot API.

Контекст: что именно меняется

Платформа уведомляет о двух связанных изменениях:

  1. Меняется домен API — старый хост выводится из эксплуатации к фиксированному дедлайну, появляется новый.
  2. Новый хост обслуживается сертификатом Russian Trusted Root CA — корневым сертификатом национального УЦ Минцифры.

Второй пункт — главная ловушка. Russian Trusted Root CA не входит в набор доверенных корней, с которым поставляется Node.js (его CA-bundle построен на наборе Mozilla). Значит, как только клиент пойдёт на новый домен, проверка цепочки доверия не соберётся.

Симптом: код верный, рвётся TLS

При обращении к домену с неизвестным корнем Node обрывает соединение ещё до прикладного уровня. Типичные ошибки:

Error: unable to get local issuer certificate
  code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'

или SELF_SIGNED_CERT_IN_CHAIN / CERT_UNTRUSTED — в зависимости от того, какого звена цепочки не хватает. Важно понимать: приложение здесь ни при чём. HTTP-запрос корректен, токен валиден — отказывает проверка TLS-цепочки, потому что рантайму нечем подтвердить подпись серверного сертификата. Лечится это не правкой кода, а добавлением корня в доверенные.

Решение: NODE_EXTRA_CA_CERTS — additive, а не замена

В Node есть штатный механизм расширить набор доверенных корней — переменная окружения NODE_EXTRA_CA_CERTS:

NODE_EXTRA_CA_CERTS=/etc/ssl/certs/russian_trusted_ca.pem

Ключевое свойство — она additive. Node добавляет сертификаты из этого файла к встроенному bundle, а не подменяет его. Это критично: соединения с базой, очередями, прочими внешними API продолжают проверяться по международным корням как раньше. Вы расширяете доверие ровно на один известный корень, не ослабляя проверку для остального трафика. Это принципиально безопаснее, чем глобальные «костыли» вроде NODE_TLS_REJECT_UNAUTHORIZED=0, который выключает проверку целиком и открывает дорогу man-in-the-middle на любом соединении — так делать нельзя.

Грабли, которые стоит знать заранее

  • PEM должен быть валидным и непустым. Если файл пуст, битый или путь указывает в никуда, Node при старте бросит ошибку парсинга CA — процесс не поднимется. Это, как ни странно, хорошо: «тихого» проигнорированного серта не будет.
  • Root + Sub в одном файле. Цепочка доверия часто состоит из корневого и промежуточного (Sub) сертификатов. Кладите оба блока BEGIN CERTIFICATE в один PEM — иначе промежуточного звена не хватит и проверка не соберётся.
  • Читается один раз при старте. NODE_EXTRA_CA_CERTS подхватывается при инициализации процесса. Поменяли серт — пересоздайте контейнер, на лету подмена файла не подхватится.
  • Переводы строк. В Linux-образе PEM с CRLF может сломать разбор цепочки. Если репозиторий редактируется и из Windows — зафиксируйте перевод строк для *.pem как LF (.gitattributes: *.pem eol=lf), иначе серт «случайно» испортится при коммите.

Источник сертификата: только официальный

Корневой сертификат — это якорь доверия. Подменённый корень означает, что злоумышленник сможет выдать себя за любой сервис, которому вы доверяете. Поэтому Russian Trusted Root CA берётся только из официального источника регулятора — портал Госуслуг / gu-st.ru (russiantrustedca.pem), а не со случайных зеркал, gist’ов и форумов. Это не формальность, а часть модели безопасности: смысл проверки цепочки исчезает, если корень взят из недоверенного места.

Параметризация base-URL: домен в env, не в коде

Вторая половина миграции — смена адреса API. Здесь принцип простой: адрес API не зашивается в код, а живёт в переменной окружения.

В нашем случае хардкода старого домена в коде и не было — он находится внутри SDK мессенджера. Но SDK позволяет переопределить базовый URL через параметр клиента, и мы выносим его в конфигурацию:

// конфигурация: домен из env с дефолтом и валидацией
const DEFAULT_BASE_URL = 'https://platform-api2.max.ru'; // новый домен
const baseUrl = process.env.MAX_API_BASE_URL || DEFAULT_BASE_URL;
new URL(baseUrl); // ранняя валидация: битый URL → понятная ошибка на старте

// клиент: base-URL приходит из конфигурации, а не зашит в SDK
const bot = new Bot(token, { clientOptions: { baseUrl } });

Что это даёт:

  • дефолт уже указывает на новый домен — после деплоя клиент сразу ходит на правильный хост;
  • переопределение через env делает откат тривиальным — вернуть старый домен можно одной переменной, без правок кода и пересборки;
  • ранняя валидация URL ловит опечатку в конфиге на старте, а не глубоко в рантайме при первом запросе.

Zero-downtime порядок: серт заранее → smoke → флип → откат

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

Порядок обратимой миграции: добавить корневой сертификат в образ заранее (additive, ничего не ломает), затем smoke-проверкой убедиться что соединение к новому endpoint валидно и getMyInfo возвращает 200, затем переключить адрес API через переменную окружения; если что-то не так — мгновенный откат на старый домен через ту же переменную без пересборки

  1. Серт заранее. Кладём CA в образ и подключаем через NODE_EXTRA_CA_CERTS. На старом домене это ничего не ломает (additive), но новый путь уже готов.
  2. Smoke на новом endpoint. Проверяем, что с добавленным CA соединение к новому домену валидно: лёгкий «пустой» вызов — например, запрос метаданных бота (getMyInfo) — должен вернуть HTTP 200. Это доказывает, что цепочка доверия собирается и токен принимается новым хостом, ещё до переключения боевого трафика.
  3. Флип через env. Переключаем MAX_API_BASE_URL (или оставляем дефолт, уже указывающий на новый домен). Код не трогаем.
  4. Откат через env. Если на новом хосте что-то пойдёт не так — возвращаем старый домен переменной (старый endpoint обычно живёт ещё какое-то время до дедлайна) и предыдущий тег образа. Откат не требует пересборки — это просто смена значения переменной.

Эта схема — частный случай общего принципа «обновляйся обратимо»: и серт, и адрес спрятаны за переменными окружения, поэтому каждый шаг можно откатить за секунды.

Docker: куда класть серт

В multi-stage Dockerfile на node:22-alpine сертификат вкладывается в финальный образ, а путь совпадает с NODE_EXTRA_CA_CERTS:

# финальная (runtime) стадия
COPY certs/russian_trusted_ca.pem /etc/ssl/certs/russian_trusted_ca.pem
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/russian_trusted_ca.pem

Серт в образе (а не во внешнем volume) делает контейнер самодостаточным: он несёт свой якорь доверия с собой, и поведение воспроизводимо между окружениями. В compose-файле переменную дублируем явно (с дефолтом), чтобы её было видно в конфигурации деплоя:

environment:
  NODE_EXTRA_CA_CERTS: "${NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/russian_trusted_ca.pem}"
  MAX_API_BASE_URL: "${MAX_API_BASE_URL:-https://platform-api2.max.ru}"

Кому это пригодится

Этот же сценарий вы встретите везде, где Node/Docker/CI-сервис ходит к российскому гос-API или нац-платформе, перешедшей на Russian Trusted Root CA: соединение валится на проверке цепочки, хотя «вчера работало» и код не менялся. Рецепт переносимый:

  • добавить корневой сертификат (Root + Sub) из официального источника через NODE_EXTRA_CA_CERTS (additive, не NODE_TLS_REJECT_UNAUTHORIZED=0);
  • вынести адрес API в переменную окружения с дефолтом и валидацией;
  • переключаться обратимо: серт заранее → smoke (200) на новом endpoint → флип → откат через env.

Так смена гос-требований превращается из аварийного простоя в плановую правку конфигурации.

Ссылки

Часто задаваемые вопросы

Что такое доверенный корневой сертификат Минцифры (Russian Trusted Root CA)?
Это корневой сертификат национального удостоверяющего центра Минцифры РФ — якорь доверия, которым подписаны сертификаты ряда российских гос-сервисов и нац-платформ. В отличие от привычных международных УЦ, его нет в стандартных наборах доверенных корней браузеров и серверных рантаймов. Чтобы программа доверяла такому сертификату, его корень нужно явно добавить в список доверенных.
Почему сервис на Node может перестать работать с гос-API при смене домена?
Когда API переезжает на домен с сертификатом Russian Trusted Root CA, рантайм проверяет цепочку доверия по своему встроенному набору корней. Этого корня там нет → проверка не собирается → соединение обрывается ошибкой вроде UNABLE_TO_GET_ISSUER_CERT. Код приложения при этом корректен — отказывает именно проверка TLS-цепочки. Лечится добавлением корневого сертификата в доверенные.
Как добавить корневой CA в Node, не сломав остальной TLS?
Через переменную окружения NODE_EXTRA_CA_CERTS с путём к PEM-файлу. Она additive: Node добавляет указанные сертификаты к встроенному набору доверенных корней, а не заменяет его. Поэтому соединения с другими сервисами (базы, очереди, прочие API) продолжают работать как раньше — расширяется только список доверенных корней.
Безопасно ли добавлять корневой CA в свой контейнер?
Да, при двух условиях. Первое: брать сертификат только из официального источника регулятора (для Russian Trusted Root CA — портал Госуслуг / gu-st.ru), а не со случайных зеркал — корневой сертификат это якорь доверия, и подменённый корень открывает дорогу man-in-the-middle. Второе: добавлять его additive (NODE_EXTRA_CA_CERTS), а не подменять весь trust store. Тогда вы расширяете доверие ровно на один известный корень, не ослабляя проверку для остального трафика.
Как провести такую миграцию без простоя?
Обратимым порядком «серт заранее → smoke → флип → откат». Сначала кладёте CA в образ и подключаете — это безопасно даже на старом домене, ничего не ломает. Затем smoke-проверкой убеждаетесь, что соединение к новому endpoint валидно (например, лёгкий вызов вроде getMyInfo должен вернуть 200). Только потом переключаете адрес API. И адрес, и серт держите за переменными окружения, чтобы откат был мгновенным, без пересборки.