added desktop and rust version

This commit is contained in:
2025-09-25 15:32:49 +06:00
parent 10af1a9a63
commit 2c2725cd19
29 changed files with 12835 additions and 1589 deletions

990
desktop/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,990 @@
# Руководство по разработке Knocker Desktop
## 🔍 Подробное описание архитектуры
### Архитектура Electron приложения
``` text
┌─────────────────────────────────────────────────────────────┐
│ MAIN PROCESS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/main/main.js │ │
│ │ • Управление жизненным циклом приложения │ │
│ │ • Создание и управление окнами │ │
│ │ • Доступ к Node.js API (fs, dialog, shell) │ │
│ │ • IPC обработчики для файловых операций │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ IPC (Inter-Process Communication)
┌─────────────────────────────────────────────────────────────┐
│ RENDERER PROCESS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/renderer/ │ │
│ │ • HTML/CSS/JS интерфейс │ │
│ │ • Взаимодействие с пользователем │ │
│ │ • HTTP запросы к API │ │
│ │ • Ограниченный доступ к системе │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ contextBridge
┌─────────────────────────────────────────────────────────────┐
│ PRELOAD SCRIPT │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ src/preload/preload.js │ │
│ │ • Безопасный мост между main и renderer │ │
│ │ • Доступ к Node.js API │ │
│ │ • Экспорт API через window.api │ │
│ │ • Изоляция от прямого доступа к Node.js │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Детальное объяснение процессов
#### 1. Main Process (Основной процесс)
**Роль**: Ядро приложения, управляет всей жизнью приложения.
**Возможности**:
- Создание и управление окнами
- Доступ к Node.js API (файловая система, диалоги, системные функции)
- Обработка системных событий (закрытие приложения, фокус окон)
- IPC сервер для связи с renderer процессами
**Код в `src/main/main.js`**:
```javascript
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
// Создание главного окна с настройками безопасности
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true, // КРИТИЧНО: изолирует контекст
nodeIntegration: false, // КРИТИЧНО: отключает прямой доступ к Node.js
sandbox: false // Позволяет preload работать
}
});
}
// IPC обработчики - "серверная часть" для renderer
ipcMain.handle('file:open', async () => {
// Безопасная работа с файлами через main процесс
const res = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
// Возвращаем данные в renderer процесс
return { canceled: res.canceled, filePath: res.filePaths[0], content: fs.readFileSync(res.filePaths[0], 'utf-8') };
});
```
#### 2. Renderer Process (Процесс рендеринга)
**Роль**: Отображение пользовательского интерфейса, взаимодействие с пользователем.
**Ограничения**:
- Нет прямого доступа к Node.js API
- Работает как обычная веб-страница
- Изолирован от файловой системы
- Может делать HTTP запросы
**Код в `src/renderer/renderer.js`**:
```javascript
// Используем безопасный API из preload
window.addEventListener('DOMContentLoaded', () => {
// Обработчики UI событий
document.getElementById('openFile').addEventListener('click', async () => {
// Вызов через contextBridge API
const result = await window.api.openFile();
if (!result.canceled) {
// Обновляем UI с данными файла
document.getElementById('configYAML').value = result.content;
}
});
// HTTP запросы к backend API
document.getElementById('execute').addEventListener('click', async () => {
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...basicAuthHeader(password) },
body: JSON.stringify(body)
});
});
});
```
#### 3. Preload Script (Preload скрипт)
**Роль**: Безопасный мост между main и renderer процессами.
**Особенности**:
- Выполняется в renderer процессе
- Имеет доступ к Node.js API
- Изолирован от глобального контекста renderer
- Создает безопасный API через `contextBridge`
**Код в `src/preload/preload.js`**:
```javascript
const { contextBridge, ipcRenderer } = require('electron');
// Создаем безопасный API для renderer процесса
contextBridge.exposeInMainWorld('api', {
// Файловые операции
openFile: () => ipcRenderer.invoke('file:open'),
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
saveToPath: (payload) => ipcRenderer.invoke('file:saveToPath', payload),
revealInFolder: (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath)
});
// Renderer процесс получает доступ к window.api
// Но НЕ имеет прямого доступа к require, fs, dialog и т.д.
```
### IPC (Inter-Process Communication) - Связь между процессами
#### Как работает IPC
``` text
┌─────────────┐ IPC Message ┌─────────────┐
│ Renderer │ ────────────────> │ Main │
│ Process │ │ Process │
│ │ <──────────────── │ │
└─────────────┘ IPC Response └─────────────┘
```
**Шаг 1**: Renderer процесс вызывает `window.api.openFile()`
**Шаг 2**: Preload скрипт отправляет IPC сообщение `'file:open'` в main процесс
**Шаг 3**: Main процесс обрабатывает сообщение и выполняет файловую операцию
**Шаг 4**: Main процесс возвращает результат через IPC
**Шаг 5**: Preload скрипт получает результат и возвращает его renderer процессу
#### Типы IPC сообщений в приложении
```javascript
// Main процесс (обработчики)
ipcMain.handle('file:open', handler); // Открытие файла
ipcMain.handle('file:saveAs', handler); // Сохранение файла
ipcMain.handle('file:saveToPath', handler); // Сохранение по пути
ipcMain.handle('os:revealInFolder', handler); // Показать в проводнике
// Preload скрипт (клиент)
ipcRenderer.invoke('file:open'); // Отправка запроса
ipcRenderer.invoke('file:saveAs', payload); // Отправка с данными
```
### Безопасность в Electron
#### Принципы безопасности
1. **Context Isolation** - изоляция контекста
```javascript
webPreferences: {
contextIsolation: true // Renderer не может получить доступ к Node.js
}
```
2. **Node Integration** - отключение интеграции Node.js
```javascript
webPreferences: {
nodeIntegration: false // Отключаем прямой доступ к require()
}
```
3. **Sandbox** - песочница
```javascript
webPreferences: {
sandbox: false // Позволяем preload работать
}
```
#### Почему такая архитектура?
**Проблема**: Renderer процесс работает с ненадежным контентом (HTML/JS от пользователя).
**Решение**: Изолируем renderer от Node.js API, но предоставляем безопасный доступ через preload.
```javascript
// ❌ НЕБЕЗОПАСНО (если включить nodeIntegration: true)
// В renderer процессе:
const fs = require('fs');
fs.readFileSync('/etc/passwd'); // Может прочитать системные файлы!
// ✅ БЕЗОПАСНО (через contextBridge)
// В renderer процессе:
const result = await window.api.openFile(); // Только разрешенные операции
```
## 🎯 Функциональность приложения
### Режимы работы
#### 1. Inline режим
```javascript
// Простые поля для быстрого ввода
const formData = {
password: 'user_password',
targets: 'tcp:127.0.0.1:22;tcp:192.168.1.1:80',
delay: '1s',
verbose: true,
waitConnection: false,
gateway: 'optional_gateway'
};
```
#### 2. YAML режим
```yaml
# Полная YAML конфигурация
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80]
wait_connection: true
- protocol: udp
host: 192.168.1.1
ports: [53]
delay: 1s
path: /etc/knocker/config.yaml # Путь на сервере
```
#### 3. Form режим
```javascript
// Табличная форма для добавления целей
const targets = [
{ protocol: 'tcp', host: '127.0.0.1', port: 22, gateway: '' },
{ protocol: 'udp', host: '192.168.1.1', port: 53, gateway: 'gw1' }
];
```
### Файловые операции
#### Открытие файлов
```javascript
// Main процесс
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
if (result.canceled) return { canceled: true };
const filePath = result.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, filePath, content };
});
```
#### Сохранение файлов
```javascript
// Main процесс
ipcMain.handle('file:saveAs', async (event, payload) => {
const result = await dialog.showSaveDialog({
defaultPath: payload.suggestedName || 'config.yaml',
filters: [{ name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] }]
});
if (result.canceled || !result.filePath) return { canceled: true };
fs.writeFileSync(result.filePath, payload.content, 'utf-8');
return { canceled: false, filePath: result.filePath };
});
```
### HTTP API интеграция
#### Basic Authentication
```javascript
function basicAuthHeader(password) {
const token = btoa(`knocker:${password}`);
return { Authorization: `Basic ${token}` };
}
// Использование в запросах
const response = await fetch('http://localhost:8080/api/v1/knock-actions/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...basicAuthHeader(password)
},
body: JSON.stringify(payload)
});
```
#### API endpoints
```javascript
const apiEndpoints = {
execute: '/api/v1/knock-actions/execute',
encrypt: '/api/v1/knock-actions/encrypt',
decrypt: '/api/v1/knock-actions/decrypt',
encryptFile: '/api/v1/knock-actions/encrypt-file'
};
```
### YAML обработка
#### Извлечение пути из YAML
```javascript
function extractPathFromYaml(text) {
try {
const doc = yaml.load(text);
if (doc && typeof doc === 'object' && typeof doc.path === 'string') {
return doc.path;
}
} catch (e) {
console.warn('Failed to parse YAML:', e);
}
return '';
}
```
#### Обновление пути в YAML
```javascript
function patchYamlPath(text, newPath) {
try {
const doc = text.trim() ? yaml.load(text) : {};
if (doc && typeof doc === 'object') {
doc.path = newPath || '';
return yaml.dump(doc, { lineWidth: 120 });
}
} catch (e) {
console.warn('Failed to update YAML path:', e);
}
return text;
}
```
#### Конвертация между режимами
```javascript
// Inline → YAML
function convertInlineToYaml(targetsStr, delay, waitConnection) {
const entries = targetsStr.split(';').filter(Boolean);
const config = {
targets: entries.map(entry => {
const [protocol, host, port] = entry.split(':');
return {
protocol: protocol || 'tcp',
host: host || '127.0.0.1',
ports: [parseInt(port) || 22],
wait_connection: waitConnection
};
}),
delay: delay || '1s'
};
return yaml.dump(config, { lineWidth: 120 });
}
// YAML → Inline
function convertYamlToInline(yamlText) {
const config = yaml.load(yamlText) || {};
const targets = [];
(config.targets || []).forEach(target => {
const protocol = target.protocol || 'tcp';
const host = target.host || '127.0.0.1';
const ports = target.ports || [target.port] || [22];
ports.forEach(port => {
targets.push(`${protocol}:${host}:${port}`);
});
});
return {
targets: targets.join(';'),
delay: config.delay || '1s',
waitConnection: !!(config.targets?.[0]?.wait_connection)
};
}
```
## 🔧 Разработка и отладка
### Настройка среды разработки
#### 1. Структура проекта
``` text
desktop/
├── src/
│ ├── main/
│ │ ├── main.js # Основной процесс (CommonJS)
│ │ └── main.ts # TypeScript версия (опционально)
│ ├── preload/
│ │ ├── preload.js # Preload скрипт (CommonJS)
│ │ └── preload.ts # TypeScript версия (опционально)
│ └── renderer/
│ ├── index.html # HTML разметка
│ ├── styles.css # Стили
│ ├── renderer.js # UI логика (ванильный JS)
│ └── renderer.ts # TypeScript версия (опционально)
├── assets/ # Иконки для сборки
├── dist/ # Собранные приложения
├── package.json # Конфигурация
├── README.md # Основная документация
└── DEVELOPMENT.md # Это руководство
```
#### 2. Зависимости
```json
{
"devDependencies": {
"electron": "^28.3.3", // Electron runtime
"electron-builder": "^26.0.12" // Сборка и пакетирование
},
"dependencies": {
"axios": "^1.12.2", // HTTP клиент (не используется в финальной версии)
"js-yaml": "^4.1.0" // YAML парсер
}
}
```
### Отладка
#### DevTools
```javascript
// В main.js автоматически открываются DevTools
mainWindow.webContents.openDevTools();
```
#### Логирование
```javascript
// Main процесс - логи в терминале
console.log('Main process:', data);
// Renderer процесс - логи в DevTools Console
console.log('Renderer process:', data);
// IPC отладка в preload
const originalInvoke = ipcRenderer.invoke;
ipcRenderer.invoke = function(channel, ...args) {
console.log(`IPC Request: ${channel}`, args);
return originalInvoke.call(this, channel, ...args).then(result => {
console.log(`IPC Response: ${channel}`, result);
return result;
});
};
```
#### Отладка файловых операций
```javascript
// В main.js добавить логирование
ipcMain.handle('file:open', async () => {
console.log('Opening file dialog...');
const result = await dialog.showOpenDialog({...});
console.log('Dialog result:', result);
// ...
});
```
### Тестирование
#### Локальное тестирование
```bash
# Запуск в режиме разработки
npm run dev
# Проверка функциональности:
# 1. Открытие файлов
# 2. Сохранение файлов
# 3. HTTP запросы к API
# 4. Переключение между режимами
# 5. Конвертация YAML ↔ Inline
```
#### Тестирование сборки
```bash
# Упаковка без установщика
npm run pack
# Полная сборка
npm run build
# Проверка на разных платформах
npm run build:win
npm run build:linux
npm run build:mac
```
## 📦 Сборка и распространение
### Electron Builder конфигурация
```json
{
"build": {
"appId": "com.knocker.desktop", // Уникальный ID приложения
"productName": "Knocker Desktop", // Имя продукта
"directories": {
"output": "dist" // Папка для сборки
},
"files": [
"src/**/*", // Исходный код
"node_modules/**/*" // Зависимости
],
"win": {
"target": "nsis", // Windows installer
"icon": "assets/icon.ico" // Иконка Windows
},
"linux": {
"target": "AppImage", // Linux portable app
"icon": "assets/icon.png" // Иконка Linux
},
"mac": {
"target": "dmg", // macOS disk image
"icon": "assets/icon.icns" // Иконка macOS
}
}
}
```
### Типы сборки
#### Windows
- **NSIS** - установщик с мастером установки
- **Portable** - портативная версия
- **Squirrel** - автообновления
#### Linux
- **AppImage** - портативное приложение
- **deb** - пакет для Debian/Ubuntu
- **rpm** - пакет для Red Hat/Fedora
- **tar.xz** - архив
#### macOS
- **dmg** - образ диска
- **pkg** - установщик пакета
- **mas** - Mac App Store
### Команды сборки
```bash
# Сборка для текущей платформы
npm run build
# Сборка для конкретных платформ
npm run build:win # Windows (NSIS)
npm run build:linux # Linux (AppImage)
npm run build:mac # macOS (DMG)
# Упаковка без установщика (для тестирования)
npm run pack
# Сборка без публикации
npm run dist
# Публикация (если настроено)
npm run publish
```
### Иконки и ресурсы
#### Требования к иконкам
``` text
assets/
├── icon.ico # Windows: 256x256, ICO формат
├── icon.png # Linux: 512x512, PNG формат
└── icon.icns # macOS: 512x512, ICNS формат
```
#### Создание иконок
```bash
# Из PNG в ICO (Windows)
convert icon.png -resize 256x256 icon.ico
# Из PNG в ICNS (macOS)
iconutil -c icns icon.iconset
```
### Автоматизация сборки
#### GitHub Actions пример
```yaml
name: Build Electron App
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.os }}
path: dist/
```
## 🚀 Производительность и оптимизация
### Оптимизация размера приложения
#### Исключение ненужных файлов
```json
{
"build": {
"files": [
"src/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/electron/**/*" // Исключаем Electron из asar
]
}
}
```
#### Tree shaking
```javascript
// Используем только нужные части библиотек
import { load, dump } from 'js-yaml'; // Вместо import * as yaml
```
### Оптимизация загрузки
#### Lazy loading
```javascript
// Загружаем YAML парсер только когда нужен
async function loadYamlParser() {
if (!window.jsyaml) {
await import('../../node_modules/js-yaml/dist/js-yaml.min.js');
}
}
```
#### Кэширование
```javascript
// Кэшируем результаты API запросов
const cache = new Map();
async function cachedApiCall(endpoint, data) {
const key = `${endpoint}:${JSON.stringify(data)}`;
if (cache.has(key)) {
return cache.get(key);
}
const result = await apiCall(endpoint, data);
cache.set(key, result);
return result;
}
```
## 🔒 Безопасность
### Принципы безопасности Electron
#### 1. Context Isolation
```javascript
webPreferences: {
contextIsolation: true // Изолирует контекст renderer от Node.js
}
```
#### 2. Node Integration
```javascript
webPreferences: {
nodeIntegration: false // Отключает прямой доступ к require()
}
```
#### 3. Sandbox
```javascript
webPreferences: {
sandbox: false // Позволяет preload работать (но только в preload)
}
```
#### 4. CSP (Content Security Policy)
```html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';">
```
### Валидация входных данных
#### Проверка паролей
```javascript
function validatePassword(password) {
if (!password || password.length < 1) {
throw new Error('Пароль не может быть пустым');
}
return password;
}
```
#### Проверка файлов
```javascript
function validateFileContent(content) {
if (typeof content !== 'string') {
throw new Error('Неверный формат файла');
}
if (content.length > 10 * 1024 * 1024) { // 10MB лимит
throw new Error('Файл слишком большой');
}
return content;
}
```
#### Проверка API ответов
```javascript
async function safeApiCall(url, options) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
```
## 🐛 Устранение неполадок
### Частые проблемы и решения
#### 1. Приложение не запускается
```bash
# Проверка зависимостей
npm install
# Очистка и переустановка
rm -rf node_modules package-lock.json
npm install
# Проверка версии Node.js
node --version # Должна быть >= 16
```
#### 2. DevTools не открываются
```javascript
// Убедитесь что в main.js есть:
mainWindow.webContents.openDevTools();
// Или добавьте горячую клавишу:
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
mainWindow.webContents.openDevTools();
}
});
```
#### 3. Файлы не открываются
```javascript
// Проверьте что backend запущен
const testConnection = async () => {
try {
const response = await fetch('http://localhost:8080/api/v1/health');
console.log('Backend is running');
} catch (error) {
console.error('Backend is not running:', error);
}
};
```
#### 4. Сборка не работает
```bash
# Очистка dist папки
rm -rf dist
# Проверка конфигурации
npm run build -- --debug
# Сборка с подробными логами
DEBUG=electron-builder npm run build
```
#### 5. IPC сообщения не работают
```javascript
// Проверьте что preload скрипт загружается
console.log('Preload loaded:', typeof window.api);
// Проверьте IPC каналы
ipcRenderer.invoke('test').then(result => {
console.log('IPC test result:', result);
});
```
### Отладка производительности
#### Профилирование
```javascript
// В main.js
const { performance } = require('perf_hooks');
const startTime = performance.now();
// ... код ...
const endTime = performance.now();
console.log(`Operation took ${endTime - startTime} milliseconds`);
```
#### Мониторинг памяти
```javascript
// В main.js
setInterval(() => {
const usage = process.memoryUsage();
console.log('Memory usage:', {
rss: Math.round(usage.rss / 1024 / 1024) + ' MB',
heapTotal: Math.round(usage.heapTotal / 1024 / 1024) + ' MB',
heapUsed: Math.round(usage.heapUsed / 1024 / 1024) + ' MB'
});
}, 5000);
```
### Логирование и мониторинг
#### Структурированное логирование
```javascript
// В main.js
const log = {
info: (message, data) => console.log(`[INFO] ${message}`, data),
error: (message, error) => console.error(`[ERROR] ${message}`, error),
debug: (message, data) => console.debug(`[DEBUG] ${message}`, data)
};
// Использование
log.info('Application started');
log.error('File operation failed', error);
```
#### Отслеживание ошибок
```javascript
// Глобальный обработчик ошибок
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Можно отправить в сервис мониторинга
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
```
## 📚 Дополнительные ресурсы
### Документация
- [Electron Documentation](https://www.electronjs.org/docs)
- [Electron Builder](https://www.electron.build/)
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
### Лучшие практики
- [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)
- [Performance Best Practices](https://www.electronjs.org/docs/latest/tutorial/performance)
- [Distribution Guide](https://www.electronjs.org/docs/latest/tutorial/distribution)
### Инструменты разработки
- [Electron DevTools](https://www.electronjs.org/docs/latest/tutorial/devtools)
- [VS Code Electron Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-electron)
- [Electron Fiddle](https://www.electronjs.org/fiddle)
## 🤝 Вклад в разработку
### Процесс разработки
1. Форкните репозиторий
2. Создайте ветку для новой функции: `git checkout -b feature/new-feature`
3. Внесите изменения с тестами
4. Проверьте на всех платформах: `npm run build:win && npm run build:linux && npm run build:mac`
5. Создайте Pull Request с описанием изменений
### Стандарты кода
- Используйте ESLint для проверки JavaScript
- Комментируйте сложную логику
- Следуйте принципам безопасности Electron
- Тестируйте на всех поддерживаемых платформах
### Тестирование (another)
```bash
# Полный цикл тестирования
npm run dev # Тест в режиме разработки
npm run pack # Тест упакованной версии
npm run build # Тест финальной сборки
npm run build:win # Тест Windows версии
npm run build:linux # Тест Linux версии
npm run build:mac # Тест macOS версии
```
Это руководство покрывает все аспекты разработки Electron приложения Knocker Desktop. Используйте его как справочник при работе с проектом.

View File

@@ -0,0 +1,186 @@
# Объяснение работы Gateway и localAddress
## Проблема
При использовании VPN (например, WireGuard) весь интернет-трафик направляется через туннель. Однако для порт-простукивания может потребоваться использовать локальный интерфейс для обхода VPN.
## Решение: localAddress
### Как работает localAddress
`localAddress` - это параметр в Node.js Socket API, который позволяет указать локальный IP-адрес для исходящих соединений. Это заставляет операционную систему использовать конкретный сетевой интерфейс вместо маршрута по умолчанию.
### TCP соединения
```javascript
const socket = new net.Socket();
// Обычное соединение (через маршрут по умолчанию, может идти через VPN)
socket.connect(80, 'example.com');
// Соединение через конкретный локальный IP (обходит VPN)
socket.connect({
port: 80,
host: 'example.com',
localAddress: '192.168.89.1' // Ваш локальный шлюз
});
```
**Важно**: TCP сокеты НЕ поддерживают `socket.bind()`. Используйте `localAddress` в `socket.connect()`.
### UDP пакеты
```javascript
const socket = dgram.createSocket('udp4');
// Привязка к конкретному локальному IP (работает для UDP)
socket.bind(0, '192.168.89.1');
// Отправка пакета через этот интерфейс
socket.send(message, 0, message.length, 53, '8.8.8.8');
```
**Важно**: UDP сокеты поддерживают `socket.bind()` для привязки к локальному IP.
## Ваш случай с WireGuard
### Текущая ситуация:
- WireGuard активен
- Весь трафик идет через туннель
- Нужно простучать порт через локальный шлюз `192.168.89.1`
### Решение:
```javascript
// В настройках приложения указать:
{
"apiBase": "internal",
"gateway": "192.168.89.1"
}
// Или в строке цели:
"tcp:example.com:22:192.168.89.1"
```
### Что происходит:
1. Приложение получает gateway `192.168.89.1`
2. Создается сокет с `localAddress: '192.168.89.1'`
3. Операционная система направляет трафик через интерфейс с IP `192.168.89.1`
4. Трафик обходит WireGuard туннель
## Технические детали
### TCP (socket.connect с localAddress)
```javascript
socket.connect({
port: 22,
host: '192.168.1.100',
localAddress: '192.168.89.1' // Принудительно использует этот локальный IP
});
```
### UDP (socket.bind с localAddress)
```javascript
socket.bind(0, '192.168.89.1'); // Привязка к локальному IP
socket.send(message, port, host); // Отправка через этот интерфейс
```
## Проверка работы
### Логи в консоли
```
Using localAddress 192.168.89.1 to bypass VPN/tunnel
TCP connection to 192.168.1.100:22 via 192.168.89.1 successful
```
### Сетевая диагностика
```bash
# Проверить маршруты
ip route show
# Проверить интерфейсы
ip addr show
# Мониторинг трафика
tcpdump -i any host 192.168.1.100
```
## Возможные проблемы
### 1. "EADDRNOTAVAIL" ошибка
**Причина**: IP-адрес не существует на локальной машине
**Решение**: Указать корректный IP локального интерфейса
### 2. "ENETUNREACH" ошибка
**Причина**: Нет маршрута к цели через указанный интерфейс
**Решение**: Проверить сетевую конфигурацию
### 3. Трафик все еще идет через VPN
**Причина**: Неправильно указан localAddress
**Решение**:
```bash
# Найти локальный IP шлюза
ip route | grep default
# Использовать этот IP как gateway
```
## Примеры конфигурации
### Конфигурация для обхода WireGuard
```json
{
"apiBase": "internal",
"gateway": "192.168.89.1",
"inlineTargets": "tcp:external-server.com:22",
"delay": "1s"
}
```
### Смешанное использование
```
tcp:127.0.0.1:22;tcp:external-server.com:22:192.168.89.1;udp:local-dns.com:53
```
- Первая цель: через VPN (системный маршрут)
- Вторая цель: через локальный шлюз (обход VPN)
- Третья цель: через VPN (системный маршрут)
## Отладка
### Включить подробные логи
```javascript
// В настройках установить verbose: true
{
"verbose": true
}
```
### Проверить в консоли main процесса
```
Knocking TCP external-server.com:22 via 192.168.89.1
Using localAddress 192.168.89.1 to bypass VPN/tunnel
TCP connection to external-server.com:22 via 192.168.89.1 successful
```
### Мониторинг сети
```bash
# Просмотр активных соединений
netstat -an | grep 192.168.89.1
# Мониторинг трафика
sudo tcpdump -i any -n host external-server.com
```
## Безопасность
### Ограничения
- `localAddress` работает только с IP-адресами, существующими на локальной машине
- Необходимы соответствующие права для привязки к сетевым интерфейсам
- Подчиняется правилам файрвола операционной системы
### Рекомендации
- Использовать только доверенные IP-адреса
- Проверять сетевую конфигурацию перед использованием
- Логировать все попытки обхода VPN для аудита
---
**Важно**: `localAddress` - это мощный инструмент для управления сетевым трафиком, но он должен использоваться осторожно, так как может обходить сетевые политики безопасности.

411
desktop/LOCAL_KNOCKING.md Normal file
View File

@@ -0,0 +1,411 @@
# Локальное простукивание портов (Local Port Knocking)
## Обзор
Функционал локального простукивания позволяет выполнять knock операции напрямую через Node.js API без использования внешнего HTTP API сервера. Это обеспечивает независимость от внешних сервисов и возможность работы в автономном режиме.
## Условия активации
Локальное простукивание активируется автоматически когда:
1. **API URL пуст** - поле `apiBase` не заполнено или содержит пустую строку
2. **API URL = "internal"** - значение `apiBase` установлено в `"internal"`
3. **API URL не задан** - значение `apiBase` равно `null` или `undefined`
## Архитектура
### Файлы реализации
#### 1. `src/main/main.js` - Основная логика
**Строки 210-367**: Реализация локального простукивания
**Ключевые функции:**
- `parseTarget(targetStr)` - парсинг строки цели в объект
- `parseDelay(delayStr)` - конвертация задержки в миллисекунды
- `knockTcp(host, port, timeout)` - TCP простукивание
- `knockUdp(host, port, timeout)` - UDP простукивание
- `performLocalKnock(targets, delay, verbose)` - основная функция простукивания
- `ipcMain.handle('knock:local', ...)` - IPC обработчик
**Поддерживаемые протоколы:**
- **TCP** - создает соединение и немедленно закрывает
- **UDP** - отправляет пакет данных (fire-and-forget)
**Формат целей:**
``` text
protocol:host:port[:gateway]
```
Примеры:
- `tcp:127.0.0.1:22`
- `udp:192.168.1.1:53`
- `tcp:example.com:80:gateway.com`
**Поддержка Gateway:**
Gateway можно указать двумя способами:
1. **В строке цели**: `tcp:host:port:gateway_ip`
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
**Приоритет gateway:**
- Gateway из строки цели имеет приоритет над глобальным
- Если gateway не указан, используется системный маршрут по умолчанию
**Обход VPN/туннелей:**
Gateway использует `localAddress` для принудительного направления трафика через указанный локальный IP-адрес. Это позволяет:
- Обходить VPN соединения (WireGuard, OpenVPN и др.)
- Использовать конкретный сетевой интерфейс
- Направлять трафик через локальный шлюз
**Пример обхода WireGuard:**
```json
{
"gateway": "192.168.89.1"
}
```
Трафик будет направлен через интерфейс с IP `192.168.89.1`, минуя WireGuard туннель.
## Хелпер для gateway (Rust приоритетно, Go как fallback)
Когда задан `gateway` (IP или имя интерфейса), десктоп-приложение запускает встроенный бинарь из `desktop/bin/`:
- `knock-local-rust` — приоритетный Rust-хелпер (если присутствует)
- `knock-local` — Go-хелпер как запасной вариант
Оба на Linux используют `SO_BINDTODEVICE` для привязки к интерфейсу и надежного обхода VPN/туннелей (WireGuard и пр.).
Сборка при разработке:
- `npm run rust:build` — соберёт Rust-хелпер
- `npm run go:build` — соберёт Go-хелпер
В прод-сборках оба бинаря автоматически включаются в образ приложения.
Важно для TCP: привязка интерфейса устанавливается до `connect()`. Это гарантирует, что исходящее соединение пойдёт через нужный интерфейс, а не в туннель.
**Формат задержки:**
- `1s` - 1 секунда
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
- `2m` - 2 минуты
- `1h` - 1 час
#### 2. `src/preload/preload.js` - IPC мост
**Строка 13**: Добавлен метод `localKnock`
```javascript
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
```
#### 3. `src/renderer/renderer.js` - UI логика
**Строки 317-376**: Логика выбора между локальным и API простукиванием
**Ключевые изменения:**
- Проверка условия `useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal'`
- Извлечение targets из всех режимов (inline, form, yaml)
- Вызов `window.api.localKnock()` вместо HTTP запросов
## Режимы работы
### 1. Inline режим
```javascript
// Извлекает targets из поля #targets
targets = qsi("#targets").value.split(';').filter(t => t.trim());
```
### 2. Form режим
```javascript
// Сериализует формы в строку targets
targets = [serializeFormTargetsToInline()];
```
### 3. YAML режим
```javascript
// Парсит YAML и извлекает targets
const config = yaml.load(yamlContent);
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();
```
## API локального простукивания
### Входные параметры
```javascript
{
targets: string[], // Массив целей в формате "protocol:host:port[:gateway]"
delay: string, // Задержка между целями (например "1s")
verbose: boolean, // Подробный вывод в консоль
gateway: string // Глобальный gateway для всех целей (опционально)
}
```
### Выходные данные
```javascript
{
success: boolean, // Успешность операции
results: [ // Детальные результаты по каждой цели
{
target: string, // Исходная строка цели
success: boolean, // Успешность простукивания
message: string // Сообщение о результате
}
],
summary: { // Общая статистика
total: number, // Общее количество целей
successful: number, // Количество успешных
failed: number // Количество неудачных
}
}
```
## Примеры использования
### Настройка для локального режима
#### Вариант 1: Пустой API URL
```json
{
"apiBase": ""
}
```
#### Вариант 2: Специальное значение
```json
{
"apiBase": "internal"
}
```
### Пример конфигурации
```json
{
"apiBase": "internal",
"gateway": "192.168.1.1",
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.100:80",
"delay": "2s"
}
```
### Пример YAML конфигурации
```yaml
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80]
- protocol: udp
host: 192.168.1.1
ports: [53]
delay: 1s
```
## Логирование и отладка
### Консольный вывод
При `verbose: true` в консоли main процесса появляются сообщения:
``` text
Knocking TCP 127.0.0.1:22
Knocking UDP 192.168.1.1:53 via 192.168.1.1
Knocking TCP example.com:80 via 10.0.0.1
```
### Результаты в DevTools
Детальные результаты логируются в консоль renderer процесса:
```javascript
console.log('Local knock results:', result.results);
```
### Статус в UI
В интерфейсе отображается краткий статус:
``` text
"Локальное простукивание завершено: 2/3 успешно"
```
## Ограничения
### Поддерживаемые протоколы
- ✅ **TCP** - полная поддержка
- ✅ **UDP** - отправка пакетов
- ❌ **ICMP** - не поддерживается
- ❌ **Другие протоколы** - не поддерживаются
### Таймауты
- **TCP**: 5 секунд по умолчанию
- **UDP**: 5 секунд по умолчанию
- Настраивается в коде функций `knockTcp` и `knockUdp`
### Сетевая безопасность
- Локальное простукивание использует системные сокеты
- Подчиняется правилам файрвола операционной системы
- Не требует дополнительных разрешений в Electron
## Совместимость
### Операционные системы
- ✅ **Windows** - полная поддержка
- ✅ **macOS** - полная поддержка
- ✅ **Linux** - полная поддержка
### Electron версии
- ✅ **v28+** - протестировано
- ⚠️ **v27 и ниже** - может потребовать адаптации
## Переключение между режимами
### API → Локальный
1. Открыть настройки (Ctrl/Cmd+,)
2. Установить `apiBase` в `"internal"`
3. Сохранить настройки
4. Перезапустить приложение
### Локальный → API
1. Открыть настройки
2. Установить корректный `apiBase` URL
3. Сохранить настройки
4. Перезапустить приложение
## Устранение неполадок
### Проблема: "No targets provided"
**Причина**: Не удалось извлечь цели из конфигурации
**Решение**: Проверить корректность заполнения полей targets
### Проблема: "Unsupported protocol"
**Причина**: Использован неподдерживаемый протокол
**Решение**: Использовать только `tcp` или `udp`
### Проблема: "Connection timeout"
**Причина**: Цель недоступна или заблокирована файрволом
**Решение**: Проверить доступность цели и настройки файрвола
### Проблема: "Invalid target format"
**Причина**: Неверный формат строки цели
**Решение**: Использовать формат `protocol:host:port`
### Проблема: "Uncaught Exception"
**Причина**: Необработанные ошибки в асинхронных операциях
**Решение**: ✅ **ИСПРАВЛЕНО** - Добавлены глобальные обработчики ошибок и защита от двойного resolve
**Исправления в версии 1.1:**
- Добавлен флаг `resolved` в TCP/UDP функциях для предотвращения двойного вызова resolve
- Глобальные обработчики `uncaughtException` и `unhandledRejection` в main процессе
- Глобальные обработчики ошибок в renderer процессе
- Улучшенная валидация входных данных в IPC обработчике
- Try-catch блоки вокруг всех критических операций
## Безопасность
### Ограничения доступа
- Локальное простукивание выполняется с правами пользователя приложения
- Не требует root/administrator прав
- Подчиняется системным ограничениям сетевого доступа
### Логирование
- Результаты простукивания логируются в консоль
- Не сохраняются в файлы по умолчанию
- Можно отключить через параметр `verbose: false`
## Разработка и расширение
### Добавление новых протоколов
1. Создать функцию `knockProtocol()` в `src/main/main.js`
2. Добавить обработку в `performLocalKnock()`
3. Обновить документацию
### Настройка таймаутов
```javascript
// В src/main/main.js
function knockTcp(host, port, timeout = 10000) { // 10 секунд
// ...
}
```
### Добавление дополнительных опций
```javascript
// Расширить payload в IPC
{
targets: string[],
delay: string,
verbose: boolean,
timeout: number, // новый параметр
retries: number // новый параметр
}
```
---
## Пример обхода WireGuard
### Проблема
WireGuard активен, весь трафик идет через туннель, но нужно простучать порт через локальный шлюз.
### Решение
```json
{
"apiBase": "internal",
"gateway": "192.168.89.1",
"inlineTargets": "tcp:external-server.com:22",
"delay": "1s"
}
```
### Логи
```
Using localAddress 192.168.89.1 to bypass VPN/tunnel
Knocking TCP external-server.com:22 via 192.168.89.1
TCP connection to external-server.com:22 via 192.168.89.1 successful
```
---
**Версия документации**: 1.2
**Дата создания**: 2024
**Дата обновления**: 2024 (поддержка обхода VPN)
**Совместимость**: Electron Desktop App v1.0+

