mailcow: сертификат обновился, а IMAP просрочен
АйТи Фреш
Linux, Docker и DevOps

Браузер видит новый сертификат mailcow, а Outlook по IMAP — просроченный: куда он на самом деле передаётся

Автор: , директор ООО «АйТи-Фреш» · · ~15 мин чтения
Свежий сертификат mailcow долетает до браузера, но не до IMAP и SMTP почтовых клиентов — разные потребители одного файла
Один сертификат, два независимых потребителя: обновление на прокси не долетает до Dovecot и Postfix само.

Если HTTPS у mailcow зелёный, а настольные почтовые клиенты по IMAP и SMTP ругаются на просроченный сертификат — прокси и почтовые службы получили сертификат из разных мест. Веб-интерфейс отдаёт то, что видит обратный прокси, а Postfix и Dovecot читают файлы напрямую из data/assets/ssl/. Разбираю, где физически лежит сертификат для каждой службы, что нужно сделать при внешнем ACME-клиенте и как проверить каждый протокол отдельно, не полагаясь на браузер.

Кейс: HTTPS работает, а сотрудники не могут получить почту с телефона

«НьюБизнес Лаб» — инкубатор стартапов, 26 рабочих мест, резиденты меняются раз в несколько месяцев, и у каждой команды свой почтовый домен на общем mailcow. Веб-почту используют не все — часть резидентов настраивает корпоративную почту на телефонах и в Thunderbird как обычно, через IMAP и SMTP. Сертификат Let's Encrypt у них выпускал не встроенный ACME-клиент mailcow, а внешний реверс-прокси на входе в инфраструктуру — так исторически сложилось, потому что через тот же прокси проходит ещё несколько внутренних сервисов инкубатора.

Прокси продлил сертификат по плану, за месяц до истечения, и все сервисы за ним обновились сами. Веб-интерфейс SOGo открывался нормально, замочек в браузере зелёный, срок действия свежий. А ровно через месяц, в понедельник утром, в поддержку посыпались жалобы: у половины резидентов Thunderbird и мобильная почта на iPhone стали ругаться на просроченный сертификат при подключении, хотя в пятницу всё работало. Администратор первым делом проверил сертификат в браузере ещё раз — убедился, что дата свежая, — и завис в недоумении: сертификат ведь обновили, а клиенты видят старый.

Дело в том, что HTTPS для веб-интерфейса и шифрование для IMAP/SMTP в mailcow — это не один и тот же путь до сертификата, хотя выглядит как единая система. Обратный прокси перед mailcow обслуживает только HTTP/HTTPS-трафик и ничего не знает про порты 993 (IMAPS) и 465/587 (SMTPS/STARTTLS) — на них напрямую отвечают контейнеры dovecot-mailcow и postfix-mailcow, у которых свой собственный, отдельно читаемый набор файлов сертификата.

Куда mailcow на самом деле складывает сертификат для почтовых служб

По умолчанию mailcow сам получает и обновляет сертификаты Let's Encrypt через собственный ACME-клиент, и тогда вся эта механика прозрачна — контейнер acme-mailcow кладёт свежий сертификат в единое место и перезапускает нужные службы сам. Проблема начинается ровно тогда, когда встроенный ACME-клиент отключают, потому что сертификатами занимается что-то снаружи — внешний certbot, Traefik, corporate PKI или, как в случае с инкубатором, общий реверс-прокси инфраструктуры. Для этого в mailcow.conf есть переменная SKIP_LETS_ENCRYPT=y, которая останавливает собственную попытку mailcow получить сертификат; чтобы она применилась, документация требует пересоздать контейнер acme-mailcow (docker compose up -d).

