MAATRIX / Блог / Антипаттерн: самоподписанный сертификат «для внутренних сервисов»

Антипаттерн: самоподписанный сертификат «для внутренних сервисов»

MAATRIX

Внутренний Grafana-дашборд, Portainer, самописный API между микросервисами — рано или поздно к ним прикручивают TLS, потому что «трафик должен быть зашифрован». И почти всегда это делают одной командой openssl req -x509, с мыслью «зачем платить/заморачиваться, если сюда снаружи никто не достучится». Формально TLS есть, галочка стоит. Но у этой экономии есть цена, которая не видна сразу — вы приучаете команду игнорировать предупреждение браузера о недоверенном сертификате, и этот рефлекс однажды сработает не там, где нужно.

Как это выглядит на практике

Сценарий почти всегда один и тот же. Поднимается внутренний сервис — панель управления, метрики, API-шлюз между бэкендом и очередью задач. Наружу он не смотрит, живёт во внутренней сети или за VPN. Кто-то вспоминает, что HTTP без шифрования — плохая практика, и выпускает сертификат прямо на месте:

openssl req -x509 -newkey rsa:4096 -keyout internal.key -out internal.crt \
  -days 3650 -nodes -subj "/CN=grafana.internal"

Сертификат подключают к nginx, всё поднимается, HTTPS работает. При первом заходе браузер показывает предупреждение:

Ваше подключение не защищено
NET::ERR_CERT_AUTHORITY_INVALID

Инженер жмёт «Дополнительно» → «Перейти на сайт (небезопасно)» и идёт дальше. Через неделю то же самое делает второй человек, через месяц — пятый, потому что по той же схеме добавили ещё пару внутренних сервисов. Складывается привычка: браузер ругается на внутренний адрес — это норма, надо кликнуть мимо. Мы уже разбирали похожий случай в настройке Portainer — та же логика, тот же клик «Продолжить», просто другой инструмент.

Почему это кажется разумным решением

Аргумент «зачем платить, если сервис не публичный» неточен сразу в двух местах. Во-первых, бесплатные сертификаты от Let's Encrypt существуют больше десяти лет — платить за TLS давно не нужно даже публичным доменам. Во-вторых, реальная причина обычно не в деньгах, а в технической преграде: внутренние домены вида grafana.internal, db01.corp или service.local не резолвятся из публичного DNS, а классическая HTTP-01 валидация Let's Encrypt требует, чтобы CA смог обратиться на ваш домен снаружи и проверить файл на веб-сервере. Раз домен снаружи не виден — HTTP-01 не проходит, и команда делает вывод: «раз обычным способом не выпустить, значит самоподписанный». Вывод верный лишь наполовину — DNS-01 валидация решает именно эту проблему, разберём её ниже.

Второй скрытый мотив — «это не публично, риска нет». Логика путает вероятность атаки с последствиями от неё: вероятность MITM во внутренней сети действительно ниже, чем в открытом интернете, но она не равна нулю, а привычка кликать мимо предупреждения работает одинаково что при вероятности 1%, что при 50% — рефлекс не различает контекст.

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

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

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

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

Это главный и самый недооценённый риск. Предупреждение о недоверенном сертификате — единственный механизм, которым обычный пользователь может заметить атаку «человек посередине» (MITM): поддельная точка Wi-Fi в кафе, скомпрометированный домашний роутер, ARP-spoofing в офисной сети, вредоносное расширение браузера. Во всех этих случаях браузер покажет то же самое NET::ERR_CERT_AUTHORITY_INVALID, что и при заходе на внутренний Grafana с самоподписанным сертификатом.

Человек, который месяцами кликал «Перейти на сайт (небезопасно)» на внутренних сервисах, физически не способен на лету отличить одно предупреждение от другого — оба воспринимаются как «это опять internal-заглушка, жмём дальше». Разница между «сертификат, который мы сами выпустили» и «кто-то перехватывает ваш трафик к банку прямо сейчас» стирается на уровне рефлекса, а не осознанного решения. Это классический эффект «усталости от предупреждений» (warning fatigue) — чем чаще человек видит алерт без реальных последствий, тем быстрее перестаёт его читать.

