"Бобер выдыхай" : Golang, WinAPI и ассемблер
Что вам приходит в голову при слове «Golang»? Google и микросервисы? Я тоже так думал, но как обычно реальность оказалась значительно интересней.
Волшебный мир Windows
Эта статья родилась внезапно — из мордобоя профессионального спора о реалиях и возможностях языка Go, которые как оказалось выходят сильно далеко за рамки его традиционной сферы примененения.
Немного матчасти для тех кто не в теме:
Go (часто также golang) — компилируемый многопоточный язык программирования, разработанный внутри компании Google[11]. Разработка Go началась в сентябре 2007 года, его непосредственным проектированием занимались Роберт Гризмер, Роб Пайк и Кен Томпсон[12], занимавшиеся до этого проектом разработки операционной системы Inferno. Официально язык был представлен в ноябре 2009 года.
Я как и наверное большинство серьезных разработчиков всегда считал Golang не более чем корпоративной игрушкой, призванной подсадить широкие программисткие массы на очередную закрытую технологию «корпорации добра» — создавалось оно внутри Гугла, для задач и реалий Гугла в первую очередь.
Но точно не для обычных офисных задач.
Поэтому когда мне показали работу Golang с WinAPI «из коробки» я если честно сильно ох#ел удивился — в более серьезных языках вроде C/C++ работа c внутренностями Windows всегда выглядела куда более монструозной.
Так и родилась эта непростая статья.
Что мы будем в этот раз творить:
Desktop-приложение с настоящим нативным интерфейсом, с учетом реалий Windows, которое запустит встроенный вебсервер, с методом API на ассемблере.
Еще будет загрузка графического файла и установка его в качестве обоев — через WinAPI.
И небольшой обход файрвола, чтобы не показывался этот дурацкий экран с предупреждением:
Он всегда меня бесил, а то что меня бесит — я отрываю отключаю.
Обещаю, то что покажу в этой статье заставит поперхнуться смузи даже опытных разработчиков на Golang.
Работа с системным треем:
Стандартный системный модальный диалог:
Веб-интерфейс встроенного HTTP-сервера:
Проект как обычно выложен на Github.
Сборка и запуск
Начнем с самого банального — как всю эту йобу вообще собрать и запустить.
Для Windows уже давно существуют готовые официальные сборки Golang, даже с инсталлятором.
Взять можно с официального сайта Golang, вот тут.
Я использовал последнюю на момент написания статьи версию 1.22.5, но язык бурно развивается, поэтому не удивлюсь если выйдет еще более новая версия еще до завершения статьи.
Разработка проекта происходила в Visual Studio Code, который давно и официально поддерживает Go:
для сборки проекта использовались не обычные Makefile и не шелл-скрипты — так характерные для проектов на «гошечке», а целая отдельная внешняя система сборки — Magefile.
Ставится она множеством разных способов, я использовал вот такой:
git clone https://github.com/magefile/mage cd mage go run bootstrap.go
После установки в окружении появляется бинарник mage, как раз отвечающий за сборку:
git clone https://github.com/alex0x08/golang-winapi-asm.git
Скачиваем и устанавливаем зависимости:
mage install
mage build
Если сборка прошла успешно, в текущем каталоге будет файл ungoogled-go.exe, который можно свободно перемещать и запускать на пользовательских компьютерах — он полностью статичный и не зависит от установленного Golang.
mage generate
Этой командой запустится генерация файлов add.s и stub.go — для метода на ассемблере.
Стоит также отметить, что конечная и отладочная сборка немного отличаются, дифференциация идет путем проброса параметра:
-X main.DebugMode=false
которым изменится значение глобальной переменной — флагом отладочного режима, который в свою очередь немного влияет на поведение программы.
Теперь начинаем разбираться как оно все работает.
Приложение Windows
Если попробовать собрать и запустить в Windows классический «Hello world» на C:
#include <stdio.h> int main() { printf("Hello, World!"); return 0; }
то вместо ожидаемого пустого графического окна запустится ч0рная консоль (на скриншоте выше).
Это происходит потому что в Windows для графических программ используется другая точка запуска (entry point):
Every Windows program includes an entry-point function named either WinMain or wWinMain.
И если уж жизнь вас заставила разрабатывать на Golang под Windows, еще и с графическим интерфейсом, то стоит «гошечке» об этом сообщить, добавив флаг:
-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 файл.
Golang и WinAPI
Стоит для начала пояснить для непосвященных — в чем вообще заключается сложность работы с WinAPI.
Для примера возьмем официальный «Hello world» на C++ под Windows:
#ifndef UNICODE #define UNICODE #endif #include <windows.h> LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR pCmdLine, int nCmdShow) { // Register the window class. const wchar_t CLASS_NAME[] = L"Sample Window Class"; WNDCLASS wc = { }; wc.lpfnWndProc = WindowProc; wc.hInstance = hInstance; wc.lpszClassName = CLASS_NAME; RegisterClass(&wc); // Create the window. HWND hwnd = CreateWindowEx( 0, // Optional window styles. CLASS_NAME, // Window class L"Learn to Program Windows", // Window text WS_OVERLAPPEDWINDOW, // Window style // Size and position CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, // Parent window NULL, // Menu hInstance, // Instance handle NULL // Additional application data ); if (hwnd == NULL) { return 0; } ShowWindow(hwnd, nCmdShow); // Run the message loop. MSG msg = { }; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return 0; } LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // All painting occurs here, between BeginPaint and EndPaint. FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1)); EndPaint(hwnd, &ps); } return 0; } return DefWindowProc(hwnd, uMsg, wParam, lParam); }
Ну что, много чего с ходу понятно?
Собственно 90% кода даже в столь простом приложении не имеют никакого отношения к C++, а являются структурами, макросами или функциями самого WinAPI.
От C++ тут только примитивные типы (int) да управляющие конструкции (case, while).
Поэтому задача как-то серьезно взаимодействовать с WinAPI (дальше чем разовый вызов какой-то функции) — всегда была, есть и будет сложной.
А разработка под Windows является отдельной специальной дисциплиной, чемпионы которой запросто могут не знать обычный C/C++ вообще и всю разработку (даже серверную) вести на инструментарии WinAPI — привет интервьюерам и поиску по ключевым словам в резюме.
Но вернемся к нашей «гошечке».
Go далеко не C++ и является экзотикой в мире Windows-разработки, по крайней мере за пределами кампусов Google.
Но внезапно оказалось, что поддержка WinAPI в нем очень даже неплоха.
Взгляните как выглядит вызов WinAPI функции для установки обоев на Golang:
var ( user32DLL = windows.NewLazyDLL("user32.dll") procSystemParamInfo = user32DLL.NewProc("SystemParametersInfoW") ) func main() { imagePath, _ := windows.UTF16PtrFromString(`image.jpg`) fmt.Println("[+] Changing background now...") procSystemParamInfo.Call(20, 0, uintptr(unsafe. Pointer(imagePath)), 0x001A) }
Тут так красиво скрыты все скользкие моменты вроде передачи указателя на участок памяти, где лежит путь до файла с картинкой:
unsafe.Pointer(imagePath)
windows.UTF16PtrFromString(`image.jpg`)
Вот так выглядит работа этого маленького приложения:
Не буду разбирать весь код, вот тут лежит отдельная большая статья, в которой все подробно расписано.
Скажу лишь что благодаря столь серьезной поддержке WinAPI, получилось сваять этот тестовый проект и не утопить читателя утонуть в деталях.
WinAPI и графический интерфейс
Сначала я честно попытался реализовать вообще всю логику работы с WinAPI полностью вручную, как в этом примере со стандартным диалоговым окном:
import ( "syscall" "unsafe" ) // MessageBox of Win32 API. func MessageBox(hwnd uintptr, caption, title string, flags uint) int { ret, _, _ := syscall.NewLazyDLL("user32.dll"). NewProc("MessageBoxW").Call( uintptr(hwnd), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(caption))), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), uintptr(flags)) return int(ret) } // MessageBoxPlain of Win32 API. func MessageBoxPlain(title, caption string) int { const ( NULL = 0 MB_OK = 0 ) return MessageBox(NULL, caption, title, MB_OK) }
Но только объем кода очень быстро вырос до конских размеров и уже никак не влезал в масштаб статьи.
Поэтому от такого подхода пришлось отказаться, оставив только нативную работу с системным треем.
Все остальное я отдал на откуп готовым библиотекам.
В частности построение окон и обработку событий были реализованы через библиотеку 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()
Разберем как формируются и связываются графические элементы, в нашем проекте за это отвечает функция:
func newMyWindow() *MyWindow
type MyWindow struct { wnd ui.WindowMain lblName ui.Static txtName ui.Edit btnShow ui.Button }
содержит все графические элементы — само окно (wnd), текстовую метку (lblName), текстовое поле (txtName) и кнопку (btnShow).
Первым делом происходит настройка создаваемого окна:
opts := ui.WindowMainOpts(). ClassStyles(co.CS_NOCLOSE). Title("Tiny Server"). ClientArea(win.SIZE{Cx: 600, Cy: 245})
С помощью константы 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, используемая для создания нового графического окна.
Дальше происходит создание отдельных элементов:
// build UI me := &MyWindow{ wnd: wnd, // add label lblName: ui.NewStatic(wnd, ui.StaticOpts(). Text("Server log"). Position(win.POINT{X: 10, Y: 22}), ), // add shutdown button btnShow: ui.NewButton(wnd, ui.ButtonOpts(). Text("&Quit"). Position(win.POINT{X: 510, Y: 17}), ), // add message log (text area) txtName: ui.NewEdit(wnd, ui.EditOpts(). WndStyles(co.WS_CHILD|co.WS_VISIBLE|co.WS_VSCROLL). CtrlStyles(co.ES_AUTOHSCROLL|co.ES_MULTILINE|co.ES_LEFT|co.ES_READONLY). Position(win.POINT{X: 0, Y: 45}). Size(win.SIZE{Cx: 600, Cy: 200}), ), }
Важно отметить, что в случае WinAPI за любой ввод текста отвечает один и тот же компонент CEdit, c разным набором настроек:
- co.ES_MULTILINE — указание на ввод нескольких строк (как textarea в HTML);
- co.WS_VISIBLE — окно не будет скрыто;
- co.WS_VSCROLL — вертикальный скролл;
- co.ES_AUTOHSCROLL — автоматический горизонтальный скролл;
- co.ES_READONLY — только для чтения.
Дальше происходит настройка обработчика кнопки для завершения работы приложения:
// setup handler on 'shutdown' button click me.btnShow.On().BnClicked(func() { // start confirmation dialog resp := me.wnd.Hwnd().MessageBox("Quit application?", "Confirm quit", co.MB_YESNO) // if user clicked 'YES' - shutdown application if resp == co.ID_YES { appendToLog("Exiting..") if httpSrv != nil { if err := httpSrv.Close(); err != nil { fmt.Printf("HTTP close error: %v", err) } } me.wnd.Hwnd().DestroyWindow() os.Exit(0) } })
По клику запускается модальный диалог с блокировкой текущего треда:
resp := me.wnd.Hwnd().MessageBox("Quit application?", "Confirm quit", co.MB_YESNO)
Если пользователь нажал кнопку «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, MacOS и Linux и всей кучи разных сред окружения.
Но фана ради и пользы обучения для, был избран более сложный путь — закат солнца вручную взаимодействие с системным треем только через WinAPI.
Все функции, относящиеся к этой задаче находятся в пакете systray:
import ( .. systray "github.com/alex0x08/ungoogled-go/systray" .. )
Место с которого начинается инициализация системного трея выглядит вот так:
// creates systray icon func CreateSysTray() *TrayIcon { // first, create hidden message-only window hwnd, err := createMessageWindow() if err != nil { panic(err) } // create systray with parent = our message-only window ti, err := newTrayIcon(hwnd) if err != nil { panic(err) } return ti }
Как видите тут не используется родительское окно — вместо него создается специальное скрытое окно, только для приема сообщений:
func createMessageWindow() (uintptr, error) { hInstance, err := GetModuleHandle(nil) if err != nil { return 0, err } wndClass := windows.StringToUTF16Ptr("MyWindow") var wcex WNDCLASSEX wcex.CbSize = uint32(unsafe.Sizeof(wcex)) wcex.LpfnWndProc = windows.NewCallback(wndProc) wcex.HInstance = hInstance wcex.LpszClassName = wndClass if _, err := RegisterClassEx(&wcex); err != nil { return 0, err } hwnd, err := CreateWindowEx( 0, wndClass, windows.StringToUTF16Ptr(""), WS_OVERLAPPED, CW_USEDEFAULT, CW_USEDEFAULT, 400, 300, uintptr(HWND_MESSAGE), 0, hInstance, nil) if err != nil { return 0, err } return hwnd, nil }
Ключевое тут — вызов функции WinAPI CreateWindowEx, c указанием специального флага HWND_MESSAGE:
hwnd, err := CreateWindowEx( .. uintptr(HWND_MESSAGE) .. )
Благодаря этому флагу можно создать невидимое окно и заставить его обработчик принимать сообщения системного трея:
// this is main window function // see https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wndproc 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) выглядит вот так:
systray.SetTrayClickHandler(func() { systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd()) , systray.SW_SHOWNORMAL) })
А systray.ShowWindow() это фактически обретка над чистым WinAPI:
func ShowWindow(hWnd uintptr, nCmdShow int32) (int32, error) { r, _, err := procShowWindow.Call(hWnd, uintptr(nCmdShow)) if r == 0 { return 0, err } return int32(r), nil }
поскольку procShowWindow — чистый definition для фукнции WinAPI ShowWindow:
.. libuser32 = windows.NewLazySystemDLL("user32.dll") .. procShowWindow = libuser32.NewProc("ShowWindow") ..
Словом, уровень интеграции с 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-сервера выглядит вот так:
// starts HTTP server func startServer() { appendToLog(fmt.Sprintf("Starting, debug mode: %s", DebugMode)) // 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 }
Разберем что тут происходит, первым шагом идет запись в лог:
appendToLog(fmt.Sprintf("Starting, debug mode: %s", DebugMode))
Затем попытка отключить NAG-screen файрвола:
// 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()
и связывание методов обработки с контекстом URL:
// 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, позволяющее пропустить вот такое откровенно дурацкое подтверждение:
Как и в случае с интерфейсом, было принято волевое решение использовать готовый пакет с биндингами:
This is a package for controlling the Windows Filtering Platform (WFP), also known as the Windows firewall.
С его помощью вся логика свелась к вот такой простой функции, взятой из issue в Github проекта и немного переделанной:
// adds firewall rule via WinAPI to bypass confirmation screen func AddAppFirewallRule() error { session, err := wf.New(&wf.Options{ Name: "ungoogled session", Dynamic: false, }) if err != nil { return err } defer session.Close() guid, _ := windows.GenerateGUID() execPath, _ := os.Executable() appID, _ := wf.AppID(execPath) err = session.AddRule(&wf.Rule{ ID: wf.RuleID(guid), Name: "Ungoogled", Layer: wf.LayerALEAuthRecvAcceptV4, Weight: 800, Conditions: []*wf.Match{ { Field: wf.FieldALEAppID, Op: wf.MatchTypeEqual, Value: appID, }, }, Action: wf.ActionPermit, }) if err != nil { return err } return nil }
Не буду детально расписывать эту довольно сложно воспринимаемую логику — слишком уж тут много специфики 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.
Да, как только начинается большая криптография и множественные вычисления на слабом железе — сразу с дальней полки достается пыльная книга по ассемблеру.
Разумеется для меня не было открытием что язык, с самого своего начала имевший компилятор в нативный код умеет вызывать вставки на ассемблере. Открытием была легкость и простота с которой это делается.
Еще одним открытием оказались торчащие уши 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
И тут я сильно так прих#ел — чего только в этой жизни не бывает.
Но продолжим тему с ассемблером.
Вообщем чтобы не #бстись с созданием и линковкой ассемблерных вставок вручную, я использовал фреймворк Avo:
avo
makes high-performance Go assembly easier to write, review and maintain.
Самое важное что он дает это вот:
И выглядит это именно так как звучит:
//go:build ignore package main import . "github.com/mmcloughlin/avo/build" 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. DO NOT 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
// 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) { query := req.URL.Query() fmt.Println("GET params were:", query) param1, param2 := query.Get("param1"), query.Get("param2") int1, _ := strconv.ParseUint(param1, 10, 64) int2, _ := strconv.ParseUint(param2, 10, 64) fmt.Fprintf(w, "int1: %v int2: %v \n", int1, int2) // yep, check stub.go in asmtest out := ungoogled.Add(int1, int2) fmt.Fprintf(w, "result: %v \n", out) logMessage( fmt.Sprintf("Called asm method with params: %v , %v and result: %v", int1, int2, out)) }
Лепота и благодать, что словами не передать.
Смена обоев через WinAPI и загрузку файлов
«Because fuck you! That’s why!» (ц)
Просто для угара демонстрации возможностей, к достаточно стандартному функционалу по загрузке файлов был приделан вызов WinAPI функции для смены обоев на рабочем столе.
Сама форма загрузки выглядит максимально стандартно:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Upload an image</title> </head> <body> <form enctype="multipart/form-data" action="/upload" method="post"> <input type="file" name="imageFile" accept="image/*" /> <input type="submit" value="upload" /> </form> </body> </html>
Затем она зашивается в приложение с помощью 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.
Надеюсь эта статья также добавит читателям восторгов и позволит взглянуть на любимый инструмент под другим углом — из серии «гляди чего еще эта х#рня умеет».