Страница работала неделями, а потом перестала открываться. Остальной интернет жил как ни в чём не бывало. Администратор заходит на роутер и видит картину, которая не сходится с симптомом: команда :resolve отдаёт валидный адрес, DoH отвечает статусом 0, монитор состояния пишет «здоров». Всё зелёное, а у людей в филиале таймаут.

Эту ситуацию описал 26 октября 2026 года автор под ником paulmann в статье на Хабре. Разбор не ограничился постом: к нему прилагается открытый репозиторий mikrotik-doh-failover с RouterOS-скриптами и документацией. Перед нами не исследование с выборкой, а инженерный кейс: одиночная авторская диагностика, зафиксированная по горячим следам. Ценность её не в статистике, а в том, как точно она показывает разницу между «резолвер ответил» и «ответ пригоден».

Адрес есть, маршрута нет

Виновниками оказались два адреса: 8.6.112.0 и 8.47.69.0. Это не блокировка и не артефакт фильтрации. Автор подчёркивает: «Это настоящий ответ Cloudflare, но из той сети до него нет маршрута. Проверка уровня „получил IP или нет“ такого не ловит никогда: адрес есть, он валиден, и он бесполезен». Система DNS честно вернула запись, синтаксис не нарушен, но пакет до этого адреса не доходит.

Чтобы отделить проблему DNS от проблемы маршрута, автор прогнал curl --resolve с двух сторон. С WAN-шлюза команда отдавала 302 примерно за 0,2 секунды. Из филиальной сети, подключённой через другого провайдера, тот же адрес уходил в таймаут. Один и тот же IP, разный результат в разных сетях. Значит, смена DNS-оператора здесь не поможет: любой резолвер, возвращающий ту же пару, вернёт ту же нерабочую страницу.

Замеры 26 октября показали любопытную картину. Стандартный DoH-эндпоинт Cloudflare сначала отдавал адреса из диапазона 104.26.x, а через полчаса начал возвращать ту самую пару 8.6.112.0 и 8.47.69.0. Family-эндпоинт отвечал тем же. Простой DNS к 1.1.1.1 и 8.8.8.8 возвращал ту же пару, причём Google отдавал 8.6.112.5. Те же запросы из точки в Германии давали настоящие адреса из 104.26.x и 172.66.x. То есть пара привязана не к резолверу, а к точке, из которой идёт запрос.

В репозитории это зафиксировано ещё резче. В документе о полевых сбоях сказано: «Где путь к нему отсутствует, каждое имя, которое разрешается в этот адрес, перестаёт работать, независимо от того, какой эндпоинт Cloudflare вы выбрали: смена резолверов не помогает». Формулировка английская, но смысл однозначный.

Проверка, которая видела не то

Первая версия скрипта, 0.1.0, считала ответ здоровым, если :resolve вернул значение типа ip. Пара 8.6.112.0 и 8.47.69.0 — валидный адрес, поэтому проверка на этом успокаивалась. В собственных заметках 0.1.0 эта пара была записана как real IP, то есть настоящий адрес. Автор называет это «не опечатка, а дефект метода»: проверялся факт ответа, а не его пригодность.

Вторая ошибка той же версии была ещё коварнее. Чтобы проверить эндпоинт, скрипт ненадолго переставлял параметр use-doh-server и смотрел, что получится. Побочный эффект обнаружился не сразу: каждая плановая проверка снимала локальную сеть с DoH. Резолвер продолжал работать, но уже без шифрования, каждые три минуты.

Третья ошибка касалась резерва. Логика «если шифрованный канал упал, вернёмся на простой DNS» выглядит разумно до тех пор, пока не измеришь, что приходит по простому каналу. На замерах 26 октября даже прямые запросы к 1.1.1.1 и 8.8.8.8 возвращали подменённые ответы. Откат в открытый DNS превращал заметную аварию в незаметную: администратор видит «интернет работает», а часть имён уже подменена.

