unbound-mailcow не отвечает за Hetzner Robot Firewall, хотя порт 53 разрешён: где реальная блокировка
Если unbound-mailcow висит unhealthy, а исходящий порт 53 в Hetzner Robot Firewall давно разрешён — проблема не в правиле для порта 53, а в том, что фильтр stateless и не пропускает ответы DNS-сервера на случайный клиентский порт. Ниже — разбор кейса, правильный синтаксис правил, сужение диапазона через outgoing-port-avoid и порядок диагностики.
Симптом: DNS падает именно тогда, когда закрываешь периметр
История из практики. «Деталь образа» — магазин аксессуаров для одежды и обуви, 24 рабочих места, свой шоурум и интернет-магазин. Весной они решили увести корпоративную почту с общего хостинга на собственный mailcow-dockerized на выделенном сервере Hetzner, а заодно закрыть внешний периметр — до этого сервер вообще стоял без файрвола. Мы делали миграцию в рамках обслуживания корпоративной почты: подняли mailcow на Debian 12, перенесли почтовые ящики, включили Hetzner Robot Firewall и оставили открытыми только нужные снаружи порты — 25, 443, 993, 995, 4190. Всё было готово к переезду в пятницу вечером.
В понедельник в 9:40 посыпались жалобы: письма внешним получателям не уходят, зависают в очереди. Я открываю docker compose ps и вижу ожидаемую картину: контейнер unbound-mailcow в статусе unhealthy, хотя два дня назад, до включения файрвола, был healthy без единого сбоя. В логах Postfix — россыпь строк Host or domain name not found по совершенно случайным доменам, то есть проблема не с одним конкретным получателем, а с резолвингом как таковым.
Первая реакция была неправильной, и я её не скрываю. Двадцать минут я потратил на то, чтобы подозревать сам unbound: смотрел docker compose logs unbound-mailcow, перечитывал data/conf/unbound/unbound.conf, перезапускал контейнер — благо, это ничего не стоило. Логика была простая: правило протокол udp, порт назначения 53, accept в Hetzner Robot Firewall я поставил заранее и специально, значит DNS наружу открыт, ищи проблему в другом месте. Именно эта уверенность и была ошибкой — правило для порта 53 закрывает только половину картины.
Почему «порт 53 разрешён» ничего не решает: Hetzner Robot Firewall — фильтр без памяти о соединениях
Ключевую фразу я нашёл в официальной документации mailcow, в разделе про подготовку системы, где отдельным блоком идёт предупреждение именно про Hetzner: firewall дедиков описан как статический — «each incoming packet is checked isolated», то есть каждый входящий пакет проверяется изолированно, без учёта того, что было до него. Это принципиально отличается от файрвола со состоянием (stateful, conntrack), к которому все привыкли по iptables/nftables на самом сервере или по MikroTik/Keenetic в офисе: там правило ct state established,related — accept само пускает назад любой ответ на исходящее соединение. У Hetzner Robot Firewall такой памяти нет физически — это фильтр на границе сети провайдера, а не на самом сервере, и он ничего не знает о том, какие сокеты открыты внутри вашего Debian.
Что это значит конкретно для DNS. Unbound внутри контейнера отправляет запрос не с порта 53, а со случайного клиентского порта — по умолчанию это любой порт из диапазона 1024–65535, не занятый IANA. Порт выбирается случайно не по прихоти авторов: RFC 5452 после атаки Каминского требует от резолверов непредсказуемый порт источника из диапазона 1024 и выше — это вторые 16 бит защиты от поддельных ответов в дополнение к ID запроса. Пакет уходит наружу с dst_port 53 — и не к Google или резолверу провайдера: unbound-mailcow в стандартной поставке работает как полноценный рекурсивный резолвер без форвардеров (в его unbound.conf подключён root.hints), то есть ходит к корневым, TLD- и авторитативным серверам напрямую. Это исходящее направление обычно ничем не ограничено, поэтому запрос спокойно летит. А вот ответ идёт в обратную сторону: с порта 53 внешнего сервера на тот самый случайный клиентский порт, на котором его ждёт unbound. Для Hetzner Robot Firewall это отдельный, никак не связанный с запросом входящий пакет на случайный высокий порт. Если во входящем направлении разрешены только 25/443/993/995/4190 — ответ просто отбрасывается. Без ICMP unreachable, без записи в лог самого firewall, вообще без каких-либо следов на стороне сервера: с точки зрения unbound это выглядит как таймаут, будто резолвер не ответил.
Здесь важно не перепутать два разных продукта Hetzner. Firewall для Hetzner Cloud (виртуалки) — полноценный stateful-файрвол, там подобной проблемы нет: правило Allow outbound UDP 53 там действительно означает «пропускать и ответы на этот исходящий запрос». А Hetzner Robot Firewall — это отдельная история именно для выделенных серверов (dedicated root servers), и она устроена иначе. Если у вас Hetzner Cloud — читайте документацию по Cloud Firewall и не переносите сюда логику этой статьи буквально.
Как выглядит рабочее правило: диапазон портов, ACK отдельно для TCP, UDP отдельно
Официальная документация mailcow в том же разделе прямым текстом даёт готовый рецепт для Hetzner: нужны два входящих (in) правила, а не одно. Первое — для TCP: протокол tcp, диапазон портов назначения 1024–65535, флаг tcp_flags — ack, действие accept. Второе — для UDP: протокол udp, тот же диапазон портов назначения 1024–65535, действие accept, флагов у UDP нет по определению протокола. У Hetzner Robot Firewall это поля объекта правила — name, protocol, dst_port, src_port, tcp_flags, action, ip_version — они одинаковые что при настройке через панель Robot, что через официальный API firewall сервера.
Почему TCP и UDP разнесены и почему для TCP обязателен именно флаг ack — не syn, не что-то ещё. DNS-запрос, который не помещается в 512 байт без EDNS0 (например, ответ с DNSSEC-подписями или крупная зона), приходит с установленным битом TC (truncated), и резолвер переоткрывает тот же запрос уже по TCP. Флаг ack в правиле firewall означает: пропускать только пакеты, которые являются частью уже установленного клиентом соединения (то есть ответные ACK/данные), а не новые входящие SYN — так вы не открываете серверу возможность принимать произвольные входящие TCP-подключения на высокие порты, только отвечать на то, что инициировал сам сервер. Для UDP такого разделения нет физически — протокол без установления соединения, поэтому весь диапазон входящих портов приходится открывать целиком, полагаясь на то, что источник ответа — легитимный резолвер, на который сам сервер отправил запрос.
В виде таблицы это выглядит так — именно эти два правила я и добавил в Robot Firewall «Деталь образа» в понедельник в 10:20, сразу после того как связал unhealthy с включением firewall в пятницу:
| Направление | Протокол | Порт назначения | Флаги | Действие | |---|---|---|---|---| | in | tcp | 1024-65535 | ack | accept | | in | udp | 1024-65535 | — | accept | | out | tcp/udp | 53 | — | accept | (третья строка — исходящее правило на 53-й порт, оно у меня уже стояло и само по себе было верным, просто недостаточным)
Применил правила через Robot UI (вкладка Firewall у сервера), сохранил. По опыту, изменения там применяются не мгновенно — обычно в пределах минуты, иногда чуть дольше, поэтому сразу после сохранения контейнер ещё может показывать старое состояние, и торопиться с выводами рано.
Сужаем диапазон: зачем нужен outgoing-port-avoid и как его применить
Открыть весь диапазон 1024–65535 во входящем направлении — это примерно 64,5 тысячи портов, доступных снаружи для UDP-пакетов. Формально это не дыра в привычном смысле (пакет пройдёт, только если источник — легитимный внешний хост, отвечающий на реальный запрос unbound), но по духу это подрывает саму идею «закрыть периметр», ради которой мы вообще включали firewall. В той же документации mailcow для этого случая описан именно такой сценарий сужения: если хочется более строгих правил, до применения firewall-правил нужно сначала поправить конфиг unbound, добавив в {mailcow-dockerized}/data/conf/unbound/unbound.conf строку outgoing-port-avoid: 0-32767.
Механика опции — из официального описания unbound.conf от NLnet Labs: outgoing-port-avoid запрещает unbound использовать указанный порт или диапазон портов для отправки запросов, обработка идёт построчно вместе с outgoing-port-permit, сначала разрешённые порты добавляются в множество, потом из него вычитаются запрещённые. По умолчанию unbound и так использует только порты выше 1024, не занятые IANA — значит, запретив диапазон 0-32767 целиком, мы вычитаем нижнюю половину и оставляем unbound ровно диапазон 32768–65535. Под этот же диапазон дальше сужаем оба правила Hetzner Firewall — TCP с ack и UDP, было 1024–65535, стало 32768–65535. Порты, доступные снаружи для входящих UDP-пакетов, сокращаются вдвое — с 64 512 (65535 − 1024 + 1) до 32 768. Честная цена: пространство случайных портов источника тоже сжимается вдвое, это минус один бит энтропии против подделки ответов в смысле RFC 5452. Около 15 бит порта плюс 16 бит ID запроса — по-прежнему приемлемо, но выигрыш здесь в меньшем числе открытых входящих портов, а не в защите от спуфинга.
У «Деталь образа» я применил это тем же вечером, после того как убедился, что широкий диапазон вернул почту в рабочее состояние. Правки: строка в unbound.conf, docker compose restart unbound-mailcow, затем сужение обоих правил в Robot Firewall. Проверял дампом на хосте: гонял docker compose exec unbound-mailcow drill example.com по разным доменам и смотрел в tcpdump -ni <публичный_интерфейс> 'udp dst port 53' на порт источника — все запросы уходили с портов из верхней половины диапазона, ни одного ниже 32768. ss внутри контейнера для этого не годится: в образе на Alpine его нет, а сокеты под запросы unbound открывает на доли секунды.
Одна деталь, о которую легко споткнуться при следующем обновлении mailcow — и тут я сам поначалу ошибался. Файл data/conf/unbound/unbound.conf — не «ваш» персистентный конфиг, а часть git-репозитория mailcow-dockerized, в контейнер он монтируется одиночным файлом только на чтение. update.sh перед обновлением коммитит локальные правки («Before update on …»), а потом делает git merge -Xtheirs: при конфликте побеждает версия апстрима. Если разработчики поменяют строки рядом с вашей outgoing-port-avoid, правка молча пропадёт, unbound снова начнёт брать порты с 1024 — и за суженными правилами Robot Firewall DNS ляжет ровно так же, как в понедельник. Способ, переживающий обновление: держать свою копию конфига вне репозитория и подменять её через docker-compose.override.yml (он в .gitignore mailcow, update.sh его не трогает). Compose сливает тома по пути в контейнере, поэтому запись volumes: - ./data/conf/unbound/unbound.local.conf:/etc/unbound/unbound.conf:ro,Z в секции unbound-mailcow override-файла заменяет штатное монтирование. Цена — изменения апстрима в unbound.conf теперь нужно переносить руками, поэтому после каждого update.sh я делаю diff data/conf/unbound/unbound.conf data/conf/unbound/unbound.local.conf и grep outgoing-port-avoid по смонтированному файлу через docker compose exec unbound-mailcow cat /etc/unbound/unbound.conf.
Как диагностировать за 15–20 минут, а не гадать по логам
Порядок, которым я прохожу такую жалобу теперь — после «Деталь образа» я довёл его до рутины и укладываюсь в 15–20 минут. Первое — docker compose ps в каталоге mailcow-dockerized, смотрим на колонку STATUS у unbound-mailcow; unhealthy подтверждает, что дело в резолвере, а не в самом Postfix. Второе — docker compose logs --tail 50 unbound-mailcow, ищем таймауты и SERVFAIL. Третье, самое информативное — снятие дампа прямо на интерфейсе хоста, а не внутри контейнера:
# имя интерфейса на дедике Hetzner обычно вида enp0s31f6 — см. ip -br link
tcpdump -ni enp0s31f6 'udp port 53' -c 40Если в выводе видны только исходящие пакеты вида host_ip.XXXXX > ns_ip.53 (адреса корневых, TLD- и авторитативных серверов) и ни одного обратного ns_ip.53 > host_ip.XXXXX — это и есть диагноз: запросы улетают, ответы не долетают, дело в фильтрации на границе сети, а не в самом unbound и не в DNS-сервере назначения. Здесь стоит сразу сделать честную оговорку: при разборе таких дампов иногда встречается пугающая надпись про неверную контрольную сумму — это не имеет отношения к нашей проблеме, а частый артефакт выгрузки чексумм в сетевую карту; я разбирал этот эффект отдельно в статье про некорректный checksum offload в tcpdump, пакет с «неверной» суммой в дампе на исходящем интерфейсе почти всегда на самом деле уходит целым.
Четвёртое — проверка изнутри контейнера, что сам unbound жив и пытается резолвить: docker compose exec unbound-mailcow drill example.com @127.0.0.1. Если снаружи фильтрация подтвердилась дампом, эта команда либо зависнет до таймаута, либо вернёт SERVFAIL. И пятое, обязательное — свериться с актуальным состоянием правил именно в Hetzner Robot Firewall, а не с тем, что вы «помните», что настроили: панель Robot показывает статус применения правил (active/in process), и если вы недавно что-то меняли, а изменения ещё не докатились, симптом будет тот же самый — так что торопиться считать проблему в другом месте рано.
На сервере «Деталь образа» дамп на третьем шаге сразу всё показал: 14 исходящих UDP-пакетов на порт 53 к корневым и TLD-серверам, ни одного входящего ответа за 40 захваченных пакетов. От первой жалобы клиента (9:40) до точного диагноза (tcpdump, 10:32) прошло меньше часа, из которых 20 минут я убил на неверную гипотезу про сам unbound — именно поэтому дамп трафика теперь у меня идёт вторым шагом, а не пятым.
Другие грабли stateless-файрвола перед mailcow
DNS — самая частая, но не единственная жертва статической фильтрации перед mailcow. Первая соседняя проблема — ICMP. Администраторы часто блокируют весь ICMP «для безопасности», оставляя только явно нужные TCP/UDP-порты, и теряют сообщения Destination Unreachable / Fragmentation Needed, которые нужны для корректной работы Path MTU Discovery. На практике это проявляется странно: маленькие TLS-хендшейки (STARTTLS на 25/587) проходят нормально, а более крупные пакеты — например, ответ с полной цепочкой сертификатов — зависают или режутся, потому что сервер не получает сигнал уменьшить MSS. Правило in icmp accept (хотя бы для типов unreachable/fragmentation) экономит потом ещё один вечер диагностики. И прямо про unbound-mailcow: его health-check (/healthcheck.sh в образе) каждые 30 секунд не только резолвит через dig fuzzy.mailcow.email, github.com и hub.docker.com, но и пингует 1.1.1.1, 8.8.8.8 и 9.9.9.9. Если на stateless-фильтре входящий ICMP закрыт, echo reply не вернётся, и контейнер останется unhealthy даже при исправном DNS — со всеми вытекающими, потому что часть сервисов mailcow стартует только после healthy unbound.
Вторая соседняя проблема — задвоение фильтрации. Robot Firewall стоит на границе сети провайдера, но на самом сервере обычно есть ещё iptables/nftables от Docker (он сам создаёт цепочки для проброса портов контейнеров) и, возможно, собственный ufw или fail2ban-mailcow (контейнер netfilter-mailcow). Если после правильных правил в Robot Firewall DNS всё ещё не резолвится — проверьте, не банит ли локальный слой те же самые IP резолверов, и не мешает ли асимметричная маршрутизация при нескольких сетевых интерфейсах или failover-IP; с этим эффектом я разбирался отдельно в статье про асимметричный маршрут и rp_filter — там похожая логика: пакет уходит одним путём, а «ответ» ядро отбрасывает по совсем другой причине.
Третья, косвенная история — получение TLS-сертификатов. Если периметр закрыт настолько жёстко, что порт 80 наружу не публикуется вовсе (например, mailcow стоит только для приёма/отправки почты, а не веб-морды), HTTP-01 валидация Let's Encrypt не пройдёт в принципе, и разумный путь — переключиться на DNS-01 через API вашего DNS-провайдера; я подробно разбирал этот вариант в статье про DNS-01 без порта 80 для mailcow. К DNS-резолвингу unbound это прямого отношения не имеет, но обе проблемы растут из одного корня — из желания закрыть периметр сильнее, чем сервис к этому готов из коробки.
И последнее наблюдение уже не про технику, а про процесс. После инцидента я завёл правило: любое изменение Hetzner Robot Firewall на почтовом сервере клиента сопровождается внешней проверкой доступности сразу после применения — не «на глаз», а по актуальному состоянию портов снаружи. Для этого удобно проходить внешним аудитом периметра, как я описывал в материале про аудит того, что реально торчит наружу: там методика для Zimbra, но принцип — «проверяй снаружи, а не по списку правил, который сам же написал» — универсален для любого почтового сервера за периметровым файрволом.
Чек-лист: как убедиться, что всё действительно заработало
После применения узких правил (TCP ack + UDP, 32768–65535) и рестарта unbound-mailcow я прохожу короткую проверку, прежде чем закрыть тикет клиенту. Она заняла у «Деталь образа» ещё 25 минут — с 10:45 до 11:10, после чего я написал владельцу, что почта восстановлена. Порядок такой:
# 1. Контейнер должен стать healthy
docker compose ps unbound-mailcow
# 2. Резолвинг изнутри — без таймаутов
docker compose exec unbound-mailcow drill example.com
# 3. Очередь Postfix реально разгружается, а не просто не растёт
# последняя строка: -- N Kbytes in M Requests. (или Mail queue is empty)
docker compose exec postfix-mailcow postqueue -p | tail -n 1
# 4. В дампе появились входящие ответы с портом источника 53
tcpdump -ni enp0s31f6 'udp port 53' -c 20У unbound-mailcow статус сменился на healthy примерно через минуту после рестарта контейнера — скрипт health-check образа раз в 30 секунд делает dig к самому unbound (fuzzy.mailcow.email, github.com, hub.docker.com) и пингует три публичных адреса; как только ответы пошли, очередной цикл записал «0» в статус, и Docker показал healthy. Очередь Postfix, в которой на момент диагноза скопилось 47 писем внешним адресатам, разошлась сама в течение следующих 10 минут — переотправлять руками ничего не пришлось: Postfix сам повторяет отложенные письма по своему расписанию, а если ждать некогда, docker compose exec postfix-mailcow postqueue -f запускает доставку всей очереди сразу.
Отдельно стоит понаблюдать сутки, а не закрывать вопрос сразу после первого успешного теста. У DNS-резолвинга есть особенность, которая маскирует недоделанные правила: пока в кэше unbound лежат недавно резолвленные записи (TTL популярных доменов — от нескольких минут до суток), почта в них будет уходить нормально даже при сломанном правиле, а откажет только резолвинг новых, ранее не запрашиваемых доменов. У «Деталь образа» я специально на следующий день прогнал drill по десятку доменов, которых заведомо не было в кэше накануне — все ответили корректно, значит, правило рабочее, а не «повезло с кэшем».
Финальная цифра по инциденту: от первой жалобы пользователя до подтверждённого закрытия — час тридцать минут, из которых собственно фикс (два правила в Robot Firewall плюс одна строка в unbound.conf) занял меньше пяти минут, а всё остальное время ушло на диагностику и последующую проверку. Именно поэтому правила диагностики из раздела выше я держу под рукой отдельным файлом — при повторении инцидента на другом сервере тот же путь занимает уже не полтора часа, а пятнадцать минут.
Частые вопросы
Почему в логах и у самого Hetzner Robot Firewall нет никаких записей о блокировке DNS-ответов?
Потому что это штатное поведение фильтра, а не сбой. Hetzner Robot Firewall — статический (stateless) фильтр: он проверяет каждый пакет изолированно и просто отбрасывает те, что не подпадают под явное правило, без ICMP-уведомления и без записи в лог на своей стороне. С точки зрения unbound это выглядит как обычный таймаут ответа от резолвера.
Можно ли обойтись одним правилом на UDP-порт 53 без диапазона 1024-65535?
Нет, если это правило для входящего направления с фиксированным портом 53 — такой ответ никогда не придёт именно на 53-й порт сервера, потому что unbound слушает исходящие ответы на своём случайном клиентском порту, а не на 53-м. Правило на входящий 53 нужно только если ваш unbound-mailcow сам принимает внешние DNS-запросы (обычно не требуется в типовой инсталляции mailcow).
Зачем для TCP отдельно указывать tcp_flags ack, если для UDP такого поля вообще нет?
TCP — протокол с установлением соединения, и флаг ack в правиле разрешает пропускать только пакеты, которые относятся к уже начатому сервером соединению (ответы, не новые входящие SYN). Это сужает правило до реального минимума. У UDP нет флагов на уровне протокола, поэтому для него приходится открывать весь диапазон портов целиком, полагаясь только на корректность порта источника.
Обязательно ли сужать диапазон через outgoing-port-avoid, или можно оставить 1024-65535?
Технически можно оставить широкий диапазон — оба варианта описаны в документации mailcow, и с обоими DNS заработает. Узкий вариант (outgoing-port-avoid: 0-32767 и правила на 32768–65535) вдвое сокращает число входящих UDP-портов, открытых снаружи, но и вдвое сужает случайность порта источника — минус один бит защиты от подделки ответов по RFC 5452. Я применяю узкий вариант там, где периметр закрывают осознанно, и обязательно защищаю правку от update.sh через docker-compose.override.yml.
Есть ли встроенный способ временно пропустить проверку unbound-mailcow, пока чинишь firewall?
Да: в mailcow.conf есть переменная SKIP_UNBOUND_HEALTHCHECK. Выставьте SKIP_UNBOUND_HEALTHCHECK=y и выполните docker compose up -d — скрипт проверки будет всегда отдавать healthy. Это костыль на время диагностики, а не решение: внешние домены резолвиться не начнут, письма продолжат копиться в очереди Postfix, переменная лишь убирает статус unhealthy.
Как узнать, что мои правила в Robot Firewall уже точно применились, а не висят в очереди?
В панели Hetzner Robot у сервера, во вкладке Firewall, статус набора правил показывает, применены ли изменения (active) или ещё обрабатываются. По опыту изменения применяются в пределах минуты — если тесты не проходят сразу после сохранения, подождите и проверьте статус, прежде чем менять правила ещё раз.
Источники
- mailcow: Prepare your system (docs.mailcow.email) — Раздел «Important for Hetzner firewalls»: Hetzner Robot Firewall описан как static firewall («each incoming packet is checked isolated»), даны точные правила — TCP dst_port 1024-65535 с tcp_flags ack, UDP dst_port 1024-65535, и вариант сужения через outgoing-port-avoid: 0-32767 в data/conf/unbound/unbound.conf. Проверено WebFetch 23.09.2026. https://docs.mailcow.email/getstarted/prerequisite-system/
- unbound.conf(5) — NLnet Labs (unbound.docs.nlnetlabs.nl) — Официальное описание опций outgoing-port-avoid и outgoing-port-permit: построчная обработка, добавление/вычитание портов из разрешённого множества; по умолчанию unbound использует порты выше 1024, не назначенные IANA. Проверено WebFetch 23.09.2026. https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound.conf.html
- Hetzner Robot Firewall — поля правил (community.hrobot.firewall, Ansible; Hetzner Robot API) — Состав полей правила Hetzner Robot Firewall: name, ip_version, protocol, dst_ip, src_ip, dst_port, src_port, tcp_flags, action (accept/discard) — подтверждено по официальному модулю управления firewall и документации Robot API. Проверено WebSearch 23.09.2026. https://docs.ansible.com/projects/ansible/latest/collections/community/hrobot/firewall_module.html
- mailcow-dockerized — docker-compose.yml и health-check unbound-mailcow (GitHub) — Сервис unbound-mailcow на образе ghcr.io/mailcow/unbound (1.26.1-1 на 23.09.2026) со статическим IP в докер-сети mailcow, health-check через скрипт /healthcheck.sh (dig + ping), переменная SKIP_UNBOUND_HEALTHCHECK для временного отключения проверки. Проверено WebSearch 23.09.2026 (issue #5651 «unbound-mailcow-1 is unhealthy», docker-compose.yml репозитория mailcow/mailcow-dockerized). https://github.com/mailcow/mailcow-dockerized/blob/master/docker-compose.yml
- RFC 5452 — Measures for Making DNS More Resilient against Forged Answers — Раздел 9.2: резолвер обязан использовать непредсказуемый порт источника из диапазона 53 или 1024 и выше; полный диапазон 1024–65535 даёт около 64 512 портов. Проверено WebFetch 23.09.2026. https://www.rfc-editor.org/rfc/rfc5452
- mailcow-dockerized — update.sh, .gitignore, data/Dockerfiles/unbound (GitHub) — update.sh коммитит локальные правки и выполняет git merge -X theirs (при конфликте побеждает апстрим); data/conf/unbound/unbound.conf отслеживается git, docker-compose.override.yml в .gitignore; healthcheck.sh образа unbound: dig fuzzy.mailcow.email/github.com/hub.docker.com и ping 1.1.1.1/8.8.8.8/9.9.9.9. Проверено по исходникам ветки master 23.09.2026. https://github.com/mailcow/mailcow-dockerized/blob/master/update.sh



