casablanque

casablanque

Инженер из Северной Каролины
Пикабушник
165 рейтинг 0 подписчиков 1 подписка 3 поста 2 в горячем
Награды:
Пикабу 17 лет!

Как Linux врет вам про DNS

Предисловие: выложил эту статью на Хабр, но тамошние модераторы с синдромом вахтера решили, что раз я пишу грамотно и структурированно, то я нейросеть. Я человек двуязычный и, возможно, это сказывается, но апломб и бескомпромиссность, с которой модераторы вещают, меня взбесили. Мол, мы тут вообще-то в универах учились, в отличие от вас, и знаем что и как. Уверен, что всегда найдутся те, кто так скажет, но это лишь их мнение. Моя же задача рассказать о проблеме, на мой взгляд любопытной, и показать инструмент.

———

Однажды ты запускаешь dig домен_нейм, видишь исчерпываюший ответ, идешь дальше — а твой процесс резолвит что-то другое. Просто твой процесс и твой dig живут в двух параллельных вселенных, и обе вселенные “резолвинг имени”.

Если ты никогда с этим не сталкивался — либо у тебя мало продакшена за плечами, либо ты просто не заметил. Оно не падает с ошибкой. Оно просто отвечает не то, что ты ожидал, и ты лезешь в дебри, да еще и не в ту сторону.

Я на это наткнулся ровно так, как обычно натыкаются на подобное — не потому что курил мануалы, а потому что два часа дебажил то, что по всем признакам не должно было ломаться. И только на дне этого дебага понял: я вообще не знаю, что происходит между вызовом getaddrinfo() и ответом. Никто, по сути, толком не знает. Все просто верят, что DNS резолвит имена и живут с этим счастливым заблуждением, пока оно их не настигает.

Так что сядем и разберем, что там реально происходит. По пути будет несколько точек, в которых система может соврать, но не со зла, а просто потому что так устроена. И в конце будет маленький инструмент, который я написал, чтобы больше не гадать.

DNS — это не резолвинг. DNS — это один из вариантов

Вот с чего стоит начать разгребать: getaddrinfo() — это не функция “спроси DNS”. Это функция “спроси NSS”, а NSS (Name Service Switch) — это диспетчер, который решает, у кого спрашивать: у файла, у mDNS, у DNS, у LDAP, у самого черта, что тебе кто-то подсунул модулем. DNS там просто один из кандидатов, причем необязательно приоритетный.

Вся эта логика описана в одном скромном файле, который почти никто не читает после того, как система его замутила при установке:

$ cat /etc/nsswitch.conf hosts: files mdns4_minimal [NOTFOUND=return] dns

Вроде все просто, сначала спроси /etc/hosts, потом mDNS, потом DNS. Ну да, пока не обращаешь внимание на [NOTFOUND=return] (кому ведь интересно че там в скобках пишут?) — а ведь это уже маленькая бомбочка, о которой мы поговорим отдельно.

Первый источник вранья: /etc/hosts

Самый банальный и самый частый в реальности. Кто-то полгода назад добавил staging.internal в /etc/hosts, чтобы обойти DNS во время какого-то расследования, и забыл убрать. И из-за этого Пинкертона теперь этот хост “резолвится” на машине навсегда независимо от того, что говорит настоящий DNS, независимо от того, что там сейчас в зоне, независимо от того, помнит ли об этом кто-то живой.

files стоит первым в цепочке почти на любом дистрибутиве по умолчанию. И это значит: если имя есть в /etc/hosts — DNS вообще не спросят. Не DNS ответит неправильно, а DNS не спросят вовсе. Для человека, который смотрит на dig и видит правильный ответ, это выглядит как необъяснимая магия. Ведь как это может резолвиться в другое, если DNS говорит правильно? А очень просто — потому что digделает прямой DNS-запрос, а твой процесс до DNS в принципе не доходит.

Ловушка [NOTFOUND=return]

Вот эта конструкция в nsswitch.conf заслуживает отдельного разговора, потому что она реально может испортить тебе в лучшем случае вечер. [NOTFOUND=return] значит: если предыдущий источник явно ответил “такого имени нет” — остановить цепочку прямо здесь и вернуть “ниче не нашел”, не пытаясь спросить следующего.

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

Самое неприятное здесь — это происходит тихо. Просто отрицательный ответ, который выглядит абсолютно легитимно.