Похожая история с консольными инструментами: curl откажется подключаться с ошибкой SSL certificate problem: self signed certificate in certificate chain, и типичное лечение в скриптах и cron-джобах — флаг -k (--insecure), который отключает проверку сертификата навсегда для всех запросов этого скрипта, а не только к внутреннему сервису.

Проблема: сертификату нужно доверие, а не только шифрование

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

Формально доверие настраивается — сертификат (или собственный корневой CA) один раз добавляется в хранилище на каждой клиентской машине: Keychain Access на macOS, certmgr.msc на Windows, update-ca-certificates на Linux. На практике это распространение — постоянно повторяющаяся операционная задача: новому сотруднику нужно ставить сертификат на ноутбук вручную или через MDM, мобильным устройствам — отдельный профиль доверия, CI-раннерам — отдельная донастройка, иначе сборки падают на проверке цепочки. А сторонние SaaS-интеграции (вебхуки от внешних сервисов на ваш внутренний эндпоинт) в принципе не смогут довериться самодельному CA — они используют системные хранилища, куда ваш корень никогда не попадёт.

Главное — если приватный ключ такого корня когда-нибудь скомпрометирован (утёк из репозитория, остался на уволенном ноутбуке), у вас, скорее всего, нет инфраструктуры для отзыва: ни CRL, ни OCSP, ни реестра машин, куда корень был установлен. Единственный вариант — перевыпустить всё и обойти каждую машину заново. Похожий сценарий с обратной стороны — не компрометация, а забытое истечение срока — мы разбирали в статье внутренний CA протух через десять лет и остановил обмен между сервисами: там никто не украл ключ, просто про сам факт существования корня забыли на годы, а когда истёк срок — легло всё разом.

Скрытые издержки: где самоподписанный сертификат ломает автоматизацию

Экономия на «настоящем» сертификате оборачивается повторяющимися часами инженерного времени в других местах:

  • Мониторинг. Zabbix, Uptime Kuma, Prometheus blackbox exporter по умолчанию валидируют цепочку сертификата. С самоподписанным они либо репортуют false positive «сервис недоступен», либо кто-то отключает проверку в конфиге — а заодно и возможность заметить, если сертификат по-настоящему протухнет или будет подменён.
  • API-клиенты. Python requests, Node axios, любой внутренний скрипт падает на проверке цепочки. Типичное лечение — verify=False в коде или NODE_TLS_REJECT_UNAUTHORIZED=0 в окружении, и это почти никогда не остаётся точечным: переменная уезжает в общий .env или Dockerfile и молча отключает проверку для всех HTTPS-запросов процесса.
  • Docker и Kubernetes. Реестр образов с самоподписанным сертификатом требует прописывать insecure-registries в daemon.json или монтировать CA-бандл в каждый под — ещё один шаг, который нужно синхронизировать по всем нодам и не забыть при добавлении новой.
  • Ротация. Сертификат на 10 лет «чтобы не думать об этом» не продлевается автоматически — об истечении узнают по внезапно упавшим интеграциям, а не по алерту, потому что мониторинг сертификатов для внутреннего домена обычно отдельно никто не настраивает.

Каждый пункт по отдельности — мелочь на пять минут. Но именно из таких костылей, разбросанных по мониторингу, CI и коду, складывается реальная стоимость «бесплатного» самоподписанного сертификата — просто она растянута во времени и не видна в моменте выпуска.

Как сделать правильно: ACME с DNS-валидацией или свой CA

Задача решается одним из двух способов, и оба закрывают проблему без ручной раздачи корневых сертификатов по машинам.

Вариант А — настоящий сертификат от публичного CA через DNS-01. Let's Encrypt и другие ACME-центры умеют выпускать сертификат не только через HTTP-01 (когда CA стучится на ваш веб-сервер снаружи), но и через DNS-01 — достаточно, чтобы CA прочитал TXT-запись в публичной зоне DNS вашего домена. Сам сервис при этом может вообще не иметь публичного IP — важна только зона DNS, а не доступность хоста. Это снимает исходную техническую причину, из-за которой команды тянутся к самоподписанным сертификатам для *.internal-доменов.

