Compare commits
9 Commits
main
...
for-articl
Author | SHA1 | Date | |
---|---|---|---|
ec2df9e612 | |||
fbb530c619 | |||
7909ec619e | |||
f94bba14a1 | |||
2012fb3afc | |||
538804b0ac | |||
7493ec95aa | |||
539d9c492d | |||
fd6cee4535 |
2
Makefile
2
Makefile
@@ -43,7 +43,7 @@ back-build: embed-ui back-deps
|
|||||||
cd $(BACK_DIR) && go build -o knocker-serve .
|
cd $(BACK_DIR) && go build -o knocker-serve .
|
||||||
|
|
||||||
run: back-build
|
run: back-build
|
||||||
cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve
|
cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve -v serve
|
||||||
|
|
||||||
run-bg: back-build
|
run-bg: back-build
|
||||||
cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p'
|
cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p'
|
||||||
|
@@ -1,781 +0,0 @@
|
|||||||
# Как приручить 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`),
|
|
||||||
- печатает в 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`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Эпилог
|
|
||||||
|
|
||||||
Мы сделали десктопное приложение, которое вообще-то «веб», но изнутри умеет кое что еще.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## А как же фреймворки?
|
|
||||||
|
|
||||||
На Vanilla js все просто и понятно писать но очень многсловно и трудно сделать все структурировано и красиво "по фен-шую".
|
|
||||||
Давайте коротенько рассмотрим, а как же нам портировать имеющийся ui ангуляр проект, который героически был вшит в go-knocker в десктопном проекте electron.
|
|
||||||
|
|
||||||
Итак, у нас есть готовый или не очень Angular проект который уже работает как веб-приложение. Теперь нужно интегрировать его в Electron так, чтобы получить все возможности поюзать его как графическое приложение.
|
|
||||||
|
|
||||||
### Архитектурное решение
|
|
||||||
|
|
||||||
Мы создали отдельную папку `desktop-angular/`, которая содержит:
|
|
||||||
|
|
||||||
1. **Electron обертку** - основной процесс и настройки
|
|
||||||
2. **Копию Angular проекта** в `src/frontend/` - для удобства разработки
|
|
||||||
3. **IPC сервисы** - для взаимодействия с нативным кодом
|
|
||||||
4. **Кастомные модальные окна** - нативные диалоги Electron
|
|
||||||
|
|
||||||
### Структура проекта
|
|
||||||
|
|
||||||
``` text
|
|
||||||
desktop-angular/
|
|
||||||
├── src/
|
|
||||||
│ ├── main/ # Electron main process
|
|
||||||
│ │ ├── main.js # Основной процесс
|
|
||||||
│ │ ├── modal.html # Кастомные модальные окна
|
|
||||||
│ │ ├── open-dialog.html # Диалог открытия файлов
|
|
||||||
│ │ └── save-dialog.html # Диалог сохранения файлов
|
|
||||||
│ ├── preload/ # Безопасный мост
|
|
||||||
│ │ └── preload.js # API для рендерера
|
|
||||||
│ └── frontend/ # Angular приложение
|
|
||||||
│ ├── src/app/
|
|
||||||
│ │ ├── ipc.service.ts # Сервис для IPC
|
|
||||||
│ │ ├── modal.service.ts # Сервис модальных окон
|
|
||||||
│ │ └── root.component.ts # Главный компонент
|
|
||||||
│ └── package.json # Зависимости Angular
|
|
||||||
├── package.json # Конфигурация Electron
|
|
||||||
└── bin/ # Скомпилированный Go бэкенд
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Ключевые особенности реализации
|
|
||||||
|
|
||||||
#### 1. Двойной режим работы
|
|
||||||
|
|
||||||
В `main.js` реализована логика, которая определяет режим работы:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const isDev = process.env.NODE_ENV !== "production" && !app.isPackaged;
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// В режиме разработки загружаем с ng serve
|
|
||||||
win.loadURL('http://localhost:4200');
|
|
||||||
win.webContents.openDevTools();
|
|
||||||
} else {
|
|
||||||
// В продакшене загружаем собранные файлы
|
|
||||||
const indexPath = app.isPackaged
|
|
||||||
? path.join(process.resourcesPath, 'ui-dist', 'index.html')
|
|
||||||
: path.resolve(__dirname, '../frontend/dist/project-front/browser/index.html');
|
|
||||||
win.loadFile(indexPath);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. IPC сервис для Angular
|
|
||||||
|
|
||||||
Создан специальный сервис `IpcService`, который предоставляет удобный API для взаимодействия с Electron:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class IpcService {
|
|
||||||
|
|
||||||
async showNativeModal(config: ModalConfig): Promise<string> {
|
|
||||||
return await (window as any).api.showNativeModal(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openFileDialog(config: FileDialogConfig): Promise<string[]> {
|
|
||||||
return await (window as any).api.openFileDialog(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveFileDialog(config: SaveDialogConfig): Promise<string> {
|
|
||||||
return await (window as any).api.saveFileDialog(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadFileContent(filePath: string): Promise<string> {
|
|
||||||
return await (window as any).api.loadFileContent(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveFileContent(filePath: string, content: string): Promise<void> {
|
|
||||||
return await (window as any).api.saveFileContent(filePath, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Кастомные нативные диалоги
|
|
||||||
|
|
||||||
Вместо стандартных системных диалогов созданы кастомные HTML/CSS/JS диалоги, которые:
|
|
||||||
|
|
||||||
- Работают даже если Angular UI "зависла"
|
|
||||||
- Имеют единый стиль с приложением
|
|
||||||
- Поддерживают превью файлов
|
|
||||||
- Имеют расширенную функциональность
|
|
||||||
|
|
||||||
#### 4. Интеграция с Go бэкендом
|
|
||||||
|
|
||||||
Angular приложение работает с тем же Go бэкендом, что и веб-версия:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class KnockService {
|
|
||||||
private apiBase = 'http://localhost:8080/api/v1';
|
|
||||||
|
|
||||||
async knock(config: KnockConfig): Promise<KnockResult> {
|
|
||||||
const response = await fetch(`${this.apiBase}/knock`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(config)
|
|
||||||
});
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Скрипты сборки
|
|
||||||
|
|
||||||
В `package.json` настроены удобные команды:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"dev": "concurrently -k -n UI,ELECTRON -c green,cyan \"cd src/frontend && npm start\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
|
|
||||||
"build:ui": "cd src/frontend && npm run build",
|
|
||||||
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop-angular/bin/full-go-knocker .'",
|
|
||||||
"dist": "npm run build:ui && npm run go:build && cross-env NODE_ENV=production electron-builder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Конфигурация упаковки
|
|
||||||
|
|
||||||
Electron Builder настроен для включения всех необходимых файлов:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"build": {
|
|
||||||
"files": [
|
|
||||||
"src/main/**/*",
|
|
||||||
"src/preload/**/*",
|
|
||||||
"package.json",
|
|
||||||
"bin/**/*"
|
|
||||||
],
|
|
||||||
"extraResources": [
|
|
||||||
{
|
|
||||||
"from": "src/frontend/dist/project-front/browser",
|
|
||||||
"to": "ui-dist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "bin",
|
|
||||||
"to": "bin"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Преимущества такого подхода
|
|
||||||
|
|
||||||
1. **Переиспользование кода** - Angular приложение остается тем же самым
|
|
||||||
2. **Нативные возможности** - доступ к файловой системе, системным диалогам
|
|
||||||
3. **Единая кодовая база** - один Angular проект для веба и десктопа
|
|
||||||
4. **Удобная разработка** - hot reload в режиме разработки
|
|
||||||
5. **Простая сборка** - автоматическая упаковка всех компонентов
|
|
||||||
|
|
||||||
### Результат
|
|
||||||
|
|
||||||
Получилось полноценное десктопное приложение, которое:
|
|
||||||
|
|
||||||
- Выглядит и работает как нативное
|
|
||||||
- Использует весь функционал Angular
|
|
||||||
- Интегрировано с Go бэкендом
|
|
||||||
- Имеет кастомные нативные диалоги
|
|
||||||
- Упаковывается в единый исполняемый файл
|
|
||||||
|
|
||||||
Это решение демонстрирует, как можно элегантно интегрировать современный веб-фреймворк в десктопное приложение, сохраняя все преимущества обеих платформ.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Мы рассмотрели два подхода к созданию десктопных приложений:
|
|
||||||
|
|
||||||
1. **Vanilla JavaScript** - простой, быстрый, но многословный
|
|
||||||
2. **Angular интеграция** - структурированный, переиспользуемый, но более сложный
|
|
||||||
|
|
||||||
Оба подхода имеют свои преимущества и недостатки. Выбор зависит от ваших потребностей:
|
|
||||||
|
|
||||||
- Для быстрого прототипа или простого UI - Vanilla JS
|
|
||||||
- Для сложного приложения с переиспользованием веб-кода - Angular
|
|
||||||
|
|
||||||
Главное - правильно спроектировать архитектуру взаимодействия между Electron и вашим UI, используя IPC и preload скрипты для безопасной передачи данных.
|
|
@@ -29,8 +29,8 @@ version: 1.0.2
|
|||||||
|
|
||||||
## Введение
|
## Введение
|
||||||
|
|
||||||
Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей.
|
Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей.
|
||||||
Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки).
|
Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки).
|
||||||
|
|
||||||
В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: <https://github.com/Direct-Dev-Ru/port-knocker.git>
|
В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: <https://github.com/Direct-Dev-Ru/port-knocker.git>
|
||||||
|
|
||||||
|
1013
rust-knocker/Cargo.lock
generated
1013
rust-knocker/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "rust-knocker"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["Direct-Dev"]
|
|
||||||
description = "Port knocking utility written in Rust - compatible with Go knocker"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
clap = { version = "4.0", features = ["derive"] }
|
|
||||||
anyhow = "1.0"
|
|
||||||
thiserror = "1.0"
|
|
||||||
nix = { version = "0.27", features = ["socket"] }
|
|
||||||
libc = "0.2"
|
|
||||||
rand = "0.8"
|
|
||||||
regex = "1.0"
|
|
||||||
|
|
||||||
# Optional crypto dependencies (for encrypted configs)
|
|
||||||
base64 = "0.21"
|
|
||||||
aes-gcm = "0.10"
|
|
||||||
sha2 = "0.10"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "rust-knocker"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "knock-local"
|
|
||||||
path = "src/electron_helper.rs"
|
|
@@ -1,148 +0,0 @@
|
|||||||
# Rust Knocker - Проект завершён! 🦀
|
|
||||||
|
|
||||||
## Что создано
|
|
||||||
|
|
||||||
Полноценный Rust-проект `rust-knocker`, который является альтернативой Go-хелпера для Electron приложения.
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
rust-knocker/
|
|
||||||
├── Cargo.toml # Конфигурация проекта
|
|
||||||
├── README.md # Подробная документация
|
|
||||||
├── examples/
|
|
||||||
│ └── config.yaml # Пример конфигурации
|
|
||||||
├── src/
|
|
||||||
│ ├── lib.rs # Основная логика port knocking
|
|
||||||
│ ├── main.rs # CLI интерфейс
|
|
||||||
│ └── electron_helper.rs # JSON API для Electron
|
|
||||||
└── target/release/
|
|
||||||
├── rust-knocker # Standalone CLI приложение
|
|
||||||
└── knock-local # Electron хелпер
|
|
||||||
```
|
|
||||||
|
|
||||||
## Возможности
|
|
||||||
|
|
||||||
### ✅ Реализовано
|
|
||||||
|
|
||||||
1. **Port Knocking**: TCP и UDP протоколы
|
|
||||||
2. **CLI Interface**: Полноценный командный интерфейс
|
|
||||||
3. **JSON API**: Совместимость с Electron через stdin/stdout
|
|
||||||
4. **Gateway Support**: Привязка к локальному IP (TCP/UDP)
|
|
||||||
5. **YAML Configuration**: Чтение конфигураций из файлов
|
|
||||||
6. **Encrypted Configs**: AES-GCM шифрование конфигураций
|
|
||||||
7. **Easter Eggs**: Пасхалки для 8.8.8.8:8888 и 1.1.1.1:1111
|
|
||||||
8. **Error Handling**: Подробная обработка ошибок
|
|
||||||
9. **Verbose Mode**: Подробный вывод для отладки
|
|
||||||
|
|
||||||
### ✅ Полная функциональность
|
|
||||||
|
|
||||||
1. **SO_BINDTODEVICE**: ✅ Реализован через libc для Linux
|
|
||||||
2. **VPN Bypass**: ✅ Полная поддержка обхода VPN через привязку к интерфейсу
|
|
||||||
3. **Cross-platform**: ✅ Linux (полная), macOS/Windows (частичная)
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Standalone CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Одна цель
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --verbose
|
|
||||||
|
|
||||||
# С gateway
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --gateway eth0
|
|
||||||
|
|
||||||
# Из конфигурации
|
|
||||||
rust-knocker --config config.yaml --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Electron Integration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# JSON API (совместим с Go-хелпером)
|
|
||||||
echo '{"targets":["tcp:192.168.1.1:22"],"delay":"1s","verbose":false,"gateway":""}' | knock-local
|
|
||||||
```
|
|
||||||
|
|
||||||
### В Electron приложении
|
|
||||||
|
|
||||||
Автоматически выбирается между Rust и Go хелперами:
|
|
||||||
|
|
||||||
1. Сначала ищет `knock-local-rust` (Rust версия)
|
|
||||||
2. Если не найден, использует `knock-local` (Go версия)
|
|
||||||
|
|
||||||
## Производительность
|
|
||||||
|
|
||||||
- **Startup time**: ~10ms (vs ~50ms у Go)
|
|
||||||
- **Memory usage**: ~2MB (vs ~8MB у Go)
|
|
||||||
- **Binary size**: ~3MB (vs ~12MB у Go)
|
|
||||||
- **Compilation time**: ~50s (первый раз), ~5s (после изменений)
|
|
||||||
|
|
||||||
## Совместимость
|
|
||||||
|
|
||||||
### С Go Knocker
|
|
||||||
|
|
||||||
- ✅ Тот же JSON API
|
|
||||||
- ✅ Та же структура конфигурации YAML
|
|
||||||
- ✅ Те же параметры командной строки
|
|
||||||
- ✅ Drop-in replacement
|
|
||||||
|
|
||||||
### Платформы
|
|
||||||
|
|
||||||
| Platform | TCP | UDP | Gateway | SO_BINDTODEVICE |
|
|
||||||
|----------|-----|-----|---------|-----------------|
|
|
||||||
| Linux | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| macOS | ✅ | ✅ | ✅ | ❌ |
|
|
||||||
| Windows | ✅ | ✅ | ✅ | ❌ |
|
|
||||||
|
|
||||||
## Сборка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Release
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Интеграция с Electron
|
|
||||||
cd ../desktop
|
|
||||||
npm run rust:build # Собирает Rust хелпер
|
|
||||||
npm run dev # Запускает Electron с Rust хелпером
|
|
||||||
```
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
Все тесты прошли успешно:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CLI тесты
|
|
||||||
✅ rust-knocker --help
|
|
||||||
✅ rust-knocker --target tcp:8.8.8.8:8888 --verbose # Easter egg
|
|
||||||
✅ rust-knocker --config examples/config.yaml --verbose
|
|
||||||
|
|
||||||
# JSON API тесты
|
|
||||||
✅ echo '{"targets":["tcp:8.8.8.8:53"]}' | knock-local
|
|
||||||
✅ echo '{"targets":["tcp:1.1.1.1:1111"]}' | knock-local # Joke
|
|
||||||
|
|
||||||
# SO_BINDTODEVICE тесты
|
|
||||||
✅ echo '{"targets":["tcp:8.8.8.8:53"],"gateway":"enp1s0"}' | knock-local # TCP + interface
|
|
||||||
✅ echo '{"targets":["udp:8.8.8.8:53"],"gateway":"enp1s0"}' | knock-local # UDP + interface
|
|
||||||
✅ echo '{"targets":["tcp:8.8.8.8:53"],"gateway":"nonexisting"}' | knock-local # Error handling (exit code 1)
|
|
||||||
|
|
||||||
# Electron интеграция
|
|
||||||
✅ Electron автоматически выбирает Rust хелпер
|
|
||||||
✅ SO_BINDTODEVICE работает в Electron приложении
|
|
||||||
```
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Проект **успешно завершён** и готов к использованию!
|
|
||||||
|
|
||||||
Rust Knocker предоставляет:
|
|
||||||
|
|
||||||
- 🚀 **Быструю альтернативу** Go-хелперу
|
|
||||||
- 🔧 **Полную совместимость** с существующим кодом
|
|
||||||
- 🛡️ **Типобезопасность** Rust
|
|
||||||
- 📦 **Меньший размер** бинарника
|
|
||||||
- ⚡ **Лучшую производительность**
|
|
||||||
|
|
||||||
Можно использовать как standalone утилиту или интегрировать в Electron приложение!
|
|
@@ -1,274 +0,0 @@
|
|||||||
# Rust Knocker 🦀
|
|
||||||
|
|
||||||
Port knocking utility written in Rust, compatible with Go knocker. Can be used standalone or as a helper for Electron applications.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **TCP/UDP Support**: Knock on both TCP and UDP ports
|
|
||||||
- **Gateway Routing**: Route packets through specific interfaces or IPs (bypass VPNs)
|
|
||||||
- **SO_BINDTODEVICE**: ✅ Linux-specific interface binding for reliable VPN bypass
|
|
||||||
- **YAML Configuration**: Human-readable configuration files
|
|
||||||
- **Encrypted Configs**: AES-GCM encryption for sensitive configurations
|
|
||||||
- **Electron Compatible**: JSON API for integration with Electron apps
|
|
||||||
- **Cross-platform**: Works on Linux, macOS, Windows (with limitations)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository>
|
|
||||||
cd rust-knocker
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### Binaries
|
|
||||||
|
|
||||||
- `rust-knocker` - Standalone CLI tool
|
|
||||||
- `knock-local` - Electron helper (JSON API)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### CLI Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Single target
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --verbose
|
|
||||||
|
|
||||||
# With gateway
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --gateway eth0 --delay 2s
|
|
||||||
|
|
||||||
# From config file
|
|
||||||
rust-knocker --config config.yaml --verbose
|
|
||||||
|
|
||||||
# With encrypted config
|
|
||||||
rust-knocker --config encrypted.yaml --key secret.key --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration File
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
targets:
|
|
||||||
- host: 192.168.1.1
|
|
||||||
ports: [22, 80, 443]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 1s
|
|
||||||
wait_connection: false
|
|
||||||
gateway: eth0 # optional
|
|
||||||
```
|
|
||||||
|
|
||||||
### Electron Integration
|
|
||||||
|
|
||||||
The `knock-local` binary provides the same JSON API as the Go helper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Input JSON to stdin
|
|
||||||
echo '{"targets":["tcp:192.168.1.1:22"],"delay":"1s","verbose":false,"gateway":"eth0"}' | ./knock-local
|
|
||||||
|
|
||||||
# Output JSON to stdout
|
|
||||||
{"success":true,"message":"ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gateway Support
|
|
||||||
|
|
||||||
### Interface Binding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Route through specific interface
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --gateway enp1s0
|
|
||||||
```
|
|
||||||
|
|
||||||
### IP Binding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Route from specific local IP
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --gateway 192.168.1.100
|
|
||||||
```
|
|
||||||
|
|
||||||
### VPN Bypass
|
|
||||||
|
|
||||||
The gateway feature is particularly useful for bypassing VPNs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Bypass WireGuard by routing through physical interface
|
|
||||||
rust-knocker --target tcp:192.168.89.1:2655 --gateway enp1s0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic Port Knocking
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Knock SSH port
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --verbose
|
|
||||||
|
|
||||||
# Knock multiple ports
|
|
||||||
rust-knocker --config examples/config.yaml --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Diagnostics
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test connectivity through specific interface
|
|
||||||
rust-knocker --target tcp:8.8.8.8:53 --gateway wlan0 --verbose
|
|
||||||
|
|
||||||
# UDP DNS query through gateway
|
|
||||||
rust-knocker --target udp:8.8.8.8:53 --gateway 192.168.1.100 --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Encrypted Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create encrypted config
|
|
||||||
rust-knocker --config config.yaml --key secret.key --encrypt
|
|
||||||
|
|
||||||
# Use encrypted config
|
|
||||||
rust-knocker --config encrypted.yaml --key secret.key --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### KnockRequest (JSON)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"targets": ["tcp:192.168.1.1:22", "udp:10.0.0.1:53"],
|
|
||||||
"delay": "1s",
|
|
||||||
"verbose": false,
|
|
||||||
"gateway": "eth0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### KnockResponse (JSON)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Connection failed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Critical Errors (Exit Code 1)
|
|
||||||
- **Interface binding errors**: If the specified network interface doesn't exist:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "Port knocking failed: Ошибка при knock'е цели 1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Invalid configuration**: Malformed targets, unsupported protocols, etc.
|
|
||||||
|
|
||||||
### Warning Mode (Exit Code 0)
|
|
||||||
- **Connection timeouts**: When `wait_connection: false`, connection failures are treated as warnings
|
|
||||||
- **Network unreachability**: Temporary network issues are logged but don't fail the operation
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cross-compilation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Linux x64
|
|
||||||
cargo build --release --target x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
cargo build --release --target x86_64-pc-windows-gnu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
cargo test -- --nocapture
|
|
||||||
|
|
||||||
# Test specific functionality
|
|
||||||
cargo test test_parse_duration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
Rust Knocker is significantly faster than the Go version:
|
|
||||||
|
|
||||||
- **Startup time**: ~10ms vs ~50ms (Go)
|
|
||||||
- **Memory usage**: ~2MB vs ~8MB (Go)
|
|
||||||
- **Binary size**: ~3MB vs ~12MB (Go)
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
### Go Knocker Compatibility
|
|
||||||
|
|
||||||
Rust Knocker is fully compatible with Go knocker:
|
|
||||||
|
|
||||||
- Same configuration format
|
|
||||||
- Same JSON API
|
|
||||||
- Same command-line interface
|
|
||||||
- Drop-in replacement
|
|
||||||
|
|
||||||
### Platform Support
|
|
||||||
|
|
||||||
| Platform | TCP | UDP | Gateway | SO_BINDTODEVICE |
|
|
||||||
|----------|-----|-----|---------|-----------------|
|
|
||||||
| Linux | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| macOS | ✅ | ✅ | ✅ | ❌ |
|
|
||||||
| Windows | ✅ | ✅ | ✅ | ❌ |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Permission denied**: Run with `sudo` for interface binding
|
|
||||||
2. **Interface not found**: Check interface name with `ip link show`
|
|
||||||
3. **Gateway not working**: Verify interface has the specified IP
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable verbose output
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --verbose
|
|
||||||
|
|
||||||
# Check interface binding
|
|
||||||
rust-knocker --target tcp:192.168.1.1:22 --gateway eth0 --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - see LICENSE file for details.
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- Inspired by Go knocker
|
|
||||||
- Compatible with Electron port knocking applications
|
|
||||||
- Uses Rust's excellent networking libraries
|
|
@@ -1,49 +0,0 @@
|
|||||||
# Пример конфигурации для Rust Knocker
|
|
||||||
# Совместим с Go knocker конфигурацией
|
|
||||||
|
|
||||||
targets:
|
|
||||||
# Простая цель - один порт
|
|
||||||
- host: 192.168.1.1
|
|
||||||
ports: [22]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 1s
|
|
||||||
wait_connection: false
|
|
||||||
|
|
||||||
# Несколько портов подряд
|
|
||||||
- host: 192.168.1.10
|
|
||||||
ports: [22, 80, 443]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 2s
|
|
||||||
wait_connection: true
|
|
||||||
|
|
||||||
# UDP порт
|
|
||||||
- host: 10.0.0.1
|
|
||||||
ports: [53]
|
|
||||||
protocol: udp
|
|
||||||
delay: 500ms
|
|
||||||
|
|
||||||
# С gateway (привязка к интерфейсу)
|
|
||||||
- host: 192.168.89.1
|
|
||||||
ports: [2655]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 1s
|
|
||||||
gateway: enp1s0 # имя интерфейса
|
|
||||||
|
|
||||||
# С gateway (привязка к IP)
|
|
||||||
- host: 8.8.8.8
|
|
||||||
ports: [53]
|
|
||||||
protocol: udp
|
|
||||||
delay: 1s
|
|
||||||
gateway: 192.168.1.100 # локальный IP
|
|
||||||
|
|
||||||
# Пасхалка - покажет шутку
|
|
||||||
- host: 1.1.1.1
|
|
||||||
ports: [1111]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 1s
|
|
||||||
|
|
||||||
# Ещё одна пасхалка
|
|
||||||
- host: 8.8.8.8
|
|
||||||
ports: [8888]
|
|
||||||
protocol: tcp
|
|
||||||
delay: 1s
|
|
@@ -1,101 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use rust_knocker::{request_to_config, KnockRequest, KnockResponse, PortKnocker};
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
|
|
||||||
/// Electron helper - читает JSON из stdin, выполняет knock, возвращает JSON в stdout
|
|
||||||
/// Совместим с Go-хелпером knock-local
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
// Читаем JSON из stdin
|
|
||||||
let mut input = String::new();
|
|
||||||
io::stdin().read_to_string(&mut input)
|
|
||||||
.context("Не удалось прочитать данные из stdin")?;
|
|
||||||
|
|
||||||
if input.trim().is_empty() {
|
|
||||||
send_error_response("Пустой ввод из stdin".to_string());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсим JSON запрос
|
|
||||||
let request: KnockRequest = match serde_json::from_str(&input.trim()) {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(e) => {
|
|
||||||
send_error_response(format!("Не удалось распарсить JSON: {}", e));
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Валидируем запрос
|
|
||||||
if request.targets.is_empty() {
|
|
||||||
send_error_response("Пустой список целей".to_string());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Конвертируем запрос в конфигурацию
|
|
||||||
let config = match request_to_config(request.clone()) {
|
|
||||||
Ok(cfg) => cfg,
|
|
||||||
Err(e) => {
|
|
||||||
send_error_response(format!("Ошибка конвертации запроса: {}", e));
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Выполняем knock
|
|
||||||
let knocker = PortKnocker::new();
|
|
||||||
|
|
||||||
// Используем tokio runtime для выполнения асинхронного кода
|
|
||||||
let rt = tokio::runtime::Runtime::new()
|
|
||||||
.context("Не удалось создать tokio runtime")?;
|
|
||||||
|
|
||||||
match rt.block_on(knocker.execute_with_config(config, request.verbose, false)) {
|
|
||||||
Ok(_) => {
|
|
||||||
send_success_response("ok".to_string());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
send_error_response(format!("Port knocking failed: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отправляет JSON ответ об ошибке в stdout
|
|
||||||
fn send_error_response(error_msg: String) {
|
|
||||||
let response = KnockResponse {
|
|
||||||
success: false,
|
|
||||||
error: Some(error_msg),
|
|
||||||
message: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::to_string(&response) {
|
|
||||||
Ok(json) => {
|
|
||||||
println!("{}", json);
|
|
||||||
let _ = io::stdout().flush();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Ошибка сериализации JSON: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отправляет JSON ответ об успехе в stdout
|
|
||||||
fn send_success_response(message: String) {
|
|
||||||
let response = KnockResponse {
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
message: Some(message),
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::to_string(&response) {
|
|
||||||
Ok(json) => {
|
|
||||||
println!("{}", json);
|
|
||||||
let _ = io::stdout().flush();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Ошибка сериализации JSON: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
@@ -1,601 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{
|
|
||||||
io::Write,
|
|
||||||
net::{IpAddr, TcpStream, UdpSocket},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub targets: Vec<Target>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct Target {
|
|
||||||
pub host: String,
|
|
||||||
pub ports: Vec<u16>,
|
|
||||||
pub protocol: String,
|
|
||||||
pub delay: Option<String>,
|
|
||||||
pub wait_connection: Option<bool>,
|
|
||||||
pub gateway: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct KnockRequest {
|
|
||||||
pub targets: Vec<String>,
|
|
||||||
pub delay: String,
|
|
||||||
pub verbose: bool,
|
|
||||||
pub gateway: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct KnockResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PortKnocker;
|
|
||||||
|
|
||||||
impl PortKnocker {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Основной метод для выполнения knock'а
|
|
||||||
pub async fn execute_with_config(&self, config: Config, verbose: bool, global_wait_connection: bool) -> Result<()> {
|
|
||||||
println!("Загружена конфигурация с {} целей", config.targets.len());
|
|
||||||
|
|
||||||
for (i, target) in config.targets.iter().enumerate() {
|
|
||||||
if verbose {
|
|
||||||
println!("Цель {}/{}: {}:{:?} ({})",
|
|
||||||
i + 1, config.targets.len(), target.host, target.ports, target.protocol);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.knock_target(target, verbose, global_wait_connection).await
|
|
||||||
.with_context(|| format!("Ошибка при knock'е цели {}", i + 1))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Port knocking завершен успешно");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Knock для одной цели
|
|
||||||
async fn knock_target(&self, target: &Target, verbose: bool, global_wait_connection: bool) -> Result<()> {
|
|
||||||
// Проверяем на "шутливые" цели
|
|
||||||
if target.host == "8.8.8.8" && target.ports == [8888] {
|
|
||||||
self.show_easter_egg();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.host == "1.1.1.1" && target.ports == [1111] {
|
|
||||||
self.show_random_joke();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let protocol = target.protocol.to_lowercase();
|
|
||||||
if protocol != "tcp" && protocol != "udp" {
|
|
||||||
return Err(anyhow::anyhow!("Неподдерживаемый протокол: {}", target.protocol));
|
|
||||||
}
|
|
||||||
|
|
||||||
let wait_connection = target.wait_connection.unwrap_or(global_wait_connection);
|
|
||||||
let delay = self.parse_duration(&target.delay.as_ref().unwrap_or(&"1s".to_string()))?;
|
|
||||||
|
|
||||||
// Вычисляем таймаут как половину интервала между пакетами
|
|
||||||
let timeout = delay.max(Duration::from_millis(100)) / 2;
|
|
||||||
|
|
||||||
for (i, &port) in target.ports.iter().enumerate() {
|
|
||||||
if verbose {
|
|
||||||
if let Some(ref gateway) = target.gateway {
|
|
||||||
println!(" Отправка пакета на {}:{} ({}) через шлюз {}",
|
|
||||||
target.host, port, protocol, gateway);
|
|
||||||
} else {
|
|
||||||
println!(" Отправка пакета на {}:{} ({})",
|
|
||||||
target.host, port, protocol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = self.send_packet(&target.host, port, &protocol, wait_connection, timeout, &target.gateway).await {
|
|
||||||
// Ошибки привязки к интерфейсу всегда критичны
|
|
||||||
if e.to_string().contains("Failed to bind socket to interface") || wait_connection {
|
|
||||||
return Err(e);
|
|
||||||
} else {
|
|
||||||
if verbose {
|
|
||||||
println!(" Предупреждение: не удалось отправить пакет на порт {}: {}", port, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Задержка между пакетами (кроме последнего)
|
|
||||||
if i < target.ports.len() - 1 && delay > Duration::ZERO {
|
|
||||||
if verbose {
|
|
||||||
println!(" Ожидание {:?}...", delay);
|
|
||||||
}
|
|
||||||
sleep(delay).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отправка одного пакета
|
|
||||||
async fn send_packet(&self, host: &str, port: u16, protocol: &str, _wait_connection: bool, timeout: Duration, gateway: &Option<String>) -> Result<()> {
|
|
||||||
match protocol {
|
|
||||||
"tcp" => {
|
|
||||||
if let Some(ref gw) = gateway {
|
|
||||||
self.send_tcp_with_gateway(host, port, timeout, gw).await?;
|
|
||||||
} else {
|
|
||||||
self.send_tcp_simple(host, port, timeout).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"udp" => {
|
|
||||||
if let Some(ref gw) = gateway {
|
|
||||||
self.send_udp_with_gateway(host, port, timeout, gw).await?;
|
|
||||||
} else {
|
|
||||||
self.send_udp_simple(host, port, timeout).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Err(anyhow::anyhow!("Неподдерживаемый протокол: {}", protocol)),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TCP без gateway
|
|
||||||
async fn send_tcp_simple(&self, host: &str, port: u16, timeout: Duration) -> Result<()> {
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
|
|
||||||
// Попытка подключения с коротким таймаутом
|
|
||||||
match TcpStream::connect_timeout(&address.parse()?, timeout) {
|
|
||||||
Ok(mut stream) => {
|
|
||||||
stream.write_all(&[])?; // Отправляем пустой пакет
|
|
||||||
stream.flush()?;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Best-effort: игнорируем ошибки подключения
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TCP с gateway (привязка к локальному IP или интерфейсу)
|
|
||||||
async fn send_tcp_with_gateway(&self, host: &str, port: u16, timeout: Duration, gateway: &str) -> Result<()> {
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
|
|
||||||
// Проверяем, является ли gateway именем интерфейса
|
|
||||||
if self.is_interface_name(gateway) {
|
|
||||||
// Привязка к интерфейсу через SO_BINDTODEVICE
|
|
||||||
self.send_tcp_with_interface(host, port, timeout, gateway).await?;
|
|
||||||
} else {
|
|
||||||
// Привязка к локальному IP
|
|
||||||
let local_addr = if gateway.contains(':') {
|
|
||||||
gateway.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}:0", gateway)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Используем std::net::TcpStream с привязкой к локальному адресу
|
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<()> {
|
|
||||||
// Создаём listener на локальном адресе для получения локального IP
|
|
||||||
let listener = std::net::TcpListener::bind(&local_addr)?;
|
|
||||||
let _local_addr = listener.local_addr()?;
|
|
||||||
|
|
||||||
// Создаём соединение с привязкой к локальному адресу
|
|
||||||
let mut stream = TcpStream::connect_timeout(&address.parse()?, timeout)?;
|
|
||||||
|
|
||||||
// Отправляем пустой пакет
|
|
||||||
stream.write_all(&[])?;
|
|
||||||
stream.flush()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
result?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TCP с привязкой к интерфейсу через SO_BINDTODEVICE
|
|
||||||
/// ВАЖНО: SO_BINDTODEVICE должен устанавливаться ДО connect(), иначе ядро
|
|
||||||
/// не применит привязку для исходящего TCP.
|
|
||||||
async fn send_tcp_with_interface(&self, host: &str, port: u16, timeout: Duration, interface: &str) -> Result<()> {
|
|
||||||
use std::ffi::CString;
|
|
||||||
use std::mem::size_of;
|
|
||||||
use std::net::ToSocketAddrs;
|
|
||||||
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
let interface = interface.to_string(); // для move в closure
|
|
||||||
let timeout_ms: i32 = timeout
|
|
||||||
.as_millis()
|
|
||||||
.try_into()
|
|
||||||
.unwrap_or(i32::MAX);
|
|
||||||
|
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<()> {
|
|
||||||
// Резолвим адрес
|
|
||||||
let mut addrs = address
|
|
||||||
.to_socket_addrs()
|
|
||||||
.with_context(|| format!("Не удалось распарсить адрес {}", address))?;
|
|
||||||
let addr = addrs
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Адрес {} не резолвится", address))?;
|
|
||||||
|
|
||||||
// Создаём сырой сокет под нужное семейство и готовим sockaddr_storage
|
|
||||||
let (domain, mut storage, socklen): (libc::c_int, libc::sockaddr_storage, libc::socklen_t) = match addr {
|
|
||||||
std::net::SocketAddr::V4(v4) => {
|
|
||||||
let mut sa: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
|
||||||
sa.sin_family = libc::AF_INET as libc::sa_family_t;
|
|
||||||
sa.sin_port = (v4.port()).to_be();
|
|
||||||
sa.sin_addr = libc::in_addr { s_addr: u32::from_ne_bytes(v4.ip().octets()).to_be() };
|
|
||||||
let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
&sa as *const _ as *const u8,
|
|
||||||
&mut storage as *mut _ as *mut u8,
|
|
||||||
size_of::<libc::sockaddr_in>(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(libc::AF_INET, storage, size_of::<libc::sockaddr_in>() as libc::socklen_t)
|
|
||||||
}
|
|
||||||
std::net::SocketAddr::V6(v6) => {
|
|
||||||
let mut sa: libc::sockaddr_in6 = unsafe { std::mem::zeroed() };
|
|
||||||
sa.sin6_family = libc::AF_INET6 as libc::sa_family_t;
|
|
||||||
sa.sin6_port = (v6.port()).to_be();
|
|
||||||
sa.sin6_flowinfo = v6.flowinfo();
|
|
||||||
sa.sin6_scope_id = v6.scope_id();
|
|
||||||
sa.sin6_addr = libc::in6_addr { s6_addr: v6.ip().octets() };
|
|
||||||
let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
&sa as *const _ as *const u8,
|
|
||||||
&mut storage as *mut _ as *mut u8,
|
|
||||||
size_of::<libc::sockaddr_in6>(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(libc::AF_INET6, storage, size_of::<libc::sockaddr_in6>() as libc::socklen_t)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
// socket(AF_*, SOCK_STREAM, IPPROTO_TCP)
|
|
||||||
let fd = libc::socket(domain, libc::SOCK_STREAM, 0);
|
|
||||||
if fd < 0 {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Не удалось создать сокет: {}",
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем O_NONBLOCK для неблокирующего connect
|
|
||||||
let flags = libc::fcntl(fd, libc::F_GETFL);
|
|
||||||
if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
|
|
||||||
let err = anyhow::anyhow!("fcntl(O_NONBLOCK) error: {}", std::io::Error::last_os_error());
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SO_BINDTODEVICE до connect()
|
|
||||||
let interface_cstr = CString::new(interface.as_str())
|
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid interface name: {}", e))?;
|
|
||||||
let bind_res = libc::setsockopt(
|
|
||||||
fd,
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_BINDTODEVICE,
|
|
||||||
interface_cstr.as_ptr() as *const libc::c_void,
|
|
||||||
interface_cstr.as_bytes().len() as libc::socklen_t,
|
|
||||||
);
|
|
||||||
if bind_res != 0 {
|
|
||||||
let err = anyhow::anyhow!(
|
|
||||||
"Failed to bind socket to interface {}: {}",
|
|
||||||
interface,
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
);
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rc = libc::connect(
|
|
||||||
fd,
|
|
||||||
&storage as *const _ as *const libc::sockaddr,
|
|
||||||
socklen,
|
|
||||||
);
|
|
||||||
if rc != 0 {
|
|
||||||
let errno = *libc::__errno_location();
|
|
||||||
if errno != libc::EINPROGRESS {
|
|
||||||
let err = anyhow::anyhow!("connect() error: {}", std::io::Error::last_os_error());
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ждём завершения соединения через poll()
|
|
||||||
let mut pfd = libc::pollfd { fd, events: libc::POLLOUT, revents: 0 };
|
|
||||||
let pret = libc::poll(&mut pfd as *mut libc::pollfd, 1, timeout_ms);
|
|
||||||
if pret <= 0 {
|
|
||||||
let err = if pret == 0 { anyhow::anyhow!("connect timeout") } else { anyhow::anyhow!("poll() error: {}", std::io::Error::last_os_error()) };
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем SO_ERROR
|
|
||||||
let mut so_error: libc::c_int = 0;
|
|
||||||
let mut optlen: libc::socklen_t = size_of::<libc::c_int>() as libc::socklen_t;
|
|
||||||
if libc::getsockopt(
|
|
||||||
fd,
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_ERROR,
|
|
||||||
&mut so_error as *mut _ as *mut libc::c_void,
|
|
||||||
&mut optlen as *mut _
|
|
||||||
) != 0 {
|
|
||||||
let err = anyhow::anyhow!("getsockopt(SO_ERROR) failed: {}", std::io::Error::last_os_error());
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
if so_error != 0 {
|
|
||||||
let err = anyhow::anyhow!("connect failed: {}", std::io::Error::from_raw_os_error(so_error));
|
|
||||||
libc::close(fd);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Успех: соединение установлено; для knock достаточно установить соединение и закрыть
|
|
||||||
libc::close(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UDP без gateway
|
|
||||||
async fn send_udp_simple(&self, host: &str, port: u16, timeout: Duration) -> Result<()> {
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
|
|
||||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
|
||||||
socket.set_write_timeout(Some(timeout))?;
|
|
||||||
|
|
||||||
socket.send_to(&[], &address)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UDP с gateway (привязка к локальному IP или интерфейсу)
|
|
||||||
async fn send_udp_with_gateway(&self, host: &str, port: u16, timeout: Duration, gateway: &str) -> Result<()> {
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
|
|
||||||
// Проверяем, является ли gateway именем интерфейса
|
|
||||||
if self.is_interface_name(gateway) {
|
|
||||||
// Привязка к интерфейсу через SO_BINDTODEVICE
|
|
||||||
self.send_udp_with_interface(host, port, timeout, gateway).await?;
|
|
||||||
} else {
|
|
||||||
// Привязка к локальному IP
|
|
||||||
let local_addr = if gateway.contains(':') {
|
|
||||||
gateway.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}:0", gateway)
|
|
||||||
};
|
|
||||||
|
|
||||||
let socket = UdpSocket::bind(&local_addr)?;
|
|
||||||
socket.set_write_timeout(Some(timeout))?;
|
|
||||||
socket.send_to(&[], &address)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UDP с привязкой к интерфейсу через SO_BINDTODEVICE
|
|
||||||
async fn send_udp_with_interface(&self, host: &str, port: u16, timeout: Duration, interface: &str) -> Result<()> {
|
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
use std::ffi::CString;
|
|
||||||
|
|
||||||
let address = format!("{}:{}", host, port);
|
|
||||||
|
|
||||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
|
||||||
let fd = socket.as_raw_fd();
|
|
||||||
|
|
||||||
// Привязываем сокет к интерфейсу через SO_BINDTODEVICE
|
|
||||||
unsafe {
|
|
||||||
let interface_cstr = CString::new(interface)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid interface name: {}", e))?;
|
|
||||||
|
|
||||||
// Используем libc напрямую для SO_BINDTODEVICE
|
|
||||||
let result = libc::setsockopt(
|
|
||||||
fd,
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_BINDTODEVICE,
|
|
||||||
interface_cstr.as_ptr() as *const libc::c_void,
|
|
||||||
interface_cstr.as_bytes().len() as libc::socklen_t,
|
|
||||||
);
|
|
||||||
|
|
||||||
if result != 0 {
|
|
||||||
return Err(anyhow::anyhow!("Failed to bind socket to interface {}: {}", interface, std::io::Error::last_os_error()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.set_write_timeout(Some(timeout))?;
|
|
||||||
socket.send_to(&[], &address)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверка, является ли строка именем интерфейса
|
|
||||||
fn is_interface_name(&self, s: &str) -> bool {
|
|
||||||
s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') &&
|
|
||||||
!s.contains('.') &&
|
|
||||||
s.parse::<IpAddr>().is_err()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Парсинг длительности из строки
|
|
||||||
fn parse_duration(&self, s: &str) -> Result<Duration> {
|
|
||||||
if s.ends_with("ms") {
|
|
||||||
let ms: u64 = s.trim_end_matches("ms").parse()?;
|
|
||||||
Ok(Duration::from_millis(ms))
|
|
||||||
} else if s.ends_with('s') {
|
|
||||||
let secs: u64 = s.trim_end_matches('s').parse()?;
|
|
||||||
Ok(Duration::from_secs(secs))
|
|
||||||
} else {
|
|
||||||
// Пытаемся парсить как секунды
|
|
||||||
let secs: u64 = s.parse()?;
|
|
||||||
Ok(Duration::from_secs(secs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Шутливые функции
|
|
||||||
fn show_easter_egg(&self) {
|
|
||||||
println!("🥚 Пасхалка найдена! 8.8.8.8:8888 - это не случайность!");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_random_joke(&self) {
|
|
||||||
let jokes = vec![
|
|
||||||
"Почему программисты предпочитают темную тему? Потому что свет притягивает баги! 🐛",
|
|
||||||
"Что такое программист без кофе? Генератор случайных чисел. ☕",
|
|
||||||
"Почему Rust не нужен garbage collector? Потому что он сам знает, где мусор! 🦀",
|
|
||||||
"Что общего у программиста и волшебника? Оба работают с магией, но один использует код, а другой — заклинания! ✨",
|
|
||||||
"Почему Rust-разработчики не боятся null pointer? Потому что у них есть Option<T>! 🛡️",
|
|
||||||
];
|
|
||||||
|
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
if let Some(joke) = jokes.choose(&mut rng) {
|
|
||||||
println!("{}", joke);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Конвертирует KnockRequest в Config для совместимости с Electron
|
|
||||||
pub fn request_to_config(request: KnockRequest) -> Result<Config> {
|
|
||||||
let mut targets = Vec::new();
|
|
||||||
|
|
||||||
for target_str in request.targets {
|
|
||||||
let parts: Vec<&str> = target_str.split(':').collect();
|
|
||||||
if parts.len() >= 3 {
|
|
||||||
let protocol = parts[0].to_string();
|
|
||||||
let host = parts[1].to_string();
|
|
||||||
let port: u16 = parts[2].parse()
|
|
||||||
.with_context(|| format!("Неверный порт в цели: {}", target_str))?;
|
|
||||||
|
|
||||||
targets.push(Target {
|
|
||||||
host,
|
|
||||||
ports: vec![port],
|
|
||||||
protocol,
|
|
||||||
delay: Some(request.delay.clone()),
|
|
||||||
wait_connection: None,
|
|
||||||
gateway: if request.gateway.is_empty() { None } else { Some(request.gateway.clone()) },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return Err(anyhow::anyhow!("Неверный формат цели: {}", target_str));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Config { targets })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Модуль криптографии для расшифровки конфигураций
|
|
||||||
pub mod crypto {
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use aes_gcm::{Aes256Gcm, Key, Nonce, KeyInit, aead::Aead};
|
|
||||||
use base64::prelude::*;
|
|
||||||
use sha2::{Sha256, Digest};
|
|
||||||
|
|
||||||
/// Расшифровывает конфигурацию с помощью AES-GCM
|
|
||||||
pub fn decrypt_config(encrypted_data: &str, key: &str) -> Result<String> {
|
|
||||||
// Декодируем base64
|
|
||||||
let data = BASE64_STANDARD.decode(encrypted_data)
|
|
||||||
.context("Не удалось декодировать base64")?;
|
|
||||||
|
|
||||||
// Хешируем ключ для получения 32 байт
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(key.as_bytes());
|
|
||||||
let key_bytes = hasher.finalize();
|
|
||||||
|
|
||||||
// Создаём AES-GCM cipher
|
|
||||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from_slice(&key_bytes));
|
|
||||||
|
|
||||||
// Извлекаем nonce (первые 12 байт)
|
|
||||||
if data.len() < 12 {
|
|
||||||
return Err(anyhow::anyhow!("Данные слишком короткие"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let nonce = Nonce::from_slice(&data[..12]);
|
|
||||||
let ciphertext = &data[12..];
|
|
||||||
|
|
||||||
// Расшифровываем
|
|
||||||
let plaintext = cipher.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Не удалось расшифровать данные: {}", e))?;
|
|
||||||
|
|
||||||
String::from_utf8(plaintext)
|
|
||||||
.context("Расшифрованные данные не являются валидным UTF-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Шифрует конфигурацию с помощью AES-GCM
|
|
||||||
pub fn encrypt_config(data: &str, key: &str) -> Result<String> {
|
|
||||||
// Хешируем ключ для получения 32 байт
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(key.as_bytes());
|
|
||||||
let key_bytes = hasher.finalize();
|
|
||||||
|
|
||||||
// Создаём AES-GCM cipher
|
|
||||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from_slice(&key_bytes));
|
|
||||||
|
|
||||||
// Генерируем случайный nonce
|
|
||||||
use rand::RngCore;
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
// Шифруем
|
|
||||||
let ciphertext = cipher.encrypt(nonce, data.as_bytes())
|
|
||||||
.map_err(|e| anyhow::anyhow!("Не удалось зашифровать данные: {}", e))?;
|
|
||||||
|
|
||||||
// Объединяем nonce и ciphertext
|
|
||||||
let mut encrypted = nonce_bytes.to_vec();
|
|
||||||
encrypted.extend_from_slice(&ciphertext);
|
|
||||||
|
|
||||||
// Кодируем в base64
|
|
||||||
Ok(BASE64_STANDARD.encode(&encrypted))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_duration() {
|
|
||||||
let knocker = PortKnocker::new();
|
|
||||||
|
|
||||||
assert_eq!(knocker.parse_duration("1s").unwrap(), Duration::from_secs(1));
|
|
||||||
assert_eq!(knocker.parse_duration("500ms").unwrap(), Duration::from_millis(500));
|
|
||||||
assert_eq!(knocker.parse_duration("2").unwrap(), Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_interface_name() {
|
|
||||||
let knocker = PortKnocker::new();
|
|
||||||
|
|
||||||
assert!(knocker.is_interface_name("eth0"));
|
|
||||||
assert!(knocker.is_interface_name("enp1s0"));
|
|
||||||
assert!(knocker.is_interface_name("wlan0"));
|
|
||||||
assert!(!knocker.is_interface_name("192.168.1.1"));
|
|
||||||
assert!(!knocker.is_interface_name("8.8.8.8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_request_to_config() {
|
|
||||||
let request = KnockRequest {
|
|
||||||
targets: vec!["tcp:192.168.1.1:22".to_string(), "udp:10.0.0.1:53".to_string()],
|
|
||||||
delay: "2s".to_string(),
|
|
||||||
verbose: false,
|
|
||||||
gateway: "192.168.1.100".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = request_to_config(request).unwrap();
|
|
||||||
assert_eq!(config.targets.len(), 2);
|
|
||||||
assert_eq!(config.targets[0].host, "192.168.1.1");
|
|
||||||
assert_eq!(config.targets[0].ports, vec![22]);
|
|
||||||
assert_eq!(config.targets[0].protocol, "tcp");
|
|
||||||
assert_eq!(config.targets[0].gateway, Some("192.168.1.100".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,147 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use clap::Parser;
|
|
||||||
use rust_knocker::{Config, PortKnocker};
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "rust-knocker",
|
|
||||||
version = "0.1.0",
|
|
||||||
about = "Port knocking utility written in Rust - compatible with Go knocker"
|
|
||||||
)]
|
|
||||||
struct Args {
|
|
||||||
/// Path to YAML configuration file
|
|
||||||
#[arg(short, long, help = "Path to YAML configuration file")]
|
|
||||||
config: Option<String>,
|
|
||||||
|
|
||||||
/// Path to encryption key file
|
|
||||||
#[arg(short, long, help = "Path to encryption key file (optional)")]
|
|
||||||
key: Option<String>,
|
|
||||||
|
|
||||||
/// Enable verbose output
|
|
||||||
#[arg(short, long, help = "Enable verbose output")]
|
|
||||||
verbose: bool,
|
|
||||||
|
|
||||||
/// Wait for connection establishment
|
|
||||||
#[arg(short, long, help = "Wait for connection establishment (default: best-effort)")]
|
|
||||||
wait_connection: bool,
|
|
||||||
|
|
||||||
/// Single target in format 'protocol:host:port'
|
|
||||||
#[arg(short, long, help = "Single target in format 'protocol:host:port'")]
|
|
||||||
target: Option<String>,
|
|
||||||
|
|
||||||
/// Delay between packets
|
|
||||||
#[arg(short, long, default_value = "1s", help = "Delay between packets")]
|
|
||||||
delay: String,
|
|
||||||
|
|
||||||
/// Gateway for packet routing
|
|
||||||
#[arg(short, long, help = "Gateway for packet routing (IP or interface name)")]
|
|
||||||
gateway: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let args = Args::parse();
|
|
||||||
|
|
||||||
let knocker = PortKnocker::new();
|
|
||||||
|
|
||||||
if let Some(config_path) = args.config {
|
|
||||||
execute_with_config_file(&knocker, &config_path, &args.key, args.verbose, args.wait_connection).await
|
|
||||||
} else if let Some(target) = args.target {
|
|
||||||
execute_single_target(&knocker, &target, &args.delay, &args.gateway, args.verbose, args.wait_connection).await
|
|
||||||
} else {
|
|
||||||
println!("Использование: rust-knocker --help");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_with_config_file(
|
|
||||||
knocker: &PortKnocker,
|
|
||||||
config_path: &str,
|
|
||||||
key_path: &Option<String>,
|
|
||||||
verbose: bool,
|
|
||||||
wait_connection: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
println!("Загрузка конфигурации из: {}", config_path);
|
|
||||||
|
|
||||||
let config_content = fs::read_to_string(config_path)
|
|
||||||
.with_context(|| format!("Не удалось прочитать файл конфигурации: {}", config_path))?;
|
|
||||||
|
|
||||||
let config: Config = if let Some(key_file) = key_path {
|
|
||||||
let encrypted_config = config_content;
|
|
||||||
let key = fs::read_to_string(key_file)
|
|
||||||
.with_context(|| format!("Не удалось прочитать файл ключа: {}", key_file))?;
|
|
||||||
|
|
||||||
let decrypted_config = rust_knocker::crypto::decrypt_config(&encrypted_config, &key.trim())
|
|
||||||
.with_context(|| "Не удалось расшифровать конфигурацию")?;
|
|
||||||
|
|
||||||
serde_yaml::from_str(&decrypted_config)
|
|
||||||
.with_context(|| "Не удалось распарсить расшифрованную конфигурацию")?
|
|
||||||
} else {
|
|
||||||
serde_yaml::from_str(&config_content)
|
|
||||||
.with_context(|| "Не удалось распарсить конфигурацию")?
|
|
||||||
};
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!("Загружено {} целей", config.targets.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
knocker.execute_with_config(config, verbose, wait_connection).await?;
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!("\n✅ Port knocking завершён успешно!");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_single_target(
|
|
||||||
knocker: &PortKnocker,
|
|
||||||
target: &str,
|
|
||||||
delay: &str,
|
|
||||||
gateway: &Option<String>,
|
|
||||||
verbose: bool,
|
|
||||||
wait_connection: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let parts: Vec<&str> = target.split(':').collect();
|
|
||||||
if parts.len() != 3 {
|
|
||||||
return Err(anyhow::anyhow!("Неверный формат цели. Используйте: protocol:host:port"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let protocol = parts[0].to_lowercase();
|
|
||||||
let host = parts[1];
|
|
||||||
let port: u16 = parts[2].parse()
|
|
||||||
.with_context(|| format!("Неверный порт: {}", parts[2]))?;
|
|
||||||
|
|
||||||
if protocol != "tcp" && protocol != "udp" {
|
|
||||||
return Err(anyhow::anyhow!("Неподдерживаемый протокол: {}. Используйте 'tcp' или 'udp'", protocol));
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
targets: vec![rust_knocker::Target {
|
|
||||||
host: host.to_string(),
|
|
||||||
ports: vec![port],
|
|
||||||
protocol,
|
|
||||||
delay: Some(delay.to_string()),
|
|
||||||
wait_connection: Some(wait_connection),
|
|
||||||
gateway: gateway.clone(),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!("Цель: {}:{} ({})", host, port, config.targets[0].protocol);
|
|
||||||
if let Some(ref gw) = gateway {
|
|
||||||
println!("Gateway: {}", gw);
|
|
||||||
}
|
|
||||||
println!("Задержка: {}", delay);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
knocker.execute_with_config(config, verbose, wait_connection).await?;
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!("\n✅ Port knocking завершён успешно!");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
@@ -1,9 +1,8 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component';
|
import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component';
|
||||||
import { FsaKnockPageComponent } from './fsa-knock/fsa-knock-page.component';
|
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: BasicKnockPageComponent },
|
{ path: '', component: BasicKnockPageComponent },
|
||||||
{ path: 'fsa', component: FsaKnockPageComponent },
|
|
||||||
{ path: '**', redirectTo: '' }
|
{ path: '**', redirectTo: '' }
|
||||||
];
|
];
|
@@ -15,7 +15,7 @@ import { KnockPageComponent } from '../knock/knock-page.component';
|
|||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Встраиваем основной компонент в базовом режиме -->
|
<!-- Встраиваем основной компонент в базовом режиме -->
|
||||||
<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>
|
<app-knock-page></app-knock-page>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Информационное модальное окно -->
|
<!-- Информационное модальное окно -->
|
||||||
|
@@ -1,132 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
import { CardModule } from 'primeng/card';
|
|
||||||
import { ButtonModule } from 'primeng/button';
|
|
||||||
import { DialogModule } from 'primeng/dialog';
|
|
||||||
import { KnockPageComponent } from '../knock/knock-page.component';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-fsa-knock-page',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="container">
|
|
||||||
<div *ngIf="!isFSASupported" class="text-center">
|
|
||||||
<h3>File System Access API не поддерживается</h3>
|
|
||||||
<p>Эта функциональность требует браузер с поддержкой File System Access API:</p>
|
|
||||||
<ul class="text-left mt-3">
|
|
||||||
<li>Google Chrome 86+</li>
|
|
||||||
<li>Microsoft Edge 86+</li>
|
|
||||||
<li>Opera 72+</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>
|
|
||||||
<button pButton
|
|
||||||
type="button"
|
|
||||||
label="Перейти к основной версии"
|
|
||||||
class="p-button-outlined mt-3"
|
|
||||||
routerLink="/">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="isFSASupported">
|
|
||||||
<!-- Встраиваем основной компонент с поддержкой FSA -->
|
|
||||||
<app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Информационное модальное окно -->
|
|
||||||
<p-dialog header="🚀 Расширенная версия с File System Access"
|
|
||||||
[(visible)]="showInfoDialog"
|
|
||||||
[modal]="true"
|
|
||||||
[closable]="true"
|
|
||||||
[draggable]="false"
|
|
||||||
[resizable]="false"
|
|
||||||
styleClass="info-dialog">
|
|
||||||
<div class="dialog-content">
|
|
||||||
<p class="mb-3">
|
|
||||||
Эта версия поддерживает прямое редактирование файлов на диске.
|
|
||||||
Файлы будут автоматически перезаписываться после шифрования/дешифрования.
|
|
||||||
</p>
|
|
||||||
<div class="p-3 bg-green-50 border-round">
|
|
||||||
<p class="text-sm mb-2">
|
|
||||||
✅ <strong>Доступные возможности:</strong>
|
|
||||||
</p>
|
|
||||||
<ul class="text-sm mb-0">
|
|
||||||
<li>Прямое открытие файлов с диска</li>
|
|
||||||
<li>Автоматическое сохранение изменений</li>
|
|
||||||
<li>Перезапись зашифрованных файлов "на месте"</li>
|
|
||||||
<li>Быстрая работа без диалогов загрузки/скачивания</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</p-dialog>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
display: inline-block;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-link {
|
|
||||||
color: #3b82f6;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-link:hover {
|
|
||||||
color: #1d4ed8;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-green-50 {
|
|
||||||
background-color: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-content {
|
|
||||||
min-width: 450px;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
|
||||||
export class FsaKnockPageComponent {
|
|
||||||
isFSASupported = false;
|
|
||||||
browserInfo = '';
|
|
||||||
showInfoDialog = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.checkFSASupport();
|
|
||||||
this.getBrowserInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkFSASupport() {
|
|
||||||
const w = window as any;
|
|
||||||
this.isFSASupported = typeof w.showOpenFilePicker === 'function';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBrowserInfo() {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
if (ua.includes('Chrome') && !ua.includes('Edg/')) {
|
|
||||||
this.browserInfo = 'Google Chrome';
|
|
||||||
} else if (ua.includes('Edg/')) {
|
|
||||||
this.browserInfo = 'Microsoft Edge';
|
|
||||||
} else if (ua.includes('Opera') || ua.includes('OPR/')) {
|
|
||||||
this.browserInfo = 'Opera';
|
|
||||||
} else if (ua.includes('Firefox')) {
|
|
||||||
this.browserInfo = 'Mozilla Firefox';
|
|
||||||
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
|
||||||
this.browserInfo = 'Safari';
|
|
||||||
} else {
|
|
||||||
this.browserInfo = 'Неизвестный браузер';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,348 +0,0 @@
|
|||||||
# Документация: FormArray в компоненте KnockPageComponent
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
В компоненте `KnockPageComponent` используется Angular FormArray для управления динамическими формами целей (targets) в режиме "form". Это позволяет пользователям добавлять, удалять и редактировать неограниченное количество целей для пропинывания портов.
|
|
||||||
|
|
||||||
## Архитектура FormArray
|
|
||||||
|
|
||||||
### 1. Структура данных
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
form = this.fb.group({
|
|
||||||
// ... другие поля
|
|
||||||
targetForms: this.fb.array([]) // FormArray для динамических форм
|
|
||||||
});
|
|
||||||
|
|
||||||
get targetForms(): FormArray {
|
|
||||||
return this.form.get('targetForms') as FormArray;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Структура отдельной формы цели
|
|
||||||
|
|
||||||
Каждая форма цели содержит следующие поля:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private createTargetForm(): FormGroup {
|
|
||||||
return this.fb.group({
|
|
||||||
protocol: ['tcp', Validators.required], // Протокол (TCP/UDP)
|
|
||||||
host: ['127.0.0.1', Validators.required], // IP адрес хоста
|
|
||||||
port: [22, [Validators.required, Validators.min(1), Validators.max(65535)]], // Порт
|
|
||||||
gateway: [''] // Шлюз (опционально)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Основные методы работы с FormArray
|
|
||||||
|
|
||||||
### 1. Создание новой формы цели
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
addTarget(): void {
|
|
||||||
const newTargetForm = this.createTargetForm();
|
|
||||||
this.targetForms.push(newTargetForm);
|
|
||||||
this.serializeFormTargets();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что происходит:**
|
|
||||||
|
|
||||||
- Создается новая FormGroup с полями по умолчанию
|
|
||||||
- Форма добавляется в FormArray через `push()`
|
|
||||||
- Автоматически вызывается сериализация данных
|
|
||||||
|
|
||||||
### 2. Удаление формы цели
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
removeTarget(index: number): void {
|
|
||||||
if (this.targetForms.length > 1) {
|
|
||||||
this.targetForms.removeAt(index);
|
|
||||||
this.serializeFormTargets();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Особенности:**
|
|
||||||
|
|
||||||
- Защита от удаления последней формы (минимум 1 форма)
|
|
||||||
- Используется `removeAt(index)` для удаления по индексу
|
|
||||||
- Автоматическая сериализация после удаления
|
|
||||||
|
|
||||||
### 3. Сериализация данных форм
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private serializeFormTargets(): void {
|
|
||||||
if (this.form.value.mode !== 'form') return;
|
|
||||||
|
|
||||||
const targets: string[] = [];
|
|
||||||
this.targetForms.controls.forEach(targetForm => {
|
|
||||||
const value = targetForm.value;
|
|
||||||
if (value.protocol && value.host && value.port) {
|
|
||||||
let targetString = `${value.protocol}:${value.host}:${value.port}`;
|
|
||||||
if (value.gateway && value.gateway.trim()) {
|
|
||||||
targetString += `:${value.gateway.trim()}`;
|
|
||||||
}
|
|
||||||
targets.push(targetString);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.form.patchValue({ targets: targets.join(';') });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Процесс сериализации:**
|
|
||||||
|
|
||||||
1. Проверка, что текущий режим - "form"
|
|
||||||
2. Итерация по всем формам в FormArray
|
|
||||||
3. Сборка строки в формате `protocol:host:port:gateway`
|
|
||||||
4. Объединение всех целей через `;`
|
|
||||||
5. Обновление поля `targets` в основной форме
|
|
||||||
|
|
||||||
## Интеграция с HTML шаблоном
|
|
||||||
|
|
||||||
### 1. Отображение форм
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="form-targets-list">
|
|
||||||
<div
|
|
||||||
*ngFor="let targetForm of targetForms.controls; let i = index"
|
|
||||||
class="form-target-item"
|
|
||||||
[formGroup]="$any(targetForm)"
|
|
||||||
>
|
|
||||||
<!-- Поля формы -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ключевые моменты:**
|
|
||||||
|
|
||||||
- `*ngFor` итерируется по `targetForms.controls`
|
|
||||||
- `[formGroup]` связывает каждую форму с FormGroup
|
|
||||||
- `$any(targetForm)` решает проблему типизации TypeScript
|
|
||||||
|
|
||||||
### 2. Поля формы
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="col-12 md:col-3">
|
|
||||||
<label>Protocol</label>
|
|
||||||
<p-dropdown
|
|
||||||
formControlName="protocol"
|
|
||||||
[options]="[
|
|
||||||
{ label: 'TCP', value: 'tcp' },
|
|
||||||
{ label: 'UDP', value: 'udp' }
|
|
||||||
]"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
class="w-full"
|
|
||||||
></p-dropdown>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Автоматическое сохранение и восстановление
|
|
||||||
|
|
||||||
### 1. Подписка на изменения
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private setupAutoSave() {
|
|
||||||
// Подписка на изменения в формах целей
|
|
||||||
this.targetForms.valueChanges.subscribe(() => {
|
|
||||||
if (this.form.value.mode === 'form') {
|
|
||||||
this.serializeFormTargets();
|
|
||||||
setTimeout(() => this.saveStateToLocalStorage(), 300);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сохранение в localStorage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private saveStateToLocalStorage() {
|
|
||||||
const state: any = {
|
|
||||||
// ... другие поля
|
|
||||||
};
|
|
||||||
|
|
||||||
// Сохраняем данные форм целей для режима form
|
|
||||||
if (formValue.mode === 'form' && this.targetForms.length > 0) {
|
|
||||||
state.targetForms = this.targetForms.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Восстановление из localStorage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private loadStateFromLocalStorage() {
|
|
||||||
// ... загрузка других полей
|
|
||||||
|
|
||||||
// Загружаем сохраненные формы целей для режима form
|
|
||||||
if (state.mode === 'form' && state.targetForms && Array.isArray(state.targetForms)) {
|
|
||||||
this.targetForms.clear();
|
|
||||||
state.targetForms.forEach((targetData: any) => {
|
|
||||||
const targetForm = this.fb.group({
|
|
||||||
protocol: [targetData.protocol || 'tcp', Validators.required],
|
|
||||||
host: [targetData.host || '127.0.0.1', Validators.required],
|
|
||||||
port: [targetData.port || 22, [Validators.required, Validators.min(1), Validators.max(65535)]],
|
|
||||||
gateway: [targetData.gateway || '']
|
|
||||||
});
|
|
||||||
this.targetForms.push(targetForm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Преобразование между режимами
|
|
||||||
|
|
||||||
### 1. Конвертация в режим Form
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private convertInlineToForm() {
|
|
||||||
const targetsString = this.form.value.targets || '';
|
|
||||||
const targets = targetsString.split(';').filter(t => t.trim());
|
|
||||||
|
|
||||||
targets.forEach(target => {
|
|
||||||
const parts = target.trim().split(':');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const targetForm = this.fb.group({
|
|
||||||
protocol: [parts[0] || 'tcp', Validators.required],
|
|
||||||
host: [parts[1] || '127.0.0.1', Validators.required],
|
|
||||||
port: [parseInt(parts[2]) || 22, [Validators.required, Validators.min(1), Validators.max(65535)]],
|
|
||||||
gateway: [parts[3] || '']
|
|
||||||
});
|
|
||||||
this.targetForms.push(targetForm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Конвертация из режима Form
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private handleModeChangeFromForm(previousMode: string, newMode: string) {
|
|
||||||
// Сначала сериализуем данные формы
|
|
||||||
this.serializeFormTargets();
|
|
||||||
|
|
||||||
if (newMode === 'inline') {
|
|
||||||
// Данные уже в targets, ничего дополнительно не нужно
|
|
||||||
} else if (newMode === 'yaml') {
|
|
||||||
this.convertInlineToYaml();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Жизненный цикл FormArray
|
|
||||||
|
|
||||||
### 1. Инициализация
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private initializeFormMode(): void {
|
|
||||||
// Если нет форм целей, создаем одну по умолчанию
|
|
||||||
if (this.targetForms.length === 0) {
|
|
||||||
this.addTarget();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Очистка при смене режима
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private handleModeChangeToForm(previousMode: string) {
|
|
||||||
// Очищаем существующие формы
|
|
||||||
this.targetForms.clear();
|
|
||||||
|
|
||||||
// Конвертируем данные из предыдущего режима
|
|
||||||
if (previousMode === 'inline') {
|
|
||||||
this.convertInlineToForm();
|
|
||||||
} else if (previousMode === 'yaml') {
|
|
||||||
this.convertYamlToForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализируем формы если их нет
|
|
||||||
if (this.targetForms.length === 0) {
|
|
||||||
this.addTarget();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Валидация
|
|
||||||
|
|
||||||
### 1. Валидация полей формы
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private createTargetForm(): FormGroup {
|
|
||||||
return this.fb.group({
|
|
||||||
protocol: ['tcp', Validators.required],
|
|
||||||
host: ['127.0.0.1', Validators.required],
|
|
||||||
port: [22, [Validators.required, Validators.min(1), Validators.max(65535)]],
|
|
||||||
gateway: ['']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Защита от удаления всех форм
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
removeTarget(index: number): void {
|
|
||||||
if (this.targetForms.length > 1) {
|
|
||||||
this.targetForms.removeAt(index);
|
|
||||||
this.serializeFormTargets();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Преимущества использования FormArray
|
|
||||||
|
|
||||||
1. **Динамичность**: Возможность добавления/удаления форм в runtime
|
|
||||||
2. **Валидация**: Встроенная валидация для каждой формы
|
|
||||||
3. **Реактивность**: Автоматическое обновление UI при изменениях
|
|
||||||
4. **Типобезопасность**: TypeScript поддержка
|
|
||||||
5. **Интеграция**: Легкая интеграция с Angular Reactive Forms
|
|
||||||
6. **Сериализация**: Простое преобразование в различные форматы
|
|
||||||
|
|
||||||
## Потенциальные проблемы и решения
|
|
||||||
|
|
||||||
### 1. Проблема типизации в шаблоне
|
|
||||||
|
|
||||||
**Проблема:**
|
|
||||||
```html
|
|
||||||
[formGroup]="targetForm" <!-- TypeScript ошибка -->
|
|
||||||
```
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
```html
|
|
||||||
[formGroup]="$any(targetForm)" <!-- Приведение типа -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Защита от пустого FormArray
|
|
||||||
|
|
||||||
**Проблема:** Пользователь может удалить все формы
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
```typescript
|
|
||||||
removeTarget(index: number): void {
|
|
||||||
if (this.targetForms.length > 1) { // Минимум 1 форма
|
|
||||||
this.targetForms.removeAt(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Производительность при большом количестве форм
|
|
||||||
|
|
||||||
**Проблема:** Много форм может замедлить приложение
|
|
||||||
|
|
||||||
**Решение:** Виртуализация или пагинация (не реализовано в текущей версии)
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
FormArray в данном компоненте обеспечивает гибкую и мощную систему управления динамическими формами. Реализация включает:
|
|
||||||
|
|
||||||
- Полный жизненный цикл форм (создание, редактирование, удаление)
|
|
||||||
- Автоматическую сериализацию/десериализацию
|
|
||||||
- Интеграцию с системой режимов
|
|
||||||
- Сохранение состояния в localStorage
|
|
||||||
- Валидацию и защиту от некорректных данных
|
|
||||||
|
|
||||||
Этот подход делает компонент удобным для пользователей и легко расширяемым для разработчиков.
|
|
@@ -1,77 +1,19 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p-card [header]="cardHeader">
|
<p-card header="Port Knocker (Minimal UI)">
|
||||||
<ng-template pTemplate="header">
|
|
||||||
<div class="flex justify-content-between align-items-center">
|
|
||||||
<h1 style="margin-left: 1rem">Port Knocker</h1>
|
|
||||||
<!-- <div class="animated-title" [class.animating]="isAnimating">
|
|
||||||
<span *ngIf="cardHeader">{{ cardHeader }}</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
*ngIf="!enableFSA"
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="📁 Info"
|
|
||||||
class="p-button-text p-button-sm"
|
|
||||||
(click)="showInfoDialog = true"
|
|
||||||
></button>
|
|
||||||
<button
|
|
||||||
*ngIf="canUseFSA && !enableFSA"
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="🚀 FSA Version"
|
|
||||||
class="p-button-text p-button-sm"
|
|
||||||
routerLink="/fsa"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
|
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12">
|
||||||
<label>Password</label>
|
|
||||||
<p-password
|
|
||||||
formControlName="password"
|
|
||||||
[feedback]="false"
|
|
||||||
toggleMask
|
|
||||||
inputStyleClass="w-full"
|
|
||||||
placeholder="GO_KNOCKER_SERVE_PASS"
|
|
||||||
></p-password>
|
|
||||||
<div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">
|
|
||||||
<span class="text-red-500" *ngIf="wrongPass">Invalid password</span>
|
|
||||||
<span class="text-600" *ngIf="!wrongPass && !form.value.password"
|
|
||||||
>Password is required</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
|
||||||
<label>Mode</label>
|
|
||||||
<p-dropdown
|
|
||||||
formControlName="mode"
|
|
||||||
[options]="[
|
|
||||||
{ label: 'Inline', value: 'inline' },
|
|
||||||
{ label: 'YAML', value: 'yaml' },
|
|
||||||
{ label: 'Form', value: 'form' }
|
|
||||||
]"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
class="w-full"
|
|
||||||
></p-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12" *ngIf="form.value.mode === 'inline'">
|
|
||||||
<label>Targets</label>
|
<label>Targets</label>
|
||||||
<input
|
<input
|
||||||
pInputText
|
pInputText
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="targets"
|
formControlName="targets"
|
||||||
placeholder="tcp:host:port;udp:host:port"
|
placeholder="tcp:host:port;udp:host:port;...;tcp:host:port"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-4" *ngIf="form.value.mode !== 'yaml'">
|
<div class="col-12 md:col-6">
|
||||||
<label>Delay</label>
|
<label>Delay</label>
|
||||||
<input
|
<input
|
||||||
pInputText
|
pInputText
|
||||||
@@ -82,170 +24,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
<div class="col-12 md:col-6 flex align-items-center gap-2">
|
||||||
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>
|
<p-checkbox formControlName="waitConnection" [binary]="true"></p-checkbox>
|
||||||
<label class="checkbox-label">Verbose</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
|
||||||
<p-checkbox
|
|
||||||
formControlName="waitConnection"
|
|
||||||
[binary]="true"
|
|
||||||
></p-checkbox>
|
|
||||||
<label class="checkbox-label">Wait connection</label>
|
<label class="checkbox-label">Wait connection</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12" style="display: none;">
|
|
||||||
<label>Gateway</label>
|
|
||||||
<input
|
|
||||||
pInputText
|
|
||||||
type="text"
|
|
||||||
formControlName="gateway"
|
|
||||||
placeholder="optional local ip:port"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<label>YAML</label>
|
|
||||||
<textarea
|
|
||||||
pInputTextarea
|
|
||||||
formControlName="configYAML"
|
|
||||||
rows="12"
|
|
||||||
placeholder="paste YAML or ENCRYPTED:"
|
|
||||||
class="w-full"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Mode Section -->
|
|
||||||
<div class="col-12" *ngIf="form.value.mode === 'form'">
|
|
||||||
<div class="form-targets-container">
|
|
||||||
<label>Targets Configuration</label>
|
|
||||||
<div class="form-targets-list">
|
|
||||||
<div
|
|
||||||
*ngFor="let targetForm of targetForms.controls; let i = index"
|
|
||||||
class="form-target-item"
|
|
||||||
[formGroup]="$any(targetForm)"
|
|
||||||
>
|
|
||||||
<div class="form-target-header">
|
|
||||||
<h4>Target {{ i + 1 }}</h4>
|
|
||||||
<button
|
|
||||||
*ngIf="targetForms.length > 1"
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
icon="pi pi-trash"
|
|
||||||
class="p-button-danger p-button-sm"
|
|
||||||
(click)="removeTarget(i)"
|
|
||||||
pTooltip="Remove target"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="col-12 md:col-3">
|
|
||||||
<label>Protocol</label>
|
|
||||||
<p-dropdown
|
|
||||||
formControlName="protocol"
|
|
||||||
[options]="[
|
|
||||||
{ label: 'TCP', value: 'tcp' },
|
|
||||||
{ label: 'UDP', value: 'udp' }
|
|
||||||
]"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
class="w-full"
|
|
||||||
></p-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-3">
|
|
||||||
<label>Host</label>
|
|
||||||
<input
|
|
||||||
pInputText
|
|
||||||
type="text"
|
|
||||||
formControlName="host"
|
|
||||||
placeholder="127.0.0.1"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-3">
|
|
||||||
<label>Port</label>
|
|
||||||
<input
|
|
||||||
pInputText
|
|
||||||
type="number"
|
|
||||||
formControlName="port"
|
|
||||||
placeholder="22"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-3">
|
|
||||||
<label>Gateway</label>
|
|
||||||
<input
|
|
||||||
pInputText
|
|
||||||
type="text"
|
|
||||||
formControlName="gateway"
|
|
||||||
placeholder="optional"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-target-actions">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Add Target"
|
|
||||||
icon="pi pi-plus"
|
|
||||||
class="p-button-outlined"
|
|
||||||
(click)="addTarget()"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File controls directly under YAML -->
|
|
||||||
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<div class="flex flex-wrap gap-2 align-items-center">
|
|
||||||
<!-- FSA version -->
|
|
||||||
<button
|
|
||||||
*ngIf="enableFSA"
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Open File (with write access)"
|
|
||||||
(click)="openFileWithWriteAccess()"
|
|
||||||
class="p-button-outlined"
|
|
||||||
></button>
|
|
||||||
<span
|
|
||||||
*ngIf="enableFSA && selectedFileName"
|
|
||||||
class="text-sm text-600"
|
|
||||||
>{{ selectedFileName }}</span
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- Basic version -->
|
|
||||||
<p-fileUpload
|
|
||||||
*ngIf="!enableFSA"
|
|
||||||
mode="basic"
|
|
||||||
name="file"
|
|
||||||
chooseLabel="Choose File"
|
|
||||||
(onSelect)="onFileUpload($event)"
|
|
||||||
[customUpload]="true"
|
|
||||||
[auto]="false"
|
|
||||||
accept=".yaml,.yml,.txt,.encrypted"
|
|
||||||
[maxFileSize]="1048576"
|
|
||||||
></p-fileUpload>
|
|
||||||
<input
|
|
||||||
*ngIf="!enableFSA && !isYamlEncrypted()"
|
|
||||||
pInputText
|
|
||||||
type="text"
|
|
||||||
class="w-full md:w-6"
|
|
||||||
placeholder="Server file path (optional)"
|
|
||||||
formControlName="serverFilePath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Row 1: Execute full width -->
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button
|
<button
|
||||||
pButton
|
pButton
|
||||||
@@ -253,58 +36,7 @@
|
|||||||
label="Execute"
|
label="Execute"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
[loading]="executing"
|
[loading]="executing"
|
||||||
[disabled]="executing || !form.value.password || wrongPass"
|
[disabled]="executing || form.invalid"
|
||||||
[ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
|
|
||||||
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Encrypt"
|
|
||||||
(click)="encrypt()"
|
|
||||||
class="p-button-secondary w-full"
|
|
||||||
[disabled]="
|
|
||||||
executing ||
|
|
||||||
!form.value.password ||
|
|
||||||
wrongPass ||
|
|
||||||
isYamlEncrypted()
|
|
||||||
"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Decrypt"
|
|
||||||
(click)="decrypt()"
|
|
||||||
class="p-button-secondary w-full"
|
|
||||||
[disabled]="
|
|
||||||
executing ||
|
|
||||||
!form.value.password ||
|
|
||||||
wrongPass ||
|
|
||||||
!isYamlEncrypted()
|
|
||||||
"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
|
|
||||||
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Download YAML"
|
|
||||||
(click)="downloadYaml()"
|
|
||||||
class="p-button-text w-full"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 md:col-6" *ngIf="form.value.mode === 'yaml'">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Download Result"
|
|
||||||
(click)="downloadResult()"
|
|
||||||
class="p-button-text w-full"
|
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,87 +51,15 @@
|
|||||||
[mode]="executing ? 'indeterminate' : 'determinate'"
|
[mode]="executing ? 'indeterminate' : 'determinate'"
|
||||||
></p-progressBar>
|
></p-progressBar>
|
||||||
<div class="mt-2 text-600" *ngIf="executing">
|
<div class="mt-2 text-600" *ngIf="executing">
|
||||||
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
Elapsed: {{ elapsedMs / 1000 | number : '1.1-1' }}s
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0">
|
<div class="mt-2 text-600" *ngIf="!executing && result">
|
||||||
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
{{ result }}
|
||||||
<span *ngIf="lastRunTime" class="ml-2 text-500">
|
</div>
|
||||||
({{ lastRunTime | date : "short" }})
|
<div class="mt-2 text-red-600" *ngIf="!executing && error">
|
||||||
</span>
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</p-card>
|
</p-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Модальное окно с результатом выполнения -->
|
|
||||||
<p-dialog
|
|
||||||
header="Результат выполнения"
|
|
||||||
[(visible)]="showResultDialog"
|
|
||||||
[modal]="true"
|
|
||||||
[closable]="true"
|
|
||||||
[draggable]="false"
|
|
||||||
[resizable]="false"
|
|
||||||
styleClass="result-dialog"
|
|
||||||
>
|
|
||||||
<div class="dialog-content">
|
|
||||||
<div *ngIf="result" class="mb-3">
|
|
||||||
<h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>
|
|
||||||
<pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="error" class="mb-3">
|
|
||||||
<h4 class="text-red-600 mb-2">❌ Ошибка</h4>
|
|
||||||
<pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{
|
|
||||||
error
|
|
||||||
}}</pre>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="lastRunTime" class="text-sm text-600">
|
|
||||||
Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
|
||||||
<br />
|
|
||||||
Завершено: {{ lastRunTime | date : "short" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ng-template pTemplate="footer">
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="OK"
|
|
||||||
class="p-button-primary"
|
|
||||||
(click)="showResultDialog = false"
|
|
||||||
></button>
|
|
||||||
</ng-template>
|
|
||||||
</p-dialog>
|
|
||||||
|
|
||||||
<!-- Информационное модальное окно -->
|
|
||||||
<p-dialog
|
|
||||||
header="📁 Базовая версия"
|
|
||||||
[(visible)]="showInfoDialog"
|
|
||||||
[modal]="true"
|
|
||||||
[closable]="true"
|
|
||||||
[draggable]="false"
|
|
||||||
[resizable]="false"
|
|
||||||
styleClass="info-dialog"
|
|
||||||
>
|
|
||||||
<div class="dialog-content">
|
|
||||||
<p class="mb-3">
|
|
||||||
Эта версия работает в любом браузере, но файлы загружаются/скачиваются
|
|
||||||
через стандартные диалоги браузера.
|
|
||||||
</p>
|
|
||||||
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
|
|
||||||
<p class="text-sm mb-2">
|
|
||||||
💡 <strong>Доступна расширенная версия!</strong>
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-3">
|
|
||||||
Ваш браузер поддерживает прямое редактирование файлов на диске.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
pButton
|
|
||||||
type="button"
|
|
||||||
label="Перейти к расширенной версии"
|
|
||||||
class="p-button-success p-button-sm"
|
|
||||||
routerLink="/fsa"
|
|
||||||
(click)="showInfoDialog = false"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</p-dialog>
|
|
||||||
|
@@ -130,46 +130,3 @@ input[type='text'], input[type='password'], select, textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form Mode Styles
|
|
||||||
.form-targets-container {
|
|
||||||
.form-targets-list {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-target-item {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-target-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-target-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user