Важно: тумблера «Сертификаты Минцифры» в панели Bothost пока нет. Пока он не вышел, сертификаты можно подключить вручную — ниже два рабочих способа. Когда тумблер появится, достаточно будет включить его и сделать редеплой: агент сам смонтирует CA и выставит переменные окружения.

Для кого эта статья

Если бот или Mini App в мессенджере MAX ведёт себя странно с HTTPS: в браузере без VPN открывается, а в MAX без VPN нет; или Python/Node пишет CERTIFICATE_VERIFY_FAILED / unable to get local issuer certificate на запросах к российским API (ФНС, банки, госуслуги, часть сервисов вокруг MAX) — почти всегда дело в сертификатах, а не в «сломанном прокси». В статье два разных TLS, которые путают чаще всего:
  • Входящий HTTPS — что видит MAX, когда открывает ваш Mini App или вешает вебхук на https://….bothost.tech.
  • Исходящий HTTPS — чем ваш контейнер на Bothost сам ходит в интернет (MAX Bot API, эквайринг, ФНС).
Сертификаты НУЦ Минцифры (Russian Trusted CA) нужны для второго случая. Первый — про полную цепочку того сертификата, которым закрыт ваш домен.

Зачем MAX вообще смотрит на сертификаты

MAX — не обычный Chrome. Встроенный WebView (Android) и серверы подписки на вебхук проверяют TLS строже, чем браузер на том же телефоне.
  • Браузер давно кэширует промежуточные CA и умеет докачать недостающее по AIA.
  • MAX WebView часто не докачивает цепочку: нет промежуточного — нет доверия.
  • Самоподписанный сертификат MAX не примет. Для вебхука это прямо сказано в POST /subscriptions: только HTTPS на 443, домен совпадает с CN/SAN, отдаётся полная цепочка.
  • На Android системное хранилище CA обновляется медленнее Chrome. Новые промежуточные Let's Encrypt (YR1 → Root YR → ISRG Root X1, цепочка 2025–2026) браузер уже ест, а приложение — ещё нет: типичная ошибка Trust anchor for certification path not found. С VPN путь другой, и «само проходит».