mDNS и иллюзия .local

Второй пункт в нашей цепочке — mDNS, тот самый multicast DNS, который отвечает за .local-имена в локальной сети. Штука сама по себе рабочая и полезная (Avahi, Bonjour и вот это все), но она добавляет еще один недетерминированный слой: ответ зависит от того, что прямо сейчас есть в сегменте сети, кто отозвался на multicast-запрос за отведенные ей несколько сотен миллисекунд, и повезло ли пакету дойти.

Тут вранья как такового меньше, но неопределенности — выше крыши. Один и тот же .local-хост может резолвиться по-разному в зависимости от того, кто именно ответил первым и насколько загружена сеть в этот момент.

systemd-resolved и обман через 127.0.0.53

А вот это, пожалуй, самый недооцененный источник путаницы на современных дистрибутивах. Открываешь /etc/resolv.conf, видишь:

nameserver 127.0.0.53

и на автомате читаешь это как “вот мой DNS-сервер”. Нет. Это заглушка, локальный прокси systemd-resolved. Реальные нейм-серверы, которые он спрашивает от твоего имени, спрятаны у него внутри в per-link конфиге, до которого обычный resolv.conf не дотягивается вообще. Чтобы узнать кто там реально отвечает за резолвинг, нужно спрашивать не файл, а сам systemd-resolved через D-Bus (org.freedesktop.resolve1.Manager).

Почему это важно? Потому что если ты хочешь сделать независимую проверку и наивно шлешь запрос на 127.0.0.53, ты снова спрашиваешь ту же самую цепочку, только другим путем. Ты сверяешься сам с собой. Ложное чувство независимой проверки, эдакая форма вранья, просто вежливая.

И вот тут есть нюанс, который удивляет: у systemd-resolved DNS-серверы не одни на всю систему, а per-link — свои на каждый сетевой интерфейс. Поднял VPN (тот же Tailscale) — получил отдельный резолвер для его зоны, который живет только на этом линке и в глобальный список серверов не попадает. Если имя относится к такой зоне, а ты сверяешься с глобальными ней серверами — ты гарантированно получишь расхождение, хотя формально все настроено правильно, просто ты спросил не того прохожего.

И тут всплывает со дна еще один источник лжи: gai.conf и сортировка.

DNS в ответе на запрос имени может вернуть сразу оба семейства адресов — и A-записи (IPv4), и AAAA-записи (IPv6). getaddrinfo() получает этот список и не отдает его как есть, а пересортировывает его по правилам RFC 6724, а конкретные веса этих правил живут в /etc/gai.conf. По умолчанию файл почти всегда пустой или отсутствует, что означает “используй дефолты RFC 6724 как есть”, и дефолты там говорят: предпочитай IPv6, если он вообще есть в ответе. Не если он работает. Просто если есть.

И вот тут расходится теория с продакшеном: IPv6 у хоста может быть объявлен в DNS, но реально не маршрутизируется, задроплен на файрволе или тупо висит на мертвом линке. getaddrinfo() про это ничего не знает — он смотрит только на RFC-таблицу приоритетов, а не на реальную доступность адреса. В итоге приложение честно берет “предпочтительный” IPv6-адрес первым, пытается на него достучаться, получает таймаут и только потом падает на IPv4-фолбэк — если он вообще предусмотрен. А dig тем временем как ни в чём не бывало показывает тебе чистый и рабочий IPv4-адрес, потому что dig никого не сортирует и никаких приоритетов не расставляет — он просто печатает, что вернул сервер.

Самое обидное, что это не баг ни у кого. Ни у DNS, ни у приложения, ни у ядра. Это ровно то поведение, которое RFC и просил реализовать. Просто оно молчаливо предполагает мир, где если IPv6-адрес объявлен, то он работает. В 2026 году это предположение все еще сильно оптимистичнее, чем хотелось бы.

Ловушка, о которой почти никто не думает: статический Go-бинарник

И последняя, моя любимая, потому что она рвет шаблон у всех, кто искренне верит, что везде же NSS одна и та же.

Тут стоит сразу закрыть придирку, которая иначе прилетит в комментарии: Go в принципе умеет резолвить и через NSS, если собран с cgo и он доступен на платформе. Но статически слинкованный бинарь — а это ровно то, что обычно льют в прод (CGO_ENABLED=0, никакой libc-зависимости, один файл, копипаст на сервер) этой возможности лишен по построению. Со стандартной статической сборкой Go использует свой собственный, чистый резолвер, написанный на Go, который парсит resolv.conf сам и ходит в DNS сам, минуя NSS, glibc и все, о чем мы говорили выше, целиком.

