Files
knock-gui/article/electron-desktop-guide.md

586 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Как приручить Electron и спрятать Go-движок внутрь дистрибутива: история одного десктопа
```metadata
id: 3
title: "Electron-приложение с секретным Go-движком внутри"
readTime: 15-25 минут
date: 2025-09-24 19:00
author: Direct-Dev (Антон)
level: Средний
tags: #electron #go #node #ipc #desktop #packaging #appimage
version: 1.0.0
```
## Пролог: почему вообще Electron?
В прошлой статье рассказали про то как можно в консольную утилиту встроить фронт на базе веб приложения - то есть запускается все вместе одним бинарником Го, который сервит статику и обрабатывает запросы апи ->
все в одном флаконе ... <https://main.direct-dev.ru/blog/2>
Там у нас был в составе приложения фронта бэкенд на Go (или это был фронт в составе бэкэнда - кому как хочется), умевший красиво «стучаться» по портам (port knocking) и даже обходить туннели, поднятые на хосте с которого «стучатся», если надо.
Но ситуации бывают разные, люди(пользователи) разные, кому-то браузер не подходит и надо «кнопочки».
Браузер — хорошо, говорят они, но хочется «как приложение».
Разработчики переглянулись, вздохнули и сказали: «Electron? Окей…»
Итак, мы тут не просто запустили вебку в окошке с помощью кучамегабайтного файла-приложения.
Мы упаковали внутрь него также Go-бинарь, который молча работает рядом, а Electron — это только мост и интерфейс.
Пользователь счастлив, WireGuard простаивает без трафика.
Правда размер супер приложухи не уменьшился, память также отжирает.
Ну ... такая текнология. Зато быстро и просто (относительно).
---
## Глава 1. Три кита: main, preload, renderer
Первым делом собираем каркас.
- Main: создаёт окна, IPC, меню — короче, дирижёр.
- Preload: аккуратно выдаёт в renderer только то, что можно.
- Renderer: UI, кнопки, формы — чтобы полетело.
>И, да! Восславим в нашем проекте чистый Vanila Js - ибо все остальное это только частности ...
Минимальный старт в main.js может выглядеть так:
```js
// src/main/main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
}
});
win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
// Глобальные обработчики ошибок
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception in main process:', error);
// Не завершаем приложение, просто логируем
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection in main process:', reason);
// Не завершаем приложение, просто логируем
});
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// логика локального простукивателя - без вызовов api
ipcMain.handle('knock:local', async (_e, payload) => {
try {
// Валидация входных данных
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload provided' };
}
const { targets, delay, verbose, gateway } = payload;
if (!targets || !Array.isArray(targets) || targets.length === 0) {
return { success: false, error: 'No targets provided' };
}
// Валидация каждого target
const validTargets = targets.filter(target => {
return typeof target === 'string' && target.trim().length > 0;
});
if (validTargets.length === 0) {
return { success: false, error: 'No valid targets provided' };
}
// Если задан gateway, используем Go-хелпер (поддерживает SO_BINDTODEVICE)
if ((gateway && String(gateway).trim()) || validTargets.some(t => t.split(':').length >= 4)) {
const { spawn } = require('child_process');
// Ищем собранный бинарь внутри Electron-пакета
// При разработке: desktop/bin/knock-local
// В продакшене: resources/bin/knock-local
const devBin = path.resolve(__dirname, '../../bin/knock-local');
const prodBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
const helperExec = fs.existsSync(devBin) ? devBin : prodBin;
const req = {
targets: validTargets,
delay: delay || '1s',
// Принудительно отключаем verbose у хелпера, чтобы stdout был чисто JSON
verbose: false,
gateway: gateway || ''
};
const input = JSON.stringify(req);
const child = spawn(helperExec, [], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.stdin.write(input);
child.stdin.end();
const code = await new Promise(resolve => child.on('close', resolve));
if (code !== 0) {
return { success: false, error: `go helper exited with code ${code}: ${stderr || stdout}` };
}
try {
// Извлекаем последнюю JSON-строку из stdout (в случае если есть текстовые логи)
const lines = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
const parsed = JSON.parse(jsonLine);
if (parsed?.success) {
return { success: true, results: [], summary: { total: validTargets.length, successful: validTargets.length, failed: 0 } };
}
return { success: false, error: parsed?.error || 'unknown helper error' };
} catch (e) {
return { success: false, error: `failed to parse helper output: ${e.message}`, raw: stdout };
}
}
const results = await performLocalKnock(validTargets, delay || '1s', Boolean(verbose), gateway || null);
return {
success: true,
results: results,
summary: {
total: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
}
};
} catch (error) {
console.error('Local knock error:', error);
return {
success: false,
error: error.message || 'Unknown error occurred'
};
}
});
```
В `preload` отдаем только белый список того, что накодили в main.js:
```js
// src/preload/preload.js
const { contextBridge, ipcRenderer } = require('electron');
// Пробрасываем конфигурацию в рендерер (безопасно)
contextBridge.exposeInMainWorld('config', {
apiBase: process.env.KNOCKER_DESKTOP_API_BASE || 'http://localhost:8080/api/v1'
});
contextBridge.exposeInMainWorld('api', {
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
});
```
Renderer остался «чистым» фронтом: получает `window.api`, дергает оттуда нужные данные и методы ...
``` js
(() => {
// Глобальные обработчики ошибок в renderer
window.addEventListener('error', (event) => {
console.error('Global error in renderer:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection in renderer:', event.reason);
});
let apiBase = window.config?.apiBase || "http://localhost:8080/api/v1";
const qs = (sel) => document.querySelector(sel);
const qsi = (sel) => document.querySelector(sel);
const qst = (sel) => document.querySelector(sel);
// ...
window.addEventListener("DOMContentLoaded", () => {
qs("#execute")?.addEventListener("click", async () => {
updateStatus("Выполнение…");
const password = qsi("#password").value;
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
// Проверяем, нужно ли использовать локальное простукивание
const useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal';
if (useLocalKnock) {
// Локальное простукивание через Node.js
try {
let targets = [];
let delay = qsi("#delay").value || '1s';
const verbose = qsi("#verbose").checked;
if (mode === "inline") {
targets = qsi("#targets").value.split(';').filter(t => t.trim());
} else if (mode === "form") {
targets = [serializeFormTargetsToInline()];
} else if (mode === "yaml") {
// Для YAML режима извлекаем targets из YAML
const yamlContent = qst("#configYAML").value;
try {
const config = yaml.load(yamlContent);
if (config?.targets && Array.isArray(config.targets)) {
targets = config.targets.map(t => {
const protocol = t.protocol || 'tcp';
const host = t.host || '127.0.0.1';
const ports = t.ports || [t.port] || [22];
return ports.map(port => `${protocol}:${host}:${port}`);
}).flat();
delay = config.delay || delay;
}
} catch (e) {
updateStatus(`Ошибка парсинга YAML: ${e.message}`);
return;
}
}
if (targets.length === 0) {
updateStatus("Нет целей для простукивания");
return;
}
// Получаем gateway из конфигурации или поля
const gateway = qsi('#gateway')?.value?.trim() || '';
const result = await window.api.localKnock({
targets,
delay,
verbose,
gateway
});
if (result?.success) {
const summary = result.summary;
updateStatus(`Локальное простукивание завершено: ${summary.successful}/${summary.total} успешно`);
// Логируем детальные результаты в консоль
if (verbose) {
console.log('Local knock results:', result.results);
}
} else {
const errorMsg = result?.error || 'Неизвестная ошибка локального простукивания';
updateStatus(`Ошибка локального простукивания: ${errorMsg}`);
console.error('Local knock failed:', result);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
return;
}
// API простукивание через HTTP
const body = {};
if (mode === "yaml") {
body.config_yaml = qst("#configYAML").value;
} else if (mode === "inline") {
body.targets = qsi("#targets").value;
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
body.gateway = qsi("#gateway").value;
} else {
body.targets = serializeFormTargetsToInline();
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
}
let result;
try {
result = await fetch(`${apiBase}/knock-actions/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify(body),
});
if (result?.ok) {
updateStatus("Успешно простучали через API...");
} else {
updateStatus(`Ошибка API: ${result.statusText}`);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
}
// ...
})();
```
---
## Глава 2. Если API нет — кнокаем локально
Итак в приложении у нас возможны два режима:
- Есть API? Хорошо, бьём HTTP-запросом.
- API === `internal`? Работать всё равно надо. Значит Node-сокеты, TCP/UDP и т.д.
И всё бы у нас было гладко да сладко ... но появился **gateway**.
---
## Глава 3. Gateway и WireGuard: «кто кого»
Задача: отправить пакеты через конкретный интерфейс, чтобы они не уходили в туннель WireGuard (когда весь трафик завернут туда). На Go реализуется через `SO_BINDTODEVICE` и поехали. На Node … ну … хбз (может как то и можно - АУ гуру node.js).
Перепробовал legit-способы: `localAddress`, пляски с таймаутами — но туннель всё равно перехватывает пакеты.
А чего мы мучаем Node? Дадим это сделать Go. Он же это уже умеет!
---
## Глава 4. Секретный Go-движок внутри
Написан маленький Go-хелпер, который делает РОВНО то, что нужно:
- читает из stdin JSON: `targets`, `delay`, `gateway`,
- превращает в конфиг,
- вызывает `internal.PortKnocker()` (в нём `SO_BINDTODEVICE`, `LocalAddr`, TCP/UDP),
- печатает в stdout один короткий JSON: «успех/ошибка».
```go
// back/cmd/knock-local/main.go (суть)
type Request struct {
Targets []string `json:"targets"`
Delay string `json:"delay"`
Verbose bool `json:"verbose"`
Gateway string `json:"gateway"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// ... парсим stdin ...
pk := internal.NewPortKnocker()
_ = pk.ExecuteWithConfig(cfg, false, false) // stdout — только JSON
_ = json.NewEncoder(os.Stdout).Encode(Response{ Success:true, Message:"ok" })
```
---
## Глава 5. Как Electron «общается» с Go
В `main` есть обработчик IPC `knock:local`. С помощью preload.js он выставлен во внешний мир.
```js
contextBridge.exposeInMainWorld('api', {
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
});
```
- Если `gateway` задан (или в цели есть `:gateway`) — запускаем Go-хелпер.
- Иначе — используем Node-сокеты.
Вот так производится запуск бинарника-хелпера:
В проде сам бинарник запакован внутрь .AppImage и приложению доступен по пути `resources/bin/knock-local`
> кстати распаковать и посмотреть что там внутри AppImage можно так `./Knocker\ Desktop-1.0.0.AppImage --appimage-extract`
```js
// src/main/main.js (фрагмент)
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const devBin = path.resolve(__dirname, '../../bin/knock-local');
const prodBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
const helperExec = fs.existsSync(devBin) ? devBin : prodBin;
const req = { targets: validTargets, delay: delay || '1s', verbose: false, gateway: gateway || '' };
const input = JSON.stringify(req);
const child = spawn(helperExec, [], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.stdin.write(input);
child.stdin.end();
const code = await new Promise(r => child.on('close', r));
if (code !== 0) {
return { success:false, error:`go helper exited ${code}: ${stderr || stdout}` };
}
// если вдруг кто-то напечатает «лишнее» в stdout — достанем последнюю JSON-строку
const lines = stdout.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
const parsed = JSON.parse(jsonLine);
```
Что делает spawn()
``` js
const child = spawn(helperExec, [], {
stdio: ['pipe', 'pipe', 'pipe']
});
```
`spawn(команда, аргументы, опции)` — это Node.js функция для запуска внешних процессов.
helperExec — путь к исполняемому файлу (наш Go-бинарник knock-local)
[] — массив аргументов командной строки (у нас пустой, т.к. всё передаём через stdin)
{ stdio: [...] } — настройки потоков ввода/вывода
**Что означает stdio: ['pipe', 'pipe', 'pipe']**
Это массив из 3 элементов, каждый отвечает за поток:
[0] — stdin (ввод) — 'pipe' = создаём трубу для записи
[1] — stdout (вывод) — 'pipe' = создаём трубу для чтения
[2] — stderr (ошибки) — 'pipe' = создаём трубу для чтения
``` js
// Альтернативы stdio:
stdio: ['pipe', 'pipe', 'pipe'] // полный контроль над всеми потоками
stdio: 'pipe' // то же самое, сокращённая запись
stdio: 'inherit' // наследовать от родительского процесса
stdio: ['ignore', 'pipe', 'pipe'] // игнорировать stdin, читать stdout/stderr
```
Процесс запускается НЕМЕДЛЕННО при вызове spawn()!
Но он ещё ничего не делает — просто "висит" и ждёт данных в stdin.
Полный цикл работы
``` js
// 1. Запускаем процесс (он ждёт)
const child = spawn(helperExec, [], { stdio: ['pipe', 'pipe', 'pipe'] });
// 2. Настраиваем "слушателей" на выходы
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
// 3. Отправляем JSON в stdin
const req = { targets: ['tcp:192.168.1.1:22'], delay: '1s', gateway: '192.168.89.18' };
const input = JSON.stringify(req);
child.stdin.write(input); // ← Go начинает читать и работать
child.stdin.end(); // ← Go понимает "всё, данных больше не будет"
// 4. Ждём завершения
const code = await new Promise(r => child.on('close', r));
// ↑ Когда Go закончит работу, получим exit code (0 = успех)
```
spawn() НЕ блокирует. Процесс запускается асинхронно.
Ждём через Promise, который резолвится когда Go вызывает os.Exit()
`const code = await new Promise(r => child.on('close', r));`
Потоки:
child.stdin — пишем в него
child.stdout — читаем из него
child.stderr — читаем из него
Если Go-процесс "зависнет", Promise никогда не резолвится. Хорошо бы добавить таймаут:
``` js
const timeout = setTimeout(() => {
child.kill('SIGTERM');
}, 30000); // 30 секунд максимум
const code = await new Promise(r => child.on('close', r));
clearTimeout(timeout);
```
---
## Глава 6. UI только для UI
В `renderer` ничего такого что непосредственно работает с ядром системы.
Собираем `targets`, `delay`, забираем `gateway` с формы — и даём команду:
```js
// src/renderer/renderer.js (фрагмент)
qs('#execute')?.addEventListener('click', async () => {
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
const gateway = qsi('#gateway')?.value?.trim() || '';
// ... собираем targets ...
const res = await window.api.localKnock({ targets, delay, verbose, gateway });
updateStatus(res.success ? 'Done' : `Ошибка: ${res.error}`);
});
```
Renderer ничего не знает про сокеты, туннели и Go. И это все прекрастно и типа безопастно ....
---
## Глава 7. Сборка и упаковка (и маленький лайфхак)
Чтобы Go не требовался у пользователя — собираем бинарь заранее и кладём его в ресурсы приложения.
В `desktop/package.json`:
```json
{
"scripts": {
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop/bin/knock-local ./cmd/knock-local'",
"dev": "npm run go:build && electron .",
"build": "npm run go:build && electron-builder"
},
"build": {
"files": ["src/**/*", "node_modules/**/*", "bin/**/*"],
"extraResources": [{ "from": "bin", "to": "bin", "filter": ["**/*"] }]
}
}
```
При разработке запускаем `npm run dev` — он сначала соберёт Go, потом стартанёт Electron. В проде `electron-builder` положит бинарь в `resources/bin`. Пользователь вообще не в курсе, что внутри слишком умный Go сидит и пинает пакеты в нужные цели.
Можно конечно и не Го-хелпер запилить - например на Rust или на С++
---
## Глава 8. Почему мы пошли этим путём (немного философии)
- Node — шикарен для UI и связки слоёв, но ему не хватает «низкоуровневого директа» в сокетах.
- Go умеет то, что нам нужно: `SO_BINDTODEVICE`, привязка к интерфейсу, «я сказал — поедешь тут».
- Electron — как контейнер: упаковали веб, настроили мосты, подложили Go, включили DevTools — красота.
Минус: надо собирать маленький бинарь.
Плюс: он работает невидимо и делает «как надо».
---
## Глава 9. Грабли и как мы на них танцевали
- Не кладите бинарь в `app.asar`. Пусть живёт в `resources/bin`, будет исполняемым и легко диагностируемым.
- Следите за stdout хелпера — там должен быть только JSON (мы жёстко это контролируем).
- Путь к бинарю в деве и проде разный — мы это учли (ищем сначала `bin/`, потом `resources/bin`).
Мем-чекилст:
- [x] «Всё уходит в WireGuard» → ставим `gateway` → Go рулит.
- [x] «JSON не парсится» → убрали verbose → теперь парсится.
- [x] «Где бинарь?» → смотрим `resources/bin`.
---
## Эпилог: помогло?
Мы сделали десктопное приложение, которое вообще-то «веб», но изнутри умеет очень взрослые вещи. Пользователь нажимает кнопку — а Go в это время спорит с системой маршрутизации, выигрывает и бьёт туда, куда надо.