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

25 KiB
Raw Blame History

Как приручить Electron и спрятать Go-движок внутрь дистрибутива: история одного десктопа

Пролог: почему вообще 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 может выглядеть так:

// 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:

// 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, дергает оттуда нужные данные и методы ...

(() => {
  // Глобальные обработчики ошибок в 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: «успех/ошибка».
// 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 он выставлен во внешний мир.


    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

// 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()

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' = создаём трубу для чтения

// Альтернативы stdio:
stdio: ['pipe', 'pipe', 'pipe']  // полный контроль над всеми потоками
stdio: 'pipe'                    // то же самое, сокращённая запись
stdio: 'inherit'                 // наследовать от родительского процесса
stdio: ['ignore', 'pipe', 'pipe'] // игнорировать stdin, читать stdout/stderr

Процесс запускается НЕМЕДЛЕННО при вызове spawn()! Но он ещё ничего не делает — просто "висит" и ждёт данных в stdin.

Полный цикл работы

// 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 никогда не резолвится. Хорошо бы добавить таймаут:

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 с формы — и даём команду:

// 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:

{
  "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).

Мем-чекилст:

  • «Всё уходит в WireGuard» → ставим gateway → Go рулит.
  • «JSON не парсится» → убрали verbose → теперь парсится.
  • «Где бинарь?» → смотрим resources/bin.

Эпилог: помогло?

Мы сделали десктопное приложение, которое вообще-то «веб», но изнутри умеет очень взрослые вещи. Пользователь нажимает кнопку — а Go в это время спорит с системой маршрутизации, выигрывает и бьёт туда, куда надо.