356
desktop/README.md Normal file
View File

@@ -0,0 +1,356 @@
# Knocker Desktop - Electron приложение
Независимое десктопное приложение для Port Knocker с полным функционалом веб-версии.
## 🚀 Быстрый старт
### Установка и запуск
```bash
cd desktop
npm install
npm run start
```
### Сборка для продакшена
```bash
# Сборка для текущей платформы
npm run build
# Сборка для конкретных платформ
npm run build:win # Windows
npm run build:linux # Linux
npm run build:mac # macOS
# Упаковка без установщика (для тестирования)
npm run pack
```
## 🏗️ Архитектура приложения
### Структура проекта
``` text
desktop/
├── src/
│ ├── main/ # Основной процесс Electron
│ │ ├── main.js # Точка входа, управление окнами
│ │ └── main.ts # TypeScript версия (опционально)
│ ├── preload/ # Preload скрипты (мост между main и renderer)
│ │ ├── preload.js # Безопасный API для renderer процесса
│ │ └── preload.ts # TypeScript версия
│ └── renderer/ # Процесс рендеринга (UI)
│ ├── index.html # HTML разметка
│ ├── styles.css # Стили
│ ├── renderer.js # Логика UI (ванильный JS)
│ └── renderer.ts # TypeScript версия
├── assets/ # Иконки для сборки
├── dist/ # Собранные приложения
├── package.json # Конфигурация и зависимости
└── README.md # Документация
```
### Как работает Electron
Electron состоит из двух основных процессов:
1. **Main Process (Основной процесс)** - `src/main/main.js`
- Управляет жизненным циклом приложения
- Создает и управляет окнами браузера
- Обеспечивает безопасный доступ к Node.js API
- Обрабатывает системные события (закрытие, фокус и т.д.)
2. **Renderer Process (Процесс рендеринга)** - `src/renderer/`
- Отображает пользовательский интерфейс
- Работает как обычная веб-страница (HTML/CSS/JS)
- Изолирован от Node.js API по соображениям безопасности
- Взаимодействует с main процессом через IPC (Inter-Process Communication)
3. **Preload Script (Preload скрипт)** - `src/preload/preload.js`
- Выполняется в renderer процессе, но имеет доступ к Node.js API
- Создает безопасный мост между main и renderer процессами
- Экспонирует только необходимые API через `contextBridge`
### Безопасность
Приложение использует современные принципы безопасности Electron:
- `contextIsolation: true` - изолирует контекст renderer от Node.js
- `nodeIntegration: false` - отключает прямой доступ к Node.js в renderer
- `sandbox: false` - позволяет preload скрипту работать (но только в preload)
## 🔧 Разработка
### Локальная разработка
```bash
npm run dev
```
Откроет приложение с включенными DevTools для отладки.
### Структура кода
#### Main Process (`src/main/main.js`)
```javascript
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
// Создание главного окна
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true, // Безопасность
nodeIntegration: false, // Безопасность
sandbox: false // Для preload
}
});
}
// IPC обработчики для файловых операций
ipcMain.handle('file:open', async () => {
const res = await dialog.showOpenDialog({...});
// Возвращает файл в renderer процесс
});
```
#### Preload Script (`src/preload/preload.js`)
```javascript
const { contextBridge, ipcRenderer } = require('electron');
// Безопасный API для renderer процесса
contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke('file:open'),
saveAs: (payload) => ipcRenderer.invoke('file:saveAs', payload),
// ... другие методы
});
```
#### Renderer Process (`src/renderer/renderer.js`)
```javascript
// Используем безопасный API из preload
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('openFile').addEventListener('click', async () => {
const result = await window.api.openFile();
// Обрабатываем результат
});
});
```
### Функциональность
#### Режимы работы
1. **Inline режим** - простые поля для ввода targets, delay, verbose
2. **YAML режим** - редактирование YAML конфигурации с поддержкой файлов
3. **Form режим** - табличная форма для добавления/удаления целей
#### Файловые операции
- Открытие файлов через системный диалог
- Сохранение файлов с предложением имени
- Автоматическое извлечение `path` из YAML
- Синхронизация между YAML и serverFilePath полем
#### HTTP API
- Вызовы к `http://localhost:8080/api/v1/knock-actions/*`
- Basic Authentication с пользователем `knocker`
- Выполнение knock операций
- Шифрование/дешифрование конфигураций
### Отладка
#### DevTools
DevTools автоматически открываются при запуске в режиме разработки (`npm run dev`).
#### Консольные сообщения
```javascript
// В renderer процессе
console.log('Debug info:', data);
// В main процессе
console.log('Main process log:', data);
```
#### IPC отладка
```javascript
// В preload можно добавить логирование
ipcRenderer.invoke('file:open').then(result => {
console.log('IPC result:', result);
});
```
## 📦 Сборка и распространение
### Electron Builder конфигурация
В `package.json` настроена конфигурация `electron-builder`:
```json
{
"build": {
"appId": "com.knocker.desktop",
"productName": "Knocker Desktop",
"files": ["src/**/*", "node_modules/**/*"],
"win": {
"target": "nsis",
"icon": "assets/icon.ico"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.icns"
}
}
}
```
### Типы сборки
- **NSIS** (Windows) - установщик с мастером установки
- **AppImage** (Linux) - портативное приложение
- **DMG** (macOS) - образ диска для установки
### Команды сборки
```bash
npm run build # Сборка для текущей платформы
npm run build:win # Сборка для Windows
npm run build:linux # Сборка для Linux
npm run build:mac # Сборка для macOS
npm run pack # Упаковка без установщика
npm run dist # Сборка без публикации
```
### Иконки
Поместите иконки в папку `assets/`:
- `icon.ico` - для Windows (256x256)
- `icon.png` - для Linux (512x512)
- `icon.icns` - для macOS (512x512)
## 🔄 Интеграция с веб-версией
### Общие компоненты
- HTTP API остается тем же (`/api/v1/knock-actions/*`)
- YAML формат конфигурации идентичен
- Логика шифрования/дешифрования совместима
### Различия
- **Файловые операции**: Electron dialog вместо браузерных File API
- **UI библиотеки**: ванильный JS вместо Angular/PrimeNG
- **Автосохранение**: localStorage в веб-версии, файловая система в desktop
- **FSA API**: не нужен в desktop версии
### Миграция данных
Пользователи могут переносить конфигурации между версиями через:
- Экспорт/импорт YAML файлов
- Копирование содержимого между интерфейсами
- Использование одинаковых server paths
## 🐛 Устранение неполадок
### Частые проблемы
#### Приложение не запускается
```bash
# Проверьте зависимости
npm install
# Очистите node_modules
rm -rf node_modules package-lock.json
npm install
```
#### DevTools не открываются
Убедитесь, что в `src/main/main.js` есть строка:
```javascript
mainWindow.webContents.openDevTools();
```
#### Файлы не открываются
Проверьте, что backend сервер запущен на `http://localhost:8080`
#### Сборка не работает
```bash
# Очистите dist папку
rm -rf dist
# Пересоберите
npm run build
```
### Логи отладки
#### Main процесс
Логи main процесса видны в терминале, где запущено приложение.
#### Renderer процесс
Логи renderer процесса видны в DevTools Console.
#### IPC сообщения
Можно добавить логирование в preload для отладки IPC:
```javascript
const originalInvoke = ipcRenderer.invoke;
ipcRenderer.invoke = function(channel, ...args) {
console.log(`IPC: ${channel}`, args);
return originalInvoke.call(this, channel, ...args);
};
```
## 📚 Дополнительные ресурсы
- [Electron Documentation](https://www.electronjs.org/docs)
- [Electron Builder](https://www.electron.build/)
- [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
- [IPC Communication](https://www.electronjs.org/docs/latest/tutorial/ipc)
## 🤝 Вклад в разработку
1. Форкните репозиторий
2. Создайте ветку для новой функции
3. Внесите изменения
4. Протестируйте на всех платформах
5. Создайте Pull Request
### Тестирование
```bash
# Тест на текущей платформе
npm run dev
# Сборка для тестирования
npm run pack
# Проверка на других платформах
npm run build:win
npm run build:linux
npm run build:mac
```

913
desktop/USAGE_GUIDE.md Normal file
View File

@@ -0,0 +1,913 @@
# Руководство по использованию Desktop приложения
## Обзор
Desktop версия knocker-приложения предоставляет полный функционал порт-простукивания (port knocking) в виде автономного Electron приложения. Поддерживает как работу через внешний API сервер, так и локальное простукивание через Node.js.
## Содержание
1. [Установка и запуск](#установка-и-запуск)
2. [Режимы работы](#режимы-работы)
3. [Конфигурация](#конфигурация)
4. [API контракты](#api-контракты)
5. [Локальное простукивание](#локальное-простукивание)
6. [Интерфейс пользователя](#интерфейс-пользователя)
7. [Примеры использования](#примеры-использования)
8. [Устранение неполадок](#устранение-неполадок)
9. [Разработка](#разработка)
---
## Установка и запуск
### Предварительные требования
- **Node.js** v18+
- **npm** v8+
- **Операционная система**: Windows, macOS, Linux
### Установка зависимостей
```bash
cd desktop
npm install
```
### Режимы запуска
#### Разработка
```bash
npm run dev
```
#### Сборка для продакшена
```bash
npm run build
```
#### Создание исполняемых файлов
```bash
npm run dist
```
#### Упаковка для конкретной платформы
```bash
# Windows
npm run dist:win
# macOS
npm run dist:mac
# Linux
npm run dist:linux
```
### Переменные окружения
```bash
# Базовый URL API (опционально)
export KNOCKER_DESKTOP_API_BASE="http://localhost:8080/api/v1"
# Запуск в режиме разработки
npm run dev
```
---
## Режимы работы
### 1. API режим (по умолчанию)
Приложение подключается к внешнему HTTP API серверу для выполнения операций простукивания.
**Активация:**
- Установить корректный `apiBase` URL в настройках
- Например: `http://localhost:8080/api/v1`
**Возможности:**
- ✅ HTTP API простукивание
- ✅ Шифрование/расшифровка YAML
- ✅ Полная функциональность backend сервера
### 2. Локальный режим
Приложение выполняет простукивание напрямую через Node.js сокеты без внешнего API.
**Активация:**
- Установить `apiBase` в `""` (пустая строка)
- Или установить `apiBase` в `"internal"`
**Возможности:**
- ✅ TCP простукивание
- ✅ UDP простукивание
- ❌ Шифрование/расшифровка (недоступно)
- ✅ Автономная работа
#### Локальное простукивание с gateway (Rust/Go helper)
- Если указано `gateway` (IP или имя интерфейса), Electron автоматически запускает встроенный helper из папки `bin/`:
- `knock-local-rust` (Rust) — используется приоритетно, если присутствует
- `knock-local` (Go) — используется как fallback, если Rust-бинарь отсутствует
- Оба helper-а на Linux используют `SO_BINDTODEVICE` для надежной привязки к интерфейсу и обхода VPN/WireGuard.
- Если `gateway` не указан — используется встроенная Node-реализация без привязки к интерфейсу.
Требования при разработке:
- Для Rust-хелпера ничего дополнительно не требуется (собирается скриптом `npm run rust:build`).
- Для Go-хелпера должен быть установлен Go toolchain (скрипт `npm run go:build`).
В релизных сборках оба бинаря включаются автоматически.
Важно (TCP): привязка интерфейса (`SO_BINDTODEVICE`) устанавливается до `connect()`. Это гарантирует, что исходящее TCP-соединение пойдёт через указанный интерфейс, а не в туннель.
### 3. Переключение между режимами
**API → Локальный:**
1. Открыть настройки (Ctrl/Cmd + ,)
2. Установить `apiBase: ""` или `apiBase: "internal"`
3. Сохранить настройки
4. Перезапустить приложение
**Локальный → API:**
1. Открыть настройки
2. Установить корректный `apiBase` URL
3. Сохранить настройки
4. Перезапустить приложение
---
## Конфигурация
### Файл конфигурации
Конфигурация сохраняется в: `~/.config/[app-name]/config.json`
**Структура конфигурации:**
```json
{
"apiBase": "http://localhost:8080/api/v1",
"gateway": "default-gateway",
"inlineTargets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "1s"
}
```
### Поля конфигурации
| Поле | Тип | Описание | По умолчанию |
|------|-----|----------|--------------|
| `apiBase` | string | URL API сервера или "internal" | `http://localhost:8080/api/v1` |
| `gateway` | string | Шлюз по умолчанию | `""` |
| `inlineTargets` | string | Цели в inline формате | `""` |
| `delay` | string | Задержка между целями | `"1s"` |
### Редактирование конфигурации
**Через интерфейс:**
1. Меню → Настройки
2. Редактирование JSON в текстовом поле
3. Кнопка "Сохранить"
**Программно:**
```javascript
// Получить значение
const apiBase = await window.api.getConfig('apiBase');
// Установить значение
await window.api.setConfig('apiBase', 'http://new-api.com');
// Получить всю конфигурацию
const config = await window.api.getAllConfig();
// Установить всю конфигурацию
await window.api.setAllConfig(newConfig);
```
---
## API контракты
### HTTP API Endpoints (для API режима)
#### 1. Выполнение простукивания
**POST** `/api/v1/knock-actions/execute`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body (YAML режим):**
```json
{
"config_yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22, 80]\ndelay: 1s"
}
```
**Body (Inline режим):**
```json
{
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "1s",
"verbose": true,
"waitConnection": false,
"gateway": "gateway.com"
}
```
**Body (Form режим):**
```json
{
"targets": "tcp:127.0.0.1:22;tcp:192.168.1.1:80",
"delay": "2s",
"verbose": true,
"waitConnection": true
}
```
**Response:**
```json
{
"success": true,
"message": "Knocking completed successfully"
}
```
#### 2. Шифрование YAML
**POST** `/api/v1/knock-actions/encrypt`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body:**
```json
{
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
}
```
**Response:**
```json
{
"encrypted": "ENCRYPTED:base64-encoded-data"
}
```
#### 3. Расшифровка YAML
**POST** `/api/v1/knock-actions/decrypt`
**Headers:**
``` text
Content-Type: application/json
Authorization: Basic <base64(username:password)>
```
**Body:**
```json
{
"encrypted": "ENCRYPTED:base64-encoded-data"
}
```
**Response:**
```json
{
"yaml": "targets:\n - protocol: tcp\n host: 127.0.0.1\n ports: [22]"
}
```
### IPC API (для локального режима)
#### Локальное простукивание
**Channel:** `knock:local`
**Request:**
```javascript
{
targets: string[], // ["tcp:127.0.0.1:22", "udp:192.168.1.1:53"]
delay: string, // "1s", "2m", "500ms"
verbose: boolean, // true/false
gateway: string // "192.168.1.1" (опционально)
}
```
**Response:**
```javascript
{
success: boolean,
results: [
{
target: string,
success: boolean,
message: string
}
],
summary: {
total: number,
successful: number,
failed: number
}
}
```
---
## Локальное простукивание _
### Поддерживаемые протоколы
- **TCP** - создание соединения и немедленное закрытие
- **UDP** - отправка пакета данных (fire-and-forget)
### Формат целей
``` text
protocol:host:port[:gateway]
```
**Примеры:**
- `tcp:127.0.0.1:22`
- `udp:192.168.1.1:53`
- `tcp:example.com:80:gateway.com`
### Поддержка Gateway
Gateway можно указать двумя способами:
1. **В строке цели**: `tcp:host:port:gateway_ip`
2. **Глобально через поле Gateway**: используется для всех целей, если не указан в самой цели
**Приоритет gateway:**
- Gateway из строки цели имеет приоритет над глобальным
- Если gateway не указан, используется системный маршрут по умолчанию
**Примеры использования gateway:**
``` text
tcp:192.168.1.100:22:192.168.1.1 # Через конкретный gateway
tcp:127.0.0.1:22 # Системный маршрут
udp:example.com:53 # Системный маршрут
```
### Формат задержек
- `1s` - 1 секунда
- `500ms` - 500 миллисекунд (не поддерживается, используйте `0.5s`)
- `2m` - 2 минуты
- `1h` - 1 час
### Таймауты
- **TCP**: 5 секунд по умолчанию
- **UDP**: 5 секунд по умолчанию
### Примеры локального простукивания
```javascript
// Простое TCP простукивание
const result = await window.api.localKnock({
targets: ["tcp:127.0.0.1:22"],
delay: "1s",
verbose: true
});
// Множественные цели
const result = await window.api.localKnock({
targets: [
"tcp:127.0.0.1:22",
"udp:192.168.1.1:53",
"tcp:example.com:80"
],
delay: "2s",
verbose: false
});
```
---
## Интерфейс пользователя
### Главное окно
#### Поля конфигурации _
- **API URL** - адрес API сервера или "internal" для локального режима
- **Gateway** - шлюз по умолчанию
- **Password** - пароль для аутентификации
#### Режимы работы _
1. **Inline** - простой текстовый формат целей
2. **YAML** - структурированная YAML конфигурация
3. **Form** - графический редактор целей
#### Inline режим
- **Targets** - цели в формате `protocol:host:port;protocol:host:port`
- **Delay** - задержка между целями
- **Verbose** - подробный вывод
- **Wait Connection** - ожидание соединения
- **Gateway** - шлюз
#### YAML режим
- **Config YAML** - YAML конфигурация
- **Server File Path** - путь к файлу на сервере
- **Encrypt/Decrypt** - шифрование/расшифровка
#### Form режим
- **Targets List** - список целей с возможностью редактирования
- **Add Target** - добавление новой цели
- **Remove** - удаление цели
### Меню приложения
#### Файл
- **Открыть файл** - загрузка YAML конфигурации
- **Сохранить как** - сохранение текущей конфигурации
- **Выход** - закрытие приложения
#### Настройки
- **Настройки** - открытие окна конфигурации
#### Справка
- **О программе** - информация о версии
- **Документация** - ссылки на документацию
### Окно настроек
#### Редактирование конфигурации _
- **JSON Editor** - многострочное поле для редактирования
- **Save** - сохранение изменений
- **Return** - возврат к главному окну
#### Валидация
- Автоматическая проверка JSON синтаксиса
- Отображение ошибок валидации
- Предотвращение сохранения некорректных данных
---
## Примеры использования
### Пример 1: Базовое простукивание SSH
**Цель:** Открыть SSH доступ к серверу
**Конфигурация:**
```json
{
"apiBase": "internal",
"gateway": "",
"inlineTargets": "tcp:192.168.1.100:22",
"delay": "1s"
}
```
**Шаги:**
1. Установить режим "Inline"
2. Ввести цель: `tcp:192.168.1.100:22`
3. Установить задержку: `1s`
4. Нажать "Выполнить"
### Пример 2: Множественные цели
**Цель:** Простучать несколько сервисов
**Конфигурация:**
``` text
tcp:server1.com:22;tcp:server1.com:80;udp:server2.com:53
```
**Задержка:** `2s`
### Пример 3: YAML конфигурация
**Файл конфигурации:**
```yaml
targets:
- protocol: tcp
host: 127.0.0.1
ports: [22, 80, 443]
wait_connection: true
- protocol: udp
host: 192.168.1.1
ports: [53, 123]
delay: 1s
path: /etc/knocker/config.yaml
```
### Пример 4: Шифрование конфигурации
**Шаги:**
1. Создать YAML конфигурацию
2. Установить пароль
3. Нажать "Зашифровать"
4. Сохранить зашифрованный файл
### Пример 5: Локальный режим с множественными целями
**Конфигурация для локального режима:**
```json
{
"apiBase": "internal",
"inlineTargets": "tcp:127.0.0.1:22;tcp:127.0.0.1:80;udp:127.0.0.1:53",
"delay": "1s"
}
```
### Пример 6: Использование Gateway
**Простукивание через определенный интерфейс:**
```json
{
"apiBase": "internal",
"gateway": "192.168.1.1",
"inlineTargets": "tcp:192.168.1.100:22",
"delay": "1s"
}
```
**Смешанное использование gateway:**
``` text
tcp:127.0.0.1:22;tcp:192.168.1.100:22:192.168.1.1;udp:example.com:53
```
- Первая цель: без gateway (системный маршрут)
- Вторая цель: через gateway 192.168.1.1
- Третья цель: без gateway
Замечания по ошибкам:
- Если указан несуществующий интерфейс в `gateway`, helper вернёт критическую ошибку и код выхода 1.
- При `waitConnection: false` сетевые отказы соединения трактуются как предупреждения, но ошибки привязки к интерфейсу — всегда ошибки.
---
## Устранение неполадок
### Общие проблемы
#### Проблема: "API URL не доступен"
**Симптомы:**
- Ошибки подключения к API
- Таймауты при выполнении операций
**Решения:**
1. Проверить доступность API сервера
2. Проверить правильность URL
3. Проверить настройки файрвола
4. Переключиться в локальный режим
#### Проблема: "Неправильный пароль"
**Симптомы:**
- HTTP 401 ошибки
- Отказ в доступе при шифровании
**Решения:**
1. Проверить правильность пароля
2. Убедиться в корректности base64 кодирования
3. Проверить настройки аутентификации на сервере
#### Проблема: "Файл не найден"
**Симптомы:**
- Ошибки при открытии файлов
- "File not found" при сохранении
**Решения:**
1. Проверить права доступа к файлам
2. Убедиться в существовании директорий
3. Проверить путь к файлу
### Проблемы локального режима
#### Проблема: "No targets provided"
**Причина:** Не удалось извлечь цели из конфигурации
**Решение:**
1. Проверить заполнение поля targets
2. Убедиться в корректности формата
3. Проверить режим работы (inline/yaml/form)
#### Проблема: "Unsupported protocol"
**Причина:** Использован неподдерживаемый протокол
**Решение:**
- Использовать только `tcp` или `udp`
- Проверить синтаксис: `protocol:host:port`
#### Проблема: "Connection timeout"
**Причина:** Цель недоступна или заблокирована
**Решение:**
1. Проверить доступность цели
2. Проверить настройки файрвола
3. Убедиться в правильности IP/порта
### Проблемы конфигурации
#### Проблема: "Invalid JSON"
**Симптомы:**
- Ошибки при сохранении настроек
- Невозможность загрузить конфигурацию
**Решения:**
1. Проверить синтаксис JSON
2. Использовать валидатор JSON
3. Проверить экранирование специальных символов
#### Проблема: "Настройки не сохраняются"
**Причина:** Проблемы с правами доступа
**Решение:**
1. Проверить права записи в директорию конфигурации
2. Запустить от имени администратора (если необходимо)
3. Проверить свободное место на диске
### Диагностика
#### Логи приложения
```bash
# Windows
%APPDATA%/[app-name]/logs/
# macOS
~/Library/Logs/[app-name]/
# Linux
~/.config/[app-name]/logs/
```
#### DevTools
1. Открыть DevTools (F12)
2. Проверить Console на ошибки
3. Проверить Network для API запросов
4. Проверить Application → Local Storage
#### Командная строка
```bash
# Запуск с отладкой
npm run dev -- --enable-logging
# Проверка переменных окружения
echo $KNOCKER_DESKTOP_API_BASE
```
---
## Разработка _
### Структура проекта
``` text
desktop/
├── src/
│ ├── main/ # Main процесс
│ │ └── main.js # Основная логика
│ ├── preload/ # Preload скрипты
│ │ └── preload.js # IPC мост
│ └── renderer/ # Renderer процесс
│ ├── index.html # Главная страница
│ ├── renderer.js # UI логика
│ ├── settings.html # Страница настроек
│ └── settings.js # Логика настроек
├── package.json # Зависимости и скрипты
├── electron-builder.yml # Конфигурация сборки
└── README.md # Документация
```
### Ключевые файлы
#### `src/main/main.js`
- Создание и управление окнами
- IPC обработчики
- Локальное простукивание
- Файловые операции
#### `src/preload/preload.js`
- Безопасный мост между процессами
- Экспорт API в renderer
#### `src/renderer/renderer.js`
- UI логика
- Обработка пользовательского ввода
- HTTP запросы к API
### Добавление новых функций
#### 1. Новый IPC метод
**В main.js:**
```javascript
ipcMain.handle('new:method', async (_e, payload) => {
// Логика метода
return { success: true, data: result };
});
```
**В preload.js:**
```javascript
contextBridge.exposeInMainWorld('api', {
// ... существующие методы
newMethod: async (payload) => ipcRenderer.invoke('new:method', payload)
});
```
**В renderer.js:**
```javascript
const result = await window.api.newMethod(data);
```
#### 2. Новый UI элемент
**В index.html:**
```html
<button id="newButton">Новая функция</button>
```
**В renderer.js:**
```javascript
qsi('#newButton')?.addEventListener('click', async () => {
// Логика обработки
});
```
### Тестирование
#### Unit тесты
```bash
npm test
```
#### Интеграционные тесты
```bash
npm run test:integration
```
#### E2E тесты
```bash
npm run test:e2e
```
### Сборка и деплой
#### Локальная сборка
```bash
npm run build
```
#### Создание дистрибутивов
```bash
npm run dist
```
#### Автоматические релизы
```bash
npm run release
```
### Отладка
#### DevTools _
- **Main процесс**: `--inspect` флаг
- **Renderer процесс**: F12 в приложении
#### Логирование
```javascript
console.log('Debug info:', data);
console.error('Error:', error);
```
#### Профилирование
```bash
npm run dev -- --enable-profiling
```
---
## Безопасность
### Рекомендации
1. **Пароли**: Используйте сильные пароли для аутентификации
2. **Сеть**: Ограничьте доступ к API серверу
3. **Файлы**: Не храните пароли в открытом виде
4. **Обновления**: Регулярно обновляйте приложение
### Ограничения
- Локальное простукивание выполняется с правами пользователя
- Не требует root/administrator прав
- Подчиняется системным ограничениям сетевого доступа
---
## Поддержка
### Контакты
- **Документация**: [LOCAL_KNOCKING.md](./LOCAL_KNOCKING.md)
- **Исходный код**: [GitHub Repository]
- **Issues**: [GitHub Issues]
### Версии
- **Текущая версия**: 1.0
- **Electron**: v28+
- **Node.js**: v18+
### Лицензия
[Указать лицензию]
---
**Версия документации**: 1.0
**Дата создания**: 2024
**Совместимость**: Electron Desktop App v1.0+

187
desktop/VPN_BYPASS_DEBUG.md Normal file
View File

@@ -0,0 +1,187 @@
# Диагностика обхода VPN для Gateway
## Проблема
Gateway не работает - пакеты все еще идут через WireGuard туннель вместо локального шлюза.
## Диагностика
### 1. Проверьте сетевые интерфейсы
Откройте DevTools (F12) и выполните:
```javascript
window.testNetworkInterfaces()
```
Это покажет все сетевые интерфейсы и их IP-адреса. Убедитесь, что `192.168.89.18` действительно существует.
### 2. Проверьте тестовое подключение
В DevTools выполните:
```javascript
window.testConnection()
```
Это протестирует подключение к `192.168.89.1:2655` с использованием gateway `192.168.89.18`.
### 3. Проверьте логи в консоли main процесса
При выполнении простукивания должны появиться логи:
```
Binding socket to localAddress 192.168.89.18 to bypass VPN/tunnel
TCP connected from 192.168.89.18:XXXXX to 192.168.89.1:2655
```
### 4. Проверьте системные маршруты
Выполните в терминале:
```bash
# Показать все маршруты
ip route show
# Показать маршрут к конкретной цели
ip route get 192.168.89.1
# Показать интерфейсы
ip addr show
```
## Возможные проблемы и решения
### Проблема 1: IP-адрес не существует
**Симптом**: Ошибка "EADDRNOTAVAIL"
**Решение**: Убедитесь, что `192.168.89.18` действительно привязан к интерфейсу
### Проблема 2: Нет маршрута к цели
**Симптом**: Ошибка "ENETUNREACH" или таймаут
**Решение**: Проверьте, что есть маршрут к `192.168.89.1` через интерфейс с IP `192.168.89.18`
### Проблема 3: WireGuard перехватывает трафик
**Симптом**: Трафик все еще идет через туннель
**Решение**:
1. Проверьте таблицу маршрутизации WireGuard
2. Убедитесь, что `192.168.89.0/24` не входит в AllowedIPs WireGuard
3. Проверьте приоритет маршрутов
### Проблема 4: Неправильное использование bind() для TCP
**Симптом**: Ошибка "socket.bind is not a function"
**Решение**: TCP сокеты НЕ поддерживают `bind()`. Используйте `localAddress` в `connect()`:
```javascript
// Неправильно (для TCP):
socket.bind(0, gateway);
socket.connect(port, host);
// Правильно (для TCP):
socket.connect({
port: port,
host: host,
localAddress: gateway
});
```
## Альтернативные подходы
### 1. Использование SO_BINDTODEVICE (Linux)
Если доступно, можно привязать сокет к конкретному интерфейсу:
```javascript
// Требует root права
socket.bind(0, '192.168.89.18');
socket._handle.setsockopt(socket._handle.constructor.SOL_SOCKET, 25, 'eth0'); // SO_BINDTODEVICE
```
### 2. Использование netstat для проверки
```bash
# Мониторинг активных соединений
netstat -an | grep 192.168.89.1
# Мониторинг с tcpdump
sudo tcpdump -i any -n host 192.168.89.1
```
### 3. Проверка через ss
```bash
# Показать активные соединения
ss -tuln | grep 192.168.89.1
# Показать соединения с конкретным локальным IP
ss -tuln src 192.168.89.18
```
## Тестирование
### Шаг 1: Проверьте интерфейсы
```javascript
window.testNetworkInterfaces()
```
### Шаг 2: Проверьте тестовое подключение
```javascript
window.testConnection()
```
### Шаг 3: Выполните реальное простукивание
```json
{
"apiBase": "internal",
"gateway": "192.168.89.18",
"inlineTargets": "tcp:192.168.89.1:2655",
"delay": "2s"
}
```
### Шаг 4: Проверьте логи
В консоли main процесса должны быть:
```
Using localAddress 192.168.89.18 to bypass VPN/tunnel
Knocking TCP 192.168.89.1:2655 via 192.168.89.18
TCP connected from 192.168.89.18:XXXXX to 192.168.89.1:2655
```
## Ожидаемые результаты
### Успешный обход VPN:
- Локальный IP в логах: `192.168.89.18`
- Подключение успешно
- На шлюзе `192.168.89.1` видны пакеты от `192.168.89.18`
### Неуспешный обход VPN:
- Локальный IP в логах: IP туннеля WireGuard
- Подключение может быть успешным, но через туннель
- На шлюзе `192.168.89.1` НЕ видны пакеты от `192.168.89.18`
## Дополнительная диагностика
### Проверка WireGuard конфигурации
```bash
# Показать статус WireGuard
sudo wg show
# Показать маршруты WireGuard
ip route show table 51820 # или другой номер таблицы
```
### Проверка таблицы маршрутизации
```bash
# Показать все таблицы маршрутизации
ip rule show
# Показать маршруты в конкретной таблице
ip route show table main
ip route show table local
```
---
**Важно**: Если диагностика показывает, что `bind()` работает, но трафик все еще идет через VPN, проблема может быть в настройках WireGuard или системной маршрутизации, а не в коде приложения.

4923
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
desktop/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "desktop",
"version": "1.0.0",
"description": "",
"main": "src/main/main.js",
"scripts": {
"start": "electron .",
"dev": "npm run rust:build && npm run go:build && electron .",
"build": "npm run rust:build && npm run go:build && electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:mac": "electron-builder --mac",
"dist": "electron-builder --publish=never",
"pack": "electron-builder --dir",
"test": "echo \"No tests\" && exit 0",
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop/bin/knock-local ./cmd/knock-local'",
"rust:build": "bash -lc 'mkdir -p bin && cd ../rust-knocker && cargo build --release && cp target/release/knock-local ../desktop/bin/knock-local-rust'"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"build": {
"appId": "com.knocker.desktop",
"productName": "Knocker Desktop",
"directories": {
"output": "dist"
},
"files": [
"src/**/*",
"node_modules/**/*",
"bin/**/*"
],
"extraResources": [{ "from": "bin", "to": "bin", "filter": ["**/*"] }],
"win": {
"target": "nsis",
"icon": "assets/icon.ico"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.icns"
}
},
"devDependencies": {
"electron": "^28.3.3",
"electron-builder": "^26.0.12"
},
"dependencies": {
"axios": "^1.12.2",
"js-yaml": "^4.1.0"
}
}

666
desktop/src/main/main.js Normal file
View File

@@ -0,0 +1,666 @@
const { app, BrowserWindow, ipcMain, dialog, shell, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
const net = require('net');
const dgram = require('dgram');
const os = require('os');
let mainWindow = null;
let settingsWindow = null;
// --- Persistent config (userData/config.json) ---
let configCache = null;
function getConfigPath() {
return path.join(app.getPath('userData'), 'config.json');
}
function loadConfig() {
if (configCache) return configCache;
const cfgPath = getConfigPath();
try {
if (fs.existsSync(cfgPath)) {
const parsed = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
configCache = parsed || {};
return configCache;
}
} catch (e) {
console.warn('Failed to read config file:', e);
}
configCache = {};
return configCache;
}
function saveConfig(partial) {
const current = loadConfig();
const next = { ...current, ...partial };
configCache = next;
try {
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), 'utf-8');
return { ok: true };
} catch (e) {
console.error('Failed to save config file:', e);
return { ok: false, error: (e?.message) || String(e) };
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
const indexPath = path.join(__dirname, '../renderer/index.html');
mainWindow.loadFile(indexPath);
// Включаем DevTools для разработки
mainWindow.webContents.openDevTools();
mainWindow.on('closed', () => {
mainWindow = null;
});
// Создаем меню
createMenu();
}
function createSettingsWindow() {
if (settingsWindow) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({
width: 600,
height: 720,
parent: mainWindow,
modal: true,
resizable: true,
closable: true,
minimizable: false,
maximizable: false,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
const settingsPath = path.join(__dirname, '../renderer/settings.html');
settingsWindow.loadFile(settingsPath);
settingsWindow.on('closed', () => {
settingsWindow = null;
// Возвращаем фокус на главное окно
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus();
}
});
}
function createMenu() {
const template = [
{
label: 'Файл',
submenu: [
{
label: 'Настройки',
accelerator: 'CmdOrCtrl+,',
click: createSettingsWindow
},
{ type: 'separator' },
{
label: 'Выход',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
app.quit();
}
}
]
},
{
label: 'Вид',
submenu: [
{ role: 'reload', label: 'Перезагрузить' },
{ role: 'forceReload', label: 'Принудительная перезагрузка' },
{ role: 'toggleDevTools', label: 'Инструменты разработчика' },
{ type: 'separator' },
{ role: 'resetZoom', label: 'Сбросить масштаб' },
{ role: 'zoomIn', label: 'Увеличить' },
{ role: 'zoomOut', label: 'Уменьшить' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: 'Полный экран' }
]
},
{
label: 'Окно',
submenu: [
{ role: 'minimize', label: 'Свернуть' },
{ role: 'close', label: 'Закрыть' }
]
}
];
if (process.platform === 'darwin') {
template.unshift({
label: app.getName(),
submenu: [
{ role: 'about', label: 'О программе' },
{ type: 'separator' },
{ role: 'services', label: 'Сервисы' },
{ type: 'separator' },
{ role: 'hide', label: 'Скрыть' },
{ role: 'hideOthers', label: 'Скрыть остальные' },
{ role: 'unhide', label: 'Показать все' },
{ type: 'separator' },
{ role: 'quit', label: 'Выход' }
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
// Глобальные обработчики ошибок
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();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// --- Config IPC ---
ipcMain.handle('config:get', async (_e, key) => {
const cfg = loadConfig();
if (key) return cfg[key];
return cfg;
});
ipcMain.handle('config:set', async (_e, key, value) => {
return saveConfig({ [key]: value });
});
ipcMain.handle('config:getAll', async () => {
return loadConfig();
});
ipcMain.handle('config:setAll', async (_e, newConfig) => {
return saveConfig(newConfig);
});
ipcMain.handle('settings:close', () => {
if (settingsWindow) {
settingsWindow.close();
return { ok: true };
}
return { ok: false, error: 'Settings window not found' };
});
// --- Local Port Knocking Implementation ---
function parseTarget(targetStr) {
const parts = targetStr.split(':');
if (parts.length < 3) {
throw new Error(`Invalid target format: ${targetStr}`);
}
return {
protocol: parts[0]?.toLowerCase() || 'tcp',
host: parts[1] || '127.0.0.1',
port: parseInt(parts[2]) || 22,
gateway: parts[3] || null
};
}
function parseDelay(delayStr) {
const match = delayStr?.match(/^(\d+)([smh]?)$/);
if (!match) return 1000; // default 1 second
const value = parseInt(match[1]);
const unit = match[2] || 's';
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
default: return value * 1000;
}
}
function knockTcp(host, port, timeout = 5000, gateway = null) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
let resolved = false;
function safeResolve(result) {
if (resolved) {
return;
}
resolved = true;
try {
socket.destroy();
} catch (e) {
// Ignore errors during cleanup
}
resolve(result);
}
socket.setTimeout(timeout);
socket.on('connect', () => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
const localAddr = socket.localAddress;
const localPort = socket.localPort;
console.log(`TCP connected from ${localAddr}:${localPort} to ${host}:${port}`);
safeResolve({ success: true, message: `TCP connection to ${host}:${port}${gatewayInfo} successful (from ${localAddr}:${localPort})` });
});
socket.on('timeout', () => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} timeout` });
});
socket.on('error', (err) => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
});
socket.on('close', () => {
// Socket was closed, nothing to do
});
try {
if (gateway?.trim()) {
// Для TCP используем localAddress в connect() для обхода VPN
console.log(`Using localAddress ${gateway.trim()} to bypass VPN/tunnel`);
socket.connect({
port,
host: host,
localAddress: gateway.trim()
});
} else {
// Обычное подключение без привязки
socket.connect(port, host);
}
} catch (error) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `TCP connection to ${host}:${port}${gatewayInfo} failed: ${error.message}` });
}
});
}
function knockUdp(host, port, timeout = 5000, gateway = null) {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4');
const message = Buffer.from('knock');
let resolved = false;
function safeResolve(result) {
if (!resolved) {
resolved = true;
socket.close();
resolve(result);
}
}
socket.on('error', (err) => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
});
// Если указан gateway, привязываем сокет к локальному адресу для обхода VPN/туннелей
if (gateway && gateway.trim()) {
try {
socket.bind(0, gateway.trim());
console.log(`UDP socket bound to localAddress ${gateway.trim()} to bypass VPN/tunnel`);
} catch (bindError) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP socket bind to ${gateway}${gatewayInfo} failed: ${bindError.message}` });
return;
}
}
socket.send(message, 0, message.length, port, host, (err) => {
if (err) {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: false, message: `UDP packet to ${host}:${port}${gatewayInfo} failed: ${err.message}` });
return;
}
// UDP is fire-and-forget, so we consider it successful if we can send
const gatewayInfo = gateway ? ` via ${gateway}` : '';
const localAddr = socket.address()?.address;
const localPort = socket.address()?.port;
console.log(`UDP packet sent from ${localAddr}:${localPort} to ${host}:${port}`);
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (from ${localAddr}:${localPort})` });
});
// Set timeout for UDP operations
const timeoutId = setTimeout(() => {
const gatewayInfo = gateway ? ` via ${gateway}` : '';
safeResolve({ success: true, message: `UDP packet sent to ${host}:${port}${gatewayInfo} (timeout reached)` });
}, timeout);
// Clean up timeout if socket resolves earlier
socket.on('close', () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
});
}
async function performLocalKnock(targets, delay, verbose = true, gateway = null) {
const results = [];
const delayMs = parseDelay(delay);
try {
for (let i = 0; i < targets.length; i++) {
const targetStr = targets[i];
try {
const target = parseTarget(targetStr);
// Используем gateway из цели или глобальный gateway
const effectiveGateway = target.gateway || gateway;
if (verbose) {
console.log(`Knocking ${target.protocol.toUpperCase()} ${target.host}:${target.port}${effectiveGateway ? ` via ${effectiveGateway}` : ''}`);
}
let result;
try {
if (target.protocol === 'tcp') {
result = await knockTcp(target.host, target.port, 5000, effectiveGateway);
} else if (target.protocol === 'udp') {
result = await knockUdp(target.host, target.port, 5000, effectiveGateway);
} else {
result = { success: false, message: `Unsupported protocol: ${target.protocol}` };
}
} catch (knockError) {
result = {
success: false,
message: `Knock operation failed: ${knockError.message}`
};
}
results.push({
target: targetStr,
...result
});
// Add delay between targets (except for the last one)
if (i < targets.length - 1 && delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
} catch (error) {
console.error(`Error processing target ${targetStr}:`, error);
results.push({
target: targetStr,
success: false,
message: `Error: ${error.message}`
});
}
}
} catch (error) {
console.error('Critical error in performLocalKnock:', error);
throw error;
}
return results;
}
// Диагностика сетевых интерфейсов
ipcMain.handle('network:interfaces', async () => {
try {
const interfaces = os.networkInterfaces();
const result = {};
for (const [name, addrs] of Object.entries(interfaces)) {
result[name] = addrs.map(addr => ({
address: addr.address,
family: addr.family,
internal: addr.internal,
mac: addr.mac
}));
}
return { success: true, interfaces: result };
} catch (error) {
return { success: false, error: error.message };
}
});
// Тест подключения с конкретным localAddress
ipcMain.handle('network:test-connection', async (_e, payload) => {
try {
const { host, port, localAddress } = payload;
return new Promise((resolve) => {
const socket = new net.Socket();
let resolved = false;
function safeResolve(result) {
if (!resolved) {
resolved = true;
socket.destroy();
resolve(result);
}
}
socket.setTimeout(5000);
socket.on('connect', () => {
const localAddr = socket.localAddress;
const localPort = socket.localPort;
safeResolve({
success: true,
message: `Connection successful from ${localAddr}:${localPort}`,
localAddress: localAddr,
localPort: localPort
});
});
socket.on('error', (err) => {
safeResolve({ success: false, error: err.message });
});
socket.on('timeout', () => {
safeResolve({ success: false, error: 'Connection timeout' });
});
try {
if (localAddress) {
console.log(`Testing connection to ${host}:${port} with localAddress ${localAddress}`);
socket.connect({
port: port,
host: host,
localAddress: localAddress
});
} else {
socket.connect(port, host);
}
} catch (error) {
safeResolve({ success: false, error: error.message });
}
});
} catch (error) {
return { success: false, error: error.message };
}
});
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-пакета
// Сначала пробуем Rust версию, потом Go версию
const devRustBin = path.resolve(__dirname, '../../bin/knock-local-rust');
const prodRustBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local-rust');
const devGoBin = path.resolve(__dirname, '../../bin/knock-local');
const prodGoBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
let helperExec;
if (fs.existsSync(devRustBin)) {
helperExec = devRustBin;
console.log('Using Rust knock-local helper (dev)');
} else if (fs.existsSync(prodRustBin)) {
helperExec = prodRustBin;
console.log('Using Rust knock-local helper (prod)');
} else if (fs.existsSync(devGoBin)) {
helperExec = devGoBin;
console.log('Using Go knock-local helper (dev)');
} else {
helperExec = prodGoBin;
console.log('Using Go knock-local helper (prod)');
}
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();
// Таймаут на 15 секунд - вдруг что-то пойдёт не так
const timeout = setTimeout(() => {
child.kill('SIGTERM');
}, 15_000);
const code = await new Promise(resolve => child.on('close', resolve));
clearTimeout(timeout);
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'
};
}
});
// File dialogs and fs operations
ipcMain.handle('file:open', async () => {
const res = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
});
if (res.canceled || res.filePaths.length === 0) return { canceled: true };
const filePath = res.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, filePath, content };
});
ipcMain.handle('file:saveAs', async (_e, payload) => {
const res = await dialog.showSaveDialog({
defaultPath: (payload && payload.suggestedName) || 'config.yaml',
filters: [ { name: 'YAML/Encrypted', extensions: ['yaml', 'yml', 'encrypted', 'txt'] } ]
});
if (res.canceled || !res.filePath) return { canceled: true };
fs.writeFileSync(res.filePath, payload.content, 'utf-8');
return { canceled: false, filePath: res.filePath };
});
ipcMain.handle('file:saveToPath', async (_e, payload) => {
try {
fs.writeFileSync(payload.filePath, payload.content, 'utf-8');
return { ok: true };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
});
ipcMain.handle('os:revealInFolder', async (_e, filePath) => {
try {
shell.showItemInFolder(filePath);
return { ok: true };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
});

View File

@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
openFile: async () => ipcRenderer.invoke('file:open'),
saveAs: async (payload) => ipcRenderer.invoke('file:saveAs', payload),
saveToPath: async (payload) => ipcRenderer.invoke('file:saveToPath', payload),
revealInFolder: async (filePath) => ipcRenderer.invoke('os:revealInFolder', filePath),
getConfig: async (key) => ipcRenderer.invoke('config:get', key),
setConfig: async (key, value) => ipcRenderer.invoke('config:set', key, value),
getAllConfig: async () => ipcRenderer.invoke('config:getAll'),
setAllConfig: async (config) => ipcRenderer.invoke('config:setAll', config),
closeSettings: async () => ipcRenderer.invoke('settings:close'),
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload),
getNetworkInterfaces: async () => ipcRenderer.invoke('network:interfaces'),
testConnection: async (payload) => ipcRenderer.invoke('network:test-connection', payload)
});
// Пробрасываем конфигурацию в рендерер (безопасно)
contextBridge.exposeInMainWorld('config', {
apiBase: process.env.KNOCKER_DESKTOP_API_BASE || 'http://localhost:8080/api/v1'
});

View File

@@ -0,0 +1 @@
Port kicker

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Knocker Desktop</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<header>
<h1 style="font-size: 2.5rem; margin-bottom: 1rem">
Port Knocker - Desktop
</h1>
<div class="modes">
<label
><input type="radio" name="mode" value="inline" checked />
Inline</label
>
<label><input type="radio" name="mode" value="yaml" /> YAML</label>
<label><input type="radio" name="mode" value="form" /> Form</label>
</div>
</header>
<section id="constant-section" class="constant-mode-section">
<div class="row">
<label style="min-width: 100px">Api URL</label>
<input id="apiUrl" type="text" placeholder="Введите api url" />
</div>
<div class="row">
<label style="min-width: 100px">Пароль</label>
<input id="password" type="password" placeholder="Введите пароль" />
</div>
<div class="row">
<label style="min-width: 100px">Задержка</label>
<input id="delay" type="text" value="1s" />
</div>
</section>
<section id="inline-section" class="mode-section">
<div class="row">
<label style="min-width: 100px">Цели</label>
<input id="targets" type="text" value="tcp:127.0.0.1:22" />
</div>
<div class="row">
<label style="min-width: 100px">Шлюз: </label>
<input id="gateway" type="text" placeholder="опционально" />
</div>
<div class="row" style="margin-top: 1rem">
<label
><input id="verbose" type="checkbox" checked /> Подробный
вывод</label
>
<label
><input id="waitConnection" type="checkbox" /> Ждать
соединение</label
>
</div>
</section>
<section id="yaml-section" class="mode-section hidden">
<div class="toolbar">
<button id="openFile">Открыть файл…</button>
<button id="saveFile">Сохранить как…</button>
<input
id="serverFilePath"
type="text"
placeholder="server file path (path в YAML)"
/>
</div>
<textarea
id="configYAML"
placeholder="Вставьте YAML или откройте файл"
></textarea>
</section>
<section id="form-section" class="mode-section hidden">
<div id="targetsList"></div>
<button id="addTarget">Добавить цель</button>
</section>
<footer>
<div class="row" style="width: 100%; margin-top: 1rem">
<button style="width: 100%" id="execute">Выполнить</button>
</div>
<div class="row hidden" id="encrypt-decrypt-row" style="width: 100%; margin-top: 1rem">
<button style="width: 50%" id="encrypt">Зашифровать</button>
<button style="width: 50%" id="decrypt">Расшифровать</button>
</div>
<div class="row" style="width: 100%; margin-top: 1rem">
<span id="status"></span>
</div>
</footer>
</div>
<script src="../../node_modules/js-yaml/dist/js-yaml.min.js"></script>
<script src="./renderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,536 @@
(() => {
// Глобальные обработчики ошибок в 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);
const yaml = window.jsyaml;
function setMode(mode) {
["inline", "yaml", "form"].forEach((m) => {
const el = qs(`#${m}-section`);
if (el) el.classList.toggle("hidden", m !== mode);
const encryptDecryptRow = qs("#encrypt-decrypt-row");
if (encryptDecryptRow) {
encryptDecryptRow.classList.toggle("hidden", mode !== "yaml");
}
});
}
function basicAuthHeader(password) {
const token = btoa(`knocker:${password}`);
return { Authorization: `Basic ${token}` };
}
function updateStatus(msg) {
const el = qs("#status");
if (el) {
el.textContent = msg;
setTimeout(() => {
el.textContent = "";
}, 5000); // Очищаем через 5 секунд
}
}
const targets = [{ protocol: "tcp", host: "127.0.0.1", port: 22, gateway: "" }];
function renderTargets() {
const list = qs("#targetsList");
if (!list) return;
list.innerHTML = "";
targets.forEach((t, idx) => {
const row = document.createElement("div");
row.className = "target-row";
row.innerHTML = `
<select data-k="protocol">
<option value="tcp" ${t.protocol === "tcp" ? "selected" : ""
}>tcp</option>
<option value="udp" ${t.protocol === "udp" ? "selected" : ""
}>udp</option>
</select>
<input type="text" data-k="host" value="${t.host}" />
<input type="number" data-k="port" value="${t.port}" />
<input type="text" data-k="gateway" value="${t.gateway || ""
}" placeholder="gateway (опц.)" />
<button class="remove" data-idx="${idx}">Удалить</button>`;
list.appendChild(row);
});
}
function serializeFormTargetsToInline() {
return targets
.map(
(t) =>
`${t.protocol}:${t.host}:${t.port}${t.gateway ? `:${t.gateway}` : ""}`
)
.join(";");
}
function convertInlineToYaml(targetsStr, delay, waitConnection) {
const entries = (targetsStr || "").split(";").filter(Boolean);
const config = {
targets: entries.map((e) => {
const parts = e.split(":");
const protocol = parts[0] || "tcp";
const host = parts[1] || "127.0.0.1";
const port = parseInt(parts[2] || "22", 10);
return {
protocol,
host,
ports: [port],
wait_connection: !!waitConnection,
};
}),
delay: delay || "1s",
};
return yaml.dump(config, { lineWidth: 120 });
}
function convertYamlToInline(yamlText) {
if (!yamlText.trim())
return {
targets: "tcp:127.0.0.1:22",
delay: "1s",
waitConnection: false,
};
const config = yaml.load(yamlText) || {};
const list = [];
(config.targets || []).forEach((t) => {
const protocol = t.protocol || "tcp";
const host = t.host || "127.0.0.1";
const ports = t.ports || [t.port] || [22];
(Array.isArray(ports) ? ports : [ports]).forEach((p) =>
list.push(`${protocol}:${host}:${p}`)
);
});
return {
targets: list.join(";"),
delay: config.delay || "1s",
waitConnection: !!config.targets?.[0]?.wait_connection,
};
}
function extractPathFromYaml(text) {
try {
const doc = yaml.load(text);
if (doc && typeof doc === "object" && typeof doc.path === "string")
return doc.path;
} catch { }
return "";
}
function patchYamlPath(text, newPath) {
try {
const doc = text.trim() ? yaml.load(text) : {};
if (doc && typeof doc === "object") {
doc.path = newPath || "";
return yaml.dump(doc, { lineWidth: 120 });
}
} catch { }
return text;
}
function isEncryptedYaml(text) {
return (text || "").trim().startsWith("ENCRYPTED:");
}
// Функция для обновления конфига из настроек
function updateConfigFromSettings() {
window.api.getConfig('apiBase')
.then((saved) => {
if (typeof saved === 'string' && saved.trim()) {
apiBase = saved;
if (qsi('#apiUrl')) {
qsi('#apiUrl').value = apiBase;
}
}
})
.catch(() => { });
window.api.getConfig('gateway')
.then((saved) => {
if (qsi('#gateway')) {
qsi('#gateway').value = saved || '';
}
})
.catch(() => { });
window.api.getConfig('inlineTargets')
.then((saved) => {
if (qsi('#targets')) {
qsi('#targets').value = saved || '';
}
})
.catch(() => { });
window.api.getConfig('delay')
.then((saved) => {
if (qsi('#delay')) {
qsi('#delay').value = saved || '';
}
})
.catch(() => { });
}
// событие возникающее когда загружается страница основная приложения
window.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('input[name="mode"]').forEach((r) => {
r.addEventListener("change", (e) => setMode(e?.target?.value || ''));
});
// Инициализация/восстановление apiBase из конфига
window.api.getConfig('apiBase')
.then((saved) => {
if (typeof saved === 'string' && saved.trim()) {
apiBase = saved;
}
})
.catch(() => { })
.finally(() => {
if (qsi('#apiUrl')) {
qsi('#apiUrl').value = apiBase;
}
});
// Сохранение apiBase при изменении поля
qsi('#apiUrl')?.addEventListener('change', async () => {
const val = qsi('#apiUrl').value.trim();
if (!val) return;
apiBase = val;
try { await window.api.setConfig('apiBase', val); } catch { }
updateStatus('API URL сохранён');
});
// Инициализация/восстановление gateway из конфига
window.api.getConfig('gateway')
.then((saved) => {
if (qsi('#gateway')) {
qsi('#gateway').value = saved || '';
}
})
.catch(() => { });
// Сохранение Gateway при изменении поля
qsi('#gateway')?.addEventListener('change', async () => {
const val = qsi('#gateway').value.trim();
try { await window.api.setConfig('gateway', val); } catch { }
updateStatus('Gateway сохранён');
});
// Инициализация/восстановление inlineTargets из конфига
window.api.getConfig('inlineTargets')
.then((saved) => {
if (qsi('#targets')) {
qsi('#targets').value = saved || '';
}
})
.catch(() => { });
// Сохранение inlineTargets при изменении поля
qsi('#targets')?.addEventListener('change', async () => {
const val = qsi('#targets').value.trim();
try { await window.api.setConfig('inlineTargets', val); } catch { }
updateStatus('inlineTargets сохранёны');
});
// Инициализация/восстановление delay из конфига
window.api.getConfig('delay')
.then((saved) => {
if (qsi('#delay')) {
qsi('#delay').value = saved || '';
}
})
.catch(() => { });
// Сохранение delay при изменении поля
qsi('#delay')?.addEventListener('change', async () => {
const val = qsi('#delay').value.trim();
try { await window.api.setConfig('delay', val); } catch { }
updateStatus('Задержка сохранёна');
});
qsi("#addTarget")?.addEventListener("click", () => {
targets.push({ protocol: "tcp", host: "127.0.0.1", port: 22 });
renderTargets();
});
qs("#targetsList")?.addEventListener("input", (e) => {
const row = e.target.closest(".target-row");
if (!row) return;
const idx = Array.from(row.parentElement.children).indexOf(row);
const key = e.target.getAttribute("data-k");
if (idx >= 0 && key) {
const val =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
targets[idx][key] = val;
}
});
qs("#targetsList")?.addEventListener("click", (e) => {
if (!e.target.classList.contains("remove")) {
return;
}
const row = e.target.closest(".target-row");
const idx = Array.from(row.parentElement.children).indexOf(row);
if (idx >= 0) {
targets.splice(idx, 1);
renderTargets();
}
});
qs("#openFile")?.addEventListener("click", async () => {
const res = await window.api.openFile();
if (!(!res.canceled && res.content !== undefined)) {
return;
}
qst("#configYAML").value = res.content;
const p = extractPathFromYaml(res.content);
qsi("#serverFilePath").value = p || "";
updateStatus(`Открыт файл: ${res.filePath}`);
});
qs("#saveFile")?.addEventListener("click", async () => {
const content = qst("#configYAML").value;
const suggested = content.trim().startsWith("ENCRYPTED:")
? "config.encrypted"
: "config.yaml";
const res = await window.api.saveAs({
suggestedName: suggested,
content,
});
if (!res.canceled && res.filePath) {
updateStatus(`Сохранено: ${res.filePath}`);
await window.api.revealInFolder(res.filePath);
}
});
qsi("#serverFilePath")?.addEventListener("input", () => {
const newPath = qsi("#serverFilePath").value;
const current = qst("#configYAML").value;
qst("#configYAML").value = patchYamlPath(current, newPath);
});
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)}`);
}
});
qs("#encrypt")?.addEventListener("click", async () => {
const password = qsi("#password").value;
const content = qst("#configYAML").value;
const pathFromYaml = extractPathFromYaml(content);
if (!content.trim()) return;
const url = `${apiBase}/knock-actions/encrypt`;
const payload = { yaml: content };
try {
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify(payload),
});
const res = await r.json();
const encrypted = res?.encrypted || "";
qst("#configYAML").value = encrypted;
updateStatus("Зашифровано");
if (!pathFromYaml) {
await window.api.saveAs({
suggestedName: "config.encrypted",
content: encrypted,
});
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
qs("#decrypt")?.addEventListener("click", async () => {
const password = qsi("#password").value;
const content = qst("#configYAML").value;
if (!content.trim() || !isEncryptedYaml(content)) return;
try {
const r = await fetch(`${apiBase}/knock-actions/decrypt`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify({ encrypted: content }),
});
const res = await r.json();
const plain = res?.yaml || "";
qst("#configYAML").value = plain;
const p = extractPathFromYaml(plain);
if (p) qsi("#serverFilePath").value = p;
updateStatus("Расшифровано");
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
renderTargets();
// Обновляем конфиг при фокусе окна (если настройки были изменены)
window.addEventListener('focus', updateConfigFromSettings);
// Диагностические функции
window.testNetworkInterfaces = async () => {
try {
const result = await window.api.getNetworkInterfaces();
if (result.success) {
console.log('Network interfaces:', result.interfaces);
updateStatus('Network interfaces logged to console');
} else {
updateStatus(`Error getting interfaces: ${result.error}`);
}
} catch (e) {
updateStatus(`Error: ${e.message}`);
}
};
window.testConnection = async () => {
try {
const gateway = qsi('#gateway')?.value?.trim();
if (!gateway) {
updateStatus('Please set gateway first');
return;
}
const result = await window.api.testConnection({
host: '192.168.89.1',
port: 2655,
localAddress: gateway
});
if (result.success) {
updateStatus(`Test connection successful: ${result.message}`);
console.log('Test connection result:', result);
} else {
updateStatus(`Test connection failed: ${result.error}`);
}
} catch (e) {
updateStatus(`Error: ${e.message}`);
}
};
// Добавляем диагностические кнопки в консоль
console.log('Diagnostic functions available:');
console.log('- window.testNetworkInterfaces() - Show network interfaces');
console.log('- window.testConnection() - Test connection with gateway');
});
})();

View File

@@ -0,0 +1,163 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Настройки - Knocker Desktop</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #f5f5f5;
}
.container {
max-width: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2c3e50;
color: white;
padding: 15px 20px;
font-size: 18px;
font-weight: 600;
}
.content {
padding: 20px;
}
.field-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
textarea {
width: 100%;
height: 300px;
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
resize: vertical;
box-sizing: border-box;
}
textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
}
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.status {
margin-top: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
display: none;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.help {
margin-top: 10px;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
font-size: 12px;
color: #666;
line-height: 1.4;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
⚙️ Настройки приложения
</div>
<div class="content">
<div class="field-group">
<label for="configJson">Конфигурация (JSON формат):</label>
<textarea id="configJson" placeholder="Загрузка конфигурации..."></textarea>
</div>
<div class="help">
<strong>Доступные параметры:</strong><br>
<code>apiBase</code> - URL API сервера (например: "http://localhost:8080/api/v1")<br>
<code>gateway</code> - Шлюз по умолчанию<br>
<code>inlineTargets</code> - Inline цели (в формате "tcp:127.0.0.1:22")<br>
<code>delay</code> - Задержка (например: "1s")
</div>
<div class="buttons">
<button class="btn-secondary" id="cancelBtn">Вернуться</button>
<button class="btn-primary" id="saveBtn">Сохранить</button>
</div>
<div id="status" class="status"></div>
</div>
</div>
<script src="settings.js"></script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
(() => {
const qs = (sel) => document.querySelector(sel);
const qst = (sel) => document.querySelector(sel);
function showStatus(message, type = 'success') {
const status = qs('#status');
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
function validateJson(text) {
try {
JSON.parse(text);
return { valid: true };
} catch (e) {
return { valid: false, error: e.message };
}
}
function formatJson(obj) {
return JSON.stringify(obj, null, 2);
}
// Загрузка текущей конфигурации
async function loadConfig() {
try {
const config = await window.api.getAllConfig();
const jsonText = formatJson(config);
qst('#configJson').value = jsonText;
} catch (e) {
console.error('Failed to load config:', e);
showStatus('Ошибка загрузки конфигурации', 'error');
qst('#configJson').value = '{}';
}
}
// Сохранение конфигурации
async function saveConfig() {
const text = qst('#configJson').value.trim();
if (!text) {
showStatus('Конфигурация не может быть пустой', 'error');
return;
}
const validation = validateJson(text);
if (!validation.valid) {
showStatus(`Неверный JSON: ${validation.error}`, 'error');
return;
}
try {
const config = JSON.parse(text);
const result = await window.api.setAllConfig(config);
if (result.ok) {
showStatus('Конфигурация успешно сохранена');
// Обновляем конфиг в главном окне
setTimeout(() => {
if (window.opener) {
window.opener.location.reload();
}
}, 1000);
} else {
showStatus(`Ошибка сохранения: ${result.error}`, 'error');
}
} catch (e) {
console.error('Failed to save config:', e);
showStatus('Ошибка сохранения конфигурации', 'error');
}
}
// Закрытие окна через IPC
async function closeWindow() {
try {
const result = await window.api.closeSettings();
if (!result.ok) {
console.error('Failed to close settings window:', result.error);
}
} catch (e) {
console.error('Error closing settings window:', e);
}
}
// Обработчики событий
window.addEventListener('DOMContentLoaded', () => {
// Загружаем конфигурацию при открытии
loadConfig();
// Кнопка сохранения
qs('#saveBtn').addEventListener('click', saveConfig);
// Кнопка возврата
qs('#cancelBtn').addEventListener('click', () => closeWindow());
// Горячие клавиши
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
saveConfig();
} else if (e.key === 'w' || e.key === 'Escape') {
e.preventDefault();
closeWindow();
}
}
});
// Автосохранение при изменении (опционально)
let saveTimeout;
qst('#configJson').addEventListener('input', () => {
clearTimeout(saveTimeout);
// Можно добавить автосохранение через 5 секунд бездействия
// saveTimeout = setTimeout(() => {
// const validation = validateJson(qst('#configJson').value);
// if (validation.valid) {
// saveConfig();
// }
// }, 5000);
});
});
// Предотвращение случайного закрытия с несохраненными изменениями
let hasUnsavedChanges = false;
qst('#configJson').addEventListener('input', () => {
hasUnsavedChanges = true;
});
// Убираем beforeunload для Electron (не работает корректно)
// window.addEventListener('beforeunload', (e) => {
// if (hasUnsavedChanges) {
// e.preventDefault();
// e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?';
// }
// });
// Сбрасываем флаг после сохранения
const originalSaveConfig = saveConfig;
saveConfig = async function() {
await originalSaveConfig();
hasUnsavedChanges = false;
};
})();

View File

@@ -0,0 +1,96 @@
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
header,
footer {
padding: 12px 16px;
background: #0f172a;
color: #fff;
}
header h1 {
margin: 0 0 8px 0;
font-size: 18px;
}
.modes label {
margin-right: 12px;
}
.mode-section {
padding: 12px 16px;
}
.constant-mode-section {
padding: 12px 16px;
}
.hidden {
display: none !important;
}
.row {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0;
}
input[type="text"],
input[type="password"],
textarea {
width: 100%;
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
}
textarea {
height: 280px;
resize: vertical;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
button {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #334155;
background: #1f2937;
color: #fff;
cursor: pointer;
}
button:hover {
filter: brightness(1.1);
}
#status {
margin-left: 12px;
color: #0ea5e9;
}
#targetsList .target-row {
display: grid;
grid-template-columns: 120px 1fr 120px 1fr auto;
gap: 8px;
margin: 8px 0;
}
#targetsList .remove {
background: #7f1d1d;
border-color: #7f1d1d;
}