Это значит следующее: ты можешь идеально настроить nsswitch.conf, добавить нужную запись в /etc/hosts, все перепроверить через getent, но твой Go-сервис все равно этого не увидит, потому что он вообще не спрашивает те источники, которые ты только что поправил. Он живет в своей отдельной реальности резолвинга, которая пересекается с системной только в одной точке — в resolv.conf, да и то не полностью.

Как я до этого докопался

Я это все, честно говоря, не вычитал заранее — я это выковырял руками, комбинацией dig, getent hosts, resolvectl status с легкой злостью. У каждой команды свой кусок правды, ни одна не показывает полную картину, и собрать их вместе в голове каждый раз совсем не кайф, особенно когда уже третий час ищешь глазами несуществующую опечатку. А пресловутый ИИ путается, несет пургу и лишь добавляет сомнений в адекватности происходящего.

В какой-то момент стало проще написать тулзу, которая проходит эту цепочку сама именно так, как ее проходил бы резолвер ОС и параллельно делает независимый DNS-запрос, чтобы явно показать: вот путь, вот его результат, а вот что говорит DNS напрямую, если их спросить в обход всего. Если расходится — вот тебе конкретная причина, а не “попробуй еще раз с флагом -v”.

Так появился gai — от getaddrinfo, а не то, что вы подумали.

$ gai doctor testhost.local

[gai] Simulating name resolution for "testhost.local"... (reality check via 212.227.123.16, 212.227.123.17, systemd-resolved stub: true) RESOLUTION PATH (simulated): 1. Files FOUND 10.0.0.1 DIAGNOSIS:

┌─ ISSUE ──────────────────────────────────────────────────────────────────┐ │ The OS chain and a direct DNS query disagree: 10.0.0.1 vs (none). │ │ Something earlier in the chain (files/mdns) is answering instead of DNS. │ └──────────────────────────────────────────────────────────────────────────┘

Никакого перехвата процессов, никакого LD_PRELOAD, никакого eBPF или ptrace — gai просто читает ту же конфигурацию, что читает сам резолвер ОС (nsswitch.conf, resolv.conf, gai.conf, /etc/hosts, D-Bus-состояние systemd-resolved, одноразовый mDNS-запрос), и честно моделирует путь, которым прошел бы NSS-диспетчер glibc. Отдельно он умеет заметить статический Go-бинарник и прямо сказать, что для этого процесса вся симуляция выше не имеет значения, у него свой резолвер.

Как поставить тулзу и что дальше

Ставится одной строкой, без Rust-тулчейна, без зависимостей вообще — статический бинарник, который просто лежит и работает.

Пойдет в /usr/local/bin/gai с обычными правами 0755 — как любой другой системный бинарник, ничего экзотического. Если хочется собрать самому — cargo install gai-inspector или cargo build --release из исходников, все через crates.io. Никакого рантайма с собой не тащит, ни Python, ни JVM, ни shared-либ, за которые надо переживать при переносе на голый сервер. Скачал, chmod не нужен даже, и можно спрашивать gai doctor <имя> хоть в контейнере, хоть на минимальном Alpine-образе.

Инструмент пока Linux-only и MVP:

  • Split-DNS/per-link-роутинг (VPN-зоны вроде Tailscale, о которых я говорил выше) он пока не умеет и честно репортит NOT FOUND вместо того чтобы соврать, но и не резолвит.

  • IPv6 mDNS — в процессе.

Репо: github.com/casablanque-code/gai. Там подробнее про возможности и флаги.

Если случится ситуация, где gai тоже наврал — тем более буду рад услышать. Код открыт, пулл реквесты принимаются. Интереснее разобрать баг, чем сделать вид, что такого не бывает.

Показать полностью
58

Самый недооцененный механизм безопасности SSH или почему known_hosts - это не кэш

Самый недооцененный механизм безопасности SSH или почему known_hosts - это не кэш

Каждый, кто хоть раз подключался к новому серверу по SSH, знаком с этим интерактивным ритуалом:

The authenticity of host example. com can't be established. ED25519 key fingerprint is SHA256:... Are you sure you want to continue connecting (yes/no)?