Через acme.sh и API DNS-провайдера (пример для Cloudflare):

export CF_Token="ваш-api-token"
acme.sh --issue --dns dns_cf -d grafana.internal.example.com

Или через certbot с DNS-плагином:

certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d "*.internal.example.com"

Обратите внимание на wildcard-вариант — если внутренних поддоменов много (grafana.internal, api.internal, db-admin.internal), один сертификат закрывает их все и не нужно перевыпускать при каждом новом сервисе. Бонус: в логах Certificate Transparency, куда публичные CA обязаны публиковать каждый выданный сертификат, засветится только факт wildcard-сертификата, а не конкретные имена внутренних хостов. Сравнение certbot и acme.sh по удобству и поддержке DNS-плагинов — в статье certbot или acme.sh, что выбрать для сервера.

Вариант Б — собственный внутренний CA с нормальным управлением. Если внутренних доменов много, они не привязаны к публичной DNS-зоне, или нужна автоматическая выдача сертификатов между десятками сервисов (mTLS), поднимается собственный центр сертификации — например, step-ca от Smallstep. Ключевое отличие от кустарного openssl req -x509 — управляемый жизненный цикл:

step ca init --name "Internal CA" --dns ca.internal.example.com \
  --address :8443 --provisioner admin

Дальше сервисы получают сертификаты через встроенный ACME-протокол с коротким сроком жизни — сутки-двое вместо десяти лет — и автопродлением по cron:

step ca certificate grafana.internal internal.crt internal.key \
  --provisioner acme

Короткий срок жизни — не прихоть, а осознанный компромисс: при компрометации ключа окно действия сертификата измеряется часами-днями, а не годами, и отдельная инфраструктура отзыва становится менее критичной, потому что сертификат и так скоро протухнет сам. Корень такого CA распространяется на клиенты один раз — через Ansible-плейбук, MDM-профиль или образ для CI-раннеров, а не вручную на каждой новой машине. Пошаговая установка и типичные грабли — в статье как установить и настроить step-ca на VPS.

КритерийСамоподписанный вручнуюACME + DNS-01 (Let's Encrypt)Свой CA (step-ca)
СтоимостьБесплатноБесплатноБесплатно (кроме времени на настройку)
Доверие ОС/браузером из коробкиНет, ручная установка на клиентДа, публичный CA уже в системеНет, корень нужно распространить один раз
Подходит без публичного IPДаДа, через DNS-01Да
АвтопродлениеОбычно нет (сертификат на годы)Да, штатноДа, штатно
Отзыв при компрометацииПрактически невозможенЧерез CA провайдераЧерез CRL/OCSP или короткий TTL
Десятки сервисов и mTLSПлохо масштабируетсяТребует управления DNS-записямиДля этого и создан

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

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

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

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

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

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

Сервис реально никогда не выйдет наружу — сертификат нужен такой строгий?

Шифрование канала нужно почти всегда — трафик может идти через общий свитч, Wi-Fi или облачную виртуальную сеть, где прослушивание технически возможно. А доверие клиента к сертификату важно именно потому, что «никогда не будет доступен снаружи» через год-два часто оказывается неправдой: сервис прокидывают через VPN или открывают для интеграции, а старые привычки остаются.

Что делать с legacy на .local или .lan, которые нельзя переименовать под управляемую DNS-зону?

DNS-01 через публичный CA здесь не сработает — зона не публичная. Это тот случай, где оправдан собственный CA: он не требует, чтобы домен резолвился снаружи, только чтобы клиенты внутри сети доверяли корню, установленному один раз централизованно.

ACME DNS-01 требует API-токен от DNS-провайдера в конфиге сервера — это не новая дыра?

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

Сколько реально стоит развернуть и поддерживать step-ca ради нескольких сервисов?

Начальная настройка — несколько часов на выделенном сервере или контейнере, сам сервис лёгкий. Дальше основная работа не в поддержке CA (продление автоматизируется), а в однократном распространении корня на клиенты и мониторинге, что CA сам не упал и не истёк.

Можно один раз добавить исключение в браузере, а не кликать «Продолжить» каждый раз?

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

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

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

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