Покрыть инструкциями все кейсы - цель, которая на стадии формирования в данный момент, но вы всегда можете задать вопрос на github, который на данный момент не освещен в документации (не забывайте добавить метку `question`) или в комментариях к постам.
Позже добавлю инструкцию по запуску с Traefik и подробнее опишу настройку уже самого DNS.
А еще docker-сборки релизов теперь сразу будут загружаться в DockerHub (в будущем планирую публиковать еще и в реестах GitHub и Yandex.Cloud для лучшей доступности, т.к. в некоторых регионах сейчас провайдерами блокируется доступ к DockerHub).
Все также ведется работа над проектом, сейчас изучаются вопросы связанные с мониторингом. А именно изучаю какие метрики важны для надежного мониторинга.
Продолжаю работу над своим пет-проектом. В первой версии оказывается плохо работали запросы к внешним DNS при запросе внешних зон. Исправил этот баг, и бонусом добавил возможность указать несколько upstream серверов (они опрашиваются параллельно и отдается первый успешный ответ).
Поправил запуск через docker. Там не совпадал порты по умолчанию с портами указанными в инструкции по запуску, если не передать переменную окружения с нужным портом.
Теперь при запуске схема базы обновится автоматически и не потребуется прописывать `docker compose exec ...`
Самое крупное изменение - SOA записи. Теперь при создании доменной зоны можно ввести refresh, retry и expire параметры, а при запросе SOA будет возвращать соответствующий ответ. Т.е. вручную не нужно прописывать SOA записи в зоне, но нужно прописать `ns.<zone>.` записи для корректной работы (но об этом позже).
При удалении доменной зоны теперь не остается в базе "висячих" записей.
Еще обновил работу кэширования и теперь удаленные доменные зоны не висят в кэше.
Настроил CI/CD процессы и автоматизировал выгрузку релизов в DockerHob. А еще поправил запуск тестов в пайплайнах, т.к. они по факту вообще не запускались 😬
Запланировано
В первую очередь хочу добавить автоматическое создание ns. записей при создании зоны т.к. SOA ответы будут уже содержать такие данные (не очень хорошо, что их по факту нет).
Не все приложение сейчас покрыто логами а если и покрыто, то в разнобой (где-то через пакет log а в других местах используется slog).
К логам, я считаю, стоит добавить и возможность мониторить сервис, поэтому добавлю метод /metrics для возможности собрать метрики в Prometheus. Выделю отдельный мультиплексер для возможности разделения доменных имен метрик и REST, чтобы можно было не светить метриками во внешние сети.
Дополнительно
Этот проект не стал одним из артефактов потерявшихся пет-проектов. Все благодаря инструментам, которые помогают не терять контекст проекта даже через месяц отлучения.
Если интересно какие инструменты для планирования я использую - маякните в комментах. Если будет интересно, то расскажу про них и в целом про то что у меня развернуто на локалхосте.
Автор, как и наверное большинство разработчиков, считал Golang всего лишь новомодной корпоративной игрушкой, призванной подсадить широкие программисткие массы на очередную технологию «корпорации добра» — создавался этот язык внутри Гугла и для задач Гугла, которые разумеется сильно отличаются от обывательских.
Поэтому когда мне показали работу Golang с WinAPI «из коробки» я был сильно удивлен — в более серьезных языках вроде C/C++ работа c внутренностями Windows всегда выглядела куда более монструозной. Так и родилась эта замечательная статья.
Что мы будем в этот раз творить:
Desktop-приложение с настоящим интерфейсом, с учетом реалий Windows, которое запустит встроенный вебсервер, с методом REST API на ассемблере.
Еще будет загрузка графического файла и установка его в качестве обоев — через WinAPI. Плюс небольшой обход файрвола, чтобы не показывался вот этот раздражающий экран с предупреждением:
Он всегда меня бесил, а то что меня бесит — я отключаю.
Надеюсь описанное в статье удивит даже опытных разработчиков на Golang.
Собственно так выглядит наш сегодняшний герой в действии:
Обратите внимание на отключенные кнопки «закрыть» и «развернуть» — даже это оказалось не так просто сделать на чистом WinAPI
Я использовал последнюю на момент написания версию 1.22.5, но язык столь бурно развивается, что не удивлюсь если выйдет более новая версия еще до завершения статьи.
Открытый проект в Visual Studio Code с установленным плагином для Golang
Теперь самое интересное:
для сборки проекта использовались не обычные Makefile и не шелл-скрипты — так характерные для проектов на «гошечке», а целая отдельная внешняя система сборки — Magefile.
Ставится она множеством разных способов, я использовал вот такой:
Если сборка прошла успешно, в текущем каталоге будет файл ungoogled-go.exe, который можно свободно перемещать и запускать на пользовательских компьютерах — он полностью статичный и не зависит от установленного Golang.
Опционально можно запустить:
mage generate
Этой командой запустится генерация файлов add.s и stub.go — для метода на ассемблере. Стоит также отметить, что конечная и отладочная сборка немного отличаются, разделение происходит путем проброса параметра:
-X main.DebugMode=false
Которым изменится значение глобальной переменной — флагом отладочного режима, который в свою очередь немного влияет на поведение программы.
Теперь начинаем разбираться, как же оно все работает.
Невероятный факт № 5668 : не каждый Windows-программист знает как скомпилировать программу из консоли.
Приложение Windows
Если попробовать собрать и запустить в Windows классический «Hello world» на C:
#include <stdio.h> int main() { printf("Hello, World!"); return 0; }
Вместо ожидаемого пустого графического окна запустится страшная черная консоль как на снимке выше. Это происходит потому что в Windows для графических программ используется другая точка запуска (entry point):
Every Windows program includes an entry-point function named either WinMain or wWinMain.
И если уж жизнь вас заставила разрабатывать на Go под Windows, еще и с графическим интерфейсом, то стоит «гошечке» об этом сообщить, добавив флаг в параметры ldflags.:
-H windowsgui
Целиком это выглядит так:
go build -ldflags "-H windowsgui"
Помимо этого, я указываю режим сборки exe:
-buildmode=exe Build the listed main packages and everything they import into executables. Packages not named main are ignored.
Для того чтобы получить в итоге сборки один большой и переносимый запускаемый exe файл.
Так выглядит «официальный Hello World» на C++ и WinAPI
Golang и WinAPI
Стоит пояснить читателям, в чем вообще заключается сложность работы с WinAPI. Для примера возьмем официальный «Hello world» на C++ под Windows:
А все потому что 90% кода даже в столь простом приложении не имеют никакого отношения к C++, а являются структурами, макросами или функциями самого WinAPI.
От C++ тут только примитивные типы (int) и управляющие конструкции (case, while).
Поэтому задача как-то серьезно взаимодействовать с WinAPI (дальше чем разовый вызов какой-то функции) — всегда была, есть и будет сложной. А разработка под Windows является отдельной специальной дисциплиной, чемпионы которой запросто могут забыть обычный C/C++ вообще и всю разработку (даже серверную) вести на инструментах WinAPI.
Но вернемся к нашей «гошечке».
Go далеко не C++ и является экзотикой в мире Windows-разработки, по крайней мере за пределами кампусов Google.
Но внезапно оказалось, что поддержка WinAPI в нем очень даже неплоха.
Взгляните как выглядит вызов WinAPI функции для установки обоев на Golang:
И оно даже работало. Но только объем кода очень быстро вырос до былинных размеров и никак не влезал в масштаб статьи.
Поэтому от такого подхода пришлось отказаться, оставив лишь работу с системным треем.
Все остальное я отдал на откуп готовым библиотекам. В частности построение окон и обработку событий были реализованы через библиотеку Windigo. — хотя это по-сути лишь набор готовых биндингов для функций WinAPI.
Вот так выглядит в работе демо-приложение на Windigo:
Собственно тут показаны все основные радости Windigo, доступные без долгих часов камлания над документацией
func main()
Запуск приложения Go согласно спецификации начинается с функции func main() в пакете main:
A complete program is created by linking a single, unimported package called the main package with all the packages it imports, transitively. The main package must have package name main and declare a function main that takes no arguments and returns no value.
Первая же строка внутри main() нашего проекта нуждается в пояснении:
runtime.LockOSThread()
Этот вызов из пакета runtime нужен для того чтобы все goroutines (легковесные потоки Go) выполнялись в отдельных системных потоках каждый.
В нашем случае это необходимо для взаимодействия с системным потоком, отвечающим за графический интерфейс:
A goroutine should call LockOSThread before calling OS services or non-Go library functions that depend on per-thread state.
Следующим шагом происходит вызов функции, отвечающей за построение графического интерфейса:
mainWindow = newMyWindow()
Разберем как формируются и связываются графические элементы, в нашем проекте за это отвечает функция:
С помощью константы co.CS_NOCLOSE отключается кнопка закрытия окна:
CS_NOCLOSE 0x0200 Disables Close on the window menu.
Ну и дальше задается заголовок и размеры создаваемого окна — тут все просто. Зато сложно чуть ниже:
if DebugMode == "false" { // ID of icon resource, see resources folder // does not work in debug mode opts = opts.IconId(101) }
Тут указывается иконка окна в виде числового ID ресурса, файл с ресурсами minimal.syso был взят из демо-проекта Windigo:
A syso file, ready to use, that contains the icon and the manifest. Just place it at the root folder of your project. You can load the icon using the resource ID 101.
Следующим шагом происходит вызов сложной цепочки инициализации окна:
// create main window wnd := ui.NewWindowMain(opts)
В конце которой вызывается известная функция WinAPI CreateWindowEx, используемая для создания нового графического окна.
Если пользователь нажал кнопку «Yes» (т.е подтвердил операцию), происходит завершение работы HTTP-сервера:
if httpSrv != nil { if err := httpSrv.Close(); err != nil { fmt.Printf("HTTP close error: %v", err) } }
Закрытие главного окна приложения:
me.wnd.Hwnd().DestroyWindow()
И завершение работы:
os.Exit(0)
Следущим шагом из функции main мы загружаем иконку, используемую в трее:
var trayIcon win.HICON
// Load icon // in debug mode, there are no resources available, so we need to load // icons from FS if DebugMode == "false" { trayIcon = win.HICON( win.GetModuleHandle(win.StrOptNone()).LoadImage( win.ResIdInt(101), co.IMAGE_ICON, 16, 16, co.LR_DEFAULTCOLOR, )) } else { trayIcon = win.HICON( win.GetModuleHandle(win.StrOptNone()).LoadImage( win.ResIdStr("gopher.ico"), co.IMAGE_ICON, 16, 16, co.LR_DEFAULTCOLOR|co.LR_LOADFROMFILE, )) }
Используется разная логика для режима отладки и запуска финального бинарника, потому что в готовом приложении иконка будет находиться в ресурсах — специальном файле, упакованном вместе с приложением.
А во время отладки либо запуска вроде:
go run main.go
ресурсов не будет, поэтому придется загружать иконку непосредственно с файловой системы.
Дальше мы настраиваем дополнительные обработчики, в первую очередь добавляем обработку на закрытие главного окна приложения:
// close systray on main window destroy mainWindow.wnd.On().WmDestroy(func() { if tray != nil { tray.Dispose() } })
При закрытии главного окна, произойдет и автоматическое закрытие трея — не будет эффекта потерянной инонки, когда приложение уже закрылось, а его иконка до сих пор отображается в трее.
Дальше мы вешаем обработчик на активацию главного окна, для того чтобы поймать момент полной готовности и отображения и запустить сервер:
var configured = false // check for action that runs only once
mainWindow.wnd.On().WmActivate(func(p wm.Activate) { // we need to run our handler logic only once at start if configured { return } configured = true go startServer() })
Столь отложенный старт необходим для большей интерактивности:
метод startServer () пишет сообщения в «графический лог», если он не будет полностью инциализирован — сообщения пропадут.
Проблема заключается в том что этот обрабочик будет запускаться и на повторную активацию (например после сворачивания окна) — чтобы логика не отрабатывала повторно стоит проверка на переменную configured, которая работает в качестве флага «инициализация завершена».
Ну и сам запуск HTTP-сервера происходит через отдельный поток, чтобы не блокировать работу обработчика:
go startServer()
Последним мы добавляем инициализацию трея по событию создания главного окна:
// action on windows create // runs once mainWindow.wnd.On().WmNcCreate(func(p wm.Create) bool { // create systray tray := systray.CreateSysTray() // set handler on icon click - just focus on main window systray.SetTrayClickHandler(func() { systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd()), systray.SW_SHOWNORMAL) })
tray.SetIcon(uintptr(trayIcon)) tray.SetTooltip("Tiny Server: click me to show main window.")
return true })
Нужно это по той простой причине что только на этой стадии появляется настоящий window handle:
mainWindow.wnd.Hwnd()
С помощью которого возможно взаимодействовать с окном:
systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd())
До этого момента (т.е. до вызова обработчика) HWND нашего окна будет пустым. Наконец финальный шаг в функции main() это запуск блокирующего цикла обработки cобытий:
mainWindow.wnd.RunAsMain()
После вызова этого метода, приложение начнет реагировать на события вроде нажатия клавиш или кликов мышкой.
Теперь разберем работу с системным треем — как пример работы с чистым WinAPI.
Вот так это выглядит в Windows 11
Работа с системным треем
Разумеется есть способ проще:
взять одну из готовых библиотек, тем более что есть универсальные — сразу для Windows, MacOS и Linux и всей кучи разных сред окружения.
Но фана ради и пользы обучения для, был избран более сложный путь — закат солнца вручную взаимодействие с системным треем только через WinAPI.
Все функции, относящиеся к этой задаче находятся в пакете systray:
Благодаря этому флагу можно создать невидимое окно и заставить его обработчик принимать сообщения системного трея:
// this is main window function // see https://learn.microsoft.com/en-us/windows/win32/api/winuser/... func wndProc(hWnd uintptr, msg uint32, wParam, lParam uintptr) uintptr { switch msg { case TrayIconMsg: nmsg := LOWORD(uint32(lParam)) // if user clicked on tray icon if nmsg == WM_LBUTTONDOWN { // if callback function exist if trayClickCallback != nil { trayClickCallback() } } case WM_DESTROY: PostQuitMessage(0) default: r, _ := DefWindowProc(hWnd, msg, wParam, lParam) return r } return 0 }
Да, это все тот же старый добрый WndProc , описанный выше в статье и хорошо знакомый любым Windows-разработчикам.
Блок внутри:
.. case TrayIconMsg: nmsg := LOWORD(uint32(lParam)) // if user clicked on tray icon if nmsg == WM_LBUTTONDOWN { // if callback function exist if trayClickCallback != nil { trayClickCallback() } } ..
Отвечает за обработку сообщений системного трея.
Функция, которая отрабатывает по событию клика левой кнопки мыши (WM_LBUTTONDOWN) выглядит вот так:
Словом, уровень интеграции с WinAPI и легкости его применения поражает воображение.
Лог с интерфейсом
Лог
Он же «журнал работы» — отображает события в приложении в центральной части рабочей области. Логика записи выглядит следующим образом:
// appends to UI log func appendToLog(message string) { // could be no window yet if mainWindow == nil || mainWindow.txtName == nil { fmt.Println(message) return } // window could be not visible yet // and attempt to add message will raise an exception if !mainWindow.txtName.Hwnd().IsWindowVisible() { fmt.Println(message) return } // get current text txt := mainWindow.txtName.Text() // to avoid overflow if len(txt) > 512 { txt = "" } b := strings.Builder{} b.WriteString(txt) // append existing text b.WriteString(message) // append new message b.WriteString("\r\n") // this is Windows, so \r\n, not \n ! // and finally set updated text (yep, there is no append, sorry) mainWindow.txtName.SetText(b.String()) }
Кроме достаточно очевидного пропуска записи в случае неполной инициализации, тут есть еще вот такая логика:
if !mainWindow.txtName.Hwnd().IsWindowVisible() { fmt.Println(message) return }
Нужно это потому, что CEdit не даст изменить текст внутри если сам компонент еще не отображается, а попытка вызова метода API изменения текста вызовет ошибку.
Также внезапно (хотя для кого как) оказалось что стандартный компонент Windows для ввода не поддерживает логику добавления (append) — только полную замену всего текстового блока:
// get current text txt := mainWindow.txtName.Text() // to avoid overflow if len(txt) > 512 { txt = "" } b := strings.Builder{} b.WriteString(txt) // append existing text b.WriteString(message) // append new message b.WriteString("\r\n") // this is Windows, so \r\n, not \n ! // and finally set updated text (yep, there is no append, sorry) mainWindow.txtName.SetText(b.String())
Поэтому с точки зрения современной разработки это выглядит как колхоз, но увы — таковы реалии WinAPI.
Встроенный HTTP-сервер
В составе Golang идет готовый встраиваемый HTTP-сервер (пакет «net/http»), с примитивами обработчиков для типовых действий.
С его помощью удалось минимальными силами реализовать весь тестовый функционал, метод инициализации и запуска встроенного HTTP-сервера выглядит вот так:
// firewall bypass does not work correctly in debug mode if DebugMode == "false" { server.AddAppFirewallRule() appendToLog("Added firewall rule..") } // create request multiplexer, see https://pkg.go.dev/net/http#ServeMux mux := http.NewServeMux() // test assembler method mux.HandleFunc("/asmtest", server.TestAsmMethod) // upload & set wallpaper image mux.HandleFunc("/upload", server.UploadHandler) // default handler mux.HandleFunc("/", server.IndexHandler)
// if this is production mode - bind to all interfaces if DebugMode == "false" { httpSrv = &http.Server{ Addr: ":8090", Handler: mux, } } else { // otherwise - bind to localhost (firewall bypass // does not work in debug mode) httpSrv = &http.Server{ Addr: "localhost:8090", Handler: mux, } }
appendToLog(fmt.Sprintf("Server started at %s", httpSrv.Addr)) // set logging handler server.SetMessageLogHandler(appendToLog) httpSrv.ListenAndServe() // here will be lock }
Разберем что тут происходит, первым шагом идет запись в лог:
// firewall bypass does not work correctly in debug mode if DebugMode == "false" { server.AddAppFirewallRule() appendToLog("Added firewall rule..") }
Как это работает подробно разобрано ниже, пока замечу что этот обход не работает в режиме отладки, поэтому тут и стоит такая странная на первый взгляд проверка.
Следующим шагом происходит инстанциация мультиплексора запросов:
// test assembler method mux.HandleFunc("/asmtest", server.TestAsmMethod) // upload & set wallpaper image mux.HandleFunc("/upload", server.UploadHandler) // default handler mux.HandleFunc("/", server.IndexHandler)
Т.е. по какой ссылке будет отвечать каждый обработчик.
Логика всех обработчиков разобрана чуть ниже, а пока пройдем дальше по логике инициализации HTTP-сервера:
// if this is production mode - bind to all interfaces if DebugMode == "false" { httpSrv = &http.Server{ Addr: ":8090", Handler: mux, } } else { // otherwise - bind to localhost (firewall bypass // does not work in debug mode) httpSrv = &http.Server{ Addr: "localhost:8090", Handler: mux, } }
Вся эта простыня нужна по той простой причине что в Golang нет тернаров, т.е нельзя сделать логику одной строкой вроде:
Addr = DebugMode? "localhost:8090" : ":8090"
Как это было бы в Java или Typescript.
Поэтому надо было либо делать отдельную функцию, отдающую адрес, внутри которой вставлять проверку на DebugMode, либо сделать как на примере выше — два повторяющихся блока.
Дальше по логике происходит установка обработчика логирования:
// set logging handler server.SetMessageLogHandler(appendToLog)
Со стороны пакета сервера он вызвается вот так:
// logs message with callback on UI func logMessage(message string) { if messageLogCallback != nil { messageLogCallback(message) } else { fmt.Println(message) } }
А вот так выглядит пример конечного использования:
logMessage(fmt.Sprintf("Background changed to %s", dst.Name()))
Наконец последним шагом происходит запуск самого HTTP-сервера:
httpSrv.ListenAndServe() // here will be lock
Обратите внимание что httpSrv объявлен как глобальная переменная:
var ( tray *systray.TrayIcon httpSrv *http.Server mainWindow *MyWindow //You can only set string variables with -X linker flag. From the docs: DebugMode = "true" )
Это нужно чтобы иметь возможность остановить HTTP-сервер при завершении работы (graceful shutdown):
if httpSrv != nil { if err := httpSrv.Close(); err != nil { fmt.Printf("HTTP close error: %v", err) } }
Обход файрвола
И не надо так подозрительно смотреть — речь про вполне себе документированное API, позволяющее пропустить вот такое откровенно дурацкое подтверждение:
Был добавлен еще в Windows 7 с официальной целью: выбешивать пользователей.
Как и в случае с интерфейсом, было принято волевое решение использовать готовый пакет с биндингами:
This is a package for controlling the Windows Filtering Platform (WFP), also known as the Windows firewall.
С его помощью вся логика свелась к вот такой простой функции, взятой из issue в Github проекта и немного переделанной:
Не буду детально расписывать эту довольно сложно воспринимаемую логику — слишком уж тут много специфики WFP, читать устанете.
Если есть желание погрузиться в тему — вам в помощь вот такая замечательная статья от авторов пакета, где расписано в деталях внутренее устройство WFP и работа с его API.
Но если в кратце — тут происходит создание и применение нового правила фильтрации, которое разрешает входящие соединения для приложения, из которого выполняется вызов API.
Golang и ассемблер
Чтобы сразу закрыть все вопросы по поводу адекватности использования ассемблера из Go, вот вам небольшая цитата:
This example is taken from the AES package of the standard Go library. It makes use of Go Assembly to leverage Intel’s hardware support for AES, calling the AES-NI CPU instructions that can perform a “round” of encryption or decryption of the AES algorithm.
Да, как только начинается большая криптография и множественные вычисления на слабом железе — сразу с дальней полки достается пыльная книга по ассемблеру.
Разумеется не было открытием что язык, с самого своего начала имевший компилятор в нативный код умеет вызывать вставки на ассемблере. Открытием была легкость и простота с которой это делается.
Не задумывались почему маскоты Go и Plan 9 так похожи?
Еще одним открытием оказались торчащие уши Plan 9:
The assembler is based on the input style of the Plan 9 assemblers, which is documented in detail elsewhere. If you plan to write assembly language, you should read that document although much of it is Plan 9-specific
А разгадка проста — один из авторов Go когда‑то работал над Plan 9:
func main() { TEXT("Add", NOSPLIT, "func(x, y uint64) uint64") Doc("Add adds x and y.") x := Load(Param("x"), GP64()) y := Load(Param("y"), GP64()) ADDQ(x, y) Store(y, ReturnIndex(0)) RET() Generate() }
Это отдельная программа на Go, которая при запуске генерирует ассемблерный код в файле asm/add.s:
// Code generated by command: go run asm.go -out asmtest/add.s -stubs asmtest/stub.go. DONOT EDIT.
#include "textflag.h"
// func Add(x uint64, y uint64) uint64 TEXT ·Add(SB), NOSPLIT, $0-24 MOVQ x+0(FP), AX MOVQ y+8(FP), CX ADDQ AX, CX MOVQ CX, ret+16(FP) RET
А также заголовочный файл на Go в файле stub.go:
// Code generated by command: go run asm.go -out asmtest/add.s -stubs asmtest/stub.go. DO NOT EDIT. package ungoogled // Add adds x and y. func Add(x uint64, y uint64) uint64
Затем данные файлы линкуются с основным приложением, а вызов метода с ассемблерной вставкой выглядит вот так:
// a test API method to call function with Assembler inside func TestAsmMethod(w http.ResponseWriter, req *http.Request) {
Просто для демонстрации возможностей, к достаточно стандартному функционалу по загрузке файлов был приделан вызов WinAPI функции для смены обоев на рабочем столе.
Сама форма загрузки выглядит максимально стандартно:
Затем она зашивается в приложение с помощью go:embed:
//go:embed upload.html var uploadTemplate string
Т.е. во время запуска приложения, переменная uploadTemplate будет содержать HTML-шаблон выше для загрузки картинки — зашивание происходит во время сборки.
За загрузку картинки отвечает функция:
func uploadFile(w http.ResponseWriter, r *http.Request) { .. }
Внутри стандартная скучная логика разборки multipart-формы и обработки загруженного файла - ее нет смысла описывать, зато дальше происходит кое-что интересное:
// build full path to image imagePath, err := windows.UTF16PtrFromString(dst.Name())
Тут происходит формирование ссылки на UTF-16 строку, содержающую полный путь к загруженному файлу.
Затем эта ссылка используется для вызова API:
// call WinAPI to change wallpaper to just uploaded image _, _, err = procSystemParamInfo.Call(20, 0, uintptr(unsafe.Pointer(imagePath)), 0x001A) // check for errors, respond 500 if any if err, ok := err.(syscall.Errno); ok { if err != 0 { fmt.Println("Error :") fmt.Println(err) http.Error(w, err.Error(), http.StatusInternalServerError) return } }
Вызывается функция SystemParametersInfoW, которая на самом деле используется для очень большого количества разных действий:
Retrieves or sets the value of one of the system-wide parameters. This function can also update the user profile while setting a parameter.
Нужный нам для смены обоев actionName называется SPI_SETDESKWALLPAPER который и указывается при вызове.
Эпилог
Разумеется я такой не один и уже достаточномногоразработчиков по всему миру делятся своим опытом разработки на Go и WinAPI. Надеюсь эта статья также добавит читателям восторгов и позволит взглянуть на любимый инструмент под другим углом.
Схема стандартная: мне нужно было выкатить наружу очередной сервис из своего хоумлаба (дашборд 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"
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 ради домашнего сервера или стейджинга - проект полностью открыт, пользуйтесь.
Буду рад фидбеку. Ломайте, тестируйте, закидывайте ишью или предлагайте фичи. Если утилита сэкономит вам пару минут на выходных - значит, все это было не зря. Только не просите меня добавлять в CLI новые кнопки. Я написал его как раз для того, чтобы их больше никогда не видеть.
Я как любитель хоумлабинга и хранитель локалнета решил организовать себе локальную DNS зону, чтобы не печатать длинные ip адреса и не редачить hosts на всех машинах. После долгого простоя решил возобновить работу сервака. Накатил CentOS (роутер в DHCP прописал его как ubunu-server, ну и ладно), взял в помощь Алису, и провел вечер за настройкой bind2.
Через пару дней решил сделать pet-проект, чтобы прокачаться в go разработке, пошел за идеей к Алисе. Та запомнив, что я недавно ставил DNS предложила сделать свою реализацию (без блэкджека и всякого такого, и вообще РКН версию DNS сервера полностью урезанного интернета). Идея понравилась, но решил сделать с возможностью удобной конфигурации, раз уж начал.
За основу взял пакет github.com/miekg/dns и изучил блог разработчика (там есть примеры кода). Начал разбираться что и к чему. Под ночь мне уже не хотелось копаться в RFC, изучать термины и флаги сообщений к серверу, а просто сделать так, чтобы все заработало минимально.
Изначально я собирался хранить в базе пару name-ip, но потом вернулся к проектированию, чтобы дать возможность определять локальные зоны и отсекать по NXDOMAIN без опроса вышестоящих DNS. Добавил кэширование, но пока TTL срабатывает во время проверки, надо бы прикрутить time.AfterFunc удаление.
Настройку и управление зонами решил организовать в веб-интерфейсе. Навайбкодил с первого выстрела веб-интерфейс (надо бы еще туда базовую авторизацию добавить хотя бы, но можно упороться и сделать систему токенов для сторонних приложений)
Страница со списком зон
Модалка для добавления зон
Проваливаемся в зону
Добавляем домен
Конечно, навайбкожено на скорую руку, но планирую сделать ввод только домена, а зону приклеивать позже. Еще желательно прикрутить валидации с опорой на тип записи.
Запустил
Пример запроса DNS
Проект доступен в github: https://github.com/vesh95/dnso Про имя проекта не спрашивайте, это я когда вводил имя случайно ввел лишний символ, решил оставить.
Если что-то сделано неправильно или увидели проблему - пишите в gh issues или комментариях. Может у кого-то появятся советы и рекомендации, дайте знать 😊
TODO - На данный момент есть проблемы с командами запуска докера (напутаны порты для запуска и те что пробрасываются, см. переменные окружения, чтобы запустить с нужными) - Переделать форму, чтобы указывать домены без зоны - Добавить авторизацию в интерфейсе - Добавить валидации
Схема авторизации достаточно простая: 1. Запрашиваем логин и контактную информацию номер телефона или почта 2. Формируем код 3. На основе того что ввели и сгенерировали формируем JWT токен, в claims кладем логин, контакты, hmac кода из сообщения, и таймштамп со временем возможности следующей отправки (для ограничения количества отправок) 4. Отправляем код 5. В UI форма переключается на шаг с вводом кода и ожидаем ввода 6. Извлекаем claims из куки, на основе введенного кода формируем hmac и сравниваем его с тем, что в токене в куках 7. Если все совпадает, то регистрируем пользователя и выдаем пару JWT токенов для аутентификации и обновления токена.
Со входом в аккаунт такая же система, но нужен только login.
Примерно так это выглядит на момент написания поста
Форма регистрации
Форма ввода кода
Код для входа (пока сделан в логах)
После входа в профиль
На момент написания статьи коды авторизации отображались в логах. Это временное решение пока не настроено smtp и сервисы для отправки.
Возможно для авторизации я сделаю немного другую стратегию, чтобы пользователи вводили только логин и TOTP-код (код базированный на времени, как в google authenticator). Это позволит сэкономить на отправке SMS-кодов (которые не бесплатны в отличие от email и TOTP).
Ок, далее хочу выстроить спецификации в swagger. Наброски того как и что делать уже в блокноте. Что нам нужно делать: - Общую доску - Доски пользователей и профиль (короче говоря, перенимаем у инсты паттерн, где контент идет сразу после блока с информацией профиля) - Авторизацию и регистрацию
Дополнительно заложим возможность делать запросы и отвечать на них (принимать/отклонять)
Сначала я хочу описать общие части спецификации вручную для того, чтобы выстроить эндпоинты в интуитивно понятной структуре, а затем через ассистента выделить их этого общие ответы, дополнить блоки secure, tag, response и другие элементы, с которыми ИИ справится лучше человека.
- Что не позвал? - Да я откуда знал что у тебя эта игра вообще есть?
С этого началась вся эта история. Идея была проста, когда я планирую во что-то поиграть надо все рассказывать. Подумал, может сделать объявления? Подумано - сделано. Через час я сижу с листком бумаги, открытым вики-редактором (своя база знаний на сетевом хранилище, прикиньте). Мусорка уже наполовину заполнена бумажками с идеями (уж слишком мало ограничений для небольшого приложения). Часа в два ночи уже имел согласованный с самим собой функционал (ну это с перерывами на 3 часа попить чай, естественно). Следующий шаг - спецификации API и детальное проектирование процессов.