Этот момент напрямую связан с более широкой проблемой валидации. Когда система проверяет только наличие значения, а не его смысл, она может сообщить об успехе при полностью бесполезном результате. Разбор похожей ловушки в программировании — почему all([]) возвращает True и как не принять пустой набор за проверенный — можно посмотреть в отдельной статье блога. Логическая структура ошибки та же: формальный критерий выполняется, а содержательная проверка не состоялась.

Два вопроса вместо одного

В версии 0.2.0 проверка разделилась на два независимых измерения. Первый вопрос: отвечает ли сам эндпоинт? Для этого используется HTTPS-проба к DNS-JSON API через /tool fetch. Она не трогает /ip dns, поэтому рутинная проверка больше не снимает сеть с DoH. Второй вопрос: пригодны ли ответы? Три контрольных имени разрешаются через живой резолвер, и результат судится по содержимому.

Критерий плохого ответа короткий. Ответ считается плохим, если он пустой, если это 0.0.0.0 — так отвечает фильтрующий резолвер, — или если адрес попадает в известную пару-заглушку 8.6.112.0 и 8.47.69.0. Контрольные имена выбраны не случайно: example.com, cdnjs.cloudflare.com и cdn.tailwindcss.com — это ровно те домены, которые ломались в реальной сети.

Здесь же пряталась ещё одна ловушка, которую автор нашёл позже, чем хотел. Проверка содержимого идёт через живой резолвер, а он отвечает из кеша. Основной эндпоинт мог умереть, кеш ещё держал валидные ответы, и монитор видел «здоров». Переключение откладывалось на время жизни TTL. В версии 0.3.1 это вылечено: отказ определяется по состоянию эндпоинта вместе с проверкой содержимого, а не только по ответам из кеша.

Важно, что проверка содержимого — это эвристика, а не полная гарантия. Список из пустой строки, 0.0.0.0 и известной пары не выявит неизвестный недостижимый адрес. Автор прямо оставляет этот вопрос открытым: научиться отличать «эндпоинт жив, но не разрешает» от «до эндпоинта нет пути» без похода на клиента пока честно не решено. В конце статьи он спрашивает сообщество, есть ли способ надёжнее, чем :resolve с последующим TCP-соединением.

Гистерезис вместо мгновенной реакции

Переключение не происходит от одного плохого прогона. В архитектурном описании это названо гистерезисом: failureThreshold последовательных плохих прогонов требуется, чтобы уйти с основного эндпоинта, и recoveryThreshold хороших — чтобы вернуться. При интервале 60 секунд и пороге 2 переключение в любую сторону занимает около двух минут, поэтому единичный сбой не двигает роутер.

Перед переключением резерв проверяется. После смены апстрима скрипт сбрасывает кеш, ждёт три секунды и пробует разрешить example.com. Если имя не разрешилось, конфигурация откатывается на основной эндпоинт. В журнале появляется запись о том, что резервный DoH не разрешил имя и роутер вернулся. Без этой проверки роутер спокойно парковался бы на эндпоинте, который открывает TCP, но молчит.

Попыток на один инцидент не больше трёх. После этого монитор ждёт восстановления основного эндпоинта и не забивает журнал. Важно, что счётчик растёт на каждом реальном переключении: втором, третьем, простом DNS. В версии 0.4.1 это исправлено — раньше счётчик рос только на первом переключении, и длинная цепочка могла прокручиваться дальше задуманного.

Само переключение автор называет управляемым, а не прозрачным. Резерв — это другой оператор с другими ответами, другой фильтрацией и другой геолокацией CDN. После переключения сеть получает не «то же самое, только через другой канал», а «другой резолвер».

Таймаут, который спасает от зависания