И почти все бездумно вводят одинаковый ответ:

yes

Эта модель называется TOFU или Trust On First Use — доверие при первом использовании.

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

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

И тут сценарий в большинстве случаев тоже простой и бестолковый:

ssh-keygen -R example. com
ssh example. com
yes

Вот и все, можно снова не беспокоиться, проблема решена.

Или нет?



До недавнего времени я относился к файлу known_hosts примерно так же. Ну какой-то там служебный кэш SSH. Если что-то пошло не так - удалил запись, подключился заново, живем дальше.

Но однажды я поймал себя на простой как два рубля мысли.

known_hosts - это вообще не кэш.

Это база доверенных идентичностей серверов.

Когда SSH спрашивает:

Вы уверены, что хотите доверять этому серверу?


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

Получается интересная ситуация.

Один из важнейших механизмов безопасности SSH хранится в обычном текстовом файле.

При этом вокруг него почти нет инструментов.

Где заканчивается OpenSSH

Давайте посмотрим, что OpenSSH умеет делать со своей собственной базой доверия.

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

  • удалить запись (ssh-keygen -R)

  • найти запись (ssh-keygen -F)

  • захэшировать файл (ssh-keygen -H)

И, по большому счёту, на этом его полномочия всё.

Если вы работаете с десятками или сотнями серверов, через несколько лет в known_hosts накапливаются сотни записей.

И неожиданно оказывается, что OpenSSH почти не помогает ответить на самые обычные вопросы.

  • Какие серверы уже давно не существуют?

  • Какие ключи изменились?

  • Есть ли дубликаты?

  • Какие записи используют устаревшие алгоритмы?

  • Кому я доверяю прямо сейчас?

И начинается дурдом с grep, awk, sed.

А мой коллега американец достает припасенную пачкау shell-скриптов и с умным видом делает вид, что (его цитата) shoveling shit)

Меня это искренне удивило.

SSH существует уже больше тридцати лет. Это один из самых распространённых сетевых протоколов в мире.

Но его модель доверия практически заканчивается сразу после первого yes.

Создать доверие - пожалуйста. Поддерживать его - уже без меня.

А как вообще SSH получает host key?

Стало интересно, как именно клиент получает публичный ключ сервера.

Мне всегда казалось, что для этого нужно установить полноценную SSH-сессию, договориться о шифровании, пройти аутентификацию и только потом получить нужную информацию.

Оказалось, все гораздо прозаичнее.

Во время подключения происходит примерно такая последовательность:

Сначала стороны просто обмениваются строками версии вроде

SSH-2.0-OpenSSH_10.0

Затем договариваются о поддерживаемых алгоритмах.

И наконец клиент отправляет `SSH_MSG_KEX_ECDH_INIT`.

Самое интересное происходит в ответном сообщении.

`SSH_MSG_KEX_ECDH_REPLY` содержит:

  • host key сервера

  • временный публичный ключ сервера

  • криптографическую подпись

Именно здесь клиент вычисляет фингерпринт, сравнивает его с known_hosts и принимает решение - продолжать соединение или нет.

Что меня удивило больше всего - все это происходит ещё до аутентификации пользователя.

Никаких паролей. Никаких приватных ключей. Никакого shell. Никакой открытой SSH-сессии.

И это абсолютно логично.

Host key существует именно для того, чтобы клиент убедился, с кем он разговаривает, прежде чем отправлять хоть какие-либо секреты.

Получается, что если цель - просто проверить идентичность сервера, то после получения `SSH_MSG_KEX_ECDH_REPLY` соединение можно сразу закрывать.

Все необходимое уже получено.

От исследования к инструменту

Когда я это понял, стало очевидно еще кое-что.

Чтобы проверить host key сервера, как я уже упомянул выше, не нужен полноценный SSH-клиент. Ни шелл, ни аут. Не нужна большая часть SSH вообще.

Нужен лишь небольшой кусок протокола.

Именно из этого наблюдения постепенно вырос небольшой CLI проект, который я назвал khm (known hosts manager).

Изначально он вообще писался для себя.

Мне хотелось иметь возможность быстро ответить на вопросы, на которые OpenSSH почему-то не отвечает.

  • изменились ли ключи у уже известных серверов

  • есть ли мусор и дубликаты в known_hosts

  • чем отличаются два файла после миграции инфраструктуры или ротации ключей

  • что вообще я имею на сегодняшний день

