Как я хотел сэкономить 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 новые кнопки. Я написал его как раз для того, чтобы их больше никогда не видеть.
Всем удачи!