Итого: браузер без VPN зелёный ≠ MAX без VPN откроет Mini App. Это не баг вашего кода и не «прокси отдал только листовой сертификат», если цепочка на самом деле полная. Проверить, что именно отдаёт сервер:
echo | openssl s_client -connect ваш-домен:443 -servername ваш-домен -showcerts
Нормально увидеть три блока BEGIN CERTIFICATE: лист сайта + промежуточный + кросс-подпись до доверенного корня (для Let's Encrypt YR1 это YR1 и Root YR, подписанный ISRG Root X1). Сам корневой ISRG Root X1 в handshake слать не нужно — он должен быть у клиента. Подробнее про вебхук и требования к URL — в статье «Вебхуки в MAX Bot API».

Что такое сертификаты Минцифры и зачем они боту

НУЦ Минцифры (Russian Trusted CA) — российский удостоверяющий центр. Его корневой и промежуточный сертификаты нет в Mozilla CA (тот bundle, который лежит в Docker-образах Python/Node из коробки). Поэтому контейнер на Bothost по умолчанию:
  • спокойно ходит на Telegram, Discord, GitHub, Let's Encrypt;
  • ломается на HTTPS к сайтам, которые подписаны только российским УЦ: ФНС, ряд банковских API, часть госуслуг и корпоративных шлюзов.
Ошибка выглядит так:
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate
В MAX это всплывает не у всех. Само https://platform-api.max.ru обычно на «обычном» коммерческом CA. Минцифры нужны, когда бот из контейнера ходит ещё и в российский контур: оплата, ФНС, внутренние API заказчика, Mini App-бэкенд на российском УЦ. Важно не перепутать направления: | Куда смотрит TLS | Что чинит Минцифры в контейнере | Что не чинит | |---|---|---| | Бот → API банков / ФНС / госов | Да: контейнер начинает доверять Russian Trusted CA | — | | MAX → ваш Mini App / вебхук | Нет: MAX видит сертификат прокси/домена, не CA внутри контейнера | Цепочку *.bothost.tech это не меняет | | Пользователь в Chrome → ваш сайт | Нет | Браузер уже доверяет Let's Encrypt | Если Mini App не открывается только в MAX, а в браузере тот же URL открывается — сначала проверяйте входящую цепочку (команда openssl выше), а не кладите Минцифры в бота вслепую.

Пока нет тумблера: как включить вручную

Штатный путь Bothost (когда тумблер выйдет): агент скачает корневой и промежуточный CA с gu-st.ru, соберёт комбинированный bundle (системные CA плюс Минцифры) и выставит переменные. Комбинированный bundle обязателен: если подставить только российские CA, отвалятся Telegram, GitHub и любой обычный HTTPS. Сейчас тумблера нет — сделайте то же в коде бота или файлами в репозитории. Оба способа ниже. После любого из них нужен редploy (пересборка / «Обновить из Git»), чтобы файлы и env попали в контейнер.

Способ 1. Скачать CA при старте (предпочтительный)

Официальные файлы Минцифры периодически обновляют. Скачивать их на старте проще, чем хранить протухший PEM в git. Python — положите рядом с точкой входа, вызовите ensure_mincifry_certs() до первого requests.get:
import os
import ssl
import urllib.request
from pathlib import Path

ROOT_CA = "https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt"
SUB_CA = "https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt"
CERT_DIR = Path(os.getenv("CERT_DIR", "/app/certs/russian-trusted"))


def _download(url: str) -> bytes:
    req = urllib.request.Request(url, headers={"User-Agent": "bothost-bot/1.0"})
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = resp.read()
    if b"BEGIN CERTIFICATE" not in data:
        raise RuntimeError(f"Не сертификат: {url}")
    return data if data.endswith(b"\n") else data + b"\n"


def _system_bundle() -> bytes:
    for path in (
        "/etc/ssl/certs/ca-certificates.crt",
        "/etc/ssl/cert.pem",
        "/etc/pki/tls/certs/ca-bundle.crt",
    ):
        p = Path(path)
        if p.is_file():
            data = p.read_bytes()
            return data if data.endswith(b"\n") else data + b"\n"
    try:
        import certifi
        data = Path(certifi.where()).read_bytes()
        return data if data.endswith(b"\n") else data + b"\n"
    except Exception:
        return b""


def ensure_mincifry_certs() -> Path:
    CERT_DIR.mkdir(parents=True, exist_ok=True)
    russian = _download(ROOT_CA) + _download(SUB_CA)
    (CERT_DIR / "russian_trusted_ca_bundle.crt").write_bytes(russian)
    combined = _system_bundle() + russian
    combined_path = CERT_DIR / "ca-bundle.crt"
    combined_path.write_bytes(combined)

    os.environ["SSL_CERT_FILE"] = str(combined_path)
    os.environ["REQUESTS_CA_BUNDLE"] = str(combined_path)
    os.environ["CURL_CA_BUNDLE"] = str(combined_path)
    os.environ["NODE_EXTRA_CA_CERTS"] = str(CERT_DIR / "russian_trusted_ca_bundle.crt")
    ssl.create_default_context()  # прогрев, чтобы процесс подхватил SSL_CERT_FILE
    return combined_path


if __name__ == "__main__":
    ensure_mincifry_certs()
    # дальше обычный запуск бота
Переменные лучше выставить до импорта requests / httpx. Если библиотека уже создала пул SSL — перезапустите процесс (на Bothost это редеплой). Node.js — Минцифры достаточно как дополнительных CA (NODE_EXTRA_CA_CERTS не затирает встроенные):
const fs = require("fs");
const path = require("path");
const https = require("https");

const ROOT = "https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt";
const SUB = "https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt";
const dir = process.env.CERT_DIR || "/app/certs/russian-trusted";

async function download(url) {
  const res = await fetch(url, { headers: { "User-Agent": "bothost-bot/1.0" } });
  const text = await res.text();
  if (!text.includes("BEGIN CERTIFICATE")) throw new Error("Не сертификат: " + url);
  return text.endsWith("\n") ? text : text + "\n";
}

async function ensureMincifryCerts() {
  fs.mkdirSync(dir, { recursive: true });
  const russian = (await download(ROOT)) + (await download(SUB));
  const russianPath = path.join(dir, "russian_trusted_ca_bundle.crt");
  fs.writeFileSync(russianPath, russian);
  process.env.NODE_EXTRA_CA_CERTS = russianPath;
}

ensureMincifryCerts().then(() => {
  require("./bot.js");
});
Для NODE_EXTRA_CA_CERTS Node читает файл при старте процесса. Поэтому скачивание должно быть в отдельном маленьком start.js, который потом require основной файл — не наоборот.

Способ 2. Файлы в репозитории + переменные в панели

Если бот в сети, откуда gu-st.ru недоступен в момент старта, положите PEM в git (каталог certs/) и соберите combined bundle один раз на своей машине:
curl -fsSL https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt -o certs/root.crt
curl -fsSL https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt -o certs/sub.crt
cat certs/root.crt certs/sub.crt > certs/russian_trusted_ca_bundle.crt
# combined: системный bundle + российские
cat /etc/ssl/certs/ca-certificates.crt certs/russian_trusted_ca_bundle.crt > certs/ca-bundle.crt
На Windows combined удобнее собрать в WSL или Git Bash. В панель Bothost (раздел переменные окружения бота) добавьте: | Переменная | Значение | Зачем | |---|---|---| | SSL_CERT_FILE | /app/certs/ca-bundle.crt | OpenSSL / Python ssl | | REQUESTS_CA_BUNDLE | /app/certs/ca-bundle.crt | requests, часть httpx | | CURL_CA_BUNDLE | /app/certs/ca-bundle.crt | curl внутри контейнера | | NODE_EXTRA_CA_CERTS | /app/certs/russian_trusted_ca_bundle.crt | Node, только российские | Путь /app/... — рабочая директория контейнера Bothost. Если код лежит не в корне образа, поправьте путь. Нельзя ставить SSL_CERT_FILE / REQUESTS_CA_BUNDLE на файл, где лежат только сертификаты Минцифры. Тогда отвалится исходящий HTTPS к Telegram и к самому MAX Bot API. После сохранения переменных — редploy. Env без пересборки контейнера у уже запущенного процесса не подхватится.

Как проверить, что заработало

В том же контейнере (логи бота или одноразовый скрипт):
import os
import requests

print("SSL_CERT_FILE", os.getenv("SSL_CERT_FILE"))
print("REQUESTS_CA_BUNDLE", os.getenv("REQUESTS_CA_BUNDLE"))

# Замените URL на тот, который у вас падал
r = requests.get("https://ваш-российский-api.example", timeout=20)
print(r.status_code)
Для Node:
require("https").get("https://ваш-российский-api.example", (res) => {
  console.log(res.statusCode);
}).on("error", console.error);
Если ошибка была unable to get local issuer certificate и после редеплоя ушёл 200 — Минцифры на месте. Проверка входящей цепочки для Mini App / вебхука (это не Минцифры в контейнере):
echo | openssl s_client -connect bot-xxxx.bothost.tech:443 -servername bot-xxxx.bothost.tech -showcerts
Ищите несколько сертификатов подряд и Verify return code: 0 (ok).

Когда Минцифры не нужны

  • Обычный Telegram / Discord бот без российских HTTPS API.
  • Long polling к platform-api.max.ru, если этот хост у вас и так открывается без SSL-ошибки.
  • «MAX не открывает Mini App, а Chrome открывает» — сначала цепочка входящего сертификата домена, не CA внутри бота.

Что будет, когда появится тумблер

В панели у бота появится флажок «Сертификаты Минцифры». Он запишет флаг в биллинг; со следующего редеплоя агент:
  • скачает CA с gu-st.ru на ноду;
  • примонтирует их в контейнер в /usr/local/share/ca-certificates/russian-trusted (только чтение);
  • выставит SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE на комбинированный bundle и NODE_EXTRA_CA_CERTS на российский.
Ручной код из этой статьи после этого можно убрать, чтобы не качать CA дважды. До выката тумблера ручной способ — рабочий продакшен, не костыль «на выходные».

Коротко

  • MAX проверяет HTTPS строже браузера: нужна полная цепочка на домене Mini App / вебхука.
  • Минцифры — это доверие из контейнера к российским УЦ. Они не подменяют сертификат *.bothost.tech.
  • Тумблера в панели пока нет: скачайте CA с gu-st.ru при старте или положите PEM в репозиторий и пропишите env, затем сделайте редеплой.
  • SSL_CERT_FILE всегда на системные CA + Минцифры, иначе сломаете Telegram и обычный интернет бота.

9 просмотров
0 лайков
0 комментариев