Постепенно стало понятно, что проблема гораздо шире, чем казалось сначала.

На удивление много людей воспринимают known_hosts как временный файл, который можно удалить при первой ошибке.

Хотя на самом деле именно он определяет, каким серверам ваш компьютер доверяет прямо сейчас.

Например, именно такого вывода мне всегда не хватало в OpenSSH:

$ khm verify --all

OK  github. com

OK  gitlab. com

CHANGED  old. example. com

UNREACHABLE  backup. example. com

4 hosts • 2 OK • 1 changed • 1 unreachable

Не список ключей. Не тупой, подверженный человеческому фактору, вывод grep, а простой ответ на простой вопрос:

Все ли ещё соответствует тому, чему я доверял раньше?

Вместо заключения

Пока я работал над этим проектом, самым неожиданным открытием оказался вовсе не SSH-протокол.

Меня удивило отношение к known_hosts. Мое в том числе.

Мы привыкли считать его временным кэшем, который можно без сожаления удалить при первом предупреждении SSH.

Но если посмотреть внимательнее, это единственная локальная база доверенных идентичностей серверов, которой пользуется OpenSSH.

И как только начинаешь воспринимать ее именно так, становится удивительно, насколько мало вокруг нее существует инструментов.

Именно поэтому появился khm.

Не как ещё один SSH-клиент. И не как замена OpenSSH.

А как попытка сделать чуть удобнее одну маленькую, но, как мне кажется, незаслуженно забытую часть его модели доверия.

Исходный код проекта, бинарные сборки и документация доступны в репо:

Смотреть

Устанавливайте, чекайте, ищите изъяны, ставьте звезды, открывайте пулл-реквесты, наводите порядок на своих хостах. Увидимся!

Показать полностью
23

Как я хотел сэкономить 15 минут на хоумлабе, а в итоге дебажил Cloudflare

Схема стандартная: мне нужно было выкатить наружу очередной сервис из своего хоумлаба (дашборд Grafana, внутреннее API, музыкальный сервер - неважно). Я открывал админку Cloudflare, создавал туннель, прописывал DNS-запись, настраивал политики Zero Trust Access, копировал ID туннеля, генерировал конфиг, устанавливал службу на сервер и со спокойной душой закрывал вкладку. Первые несколько раз меня это вообще не парило.

Примерно на пятом сервисе до меня дошло: я больше не настраиваю инфраструктуру. Я исполняю ритуал.

И я задался вопросом нафига я вообще это делаю руками? У Cloudflare есть отличный API. Всё, по чему я кликаю мышкой в красивом (не очень) интерфейсе, в конечном итоге превращается в обычный HTTP-запрос. Так почему весь этот адский воркфлоу нельзя упаковать в одну единственную команду?

Так родился проект cfzt.

Изначально это не планировалось как какой-то коммерческий продукт или крутой опенсорс. Мне просто хотелось перестать страдать фигнёй по выходным.

Идеальная цель выглядела так:

zt up grafana 3000

И всё. За этими четырьмя словами утилита сама создаёт туннель Cloudflare, настраивает правила ингресса, регистрирует DNS, вешает авторизацию через Zero Trust Access, ставит демона в систему и запоминает состояние, чтобы потом всё это можно было чисто удалить одной командой.

Команда выглядит крошечной. Объём работы под капотом - огромный. И, как выяснилось позже, это была самая простая часть.

Процесс от init до поднятия туннеля

Дело было не в Cloudflare

Самое забавное, что я вообще не пытался заменить Cloudflare. Скорее наоборот.

Cloudflare Tunnel - это одна из тех штук, которая при первом знакомстве кажется чистой магией. Твой сервер может сидеть за NAT, за жёстким провайдерским CGNAT, вообще где угодно, но он всё равно будет доступен из интернета без единого открытого входящего порта на роутере.

Добавь сверху zero trust - и бах! Каждый твой внутренний сервис по дефолту защищён нормальной авторизацией, привязан к identity-провайдеру и еще сертификат насыпают на сдачу.

База просто монументальная. Проблема была во всём том, что её окружало. Каждый новый чих требовал повторения одного и того же алгоритма:

- Создать туннель.

- Прописать DNS-запись.

- Настроить Ingress rule.

- Создать приложение в Access.

- Привязать к нему политику доступа.

- Сгенерировать конфигурационный файл.

- Установить системную службу на сервере.

- Запустить её.

Ни один шаг сам по себе не сложен. Они просто… нудные. И хуже всего была даже не потеря времени, а ментальная нагрузка. А я точно создал DNS? А access включить не забыл? Какой туннель держит этот домен? А если я его удалю, ничего не сломается?

В хоумлабе и так хватает хаоса, чтобы ещё держать в голове весь этот чек-лист деплоя.

И я подумал: а что, если бы публикация сервиса в сеть была такой же простой, как запуск Docker-контейнера?

Docker внутри устроен невероятно сложно, но его интерфейс скрывает весь этот ужас от пользователя. Ты пишешь команду - Docker разбирается со всем остальным. Это и стало главной фишкой cfzt: не плодить миллион галочек и настроек, а сделать стандартный путь банальным и скучным.

За одной строчкой скрывается куча работы с API, но пользователю до этого не должно быть никакого дела. Сложность должна оставаться внутри инструмента. Мне кажется, создателям современного софта для инфраструктуры стоит почаще выбирать именно такой подход.

Фича, которую никто не заметит

Ирония в том, что больше всего я горжусь функцией, которую пользователи, надеюсь, никогда не увидят. Это роллбэк или откат по-нашему.

Инструменты автоматизации обожают ломаться на середине пути. То API Cloudflare выдаст rate limit, то сессия протухнет, то один запрос пройдёт, а следующий за ним упадет с ошибкой. В итоге у вас остаётся полурабочее нечто:

- Туннель вроде создался, но до него не достучаться.

- DNS-запись смотрит в никуда.

- В панели access висит приложение для сервиса, которого уже нет.

Убирать это дерьмо руками несложно, но выбешивает знатно.

Я хотел, чтобы cfzt работал иначе. Любой деплой - это транзакция. Если все шаги прошли успешно - сервис онлайн. Если споткнулись хоть на одном этапе - утилита автоматически сносит всё, что успела насоздавать до этого момента. Никаких осиротевших ресурсов и никакого гадания в веб-морде Cloudflare, что там надо подчистить.

Звучит очевидно? Да. На практике же пришлось детально логировать каждый чих к API, записывать ID каждого созданного ресурса и писать под них логику удаления. Зато теперь я могу запускать утилиту и не бояться, что она оставит после себя кучу мусора. Лучший откат - это тот, о котором тебе не пришлось думать.

Но откат шага в своей проге это не то же, что откат на стороне cloudflare...

А потом я почитал логи...

В какой-то момент проект дошёл до стадии, когда пилить фичи надоело и я начал просто пользоваться утилитой каждый день. Вот тут-то и полезли самые интересные артефакты.

Как-то вечером после очередного деплоя я заглянул в логи и заметил странное: cloudflared (официальный демон Cloudflare) внезапно переключился с протокола QUIC на старый добрый HTTP/2.

Вообще, это штатное поведение. QUIC работает поверх UDP, и если с UDP что-то идёт не так (роутер ребутнулся, Wi-Fi мигнул, провайдер решил подрезать пакеты) - туннель автоматически падает в фоллбэк на HTTP/2 поверх TCP, чтобы связь вообще не оборвалась.

Странно было другое. Он никогда не переключался обратно.

Подождите... Это что, навсегда?

Сначала я подумал, что чего-то не понимаю в работе туннелей. Ну, наверное, он проверяет сеть раз в пару минут? Или есть скрытая настройка?

Я сидел и караулил логи. Прошёл час, два. Туннель работал идеально, трафик шёл, но намертво сидел на TCP.

Я перерыл официальную документацию - тишина. Пошёл штурмовать гитхаб. И бинго! Нашёл старый тикет, где чувак жаловался ровно на то же самое. Логика у демона Cloudflare простая: если туннель один раз упал в HTTP/2, он больше никогда не попытается вернуться на быстрый QUIC. Только если полностью перезапустить сам процесс ручками. И баг этот висел в issues в официальном репозитории уже очень давно.

Выбора было два: либо смириться и ждать, пока пацаны из Cloudflare это починят (спойлер: можно не дождаться), либо костылить обходной путь. Я выбрал второй вариант.

Пишем вочдог вместо патча

Лезть в исходники самого cloudflared, форкать его и пересобирать сетевой стек ради одного бага - так себе идея. Поэтому я посмотрел на задачу проще: какие данные у меня реально есть на руках?

Ответ оказался банальным: при переходе с QUIC на HTTP/2 демон пишет в stderr конкретную строчку. Всё, это и есть наш API! Зачем анализировать пакеты или пинговать порты, если можно просто читать логи процесса?

План получился такой: cfzt запускает туннель и начинает грепать его логи на лету. Как только ловим строчку о фоллбэке, запускается таймер. Мы не рубим процесс сразу - вдруг сеть ещё штормит. Выжидаем паузу, и если туннель всё ещё на HTTP/2 - тихонько дергаем службу.

При перезапуске cloudflared снова пытается инициировать модный QUIC. Если UDP всё ещё лежит - ок, он опять упадёт в HTTP/2. Наш вочдог это увидит и увеличит таймер ожидания: 10 минут, 20, 40... В итоге шаг баг-оффа упирается в 1 час. Мы не боремся с сетью, мы просто даём туннелю шанс очухаться, когда шторм пройдёт.

Выглядит этот «инженерный фикс» до смешного просто. Вот кусок кода на Go, который закрыл эту проблему раз и навсегда:

// Строка, которую выплёвывает Cloudflare, когда сдается и уходит на TCP

const FallbackSignal = "Failed to connect to the edge over QUIC, falling back to HTTP2"

func watchTunnelLogs(scanner *bufio.Scanner, restartTrigger chan<- bool) {

backoff := 5 * time.Minute

for scanner.Scan() {

line := scanner.Text()

if strings.Contains(line, FallbackSignal) {

log.Warn("Обнаружен откат с QUIC на HTTP/2. Запускаем вочдог.")

// Ждём, пока сеть стабилизируется

time.Sleep(backoff)

// Триггерим мягкий перезапуск процесса

restartTrigger <- true

// Экспоненциальный шаг ожидания (максимум до 1 часа)

backoff = min(backoff * 2, 1 * time.Hour)

}

}

}

Решение не претендует на учебники по Computer Science, но оно работает. Теперь, если посреди ночи роутер решит обновиться, утилита подхватит этот момент, переждет грозу и молча вернет туннель на быстрый протокол.

Чему меня научил этот проект

Я начинал писать эту утилиту просто потому, что мне надоело кликать по кнопкам в браузере. В итоге я несколько недель залипал в жизненные циклы процессов, парсинг логов и логику сетевых фоллбэков.

И в этом вся прелесть создания своих инструментов для автоматизации (переизобретенных велосипедов, если хотите). Ты берёшь мелкую бытовую проблему, думаешь: да ладно, сейчас набросаю тонкую CLI обертку за пару часов в субботу. Но как только ты пытаешься сделать интерфейс по-настоящему простым для пользователя, тебе приходится впитать в свой код всю ту сложность, которую разработчики платформы оставили за бортом.

Ты пишешь транзакционный откат, чтобы не разгребать полуживое состояние руками. Ты пишешь вочдог, чтобы не думать о моргающем интернете.

Интерфейс остается крошечным. Инструмент делает всю грязную работу. Ритуал наконец-то разрушен.

Теперь, когда мне нужно поднять новый сервис, я не открываю пять вкладок в браузере. Я просто пишу в консоли:

$ zt up grafana 3000

✔ Creating Cloudflare Tunnel... Done.

✔ Provisioning DNS records... Done.

✔ Setting up Zero Trust Access policies... Done.

✔ Launching background service with QUIC... Done.

Service is live at https: //grafana. yourdomain. com

И спокойно иду пить кофе. Потому что инфраструктура, черт возьми, должна быть скучной.

Ссылка на проект

Если вам тоже надоел этот бесконечный марафон по дашбордам Cloudflare ради домашнего сервера или стейджинга - проект полностью открыт, пользуйтесь.

GitHub: репозиторий

Буду рад фидбеку. Ломайте, тестируйте, закидывайте ишью или предлагайте фичи. Если утилита сэкономит вам пару минут на выходных - значит, все это было не зря. Только не просите меня добавлять в CLI новые кнопки. Я написал его как раз для того, чтобы их больше никогда не видеть.

Всем удачи!

Показать полностью 1
Отличная работа, все прочитано!

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества

Недвижимость и ремонт

Теги

Популярные авторы

Сообщества