В версии 0.4.1 у каждого вызова /tool fetch появился параметр duration=5s. Причина не косметическая. Эндпоинт, который принимает соединение и молчит, держит /tool fetch столько, сколько даст сеть. Прогон выходит за интервал планировщика, следующий запуск накладывается на предыдущий, а наложение отсекает защитный флаг DNSMonitorRunning. Без таймаута сеть в такой ситуации не переключилась бы вообще, пока висит первая проба: защита от наложения превращалась в запрет на работу.

Проба в первых версиях была ещё и грязной в другом смысле. Ответ складывался во временный файл в /file, файл удалялся, и на этом месте жили гонки: не удалили, удалили не тот, удалили в момент, когда следующий прогон его читает. В 0.4.0 проба переписана на чтение тела в память через as-value output=user. Временных файлов больше нет, а значит, нет и гонок.

Ещё одна деталь, которую легко пропустить: проверка JSON-ответа принимает оба варианта записи, "Status":0 и "Status": 0. Провайдеры сериализуют JSON по-разному, и проверка, знающая только один вариант, молча не находила ответ.

Автор уточняет число вызовов /tool fetch в мониторе: их пять, и таймаут стоит на всех пяти. Поиск по строке duration=5s находит шесть вхождений, но шестое — в шапке-комментарии файла. Автор специально поправляет прошлый обзор, в том числе собственный, где говорилось о шести вызовах. Уточнение мелкое, но именно так появляются числа, которые потом расходятся с кодом.

Ограничения, которые автор не прячет

Случай описан честно, и границы вывода очерчены. Проверка выполнялась вручную на шести устройствах MikroTik с RouterOS 7.23.1 и 7.24.5: Chateau PRO ax, cAP ax, RB4011iGS+, hAP ax², RB951Ui-2HnD и RB2011UiAS. Автоматических тестов у набора скриптов нет, исполнять RouterOS Script негде. Длительная недоступность всех эндпоинтов, поведение под нагрузкой, расход ресурсов на слабом железе и работа на прошивках 7.20–7.22 не измерялись.

Есть и расхождения между источниками. Статья сообщает о 41 файле под git и 552 строках RouterOS-скриптов, а README репозитория — о 38 файлах и 360 строках. Автор объясняет часть расхождений разными датами и ветками: статья пересчитана 29 октября, README измерялся 26 октября, тег v0.4.1 вышел 28 октября. Но независимой сверки нет, и это стоит держать в уме.

Проба через DNS-JSON API применима не ко всем DoH-провайдерам. Quad9 на выбранный JSON-запрос отвечает ошибкой «DoH unable to decode BASE64-URL», поэтому как звено цепочки он всегда выглядит недоступным, хотя как апстрим работает. Резервом пришлось ставить Google. Это значит, что метод проверки ограничен конкретным форматом API, а не универсален для любого DoH-сервиса.

Отдельный вопрос — подлинность ответов. TLS и проверка сертификата подтверждают защищённый транспорт до DoH-эндпоинта, но не подлинность каждого DNS-ответа. В архитектурном документе прямо сказано, что DNSSEC-верификация не добавлена: TLS защищает транспорт, а не сам ответ. В конце статьи автор спрашивает, даёт ли DNSSEC на RouterOS что-то практическое или это ещё один слой, который сломается тише, чем хочется.

Что из этого следует

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

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

Для читателя практический смысл простой. Если страница не открывается, а мониторинг показывает зелёный статус, не спешите доверять статусу. Проверьте, что именно вернул резолвер, и попробуйте достучаться до этого адреса с клиента. Возможно, вы увидите тот же парадокс, что описал paulmann: валидный IP, исправный DoH, статус 0 — и таймаут на последнем участке пути.

Источники: статья paulmann на Хабре; репозиторий mikrotik-doh-failover; архитектурное описание; чек-лист проверок; полевые сбои; журнал изменений.

Источники и литература

Схема объясняет разделение проверки DoH на проверку факта ответа и проверку пригодности ответа, а также последовательность действий монитора при отказе

Фото: Pixabay / Pexels