После этого ответственность за доставку сертификата в нужное место полностью переходит на администратора. Документация прямо указывает единственный официальный путь: полную цепочку сертификата (сам сертификат плюс промежуточный CA, если он есть) нужно сохранить в data/assets/ssl/cert.pem, а соответствующий приватный ключ — в data/assets/ssl/key.pem. И отдельным пунктом с пометкой IMPORTANT: файлы нужно именно копировать, символические ссылки на файлы, лежащие где-то ещё (например, в каталоге Let's Encrypt другого сервиса), использовать нельзя.

Именно эти два файла и читают напрямую postfix-mailcow для SMTPS/STARTTLS и dovecot-mailcow для IMAPS/POP3S — без какого-либо посредничества реверс-прокси. Прокси в этой схеме обслуживает только порты 80/443 для веб-интерфейса и, возможно, свои собственные сервисы, а про то, что происходит на 993 и 465/587 портах, вообще ничего не знает и знать не должен. Поэтому обновление сертификата на прокси в принципе не может само по себе долететь до Dovecot и Postfix — это два независимых потребителя одного и того же логического сертификата, и синхронизировать их должен именно администратор, руками или автоматическим хуком.

Если сертификат для mailcow получает внешний ACME-клиент, обновление на самом прокси не долетает до почтовых протоколов автоматически ни при каких обстоятельствах — это архитектурно два разных потребителя одного файла, и без явного шага копирования плюс перезапуска ничего не произойдёт.
Схема: nginx, dovecot и postfix читают один сертификат из data/assets/ssl, но внешний реверс-прокси не связан с почтовыми портами
Реверс-прокси обновляет только то, что видит сам — порты 993 и 587 вне его зоны ответственности.

Post-hook для внешнего ACME-клиента: автоматизируем разовую боль

Чтобы не повторять ручное копирование при каждом продлении — а сертификат Let's Encrypt по умолчанию живёт 90 дней, и certbot продлевает его примерно за 30 дней до истечения — документация mailcow описывает вариант с post-hook скриптом, который запускается внешним ACME-клиентом сразу после успешного получения нового сертификата. Идея простая: скрипт копирует свежие fullchain.pem и privkey.pem из каталога, где их хранит внешний клиент (например certbot кладёт их в /etc/letsencrypt/live/домен/), в data/assets/ssl/cert.pem и data/assets/ssl/key.pem внутри каталога mailcow, а затем перезапускает три контейнера, которые реально используют TLS: postfix-mailcow, dovecot-mailcow и nginx-mailcow.

#!/usr/bin/env bash
# post-hook certbot: /etc/letsencrypt/renewal-hooks/deploy/mailcow-deploy.sh
MAILCOW_DIR=/opt/mailcow-dockerized
DOMAIN=mail.example.com

cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $MAILCOW_DIR/data/assets/ssl/cert.pem
cp /etc/letsencrypt/live/$DOMAIN/privkey.pem  $MAILCOW_DIR/data/assets/ssl/key.pem

# хук вызывается после продления любого домена — реагируем только на свой
[ "$RENEWED_LINEAGE" = "/etc/letsencrypt/live/$DOMAIN" ] || exit 0

docker restart $(docker ps -qaf name=postfix-mailcow) $(docker ps -qaf name=dovecot-mailcow) $(docker ps -qaf name=nginx-mailcow)

Обратите внимание на команду перезапуска — в документации она дана именно как docker restart $(docker ps -qaf name=...) (в примере post-hook — одной командой для трёх контейнеров), а не как docker compose restart служба. Разница практическая: docker compose restart требует, чтобы скрипт запускался из каталога с docker-compose.yml и знал точное имя сервиса из docker-compose.yml, а docker ps -qaf name=... находит контейнер по маске имени независимо от текущей директории — это удобнее для post-hook скрипта, который запускается внешним ACME-клиентом со своим рабочим каталогом, а не из-под mailcow.

Certbot поддерживает такие хуки нативно через каталог /etc/letsencrypt/renewal-hooks/deploy/ — любой исполняемый скрипт там запускается автоматически после успешного продления любого домена, а путь к продлённому сертификату certbot передаёт в переменной RENEWED_LINEAGE — поэтому в примере выше стоит проверка, чтобы продление соседнего домена не перезапускало почту. Если у вас Traefik или другой ACME-клиент — принцип тот же, но механизм подключения хука будет другим, нужно смотреть в его собственной документации, как выполнить произвольную команду после выпуска сертификата.

Проверять такой хук на боевом сертификате рискованно: реальное продление случится только через месяцы, а до тех пор ошибка в скрипте останется незамеченной. Certbot закрывает эту проблему флагом --dry-run — он проходит весь процесс продления против тестового окружения Let's Encrypt, не трогая боевой сертификат, а на выходе всё равно выполняет deploy-hook, если добавить к вызову --deploy-hook /etc/letsencrypt/renewal-hooks/deploy/mailcow-deploy.sh вручную. Права на сам скрипт должны быть chmod +x, иначе certbot его молча пропустит, а в логе /var/log/letsencrypt/letsencrypt.log останется только смутное упоминание, что deploy-hook не запустился. Я в первый прогон всегда добавляю в скрипт строку logger -t mailcow-cert-hook "renewed and restarted", чтобы потом одной командой journalctl -t mailcow-cert-hook убедиться, что хук действительно сработал в прошлый раз, а не просто лежит в каталоге как красивое намерение.

Четыре шага автоматического post-hook: от продления сертификата certbot до синхронного обновления всех протоколов mailcow
Автоматический хук убирает человеческий фактор — сертификат синхронизируется при каждом продлении сам.

Как проверить каждый протокол отдельно, не веря браузеру на слово

Самая частая ошибка при диагностике таких инцидентов — судить обо всём сертификате по одному-единственному протоколу, обычно HTTPS в браузере, потому что это самое заметное и быстро проверяемое место. Правильная диагностика — прогнать каждый протокол, который использует TLS, отдельной командой и сравнить срок действия сертификата на каждом порту. Документация mailcow (Advanced SSL) для этого приводит openssl s_client для портов 587, 465, 143, 993 и 443 — с ключом -starttls для протоколов со STARTTLS.

# IMAPS, порт 993 — TLS с самого начала соединения
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

# SMTP с STARTTLS, порт 587
openssl s_client -starttls smtp -connect mail.example.com:587 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

# SMTPS, порт 465 — implicit TLS
openssl s_client -connect mail.example.com:465 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

# HTTPS для сравнения
openssl s_client -connect mail.example.com:443 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

Флаг -servername важен даже для однодоменной установки — без него при наличии на сервере нескольких сертификатов за одним IP (SNI) можно случайно получить не тот сертификат и запутать диагностику ещё больше. Три команды дают три независимых ответа notBefore/notAfter, и если по HTTPS дата свежая, а по 993 и 587 — старая, это прямое подтверждение, что прокси и почтовые контейнеры используют разные файлы сертификата, ровно как было в инкубаторе.

Отдельно стоит проверить, что сами файлы в data/assets/ssl/ — не символические ссылки, если в прошлом кто-то пытался сделать «универсальное» решение и слинковать их с каталогом Let's Encrypt: ls -la data/assets/ssl/cert.pem data/assets/ssl/key.pem. Символическая ссылка может смотреть на путь, недоступный изнутри контейнера (например, на хостовый путь, не примонтированный в dovecot-mailcow), — внутри контейнера такая ссылка битая: пока служба не перезапущена, она держит в памяти старый сертификат, а после перезапуска не сможет прочитать файл вовсе.

В инкубаторе причина оказалась именно в отсутствии post-hook: администратор продлевал сертификат на реверс-прокси и вручную копировал его в mailcow «когда вспоминал», а в этот раз забыл: прокси продлил сертификат за 30 дней до истечения, копия в data/assets/ssl/ осталась от прошлого цикла и через месяц честно истекла. После настройки автоматического post-hook в certbot и разового ручного копирования актуального сертификата проблема ушла полностью, и с тех пор уже два цикла продления прошли без единой жалобы.

Если у вас нет автоматического post-hook и продление идёт «по памяти» — рано или поздно это повторится. Заведите post-hook сразу при первой настройке внешнего ACME-клиента, не откладывая на потом: ручной процесс, который зависит от того, что кто-то не забудет, статистически ломается на третьем-четвёртом цикле продления.
Чек-лист проверки актуальности сертификата mailcow отдельно на HTTPS, IMAPS и SMTP через openssl s_client
Три быстрые команды точнее одного взгляда на замочек в браузере.

Что делать, если вы ещё не решили, кто выпускает сертификаты

Если инфраструктура ещё небольшая и mailcow — единственный сервис за прокси или вообще стоит напрямую, я в большинстве случаев советую не изобретать схему с внешним ACME-клиентом, а оставить встроенный ACME mailcow — он сам получает сертификат, сам кладёт его в нужное место и сам перезапускает нужные контейнеры, без единого дополнительного скрипта, который может однажды перестать срабатывать. Внешний ACME-клиент оправдан, когда сертификатами централизованно управляет что-то ещё — например, единый Traefik или corporate PKI перед десятком сервисов, и заводить для mailcow отдельный процесс получения было бы избыточно. Именно такой случай был у инкубатора — там прокси обслуживает не только почту.

Если у вас порт 80 закрыт периметровым фаерволом и встроенный HTTP-01 ACME mailcow физически не может отработать, это не повод сразу переходить на внешний клиент — у mailcow есть встроенная поддержка DNS-01 challenge (ACME_DNS_CHALLENGE=y и ACME_DNS_PROVIDER в mailcow.conf, внутри работает acme.sh), которая не требует открытого 80-го порта вообще. Я подробно разбирал этот сценарий отдельно в статье про выпуск сертификата Let's Encrypt при закрытом порте 80 — там же способ снять с себя постоянную заботу о синхронизации сертификата между прокси и почтовыми протоколами, потому что при DNS-01 через встроенный клиент такой проблемы в принципе не возникает.

Если внешний ACME уже часть инфраструктуры и переделывать архитектуру ради одного mailcow нецелесообразно — тогда именно post-hook с явным перезапуском трёх контейнеров и есть правильное постоянное решение, а не разовая заплатка. Такой хук я закладываю сразу при настройке mailcow, вместе с остальными пунктами первичного разворачивания — например, когда переносим компанию на mailcow, весь процесс от миграции ящиков до финальной проверки TLS на всех протоколах я подробно описал в пошаговом регламенте переноса на mailcow. Отдельно на плановой проверке раз в квартал прогоняю те же три команды openssl s_client по IMAPS/SMTP/HTTPS на всех клиентских mailcow — это пять минут, которые снимают целый класс тикетов «не могу получить почту с телефона» после каждого продления.

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

Почему сертификат обновился в браузере, а в Outlook и на телефоне — нет?

HTTPS для веб-интерфейса обслуживает обратный прокси перед mailcow, а IMAPS (993) и SMTPS/STARTTLS (465/587) — напрямую контейнеры dovecot-mailcow и postfix-mailcow, которые читают отдельные файлы data/assets/ssl/cert.pem и key.pem. Обновление на прокси не долетает до этих файлов автоматически, если сертификатами занимается внешний ACME-клиент.

Как проверить срок действия сертификата отдельно на IMAP и SMTP, не открывая почтовый клиент?

Командой openssl s_client с указанием конкретного порта: openssl s_client -connect домен:993 для IMAPS, -connect домен:465 для SMTPS, openssl s_client -starttls smtp -connect домен:587 для SMTP с STARTTLS; openssl x509 -noout -dates на выходе покажет срок действия сертификата, отданного именно этим портом.

Куда именно нужно класть сертификат, если его выпускает внешний ACME-клиент?

Полную цепочку (сертификат плюс промежуточный CA) — в data/assets/ssl/cert.pem, соответствующий приватный ключ — в data/assets/ssl/key.pem внутри каталога mailcow-dockerized. Символические ссылки на файлы в другом каталоге документация использовать запрещает — только реальное копирование.

Какие контейнеры нужно перезапускать после замены сертификата вручную?

Три: postfix-mailcow, dovecot-mailcow и nginx-mailcow. Без перезапуска процессы внутри контейнеров продолжат использовать старый сертификат, загруженный в память при старте, даже если файл на диске уже обновлён.

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

Да, через post-hook скрипт у ACME-клиента — у certbot это исполняемый файл в /etc/letsencrypt/renewal-hooks/deploy/, который автоматически запускается после каждого успешного продления (путь к сертификату — в переменной RENEWED_LINEAGE) и сам копирует свежие файлы плюс перезапускает три нужных контейнера.

Стоит ли вообще использовать внешний ACME-клиент вместо встроенного в mailcow?

Если mailcow — единственный сервис за доменом, проще оставить встроенный ACME: он сам всё синхронизирует без дополнительных скриптов. Внешний клиент оправдан, когда один и тот же прокси обслуживает ещё несколько сервисов и сертификатами удобнее управлять централизованно — но тогда post-hook обязателен.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

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

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи