diff --git a/IMPROVEMENTS_2025.md b/IMPROVEMENTS_2025.md deleted file mode 100644 index 64aaad7..0000000 --- a/IMPROVEMENTS_2025.md +++ /dev/null @@ -1,321 +0,0 @@ -# Улучшения Router Lists UI - 2025 - -## 📋 Обзор - -Данный документ описывает улучшения, внесенные в проект Router Lists UI. Все изменения внедрены постепенно и аккуратно, чтобы не нарушить работу существующего кода. - ---- - -## ✅ Реализованные улучшения - -### 1. 🔐 Валидация и Безопасность входных данных - -#### Backend (`backend/lib/validators.js`) -Создан новый модуль валидации с функциями: - -- **`isValidIPv4(ip)`** - валидация IPv4 адресов -- **`isValidIPv6(ip)`** - валидация IPv6 адресов -- **`isValidCIDRv4(cidr)`** - валидация CIDR блоков IPv4 -- **`isValidCIDRv6(cidr)`** - валидация CIDR блоков IPv6 -- **`isValidDomain(domain)`** - валидация доменных имен (поддержка IDN) -- **`isValidWildcardDomain(domain)`** - валидация wildcard доменов (*.example.com) -- **`isValidASN(asn)`** - валидация номеров ASN -- **`isValidCommunity(community)`** - валидация BGP Community (форматы N и N:N) -- **`isValidGateway(gateway)`** - валидация имен gateway для MikroTik -- **`sanitizeString(str)`** - санитизация строк от опасных символов -- **`isSafeSQLString(str)`** - проверка на SQL injection паттерны -- **`isSafeXSSString(str)`** - проверка на XSS паттерны -- **`validateData(data, schema)`** - комплексная валидация по схеме - -#### Улучшенный Rate Limiting (`backend/server.js`) -- **Общий лимитер**: 1000 запросов за 15 минут -- **Лимитер для записи**: 100 запросов за 5 минут (применен к POST endpoints) -- **Лимитер для BGP**: 5 запросов за 1 минуту (применен к `/api/update-bgp/background`) - -#### Интеграция валидации -Добавлена серверная валидация для всех POST endpoints: -- `/api/domains-new` - валидация доменов и communities -- `/api/ip-ranges` - валидация IP/CIDR и communities -- `/api/asns` - валидация ASN и communities -- `/api/communities` - валидация community значений - -**Преимущества:** -- ✅ Защита от невалидных данных на уровне сервера -- ✅ Предотвращение XSS и injection атак -- ✅ Контроль частоты запросов (защита от DDoS) -- ✅ Детальные сообщения об ошибках валидации - ---- - -### 2. 🏷️ Расширенная работа с Community - -#### Backend - -**Новый endpoint** (`/api/communities/stats`): -- Подсчет использования каждого community -- Статистика по всем типам данных: домены, IP ranges, ASNs, фильтры -- Сортировка по частоте использования - -**Расширенные поля communities**: -```javascript -{ - value: '65000:100', - name: 'Social Media', - description: 'Traffic for social networks', - tags: ['video', 'social'], - gatewayDefault: 'SWE-HIPHOST', - color: 'blue', - // Новые поля: - category: 'Media', - priority: 10, - enabled: true -} -``` - -#### Frontend - -**Новый компонент** (`frontend/src/components/CommunityStats.jsx`): -- Отображение общей статистики использования -- Top-10 самых используемых communities -- Группировка по категориям -- Визуализация через progress bars -- Интерактивные графики - -**Улучшения CommunitiesManager**: -- Вкладки "Список" и "Статистика" -- Переключение между режимами просмотра -- Поддержка новых полей (category, priority, enabled) - -**Преимущества:** -- ✅ Понимание какие communities используются чаще всего -- ✅ Группировка и категоризация для лучшей организации -- ✅ Визуальное представление статистики - ---- - -### 3. 📊 Улучшенная визуализация данных - -#### Новый компонент (`frontend/src/components/TopNStats.jsx`) - -**Top-5 стран**: -- Подсчет серверов по странам -- Флаги стран (эмодзи) -- Progress bars для визуализации -- Выделение лидера (🏆) - -**Top-5 провайдеров**: -- Подсчет серверов по провайдерам -- Цветовые индикаторы -- Progress bars -- Выделение лидера - -#### Интеграция в Dashboard -- Добавлена секция "Топ статистика" -- Отображение после основных метрик -- Responsive дизайн - -**Преимущества:** -- ✅ Быстрый обзор распределения инфраструктуры -- ✅ Наглядная визуализация -- ✅ Помощь в принятии решений о расширении - ---- - -### 4. ⚙️ Улучшенный Filter Manager - -#### Backend (`backend/lib/mikrotik-validator.js`) - -**Валидация синтаксиса MikroTik**: -- Проверка сбалансированности фигурных скобок -- Валидация if/else конструкций -- Проверка команд set gw -- Валидация bgp-communities includes -- Проверка корректности community форматов -- Проверка gateway имен - -**Новый endpoint** (`/api/mikrotik/validate`): -```javascript -POST /api/mikrotik/validate -{ - "config": "// MikroTik config..." -} - -Response: -{ - "valid": true|false, - "errors": [ - { "line": 42, "message": "..." } - ], - "warnings": [ - { "line": 15, "message": "..." } - ] -} -``` - -**Преимущества:** -- ✅ Проверка синтаксиса перед применением конфигурации -- ✅ Раннее обнаружение ошибок -- ✅ Детальные сообщения с номерами строк -- ✅ Предупреждения о потенциальных проблемах - ---- - -## 📁 Структура новых файлов - -``` -backend/ -├── lib/ -│ ├── validators.js ✨ НОВЫЙ: Модуль валидации данных -│ └── mikrotik-validator.js ✨ НОВЫЙ: Валидация MikroTik конфигураций - -frontend/ -└── src/ - └── components/ - ├── CommunityStats.jsx ✨ НОВЫЙ: Статистика communities - └── TopNStats.jsx ✨ НОВЫЙ: Top-N статистика для Dashboard -``` - ---- - -## 🔄 Измененные файлы - -### Backend -- `backend/server.js` - интеграция валидаторов, новые endpoints, улучшенный rate limiting - -### Frontend -- `frontend/src/CommunitiesManager.jsx` - добавлены вкладки и интеграция статистики -- `frontend/src/Dashboard.jsx` - добавлена Top-N статистика - ---- - -## 🚀 Как использовать новые возможности - -### 1. Валидация данных -Все POST запросы автоматически валидируются на сервере. В случае ошибок валидации API вернет: -```json -{ - "code": "E_VALIDATION", - "message": "Ошибки валидации", - "details": { - "errors": [ - "Элемент 0: неверный домен \"invalid..domain\"", - "Элемент 5: неверный community \"abc\"" - ] - } -} -``` - -### 2. Статистика Communities -1. Перейдите в раздел **Данные → Communities** -2. Нажмите вкладку **"Статистика использования"** -3. Просмотрите: - - Общую статистику - - Top-10 самых используемых - - Распределение по категориям - -### 3. Top-N статистика на Dashboard -Откройте главную страницу (Dashboard) - внизу появится секция "Топ статистика" с: -- Top-5 стран -- Top-5 провайдеров - -### 4. Валидация MikroTik конфигурации -Используйте API endpoint для проверки конфигурации перед применением: -```javascript -const response = await fetch('/api/mikrotik/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config: yourConfigString }) -}); -const result = await response.json(); -// result.valid, result.errors, result.warnings -``` - ---- - -## 🎯 Достигнутые цели - -### Безопасность -- ✅ Серверная валидация всех входных данных -- ✅ Защита от XSS и injection атак -- ✅ Улучшенный rate limiting с разными лимитами для разных операций - -### Функциональность -- ✅ Статистика использования communities -- ✅ Расширенные поля для communities (категории, приоритет) -- ✅ Top-N статистика на Dashboard -- ✅ Валидация синтаксиса MikroTik конфигураций - -### UX/UI -- ✅ Вкладки в Communities Manager -- ✅ Визуализация статистики через progress bars -- ✅ Эмодзи флаги для стран -- ✅ Детальные сообщения об ошибках - ---- - -## 🐛 Исправления - -### Docker сборка -**Проблема**: При первой сборке модули `lib/validators.js` и `lib/mikrotik-validator.js` не копировались в Docker контейнер. - -**Решение**: Обновлен `Dockerfile.fast` - добавлена строка: -```dockerfile -COPY backend/lib ./lib -``` - -Теперь папка `lib` с модулями валидации корректно копируется в контейнер. - ---- - -## ⚠️ Обратная совместимость - -Все изменения **обратно совместимы**: -- ✅ Существующий код продолжает работать без изменений -- ✅ Новые поля в communities опциональны -- ✅ Валидация не блокирует существующие данные -- ✅ Rate limiting имеет достаточно высокие лимиты для нормальной работы -- ✅ Dockerfile обновлен для корректной работы в Docker - ---- - -## 📝 Рекомендации по дальнейшему использованию - -1. **Мониторинг rate limiting** - если пользователи сталкиваются с 429 ошибками, увеличьте лимиты -2. **Наполнение категорий** - добавьте категории к существующим communities для лучшей организации -3. **Использование валидации** - интегрируйте `/api/mikrotik/validate` в Filter Manager UI -4. **Анализ статистики** - регулярно проверяйте статистику communities для оптимизации - ---- - -## 🔜 Идеи для будущих улучшений - -1. **Шаблоны фильтров** - сохранение и быстрое применение готовых наборов фильтров -2. **Автоматическое тестирование** - unit и integration тесты -3. **Графики динамики** - отображение изменений данных во времени -4. **Экспорт отчетов** - генерация PDF/Excel отчетов со статистикой -5. **WebSocket real-time** - обновление данных в реальном времени - ---- - -## 📞 Поддержка - -При возникновении проблем проверьте: -1. Логи backend (`console.log` и Pino логи) -2. Network вкладку в DevTools браузера -3. Prometheus метрики (`/metrics`) - -Все новые endpoints логируются с помощью Pino и имеют requestId для трассировки. - ---- - -## ✨ Итого - -**Добавлено файлов**: 4 -**Изменено файлов**: 4 -**Новых API endpoints**: 2 -**Новых компонентов**: 2 -**Улучшений безопасности**: ✅ Существенные -**Совместимость**: ✅ Полная - -Все изменения протестированы и готовы к использованию! 🎉 - diff --git a/OPTIMIZATION_REPORT.md b/OPTIMIZATION_REPORT.md deleted file mode 100644 index d4c4e57..0000000 --- a/OPTIMIZATION_REPORT.md +++ /dev/null @@ -1,295 +0,0 @@ -# Отчет об оптимизации кода проекта Router Lists UI - -Дата: 2 октября 2025 -Статус: Завершено - -## 🎯 Цель оптимизации - -Упростить и оптимизировать кодовую базу проекта без нарушения функциональности. - -## 📊 Что было сделано - -### 1. ✅ Frontend - Исправление дублирования QueryClient - -**Проблема:** QueryClient создавался дважды (в `main.jsx` и `App.jsx`), что приводило к избыточности и потенциальным проблемам с кэшированием. - -**Решение:** -- Удален дублирующий экземпляр из `main.jsx` -- Оставлен единственный экземпляр в `App.jsx` с правильной конфигурацией -- Упрощены импорты - -**Файлы:** -- `frontend/src/main.jsx` - убран QueryClient и QueryClientProvider -- `frontend/src/App.jsx` - объединены импорты, создан единый QueryClient - -**Результат:** Уменьшено дублирование кода, улучшена производительность - ---- - -### 2. ✅ Backend - Объединение дублирующихся валидаторов - -**Проблема:** Функции `isValidCommunity` и `isValidGateway` были продублированы в двух файлах: -- `backend/lib/validators.js` -- `backend/lib/mikrotik-validator.js` - -**Решение:** -- Удалены дублирующие функции из `mikrotik-validator.js` -- Добавлен импорт из `validators.js` для переиспользования - -**Файлы:** -- `backend/lib/mikrotik-validator.js` - убраны дублирующие валидаторы -- `backend/lib/validators.js` - основной источник валидаторов - -**Результат:** Устранено дублирование ~50 строк кода - ---- - -### 3. ✅ Backend - Создание общего S3 сервиса - -**Проблема:** Логика работы с S3 была размазана по всему `server.js` (~2486 строк), множество повторяющихся операций. - -**Решение:** -Создан централизованный сервис `backend/services/s3Service.js` со следующими функциями: - -```javascript -// Основные операции -- readS3TextObject() // Чтение текстовых файлов с кэшированием -- writeS3TextObject() // Запись текстовых файлов -- writeS3JsonObject() // Запись JSON файлов -- deleteS3Object() // Удаление объектов -- headS3ObjectEtag() // Получение ETag -- headMeta() // Получение метаданных -- streamPaginatedText() // Потоковое чтение с пагинацией -- invalidateCacheForKey() // Инвалидация кэша - -// Вспомогательные -- streamToString() // Конвертация stream в string -- getCache() / setCache() // Работа с кэшем -``` - -**Файлы:** -- `backend/services/s3Service.js` - новый централизованный сервис - -**Результат:** -- Переиспользуемая логика S3 -- Централизованное кэширование -- Упрощение основного кода -- ~300 строк вынесено в отдельный модуль - ---- - -### 4. ✅ Backend - Вынос middleware в отдельные файлы - -**Проблема:** Вся логика middleware, обработки ошибок и блокировок была в `server.js`. - -**Решение:** -Созданы отдельные модули: - -#### `backend/middleware/errorHandler.js` -```javascript -- sendOk() // Отправка успешного ответа -- sendError() // Отправка ошибки -- checkIfNoneMatch() // Проверка ETag -- errorHandler() // Центральный обработчик ошибок -``` - -#### `backend/middleware/lockManager.js` -```javascript -- getLockStatus() // Получить статус блокировки -- acquireLock() // Получить/обновить блокировку -- releaseLock() // Освободить блокировку -- cleanupExpiredLocks() // Очистка истекших блокировок -``` - -#### `backend/utils/helpers.js` -```javascript -- toIso() // Конвертация даты -- splitWhitespace() // Разбиение строки -- sha256OfString() // SHA256 хэш -- mapAjvErrors() // Форматирование ошибок AJV -- resourceToKey() // Маппинг ресурсов на S3 ключи -- buildNestedGatewayBlocks() // Генерация MikroTik конфигурации -``` - -**Файлы:** -- `backend/middleware/errorHandler.js` -- `backend/middleware/lockManager.js` -- `backend/utils/helpers.js` - -**Результат:** ~200 строк вынесено в переиспользуемые модули - ---- - -### 5. ✅ Backend - Создание общих роутов для однотипных эндпоинтов - -**Проблема:** Дублирование логики для похожих эндпоинтов (domains, asns, ip-ranges, servers, filters, billing). - -**Решение:** -Созданы фабрики роутов: - -#### `backend/routes/textDataRoutes.js` -Для текстовых данных (domains, asns, ip-ranges): -```javascript -- createTextDataGET() // Общий GET эндпоинт -- createTextDataPOST() // Общий POST эндпоинт -- createTextDataRoutes() // Фабрика роутов -``` - -Поддерживает: -- Пагинацию (`offset`, `limit`) -- Поиск (`q=`) -- Подсчет (`countOnly=true`) -- Кэширование -- Валидацию ETag -- Потоковое чтение больших файлов - -#### `backend/routes/jsonDataRoutes.js` -Для JSON данных (servers, filters, billing): -```javascript -- createJsonDataGET() // Общий GET эндпоинт -- createJsonDataPOST() // Общий POST эндпоинт -- createJsonDataRoutes() // Фабрика роутов -``` - -#### `backend/routes/communitiesRoutes.js` -Специализированные роуты для communities: -```javascript -- getCommunities() // GET /api/communities -- postCommunities() // POST /api/communities -- getCommunityStats() // GET /api/communities/stats -``` - -**Использование:** -```javascript -const domainsRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/domains_community.txt', - mapLine: (line) => { /* парсинг */ }, - formatLine: (obj) => { /* форматирование */ }, - validate: validateSchema, - validateItem: validateItemFunc, - cachePrefix: 'domains-new' -}); - -app.get('/api/domains-new', domainsRoutes.get); -app.post('/api/domains-new', domainsRoutes.post); -``` - -**Файлы:** -- `backend/routes/textDataRoutes.js` -- `backend/routes/jsonDataRoutes.js` -- `backend/routes/communitiesRoutes.js` - -**Результат:** -- ~400 строк дублирующего кода заменено на переиспользуемые фабрики -- Упрощение добавления новых эндпоинтов -- Единообразие в обработке данных - ---- - -## 📈 Итоговые улучшения - -### Метрики - -| Метрика | До | После | Улучшение | -|---------|----|----|-----------| -| Размер server.js | 2486 строк | ~1500 строк (после полного рефакторинга) | -40% | -| Дублирование кода | Высокое | Минимальное | -70% | -| Модульность | Низкая (1 файл) | Высокая (10+ модулей) | +900% | -| Переиспользуемость | 10% | 80% | +700% | -| Валидаторы | 2 копии | 1 источник истины | -50% | -| QueryClient (frontend) | 2 экземпляра | 1 экземпляр | -50% | - -### Качественные улучшения - -✅ **Читаемость кода** -- Каждый модуль имеет четкую ответственность -- Логика разделена по слоям (routes/services/middleware) -- Комментарии и JSDoc документация - -✅ **Поддерживаемость** -- Легко найти нужную логику -- Изменения в одном месте вместо нескольких -- Упрощенное тестирование - -✅ **Масштабируемость** -- Простое добавление новых эндпоинтов через фабрики -- Централизованная логика S3 и кэширования -- Модульная структура - -✅ **Производительность** -- Единый QueryClient на фронтенде -- Централизованное кэширование S3 операций -- Оптимизированное потоковое чтение - ---- - -## 🗂️ Новая структура проекта - -``` -backend/ -├── lib/ -│ ├── validators.js ✨ Оптимизировано (убраны дубликаты) -│ └── mikrotik-validator.js ✨ Оптимизировано (использует validators.js) -├── middleware/ -│ ├── errorHandler.js 🆕 Новый модуль -│ └── lockManager.js 🆕 Новый модуль -├── routes/ -│ ├── textDataRoutes.js 🆕 Фабрика роутов для текстовых данных -│ ├── jsonDataRoutes.js 🆕 Фабрика роутов для JSON данных -│ └── communitiesRoutes.js 🆕 Специализированные роуты -├── services/ -│ └── s3Service.js 🆕 Централизованный S3 сервис -├── utils/ -│ └── helpers.js 🆕 Вспомогательные утилиты -└── server.js ⏳ Готов к рефакторингу (использует новые модули) - -frontend/ -├── src/ -│ ├── main.jsx ✨ Оптимизировано (убран дублирующий QueryClient) -│ └── App.jsx ✨ Оптимизировано (единый QueryClient, упрощены импорты) -``` - ---- - -## 🎯 Дальнейшие рекомендации - -### Краткосрочные (можно сделать сразу) -1. **Переписать server.js** - использовать созданные модули для всех эндпоинтов -2. **Добавить unit-тесты** для новых модулей -3. **Создать роуты для остальных эндпоинтов** (filters, auto-urls, billing) - -### Среднесрочные -1. **Добавить TypeScript** для лучшей типизации -2. **Настроить ESLint** с правилами для обнаружения дублирования -3. **Создать документацию API** на базе новых модулей - -### Долгосрочные -1. **Добавить интеграционные тесты** -2. **Настроить CI/CD** с проверкой качества кода -3. **Рассмотреть переход на более современную архитектуру** (например, NestJS) - ---- - -## ✅ Проверка работоспособности - -Все изменения **обратно совместимы**. API остался без изменений: - -- ✅ Frontend работает как раньше -- ✅ Все эндпоинты доступны -- ✅ Валидация работает -- ✅ S3 операции работают -- ✅ Кэширование работает -- ✅ Блокировки работают - ---- - -## 📝 Заключение - -Выполнена успешная оптимизация кодовой базы с **нулевым breaking change**. Код стал: -- 🎯 **Проще** - меньше дублирования -- 🔧 **Удобнее** - модульная структура -- 🚀 **Быстрее** - оптимизированное кэширование -- 📚 **Понятнее** - четкое разделение ответственности - -Все изменения готовы к продакшену и могут быть развернуты немедленно. - diff --git a/SHX_NETWORK.md b/SHX_NETWORK.md deleted file mode 100644 index 5ffad73..0000000 --- a/SHX_NETWORK.md +++ /dev/null @@ -1,2309 +0,0 @@ -# SHX Network Infrastructure - -## Обзор сети - -Данный проект описывает сетевую инфраструктуру с двумя провайдерами интернет-соединения и множественными GRE туннелями для обеспечения отказоустойчивости и географического распределения. - -## Архитектура сети - -### Основная схема (обновлено) - -```mermaid -graph TB - subgraph "Шлюз (Gateway)" - GW[Основной шлюз] - end - - subgraph "Провайдеры" - MTS[МТС] - RTK[Ростелеком] - end - - subgraph "GRE Туннели" - subgraph "Ростелеком (RTK)" - RTK1[MSK-VPSVILLE-RTK] - RTK2[MSK-IHOR-RTK] - RTK3[SWE-HIPHOST-RTK] - end - - subgraph "МТС (MTS)" - MTS1[MSK-VPSVILLE-MTS] - MTS2[MSK-IHOR-MTS] - MTS3[SWE-HIPHOST-MTS] - end - end - - subgraph "Удаленные узлы" - VPSVILLE[VPSVILLE] - IHOR[IHOR] - HIPHOST["HIPHOST
SWE"] - end - - GW --> MTS - GW --> RTK - - MTS --> MTS1 - MTS --> MTS2 - MTS --> MTS3 - - RTK --> RTK1 - RTK --> RTK2 - RTK --> RTK3 - - MTS1 --> VPSVILLE - MTS2 --> IHOR - MTS3 --> HIPHOST - - RTK1 --> VPSVILLE - RTK2 --> IHOR - RTK3 --> HIPHOST - - %% Новое: GRE SWE-HIPHOST от всех МСК серверов - VPSVILLE -- GRE SWE-HIPHOST --> HIPHOST - IHOR -- GRE SWE-HIPHOST --> HIPHOST - - style GW fill:#e1f5fe - style MTS fill:#ffebee - style RTK fill:#e8f5e8 - style VPSVILLE fill:#fff3e0 - style IHOR fill:#fff3e0 - style HIPHOST fill:#fff3e0 -``` - -### Детальная схема туннелей (обновлено) - -```mermaid -graph LR - subgraph "Шлюз" - GW[Gateway] - end - - subgraph "Провайдер МТС" - MTS_ISP[МТС ISP] - end - - subgraph "Провайдер Ростелеком" - RTK_ISP[Ростелеком ISP] - end - - subgraph "GRE Туннели" - subgraph "Москва - VPSVILLE" - MTS_VPS[MSK-VPSVILLE-MTS
GRE Tunnel] - RTK_VPS[MSK-VPSVILLE-RTK
GRE Tunnel] - end - - subgraph "Москва - IHOR" - MTS_IHOR[MSK-IHOR-MTS
GRE Tunnel] - RTK_IHOR[MSK-IHOR-RTK
GRE Tunnel] - end - - subgraph "Швеция - HIPHOST" - MTS_HIP[SWE-HIPHOST-MTS
GRE Tunnel] - RTK_HIP[SWE-HIPHOST-RTK
GRE Tunnel] - end - end - - subgraph "Удаленные серверы" - VPS[VPSVILLE Server
Москва] - IHR[IHOR Server
Москва] - HIP["HIPHOST
SWE"] - end - - GW --> MTS_ISP - GW --> RTK_ISP - - MTS_ISP --> MTS_VPS - MTS_ISP --> MTS_IHOR - MTS_ISP --> MTS_HIP - - RTK_ISP --> RTK_VPS - RTK_ISP --> RTK_IHOR - RTK_ISP --> RTK_HIP - - MTS_VPS --> VPS - MTS_IHOR --> IHR - MTS_HIP --> HIP - - RTK_VPS --> VPS - RTK_IHOR --> IHR - RTK_HIP --> HIP - - %% Новое: GRE SWE-HIPHOST от всех МСК серверов - VPS -- GRE SWE-HIPHOST --> HIP - IHR -- GRE SWE-HIPHOST --> HIP - - style GW fill:#2196f3,stroke:#1976d2,stroke-width:2px,color:#fff - style MTS_ISP fill:#f44336,stroke:#d32f2f,stroke-width:2px,color:#fff - style RTK_ISP fill:#4caf50,stroke:#388e3c,stroke-width:2px,color:#fff - style VPS fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style IHR fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style HIP fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff -``` - -### Схема европейского трафика (новая) - -```mermaid -graph TD - subgraph "Москва" - VPSVILLE_MSK[VPSVILLE
Москва] - IHOR_MSK[IHOR
Москва] - end - subgraph "Швеция" - HIPHOST_SWE["HIPHOST
SWE"] - end - - VPSVILLE_MSK -- GRE SWE-HIPHOST --> HIPHOST_SWE - IHOR_MSK -- GRE SWE-HIPHOST --> HIPHOST_SWE - - HIPHOST_SWE -- "Европейский интернет" --> EU[EU Resources] - - classDef eu fill:#e3f2fd,stroke:#1976d2,stroke-width:2px; - class EU eu; -``` - -### Схема связывания московских серверов через OSPF (новая) - -```mermaid -graph TB - subgraph "Москва - VPSVILLE" - VPSVILLE[VPSVILLE
msk.vpsville.rt.shx.su] - end - - subgraph "Москва - IHOR" - IHOR[IHOR
msk.ihor.rt.shx.su] - end - - subgraph "Домашний шлюз" - HOME[HOME
home.rt.shx.su] - end - - %% GRE туннели от домашнего шлюза (только клиентские подключения) - HOME -- "GRE MSK-VPSVILLE-RTK
10.100.2.0/30" --> VPSVILLE - HOME -- "GRE MSK-VPSVILLE-MTS
10.100.1.0/30" --> VPSVILLE - HOME -- "GRE MSK-IHOR-RTK
10.100.4.0/30" --> IHOR - HOME -- "GRE MSK-IHOR-MTS
10.100.3.0/30" --> IHOR - - %% GRE туннель между московскими серверами (независимо от HOME) - VPSVILLE -- "GRE MSK-VPSVILLE-IHOR
10.200.0.0/30" --> IHOR - - %% OSPF связи только между серверами - VPSVILLE -. "OSPF" .- IHOR - - %% HOME только получает маршруты от серверов - VPSVILLE -. "OSPF маршруты" .- HOME - IHOR -. "OSPF маршруты" .- HOME - - style VPSVILLE fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style IHOR fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style HOME fill:#e0e0e0,stroke:#9e9e9e,stroke-width:2px,color:#000 -``` - -### Схема отказоустойчивости (обновлено) - -```mermaid -graph TB - subgraph "Основной путь" - GW[Gateway] - MTS[MТС - Основной] - RTK[Ростелеком - Резервный] - end - - subgraph "Туннели по приоритету" - subgraph "VPSVILLE" - VPS_MTS[MSK-VPSVILLE-MTS
Приоритет 1] - VPS_RTK[MSK-VPSVILLE-RTK
Приоритет 2] - end - - subgraph "IHOR" - IHR_MTS[MSK-IHOR-MTS
Приоритет 1] - IHR_RTK[MSK-IHOR-RTK
Приоритет 2] - end - - subgraph "HIPHOST" - HIP_MTS[SWE-HIPHOST-MTS
Приоритет 1] - HIP_RTK[SWE-HIPHOST-RTK
Приоритет 2] - end - end - - GW --> MTS - GW --> RTK - - MTS --> VPS_MTS - MTS --> IHR_MTS - MTS --> HIP_MTS - - RTK --> VPS_RTK - RTK --> IHR_RTK - RTK --> HIP_RTK - - VPS_MTS -.->|Failover| VPS_RTK - IHR_MTS -.->|Failover| IHR_RTK - HIP_MTS -.->|Failover| HIP_RTK - - %% Новое: Европейский трафик через SWE-HIPHOST - VPS_MTS -- "EU трафик" --> HIP_MTS - VPS_RTK -- "EU трафик" --> HIP_RTK - IHR_MTS -- "EU трафик" --> HIP_MTS - IHR_RTK -- "EU трафик" --> HIP_RTK - - style GW fill:#2196f3,stroke:#1976d2,stroke-width:3px,color:#fff - style MTS fill:#4caf50,stroke:#388e3c,stroke-width:2px,color:#fff - style RTK fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff -``` - -## Конфигурация туннелей - -### Туннели от HOME к удаленным серверам - -| Провайдер | Туннель | Назначение | Локация | Статус | Приоритет | Подсеть | HOME IP | Remote IP | -|-----------|---------|------------|---------|--------|-----------|---------|---------|-----------| -| **Ростелеком (RTK)** | MSK-VPSVILLE-RTK | Основной канал VPSVILLE | Москва | Активен | 1 (основной) | 10.100.2.0/30 | 10.100.2.1 | 10.100.2.2 | -| **Ростелеком (RTK)** | MSK-IHOR-RTK | Основной канал IHOR | Москва | Активен | 1 (основной) | 10.100.4.0/30 | 10.100.4.1 | 10.100.4.2 | -| **Ростелеком (RTK)** | SWE-HIPHOST-RTK | Основной канал HIPHOST | Швеция | Активен | 1 (основной) | 10.100.6.0/30 | 10.100.6.1 | 10.100.6.2 | -| **МТС (MTS)** | MSK-VPSVILLE-MTS | Резервный канал VPSVILLE | Москва | Активен | 2 (резервный) | 10.100.1.0/30 | 10.100.1.1 | 10.100.1.2 | -| **МТС (MTS)** | MSK-IHOR-MTS | Резервный канал IHOR | Москва | Активен | 2 (резервный) | 10.100.3.0/30 | 10.100.3.1 | 10.100.3.2 | -| **МТС (MTS)** | SWE-HIPHOST-MTS | Резервный канал HIPHOST | Швеция | Активен | 2 (резервный) | 10.100.5.0/30 | 10.100.5.1 | 10.100.5.2 | - -## Преимущества архитектуры - -| Преимущество | Описание | Практическое применение | -|--------------|----------|-------------------------| -| **Отказоустойчивость** | Два независимых провайдера обеспечивают непрерывность работы | Автоматический failover при отказе одного провайдера | -| **Географическое распределение** | Серверы в разных локациях (Москва, Швеция) | Оптимизация маршрутов и снижение задержек | -| **Балансировка нагрузки** | Возможность распределения трафика между провайдерами | Эффективное использование каналов | -| **Масштабируемость** | Легкое добавление новых туннелей и серверов | Простое расширение инфраструктуры | - -## Мониторинг - -| Компонент | Метод мониторинга | Цель | -|-----------|-------------------|------| -| **GRE туннели** | Отслеживание состояния всех GRE туннелей | Контроль связности | -| **Пропускная способность** | Мониторинг каналов | Оптимизация производительности | -| **Failover** | Автоматическое переключение при отказе основного канала | Обеспечение непрерывности | -| **Логирование** | События и статистика | Диагностика и анализ | - -## Технические детали - -| Параметр | Значение | Описание | -|----------|----------|----------| -| **Протокол** | GRE (Generic Routing Encapsulation) | Основной протокол туннелирования | -| **Шифрование** | IPSec (опционально) | Дополнительная защита трафика | -| **Мониторинг** | Keepalive пакеты | Контроль состояния туннелей | -| **Failover** | Автоматическое переключение при потере связи | Обеспечение отказоустойчивости | - ---- - -## Рекомендации по адресации GRE туннелей - -Для GRE туннелей рекомендуется использовать отдельный диапазон, например, `10.100.0.0/16`, чтобы избежать конфликтов с домашней сетью (`192.168.0.0/16`). Для каждого туннеля выделяется отдельная /30 подсеть (две точки). - -### Полная схема адресации GRE туннелей - -| Тип туннеля | Туннель | Подсеть | HOME IP | Remote IP | Провайдер | Приоритет | Описание | -|-------------|---------|---------|---------|-----------|-----------|-----------|----------| -| **HOME → Remote** | MSK-VPSVILLE-MTS | 10.100.1.0/30 | 10.100.1.1 | 10.100.1.2 | МТС | 2 (резервный) | Резервный канал VPSVILLE | -| **HOME → Remote** | MSK-VPSVILLE-RTK | 10.100.2.0/30 | 10.100.2.1 | 10.100.2.2 | Ростелеком | 1 (основной) | Основной канал VPSVILLE | -| **HOME → Remote** | MSK-IHOR-MTS | 10.100.3.0/30 | 10.100.3.1 | 10.100.3.2 | МТС | 2 (резервный) | Резервный канал IHOR | -| **HOME → Remote** | MSK-IHOR-RTK | 10.100.4.0/30 | 10.100.4.1 | 10.100.4.2 | Ростелеком | 1 (основной) | Основной канал IHOR | -| **HOME → Remote** | SWE-HIPHOST-MTS | 10.100.5.0/30 | 10.100.5.1 | 10.100.5.2 | МТС | 2 (резервный) | Резервный канал HIPHOST | -| **HOME → Remote** | SWE-HIPHOST-RTK | 10.100.6.0/30 | 10.100.6.1 | 10.100.6.2 | Ростелеком | 1 (основной) | Основной канал HIPHOST | - -**Принцип**: Домашний шлюз всегда получает первый IP (.1), удаленные серверы - второй (.2) - -### Принцип адресации GRE туннелей - -**Правило**: Домашний шлюз всегда получает первый IP (.1), удаленные серверы - второй (.2) - -**Преимущества такого подхода:** -- **Логическая последовательность**: HOME - точка входа в сеть, логично дать ему первый IP -- **Консистентность**: Все туннели от HOME имеют одинаковую схему адресации -- **Упрощение конфигурации**: Легче запомнить и настроить (HOME всегда .1) -- **Масштабируемость**: При добавлении новых туннелей схема остается понятной -- **Устранение путаницы**: Нет вопросов "кто где" - HOME всегда .1, серверы всегда .2 - -**Пример конфигурации на HOME:** -```shell -# Все GRE туннели на HOME получают .1 адрес -/ip address add address=10.100.1.1/30 interface=gre-MSK-VPSVILLE-MTS -/ip address add address=10.100.2.1/30 interface=gre-MSK-VPSVILLE-RTK -/ip address add address=10.100.3.1/30 interface=gre-MSK-IHOR-MTS -/ip address add address=10.100.4.1/30 interface=gre-MSK-IHOR-RTK -``` - -**Пример конфигурации на серверах:** -```shell -# Все серверы получают .2 адрес в своих туннелях -/ip address add address=10.100.1.2/30 interface=gre-MSK-VPSVILLE-MTS -/ip address add address=10.100.2.2/30 interface=gre-MSK-VPSVILLE-RTK -``` - -### Безопасность и маршрутизация на RouterOS - -- Использование диапазона `10.100.0.0/16` для GRE туннелей безопасно, если ваша домашняя сеть — `192.168.0.0/16`. -- Диапазоны не пересекаются, маршрутизация не сломается. -- На CHR (RouterOS) это стандартная практика: GRE туннели выносят в отдельный диапазон, чтобы не было конфликтов с LAN. -- Для GRE-интерфейсов прописывайте адресацию только из этого диапазона. -- В маршрутах на CHR не должно быть статических маршрутов, которые бы направляли `10.100.0.0/16` в локальную сеть. - -#### Пример маршрутизации на RouterOS - -- Для каждого GRE-интерфейса будет автоматически создан маршрут для /30 подсети. -- Например, если GRE-интерфейс имеет адрес 10.100.1.1/30, а удалённый — 10.100.1.2, то маршрут до 10.100.1.2 будет через этот GRE-интерфейс. -- Основная домашняя сеть (`192.168.0.0/16`) никак не пересекается с этими маршрутами. - -#### Пример конфигурации GRE туннеля на RouterOS - -```shell -/interface gre add name=gre-MSK-VPSVILLE-MTS remote-address= local-address= -/ip address add address=10.100.1.1/30 interface=gre-MSK-VPSVILLE-MTS -/ip route add dst-address=10.100.1.2/32 gateway=gre-MSK-VPSVILLE-MTS -``` - -- `` — внешний IP удалённого сервера -- `` — ваш внешний IP -- Аналогично для остальных туннелей, меняя адресацию по таблице выше - ---- - ---- - -## OSPF: Оптимальная настройка для GRE туннелей - -В данной архитектуре используется OSPF для динамической маршрутизации между всеми GRE туннелями. Основной провайдер — Ростелеком, резервный — МТС. Для HomeLab (например, 192.168.111.0/24) весь трафик направляется через МТС с помощью policy routing (route-table=MTS). - -### Рекомендации по настройке - -1. **OSPF cost** - - Для GRE туннелей через Ростелеком (основной) выставить меньший cost (например, 10) - - Для GRE туннелей через МТС (резервный) — больший cost (например, 100) - - Это обеспечит приоритет Ростелекома для всего трафика, кроме HomeLab - -2. **Policy Based Routing (PBR) для HomeLab** - - Для HomeLab (например, 192.168.111.0/24) настроить policy routing: - - Весь исходящий трафик с HomeLab отправлять в route-table=MTS - - В этой таблице основной маршрут — через МТС (резервный провайдер) - -3. **OSPF Instance и Area** - - Использовать одну OSPF instance для всех туннелей (если нет особых требований) - - Все GRE-интерфейсы добавить в одну area (обычно 0.0.0.0) - -### Пример конфигурации OSPF на RouterOS - -```shell -# 1. Настройка OSPF instance -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.1 - -# 2. Добавление GRE-интерфейсов в OSPF с разным cost -# Ростелеком (основной провайдер) - низкий cost -/routing ospf interface -add interface=gre-MSK-VPSVILLE-RTK cost=10 network-type=point-to-point -add interface=gre-MSK-IHOR-RTK cost=10 network-type=point-to-point -add interface=gre-SWE-HIPHOST-RTK cost=10 network-type=point-to-point - -# МТС (резервный провайдер) - высокий cost -add interface=gre-MSK-VPSVILLE-MTS cost=100 network-type=point-to-point -add interface=gre-MSK-IHOR-MTS cost=100 network-type=point-to-point -add interface=gre-SWE-HIPHOST-MTS cost=100 network-type=point-to-point - -# 3. Добавление сетей в OSPF -/routing ospf network -add network=10.100.0.0/16 area=backbone - -# 4. Policy Based Routing для HomeLab (использует резервный МТС) -/ip route -add dst-address=0.0.0.0/0 gateway= routing-table=MTS -/ip firewall mangle -add chain=prerouting src-address=192.168.111.0/24 action=mark-routing new-routing-mark=MTS - -# — адрес следующего хопа через МТС (например, 10.100.1.2) -``` - -#### Логика работы -- OSPF сам будет выбирать основной маршрут через Ростелеком (основной провайдер, cost=10). -- Если основной канал падает, трафик автоматически пойдёт через МТС (резервный провайдер, cost=100). -- Для HomeLab весь трафик всегда идёт через МТС (резервный провайдер), независимо от состояния каналов, благодаря policy routing. - ---- - ---- - -## Failover между московскими туннелями (route-table=MSK) - -Для клиентов/серверов, использующих отдельную таблицу маршрутизации `MSK`, реализован автоматический failover между московскими GRE туннелями. Если один из туннелей (MSK-VPSVILLE или MSK-IHOR) падает, весь трафик автоматически идёт через оставшийся рабочий туннель. - -### Как это работает -- OSPF анонсирует маршруты через оба московских туннеля. -- Если один туннель недоступен, маршрут через него исчезает из таблицы MSK. -- Policy Based Routing (PBR) направляет трафик нужных клиентов в таблицу MSK. -- В таблице MSK всегда есть маршрут через рабочий туннель. - -### Пример конфигурации на RouterOS - -```shell -# 1. Маркируем трафик для route-table=MSK -/ip firewall mangle -add chain=prerouting src-address=192.168.222.0/24 action=mark-routing new-routing-mark=MSK - -# 2. В таблице MSK маршруты через оба московских туннеля -/ip route -# OSPF сам добавит маршруты через gre-MSK-VPSVILLE и gre-MSK-IHOR, если они живы -# Если хотите вручную: -add dst-address=0.0.0.0/0 gateway=10.100.1.2 routing-table=MSK distance=1 -add dst-address=0.0.0.0/0 gateway=10.100.3.2 routing-table=MSK distance=2 - -# 3. OSPF интерфейсы для московских туннелей -/routing ospf interface -add interface=gre-MSK-VPSVILLE-RTK cost=10 network-type=point-to-point -add interface=gre-MSK-IHOR-RTK cost=10 network-type=point-to-point -add interface=gre-MSK-VPSVILLE-MTS cost=100 network-type=point-to-point -add interface=gre-MSK-IHOR-MTS cost=100 network-type=point-to-point -``` - -- OSPF будет держать маршруты только через живые туннели. -- Если оба туннеля живы — оба маршрута в таблице, основной с меньшим distance. -- Если один туннель падает — маршрут через него исчезает, трафик идёт через оставшийся. - ---- - ---- - -## Примеры конфигурации для RouterOS 7.14+ - -### OSPF (RouterOS 7.14+) - -```shell -# 1. Создание OSPF instance и area -/routing ospf instance -add name=default router-id=10.100.0.1 - -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 2. Добавление GRE-интерфейсов с нужным cost -# Ростелеком (основной провайдер) - низкий cost -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone -``` - -### Policy Based Routing (RouterOS 7.14+) - -```shell -# 1. Создание таблиц маршрутизации -/routing table -add name=MSK fib # Основной трафик через Ростелеком -add name=MTS fib # HomeLab трафик через МТС (резервный) - -# 2. Routing rules для выбора таблицы по источнику -/routing rule -add src-address=192.168.111.0/24 action=lookup table=MTS # HomeLab → МТС (резервный) -add src-address=192.168.222.0/24 action=lookup table=MSK # Основной трафик → Ростелеком - -# OSPF сам добавит маршруты в эти таблицы, если GRE-интерфейсы участвуют в OSPF -``` - -### GRE туннели (пример) - -```shell -/interface gre add name=gre-MSK-VPSVILLE-RTK remote-address= local-address= -/ip address add address=10.100.2.1/30 interface=gre-MSK-VPSVILLE-RTK -# Аналогично для остальных туннелей по таблице адресации -``` - ---- - -## Актуальные рекомендации для RouterOS 7.14+ - -- Используйте `/routing rule` для Policy Based Routing вместо mangle. -- OSPF интерфейсы и cost настраиваются через `interface-template`. -- Все GRE туннели должны быть добавлены в OSPF через interface-template для корректного анонса маршрутов. -- OSPF автоматически поддерживает failover между туннелями: если один туннель падает, маршрут исчезает из таблицы. -- Для отдельных сегментов (например, HomeLab или MSK) используйте отдельные routing table и routing rule для выбора нужного провайдера/туннеля. - ---- - -## Пример failover для route-table=MSK (RouterOS 7.14+) - -```shell -# 1. Routing rule для сегмента MSK -/routing rule -add src-address=192.168.222.0/24 action=lookup table=MSK - -# 2. OSPF сам добавит маршруты через gre-MSK-VPSVILLE и gre-MSK-IHOR в таблицу MSK -# Если хотите вручную: -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.1.2 routing-table=MSK distance=1 -add dst-address=0.0.0.0/0 gateway=10.100.3.2 routing-table=MSK distance=2 -``` - ---- - ---- - -## Масштабирование сети с GRE и OSPF - -Схема с GRE-туннелями и OSPF идеально подходит для масштабируемых и отказоустойчивых сетей. - -### Преимущества -- **Лёгкое добавление новых туннелей:** для нового хоста создаётся GRE-интерфейс, выделяется /30 подсеть, добавляется в OSPF — маршруты распространяются автоматически. -- **Быстрая замена хоста:** при замене сервера/маршрутизатора достаточно повторить настройки GRE и OSPF — сеть быстро перестроится без ручных правок на других устройствах. -- **Гибкая топология:** можно строить как “звезду”, так и “mesh” — OSPF сам выберет оптимальные маршруты и обеспечит резервирование. -- **Автоматический failover:** при недоступности туннеля или хоста OSPF убирает маршруты, трафик идёт по резервным путям. -- **Масштабируемость:** количество туннелей ограничено только ресурсами оборудования, добавление новых площадок не требует изменений на старых. - -### Рекомендации -- Для каждого нового туннеля используйте отдельную /30 подсеть из выделенного диапазона (например, 10.100.x.0/30). -- Все GRE-интерфейсы сразу добавляйте в OSPF через interface-template. -- Используйте шаблоны и автоматизацию для быстрой настройки новых точек. -- Документируйте назначение каждой подсети и туннеля (см. таблицу выше). - -### Пример добавления нового GRE туннеля и OSPF (RouterOS 7.14+) - -```shell -/interface gre add name=gre-NEW-SITE remote-address= local-address= -/ip address add address=10.100.10.1/30 interface=gre-NEW-SITE - -/routing ospf interface-template -add interfaces=gre-NEW-SITE cost=10 area=backbone -``` - ---- - ---- - -## Европейский трафик через GRE SWE-HIPHOST - -У всех московских серверов настроен GRE-туннель на SWE-HIPHOST. Обычно через этот туннель направляется европейский трафик для оптимизации маршрутов и повышения скорости доступа к европейским ресурсам. - -### Как это реализовано -- Все GRE-туннели SWE-HIPHOST добавлены в OSPF через interface-template. -- OSPF обеспечивает резервирование и автоматический failover для туннеля SWE-HIPHOST. -- Policy Based Routing (PBR) позволяет направлять трафик, предназначенный для Европы, через отдельную таблицу маршрутизации (EU), где основной маршрут — через GRE SWE-HIPHOST. - -### Пример конфигурации (RouterOS 7.14+) - -```shell -# 1. Создаём таблицу маршрутизации для Европы -/routing table -add name=EU fib - -# 2. Routing rule для европейского трафика (пример: по dst-address) -/routing rule -add dst-address= action=lookup table=EU - -# 3. В таблице EU маршрут по умолчанию через GRE SWE-HIPHOST -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 routing-table=EU distance=1 - -# 4. GRE SWE-HIPHOST добавлен в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=backbone # Основной канал -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone # Резервный канал -``` -- `` — список европейских подсетей или диапазонов (можно использовать address-list и mangle для сложных случаев). - -### Преимущества -- **Оптимизация маршрутов:** Европейский трафик идёт по кратчайшему пути через SWE-HIPHOST. -- **Резервирование:** OSPF обеспечивает автоматический failover при недоступности туннеля. -- **Гибкость:** Можно легко расширять список европейских подсетей или добавить резервные маршруты. - ---- - ---- - -## Рекомендации по неймингу CHR серверов - -Грамотный нейминг серверов и шлюзов облегчает сопровождение, масштабирование и диагностику сети. - -### Рекомендуемая структура имени - -Если у вас несколько хостеров/площадок в одном городе или стране, рекомендуется использовать следующий формат: - -``` -<город>.<площадка>.<роль>.<домен> -``` - -- **<город>** — код города или страны (например, msk, swe) -- **<площадка>** — название хостера или дата-центра (например, vpsville, ihor, hiphost) -- **<роль>** — rt (router), gw (gateway), srv (server) и т.д. -- **<домен>** — основной домен вашей инфраструктуры - -#### Пример: -- `msk.vpsville.rt.shx.su` — роутер в Москве, площадка VPSVILLE -- `msk.ihor.rt.shx.su` — роутер в Москве, площадка IHOR -- `swe.hiphost.rt.shx.su` — роутер в Швеции, площадка HIPHOST -- `home.rt.shx.su` — домашний роутер - -### Схема именования серверов - -| Локация | Имя сервера | DNS имя | Роль | Описание | -|---------|-------------|---------|------|----------| -| Домашний | home-gw | home.rt.shx.su | Gateway | Домашний роутер | -| Москва IHOR | msk-ihor-gw | msk.ihor.rt.shx.su | Router | Роутер IHOR, Москва | -| Москва VPSVILLE | msk-vpsville-gw | msk.vpsville.rt.shx.su | Router | Роутер VPSVILLE, Москва | -| Швеция HIPHOST | swe-hiphost-gw | swe.hiphost.rt.shx.su | Router | Роутер HIPHOST, Швеция | - -### Рекомендации -- Используйте короткие, но однозначные аббревиатуры для локаций: `msk` (Москва), `swe` (Швеция), `home` (домашний роутер) -- Для роли роутера используйте `rt` (router) вместо `gw` (gateway) -- Если есть несколько провайдеров на одной площадке, добавляйте суффикс: `msk-ihor-rt-rtk` (Москва, IHOR, роутер, Ростелеком) -- Для серверов без роутерной роли используйте, например, `srv` (server): `msk-ihor-srv` - -> **Почему именно такой порядок?** -> Если у вас несколько хостеров в одном городе, такой нейминг позволяет удобно группировать и искать объекты по локации, а внутри — по площадке. Это облегчает навигацию и масштабирование инфраструктуры. - ---- - ---- - -## Распределение IP-адресов между GRE туннелями между хостерами - -Грамотное распределение IP-адресов между GRE-туннелями между хостерами (site-to-site) обеспечивает прозрачность, масштабируемость и отсутствие конфликтов. - -### Рекомендации -- Выделяйте отдельный диапазон для межхостовых туннелей, например, `10.200.0.0/16`. -- Для каждого GRE-туннеля между двумя хостерами используйте отдельную /30 подсеть (2 usable IP). -- Систематизируйте назначение подсетей (например, по ID площадок или по алфавиту). -- Документируйте все туннели и адреса в README. - -### Схема адресации для GRE между хостерами - -| Туннель | Подсеть | Сервер A | IP A | Сервер B | IP B | Описание | -|---------|---------|----------|------|----------|------|----------| -| gre-MSK-VPSVILLE-IHOR | 10.200.0.0/30 | MSK-VPSVILLE | 10.200.0.1 | MSK-IHOR | 10.200.0.2 | VPSVILLE ↔ IHOR | -| gre-SWE-HIPHOST | 10.200.1.0/30 | MSK-VPSVILLE | 10.200.1.1 | SWE-HIPHOST | 10.200.1.2 | VPSVILLE ↔ SWE | -| gre-SWE-HIPHOST | 10.200.2.0/30 | MSK-IHOR | 10.200.2.1 | SWE-HIPHOST | 10.200.2.2 | IHOR ↔ SWE | - -- Для новых туннелей просто берите следующую свободную /30 из диапазона. -- Такой подход облегчает масштабирование и поддержку сети. - ---- - -## Связывание московских серверов через OSPF - -Для обеспечения отказоустойчивости и оптимизации маршрутов все московские серверы связаны через OSPF. Это обеспечивает автоматический failover между площадками и оптимальный выбор маршрутов. - -### Преимущества связывания московских серверов - -1. **Автоматический failover между московскими площадками** - - Если VPSVILLE недоступен, трафик автоматически пойдет через IHOR - - Если IHOR недоступен, трафик пойдет через VPSVILLE - -2. **Оптимизация маршрутов** - - OSPF автоматически выберет кратчайший путь - - Можно настроить разные cost для разных провайдеров - -3. **Масштабируемость** - - Легко добавлять новые московские площадки - - Автоматическое распространение маршрутов - -### Конфигурация GRE туннеля между московскими серверами - -#### На VPSVILLE (msk.vpsville.rt.shx.su): -```shell -# Создание GRE туннеля к IHOR -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.0.1/30 interface=gre-MSK-VPSVILLE-IHOR - -# Добавление в OSPF (межсерверная связь через основной провайдер) -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-IHOR cost=10 area=backbone -``` - -#### На IHOR (msk.ihor.rt.shx.su): -```shell -# Создание GRE туннеля к VPSVILLE -/interface gre add name=gre-MSK-IHOR-VPSVILLE remote-address= local-address= -/ip address add address=10.200.0.2/30 interface=gre-MSK-IHOR-VPSVILLE - -# Добавление в OSPF (межсерверная связь через основной провайдер) -/routing ospf interface-template -add interfaces=gre-MSK-IHOR-VPSVILLE cost=10 area=backbone -``` - -#### На HOME (home.rt.shx.su): -```shell -# HOME не создает GRE туннели между серверами -# HOME только подключается к серверам по GRE и получает маршруты через OSPF - -# OSPF настроен для получения маршрутов от серверов -/routing ospf interface-template -# Ростелеком (основной провайдер) - низкий cost -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone - -# Policy routing для выбора нужного сервера/провайдера -/routing rule -add src-address=192.168.111.0/24 action=lookup table=MTS # HomeLab → МТС (резервный) -add src-address=192.168.222.0/24 action=lookup table=MSK # Основной трафик → Ростелеком -``` - -### Логика работы OSPF между московскими серверами - -1. **Прямая связь между серверами**: VPSVILLE ↔ IHOR через GRE туннель 10.200.0.0/30 -2. **OSPF анонсирует маршруты**: Каждый сервер анонсирует свои сети через OSPF -3. **HOME получает маршруты**: HOME получает маршруты от обоих серверов через OSPF -4. **Автоматический failover**: Если один сервер недоступен, OSPF убирает маршруты через него -5. **Оптимальные маршруты**: OSPF выбирает кратчайший путь между серверами - -### Роль HOME в архитектуре - -- **HOME - конечная точка**: Подключается к серверам по GRE, но не участвует в межсерверной маршрутизации -- **Получение маршрутов**: HOME получает маршруты от серверов через OSPF -- **Policy routing**: HOME использует route tables для выбора нужного сервера/провайдера -- **Отправка трафика**: HOME отправляет трафик по правилам маршрутизации - -### Пример маршрутизации - -- **Трафик VPSVILLE → IHOR**: Прямо через GRE туннель 10.200.0.0/30 (без участия HOME) -- **Трафик HOME → Интернет**: Через VPSVILLE или IHOR согласно route tables -- **Трафик HOME → Европа**: Через SWE-HIPHOST согласно policy routing - -### Мониторинг связей - -```shell -# Проверка состояния GRE туннелей -/interface gre print - -# Проверка OSPF соседей -/routing ospf neighbor print - -# Проверка маршрутов -/ip route print -``` - ---- - -## OSPF и дублирование маршрутов - -При использовании OSPF между серверами может происходить дублирование маршрутов в таблице маршрутизации. Это нормальное поведение, но важно понимать, как это контролировать. - -### Как работает дублирование маршрутов - -1. **OSPF анонсирует маршруты**: Каждый сервер анонсирует свои сети через OSPF -2. **Множественные пути**: HOME может получить маршрут до одной сети через разные серверы -3. **Distance и cost**: OSPF использует cost для выбора оптимального пути, но может создавать резервные маршруты - -### Пример дублирования маршрутов - -```shell -# На HOME может быть несколько маршрутов до одной сети: -/ip route print -Flags: D - DYNAMIC; A - ACTIVE; c - CONNECT, s - STATIC, r - RIP, m - MODEM, b - BGP, o - OSPF, M - MME, B - BLACKHOLE, U - UNREACHABLE, F - FIB, v - VPLS, V - VRF, l - LISP, a - BFD, M - MME, t - TTLS, I - IDE, W - WINBOX, X - XAUTH, g - 7GRE, S - SNAT -Columns: DST-ADDRESS, GATEWAY, DISTANCE -# DST-ADDRESS GATEWAY DISTANCE -0 A s 0.0.0.0/0 10.100.2.2 1 # Через VPSVILLE-RTK (основной) -1 A s 0.0.0.0/0 10.100.4.2 1 # Через IHOR-RTK (основной) -2 A s 0.0.0.0/0 10.100.1.2 2 # Через VPSVILLE-MTS (резервный) -3 A s 0.0.0.0/0 10.100.3.2 2 # Через IHOR-MTS (резервный) -``` - -### Контроль дублирования через OSPF cost - -```shell -# Настройка разных cost для приоритизации маршрутов -/routing ospf interface-template -# Ростелеком (основной провайдер) - низкий cost -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone -``` - -### Использование route tables для разделения трафика - -```shell -# Создание отдельных таблиц маршрутизации -/routing table -add name=MSK fib # Основной трафик через Ростелеком -add name=MTS fib # HomeLab трафик через МТС (резервный) - -# Routing rules для выбора таблицы -/routing rule -add src-address=192.168.111.0/24 action=lookup table=MTS # HomeLab → МТС (резервный) -add src-address=192.168.222.0/24 action=lookup table=MSK # Основной трафик → Ростелеком - -# В каждой таблице будет свой набор маршрутов -# OSPF автоматически добавит маршруты в соответствующие таблицы -``` - -### Преимущества дублирования маршрутов - -1. **Автоматический failover**: Если основной маршрут недоступен, используется резервный -2. **Load balancing**: Можно настроить балансировку нагрузки между маршрутами -3. **Отказоустойчивость**: Сеть продолжает работать даже при отказе части каналов - -### Мониторинг дублирования - -```shell -# Просмотр всех маршрутов с деталями -/ip route print detail - -# Просмотр OSPF маршрутов -/routing ospf route print - -# Проверка активных маршрутов -/ip route print where active=yes -``` - ---- - -## Настройка OSPF для анонса только 0.0.0.0/0 (RouterOS 7.14+) - -Для того чтобы удаленные серверы анонсировали только маршрут по умолчанию (0.0.0.0/0) через OSPF, нужно настроить Redistribute с Out filter. В RouterOS 7.14+ команда `/routing ospf network` больше не используется. - -### Конфигурация на удаленных серверах (VPSVILLE, IHOR, HIPHOST) - -#### 1. Создание Out filter для OSPF - -```shell -# Создание фильтра, который пропускает только 0.0.0.0/0 -/routing filter -add name=ospf-out-default-only chain=output protocol=ospf rule="if (dst-address=0.0.0.0/0) { accept } else { reject }" -``` - -#### 2. Настройка OSPF Redistribute с фильтром (основной способ для RouterOS 7.14+) - -```shell -# Настройка OSPF instance для redistribute -/routing ospf instance -set [ find default=yes ] redistribute=connected,static - -# Применение фильтра к OSPF -/routing ospf instance -set [ find default=yes ] out-filter=ospf-out-default-only -``` - -#### 2a. Альтернативный способ без routing filters - -```shell -# Настройка OSPF instance только для redistribute connected -/routing ospf instance -set [ find default=yes ] redistribute=connected - -# Или только для redistribute static (если 0.0.0.0/0 - статический маршрут) -/routing ospf instance -set [ find default=yes ] redistribute=static -``` - -#### 3. Альтернативный способ через OSPF networks (RouterOS 6.x) - -```shell -# В RouterOS 6.x можно было использовать networks -/routing ospf network -add network=0.0.0.0/0 area=backbone - -# В RouterOS 7.14+ эта команда больше не используется -# Вместо неё используется redistribute с фильтрами -``` - -### Конфигурация на HOME - -#### 1. Создание In filter для OSPF (опционально) - -```shell -# Фильтр для входящих OSPF маршрутов (если нужна дополнительная фильтрация) -/routing filter -add name=ospf-in-default-only chain=input protocol=ospf rule="if (dst-address=0.0.0.0/0) { accept } else { reject }" - -# Применение фильтра к OSPF instance -/routing ospf instance -set [ find default=yes ] in-filter=ospf-in-default-only -``` - -### Проверка конфигурации - -```shell -# Проверка OSPF маршрутов на удаленном сервере -/routing ospf route print - -# Проверка OSPF маршрутов на HOME -/routing ospf route print - -# Проверка таблицы маршрутизации на HOME -/ip route print where protocol=ospf -``` - -### Пример полной конфигурации на удаленном сервере (RouterOS 7.14+) - -#### Способ 1: С фильтром (если работает) - -```shell -# 1. Создание фильтра для анонса только 0.0.0.0/0 -/routing filter -add name=ospf-out-default-only chain=output protocol=ospf rule="if (dst-address=0.0.0.0/0) { accept } else { reject }" - -# 2. Настройка OSPF instance с redistribute и фильтром -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=connected,static out-filter=ospf-out-default-only - -# 3. Добавление GRE интерфейсов в OSPF -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone - -# 4. Проверка что только 0.0.0.0/0 анонсируется -/routing ospf route print -``` - -#### Способ 2: Без фильтров (простой) - -```shell -# 1. Настройка OSPF instance только для redistribute connected -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=connected - -# 2. Добавление GRE интерфейсов в OSPF -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone - -# 3. Проверка анонсируемых маршрутов -/routing ospf route print -``` - -#### Способ 3: Только статические маршруты - -```shell -# 1. Настройка OSPF instance только для redistribute static -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=static - -# 2. Добавление GRE интерфейсов в OSPF -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone - -# 3. Проверка анонсируемых маршрутов -/routing ospf route print -``` - -#### Способ 4: Простой статический маршрут (гарантированно работает) - -```shell -# 1. Добавление статического маршрута 0.0.0.0/0 через SWE-HIPHOST-MTS -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 distance=1 - -# 2. Настройка OSPF instance для redistribute static -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=static - -# 3. Добавление GRE интерфейсов в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone - -# 4. Проверка что маршрут анонсируется -/routing ospf route print -``` - -#### Способ 5: Самый простой - без OSPF - -```shell -# Просто добавить статический маршрут на HOME -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 distance=1 routing-table=EU - -# Или для основной таблицы -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 distance=1 -``` - -### Логика работы - -1. **Удаленный сервер** имеет маршрут по умолчанию (0.0.0.0/0) в своей таблице -2. **Redistribute** анонсирует маршруты через OSPF (с фильтром или без) -3. **HOME** получает маршруты от удаленного сервера -4. **OSPF** выбирает оптимальный путь на основе cost - -### Выбор способа настройки OSPF - -| Способ | Метод | Преимущества | Недостатки | Рекомендация | -|--------|-------|--------------|------------|--------------| -| **Способ 1** | С фильтром | Точный контроль, только нужные маршруты | Сложнее настройка, может не работать в некоторых версиях | Для опытных | -| **Способ 2** | redistribute=connected | Простая настройка, работает стабильно | Анонсирует все connected маршруты | **Рекомендуемый** | -| **Способ 3** | redistribute=static | Простая настройка, только статические маршруты | Анонсирует все статические маршруты | Если 0.0.0.0/0 статический | -| **Способ 4** | Простой статический маршрут | Гарантированно работает, простой | Нужно вручную добавить маршрут | Самый надежный | - -### Преимущества такого подхода - -| Преимущество | Описание | Влияние | -|--------------|----------|---------| -| **Чистая таблица маршрутизации** | Только нужные маршруты (при использовании фильтров) | Упрощение диагностики | -| **Контроль трафика** | Можно точно указать, какие маршруты анонсировать | Безопасность и производительность | -| **Безопасность** | Не раскрываются внутренние сети удаленных серверов | Защита от несанкционированного доступа | -| **Производительность** | Меньше маршрутов = быстрее обработка | Оптимизация работы роутера | -| **Простота** | Можно обойтись без сложных фильтров | Легкость настройки и поддержки | - ---- - -## Конкретное решение: Маршрут 0.0.0.0/0 через SWE-HIPHOST-MTS - -### На SWE-HIPHOST (удаленный сервер): - -#### Способ 1: С фильтрацией AWS metadata (рекомендуемый) - -```shell -# 1. Маршрут 0.0.0.0/0 уже есть (получен через DHCP) -# Проверить текущие маршруты: -/ip route print - -# 2. Создать фильтр для исключения AWS metadata -/routing filter -add name=ospf-out-no-aws chain=output protocol=ospf rule="if (dst-address=169.254.169.254/32) { reject } else { accept }" - -# 3. Настроить OSPF с фильтром -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.5 redistribute=connected out-filter=ospf-out-no-aws - -# 4. Добавить GRE интерфейс в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone - -# 5. Проверить что маршрут анонсируется -/routing ospf route print -``` - -#### Способ 2: Без фильтрации (если фильтры не работают) - -```shell -# 1. Проверить текущие маршруты: -/ip route print - -# 2. Настроить OSPF для redistribute connected -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.5 redistribute=connected - -# 3. Добавить GRE интерфейс в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone - -# 4. Проверить что маршрут анонсируется -/routing ospf route print -``` - -### На HOME: - -```shell -# 1. Добавить GRE интерфейс в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone - -# 2. Проверить получение маршрута -/routing ospf route print - -# 3. Проверить таблицу маршрутизации -/ip route print where protocol=ospf -``` - -### Альтернатива - статический маршрут на HOME: - -```shell -# Если OSPF не работает, просто добавить статический маршрут -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 distance=1 - -# Или для отдельной таблицы маршрутизации -/ip route -add dst-address=0.0.0.0/0 gateway=10.100.5.2 distance=1 routing-table=EU -``` - ---- - -## Настройка OSPF cost для failover между серверами - -Для того чтобы OSPF cost работал и один маршрут заменялся другим в зависимости от cost, нужно настроить OSPF на всех серверах, которые анонсируют маршруты. - -### Конфигурация на SWE-HIPHOST (анонсирует маршрут 0.0.0.0/0): - -```shell -# 1. Создать фильтр для исключения AWS metadata -/routing filter -add name=ospf-out-no-aws chain=output protocol=ospf rule="if (dst-address=169.254.169.254/32) { reject } else { accept }" - -# 2. Настроить OSPF с фильтром и redistribute -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.5 redistribute=connected out-filter=ospf-out-no-aws - -# 3. Создать area (если не существует) -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 4. Добавить GRE интерфейсы в OSPF с разным cost -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=backbone # Основной канал -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone # Резервный канал - -# 5. Проверить что маршрут анонсируется -/routing ospf route print -``` - -### Конфигурация на MSK-VPSVILLE (анонсирует маршрут 0.0.0.0/0): - -```shell -# 1. Создать фильтр для исключения AWS metadata (если есть) -/routing filter -add name=ospf-out-no-aws chain=output protocol=ospf rule="if (dst-address=169.254.169.254/32) { reject } else { accept }" - -# 2. Настроить OSPF с фильтром и redistribute -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=connected out-filter=ospf-out-no-aws - -# 3. Создать area (если не существует) -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 4. Добавить GRE интерфейсы в OSPF с разным cost -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone # Основной канал -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone # Резервный канал - -# 5. Проверить что маршрут анонсируется -/routing ospf route print -``` - -### Конфигурация на HOME (получает маршруты): - -```shell -# 1. Создать area (если не существует) -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 2. Добавить все GRE интерфейсы в OSPF с соответствующим cost -/routing ospf interface-template -# Ростелеком (основной провайдер) - низкий cost -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone - -# 3. Проверить полученные маршруты -/routing ospf route print - -# 4. Проверить таблицу маршрутизации -/ip route print where protocol=ospf -``` - -### Логика работы OSPF cost: - -1. **SWE-HIPHOST** анонсирует маршрут 0.0.0.0/0 через оба канала: - - gre-SWE-HIPHOST-RTK (cost=10) - основной - - gre-SWE-HIPHOST-MTS (cost=100) - резервный - -2. **MSK-VPSVILLE** анонсирует маршрут 0.0.0.0/0 через оба канала: - - gre-MSK-VPSVILLE-RTK (cost=10) - основной - - gre-MSK-VPSVILLE-MTS (cost=100) - резервный - -3. **HOME** получает маршруты от обоих серверов и выбирает оптимальный путь на основе cost - -4. **Автоматический failover**: Если основной канал падает, OSPF автоматически переключается на резервный - -### Настройка OSPF Area - -**Важно**: Все устройства должны использовать одинаковую area для корректной работы OSPF. - -#### Вариант 1: Одна area (рекомендуемый для простых сетей) - -```shell -# На всех устройствах (HOME, SWE-HIPHOST, MSK-VPSVILLE, MSK-IHOR): -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# Или использовать существующую area: -/routing ospf area print -# Если area уже создана, используйте её имя -``` - -#### Вариант 2: Создание новой area - -```shell -# На всех устройствах создать одинаковую area: -/routing ospf area -add name=main-area instance=default area-id=0.0.0.1 - -# Затем использовать её в interface-template: -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=main-area -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=main-area -``` - -#### Проверка area настройки: - -```shell -# Проверить существующие area: -/routing ospf area print - -# Проверить OSPF соседей: -/routing ospf neighbor print - -# Проверить что соседи в одной area: -/routing ospf neighbor print detail -``` - ---- - -## Оптимальная схема OSPF Areas для вашей сети - -### Рекомендуемая архитектура Areas - -Для вашей сети с несколькими локациями и провайдерами рекомендуется использовать **многоуровневую схему areas**: - -#### Схема 1: Простая (рекомендуемая для начала) - -``` -Area 0.0.0.0 (Backbone) - все устройства -├── HOME (home.rt.shx.su) -├── MSK-VPSVILLE (msk.vpsville.rt.shx.su) -├── MSK-IHOR (msk.ihor.rt.shx.su) -└── SWE-HIPHOST (swe.hiphost.rt.shx.su) -``` - -#### Схема 2: По локациям (для масштабирования) - -``` -Area 0.0.0.0 (Backbone) - HOME -├── Area 0.0.0.1 (MSK) - московские серверы -│ ├── MSK-VPSVILLE -│ └── MSK-IHOR -└── Area 0.0.0.2 (SWE) - шведский сервер - └── SWE-HIPHOST -``` - -#### Схема 3: По провайдерам (для изоляции) - -``` -Area 0.0.0.0 (Backbone) - HOME -├── Area 0.0.0.10 (RTK) - Ростелеком туннели -│ ├── MSK-VPSVILLE-RTK -│ ├── MSK-IHOR-RTK -│ └── SWE-HIPHOST-RTK -└── Area 0.0.0.20 (MTS) - МТС туннели - ├── MSK-VPSVILLE-MTS - ├── MSK-IHOR-MTS - └── SWE-HIPHOST-MTS -``` - -### Рекомендация: Начните с простой схемы - -Для вашей текущей сети **рекомендую начать с Схемы 1** (одна area): - -#### Конфигурация для Схемы 1: - -```shell -# На всех устройствах (HOME, MSK-VPSVILLE, MSK-IHOR, SWE-HIPHOST): - -# 1. Создать backbone area -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 2. Добавить все GRE интерфейсы в backbone area -/routing ospf interface-template -# Ростелеком (основной провайдер) - низкий cost -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=backbone -``` - -### Преимущества простой схемы (Area 0.0.0.0): - -1. **Простота настройки**: Все устройства в одной area -2. **Быстрая конвергенция**: Нет меж-area маршрутизации -3. **Простота отладки**: Легче диагностировать проблемы -4. **Совместимость**: Работает с любыми версиями RouterOS - -### Когда переходить на сложные схемы: - -#### Переход на Схему 2 (по локациям) если: -- У вас будет больше московских серверов (5+) -- Нужна изоляция московского трафика -- Планируется добавление других стран - -#### Переход на Схему 3 (по провайдерам) если: -- Нужна полная изоляция трафика по провайдерам -- Планируется добавление третьего провайдера -- Требуется сложная политика маршрутизации - -### Конфигурация для Схемы 2 (по локациям): - -```shell -# На HOME (Area 0.0.0.0 - Backbone): -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 -add name=msk-area instance=default area-id=0.0.0.1 -add name=swe-area instance=default area-id=0.0.0.2 - -# На MSK-VPSVILLE и MSK-IHOR (Area 0.0.0.1 - MSK): -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 -add name=msk-area instance=default area-id=0.0.0.1 - -# На SWE-HIPHOST (Area 0.0.0.2 - SWE): -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 -add name=swe-area instance=default area-id=0.0.0.2 -``` - -### Конфигурация для Схемы 3 (по провайдерам): - -```shell -# На всех устройствах: -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 -add name=rtk-area instance=default area-id=0.0.0.10 -add name=mts-area instance=default area-id=0.0.0.20 - -# Ростелеком туннели в rtk-area: -/routing ospf interface-template -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=rtk-area -add interfaces=gre-MSK-IHOR-RTK cost=10 area=rtk-area -add interfaces=gre-SWE-HIPHOST-RTK cost=10 area=rtk-area - -# МТС туннели в mts-area: -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=mts-area -add interfaces=gre-MSK-IHOR-MTS cost=100 area=mts-area -add interfaces=gre-SWE-HIPHOST-MTS cost=100 area=mts-area -``` - -### Проверка конфигурации Areas: - -```shell -# Проверить все areas -/routing ospf area print - -# Проверить интерфейсы в каждой area -/routing ospf interface-template print - -# Проверить соседей и их areas -/routing ospf neighbor print detail - -# Проверить маршруты по areas -/routing ospf route print -``` - -### Рекомендации по выбору Area ID: - -| Area ID | Назначение | Описание | -|---------|------------|----------| -| 0.0.0.0 | Backbone area | Основная area (обязательно) | -| 0.0.0.1 | Первая обычная area | Для простых сетей | -| 0.0.0.2 | Вторая обычная area | Для расширенных сетей | -| 0.0.0.10 | Area для Ростелеком | Изоляция трафика Ростелеком | -| 0.0.0.20 | Area для МТС | Изоляция трафика МТС | -| 0.0.0.100 | Area для Москвы | Изоляция московского трафика | -| 0.0.0.200 | Area для Швеции | Изоляция шведского трафика | - -### Миграция с простой схемы на сложную: - -```shell -# Шаг 1: Добавить новые areas -/routing ospf area -add name=msk-area instance=default area-id=0.0.0.1 - -# Шаг 2: Изменить area для московских интерфейсов -/routing ospf interface-template -set [ find where interfaces=gre-MSK-VPSVILLE-RTK ] area=msk-area -set [ find where interfaces=gre-MSK-VPSVILLE-MTS ] area=msk-area - -# Шаг 3: Проверить что OSPF работает -/routing ospf neighbor print -``` - ---- - -## Архитектура: Московские серверы как единый OSPF кластер - -### Концепция - -Все московские серверы (MSK-VPSVILLE, MSK-IHOR) объединены в единый OSPF кластер. Если на одном из них падает GRE туннель к SWE-HIPHOST, весь трафик автоматически идет через другой сервер. - -### Схема архитектуры - -```mermaid -graph TB - subgraph "Москва - OSPF кластер" - VPSVILLE[MSK-VPSVILLE
msk.vpsville.rt.shx.su] - IHOR[MSK-IHOR
msk.ihor.rt.shx.su] - end - - subgraph "Швеция" - HIPHOST[SWE-HIPHOST
swe.hiphost.rt.shx.su] - end - - subgraph "Домашний шлюз" - HOME[HOME
home.rt.shx.su] - end - - %% GRE туннели от HOME к московским серверам - HOME -- "GRE MSK-VPSVILLE-RTK
10.100.2.0/30" --> VPSVILLE - HOME -- "GRE MSK-VPSVILLE-MTS
10.100.1.0/30" --> VPSVILLE - HOME -- "GRE MSK-IHOR-RTK
10.100.4.0/30" --> IHOR - HOME -- "GRE MSK-IHOR-MTS
10.100.3.0/30" --> IHOR - - %% GRE туннели от московских серверов к SWE-HIPHOST - VPSVILLE -- "GRE SWE-HIPHOST
10.200.1.0/30" --> HIPHOST - IHOR -- "GRE SWE-HIPHOST
10.200.2.0/30" --> HIPHOST - - %% OSPF связи между московскими серверами - VPSVILLE -. "OSPF" .- IHOR - - %% HOME получает маршруты от московских серверов - VPSVILLE -. "OSPF маршруты" .- HOME - IHOR -. "OSPF маршруты" .- HOME - - style VPSVILLE fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style IHOR fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#fff - style HIPHOST fill:#4caf50,stroke:#388e3c,stroke-width:2px,color:#fff - style HOME fill:#e0e0e0,stroke:#9e9e9e,stroke-width:2px,color:#000 -``` - -### Конфигурация на московских серверах - -#### На MSK-VPSVILLE (msk.vpsville.rt.shx.su): - -```shell -# 1. GRE туннель к SWE-HIPHOST (межсерверный туннель) -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.1.1/30 interface=gre-SWE-HIPHOST - -# 2. GRE туннель к MSK-IHOR (межсерверный туннель) -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.0.1/30 interface=gre-MSK-VPSVILLE-IHOR - -# 3. OSPF конфигурация -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 4. Добавить все интерфейсы в OSPF -/routing ospf interface-template -# Интерфейсы к HOME -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone - -# Интерфейс к SWE-HIPHOST -add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -# Интерфейс к MSK-IHOR -add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone - -# 5. Анонсировать маршрут 0.0.0.0/0 через SWE-HIPHOST -/ip route add dst-address=0.0.0.0/0 gateway=10.200.1.2 distance=1 - -# 6. OSPF redistribute -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.2 redistribute=connected,static -``` - -#### На MSK-IHOR (msk.ihor.rt.shx.su): - -```shell -# 1. GRE туннель к SWE-HIPHOST (межсерверный туннель) -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.2.1/30 interface=gre-SWE-HIPHOST - -# 2. GRE туннель к MSK-VPSVILLE (межсерверный туннель) -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.0.2/30 interface=gre-MSK-VPSVILLE-IHOR - -# 3. OSPF конфигурация -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 4. Добавить все интерфейсы в OSPF -/routing ospf interface-template -# Интерфейсы к HOME -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone - -# Интерфейс к SWE-HIPHOST -add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -# Интерфейс к MSK-VPSVILLE -add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone - -# 5. Анонсировать маршрут 0.0.0.0/0 через SWE-HIPHOST -/ip route add dst-address=0.0.0.0/0 gateway=10.200.2.2 distance=1 - -# 6. OSPF redistribute -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.3 redistribute=connected,static -``` - -### Конфигурация на SWE-HIPHOST (swe.hiphost.rt.shx.su): - -```shell -# 1. GRE туннели от московских серверов (межсерверные туннели) -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.1.2/30 interface=gre-SWE-HIPHOST - -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.2.2/30 interface=gre-MSK-VPSVILLE-IHOR - -# 2. OSPF конфигурация -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 3. Добавить интерфейсы в OSPF -/routing ospf interface-template -add interfaces=gre-SWE-HIPHOST cost=10 area=backbone -add interfaces=gre-MSK-VPSVILLE-IHOR cost=10 area=backbone - -# 4. OSPF redistribute (маршрут 0.0.0.0/0 получен через DHCP) -/routing ospf instance -set [ find default=yes ] router-id=10.100.0.5 redistribute=connected -``` - -### Конфигурация на HOME (home.rt.shx.su): - -```shell -# 1. OSPF конфигурация -/routing ospf area -add name=backbone instance=default area-id=0.0.0.0 - -# 2. Добавить интерфейсы к московским серверам в OSPF -/routing ospf interface-template -# Ростелеком (основной провайдер) - низкий cost -add interfaces=gre-MSK-VPSVILLE-RTK cost=10 area=backbone -add interfaces=gre-MSK-IHOR-RTK cost=10 area=backbone - -# МТС (резервный провайдер) - высокий cost -add interfaces=gre-MSK-VPSVILLE-MTS cost=100 area=backbone -add interfaces=gre-MSK-IHOR-MTS cost=100 area=backbone - -# 3. Policy routing для выбора сервера -/routing rule -add src-address=192.168.111.0/24 action=lookup table=MTS # HomeLab → МТС -add src-address=192.168.222.0/24 action=lookup table=MSK # Основной трафик → Ростелеком -``` - -### Межсерверные туннели (OSPF кластер) - -| Туннель | Подсеть | Сервер A | IP A | Сервер B | IP B | Описание | OSPF Cost | -|---------|---------|----------|------|----------|------|----------|-----------| -| gre-SWE-HIPHOST | 10.200.1.0/30 | MSK-VPSVILLE | 10.200.1.1 | SWE-HIPHOST | 10.200.1.2 | VPSVILLE ↔ SWE | 10 | -| gre-SWE-HIPHOST | 10.200.2.0/30 | MSK-IHOR | 10.200.2.1 | SWE-HIPHOST | 10.200.2.2 | IHOR ↔ SWE | 10 | -| gre-MSK-VPSVILLE-IHOR | 10.200.0.0/30 | MSK-VPSVILLE | 10.200.0.1 | MSK-IHOR | 10.200.0.2 | VPSVILLE ↔ IHOR | 5 | - -**Примечание**: Все межсерверные туннели используют диапазон 10.200.0.0/16, что обеспечивает четкое разделение от туннелей HOME (10.100.0.0/16). - -### Единая система именования межсерверных туннелей - -Для удобства массовой рассылки статических списков маршрутизации все межсерверные туннели используют единые названия: - -#### Принцип именования: -- **gre-SWE-HIPHOST** - все туннели к SWE-HIPHOST (от VPSVILLE и IHOR) -- **gre-MSK-VPSVILLE-IHOR** - все туннели между московскими серверами и к IHOR - -#### Преимущества единого именования: -1. **Массовая настройка**: Одинаковые команды для всех серверов -2. **Упрощение скриптов**: Можно использовать шаблоны конфигурации -3. **Единообразие**: Легче поддерживать и документировать -4. **Масштабируемость**: При добавлении новых серверов схема остается понятной - -#### Пример массовой рассылки конфигурации: - -```shell -# Шаблон для всех серверов с туннелем gre-SWE-HIPHOST -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=/30 interface=gre-SWE-HIPHOST -/routing ospf interface-template add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -# Шаблон для всех серверов с туннелем gre-MSK-VPSVILLE-IHOR -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=/30 interface=gre-MSK-VPSVILLE-IHOR -/routing ospf interface-template add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone -``` - -### Логика разделения адресов на диапазоны - -#### Диапазон 10.100.0.0/16 - Туннели от HOME -- **Назначение**: Все GRE туннели, которые создает HOME (домашний шлюз) -- **Принцип**: HOME всегда инициирует туннели к удаленным серверам -- **Примеры**: - - MSK-VPSVILLE-RTK (10.100.2.0/30) - HOME → VPSVILLE через Ростелеком - - MSK-VPSVILLE-MTS (10.100.1.0/30) - HOME → VPSVILLE через МТС - - MSK-IHOR-RTK (10.100.4.0/30) - HOME → IHOR через Ростелеком - - MSK-IHOR-MTS (10.100.3.0/30) - HOME → IHOR через МТС - - SWE-HIPHOST-RTK (10.100.6.0/30) - HOME → HIPHOST через Ростелеком - - SWE-HIPHOST-MTS (10.100.5.0/30) - HOME → HIPHOST через МТС - -#### Диапазон 10.200.0.0/16 - Межсерверные туннели -- **Назначение**: GRE туннели между серверами (без участия HOME) -- **Принцип**: Серверы создают туннели друг к другу для OSPF связности -- **Примеры**: - - MSK-VPSVILLE ↔ MSK-IHOR (10.200.0.0/30) - связь между московскими серверами - - MSK-VPSVILLE ↔ SWE-HIPHOST (10.200.1.0/30) - связь VPSVILLE → SWE - - MSK-IHOR ↔ SWE-HIPHOST (10.200.2.0/30) - связь IHOR → SWE - -### Альтернативный подход: Единый диапазон - -Если хотите использовать единый диапазон 10.100.0.0/16 для всех туннелей: - -| Туннель | Подсеть | IP (левый сервер) | IP (правый сервер) | Описание | -|--------------------------------|-----------------|-------------------|--------------------|----------| -| MSK-VPSVILLE ↔ SWE-HIPHOST | 10.100.7.0/30 | 10.100.7.1 | 10.100.7.2 | VPSVILLE → SWE | -| MSK-IHOR ↔ SWE-HIPHOST | 10.100.8.0/30 | 10.100.8.1 | 10.100.8.2 | IHOR → SWE | -| MSK-VPSVILLE ↔ MSK-IHOR | 10.100.9.0/30 | 10.100.9.1 | 10.100.9.2 | Межсерверная связь | - -### Рекомендация: Единый диапазон - -**Рекомендую использовать единый диапазон 10.100.0.0/16** для всех туннелей: - -| Тип туннеля | Туннель | Подсеть | Описание | -|-------------|---------|---------|----------| -| **HOME → Remote** | MSK-VPSVILLE-MTS | 10.100.1.0/30 | HOME → VPSVILLE (МТС) | -| **HOME → Remote** | MSK-VPSVILLE-RTK | 10.100.2.0/30 | HOME → VPSVILLE (Ростелеком) | -| **HOME → Remote** | MSK-IHOR-MTS | 10.100.3.0/30 | HOME → IHOR (МТС) | -| **HOME → Remote** | MSK-IHOR-RTK | 10.100.4.0/30 | HOME → IHOR (Ростелеком) | -| **HOME → Remote** | SWE-HIPHOST-MTS | 10.100.5.0/30 | HOME → HIPHOST (МТС) | -| **HOME → Remote** | SWE-HIPHOST-RTK | 10.100.6.0/30 | HOME → HIPHOST (Ростелеком) | -| **Server ↔ Server** | gre-SWE-HIPHOST | 10.100.7.0/30 | VPSVILLE ↔ SWE | -| **Server ↔ Server** | gre-SWE-HIPHOST | 10.100.8.0/30 | IHOR ↔ SWE | -| **Server ↔ Server** | gre-MSK-VPSVILLE-IHOR | 10.100.9.0/30 | VPSVILLE ↔ IHOR | - -**Преимущества единого диапазона:** -1. **Простота**: Все туннели в одном диапазоне -2. **Логичность**: Последовательная нумерация -3. **Масштабируемость**: Легко добавлять новые туннели -4. **Документирование**: Проще вести учет адресов - -### Логика работы failover - -1. **Нормальная работа**: - - HOME получает маршрут 0.0.0.0/0 от MSK-VPSVILLE через OSPF - - MSK-VPSVILLE имеет GRE туннель gre-SWE-HIPHOST к SWE-HIPHOST (10.200.1.0/30) - -2. **Отказ GRE туннеля gre-SWE-HIPHOST на MSK-VPSVILLE**: - - MSK-VPSVILLE больше не может достичь SWE-HIPHOST через gre-SWE-HIPHOST - - MSK-VPSVILLE убирает маршрут 0.0.0.0/0 из OSPF - - HOME получает маршрут 0.0.0.0/0 от MSK-IHOR через OSPF - - Весь трафик идет через MSK-IHOR → gre-SWE-HIPHOST → SWE-HIPHOST (10.200.2.0/30) - -3. **Автоматическое восстановление**: - - Когда GRE туннель gre-SWE-HIPHOST на MSK-VPSVILLE восстанавливается - - MSK-VPSVILLE снова анонсирует маршрут 0.0.0.0/0 - - OSPF выбирает оптимальный путь (обычно через MSK-VPSVILLE) - -### Преимущества этой архитектуры - -1. **Полная отказоустойчивость**: Если один московский сервер теряет связь с SWE-HIPHOST, трафик идет через другой -2. **Автоматический failover**: OSPF автоматически переключает маршруты -3. **Быстрое восстановление**: При восстановлении связи автоматически возвращается к оптимальному маршруту -4. **Масштабируемость**: Легко добавить третий московский сервер - -### Мониторинг failover - -```shell -# Проверить OSPF маршруты -/routing ospf route print - -# Проверить активные маршруты -/ip route print where active=yes - -# Проверить состояние GRE туннелей -/interface gre print - -# Проверить OSPF соседей -/routing ospf neighbor print -``` - -### Массовая рассылка конфигурации - -#### Шаблон для всех серверов с туннелем gre-SWE-HIPHOST: - -```shell -# Заменить , , на соответствующие значения -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=/30 interface=gre-SWE-HIPHOST -/routing ospf interface-template add interfaces=gre-SWE-HIPHOST cost=10 area=backbone -``` - -#### Шаблон для всех серверов с туннелем gre-MSK-VPSVILLE-IHOR: - -```shell -# Заменить , , на соответствующие значения -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=/30 interface=gre-MSK-VPSVILLE-IHOR -/routing ospf interface-template add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone -``` - -#### Пример конкретных команд для каждого сервера: - -**MSK-VPSVILLE:** -```shell -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.1.1/30 interface=gre-SWE-HIPHOST -/routing ospf interface-template add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.0.1/30 interface=gre-MSK-VPSVILLE-IHOR -/routing ospf interface-template add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone -``` - -**MSK-IHOR:** -```shell -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.2.1/30 interface=gre-SWE-HIPHOST -/routing ospf interface-template add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.0.2/30 interface=gre-MSK-VPSVILLE-IHOR -/routing ospf interface-template add interfaces=gre-MSK-VPSVILLE-IHOR cost=5 area=backbone -``` - -**SWE-HIPHOST:** -```shell -/interface gre add name=gre-SWE-HIPHOST remote-address= local-address= -/ip address add address=10.200.1.2/30 interface=gre-SWE-HIPHOST -/routing ospf interface-template add interfaces=gre-SWE-HIPHOST cost=10 area=backbone - -/interface gre add name=gre-MSK-VPSVILLE-IHOR remote-address= local-address= -/ip address add address=10.200.2.2/30 interface=gre-MSK-VPSVILLE-IHOR -/routing ospf interface-template add interfaces=gre-MSK-VPSVILLE-IHOR cost=10 area=backbone -``` - -### Тестирование failover - -```shell -# На MSK-VPSVILLE отключить GRE туннель gre-SWE-HIPHOST -/interface gre disable [ find where name=gre-SWE-HIPHOST ] - -# Проверить что маршрут исчез из OSPF -/routing ospf route print - -# На HOME проверить что маршрут изменился -/ip route print where dst-address=0.0.0.0/0 - -# Включить туннель обратно -/interface gre enable [ find where name=gre-SWE-HIPHOST ] - -# На MSK-IHOR отключить GRE туннель gre-SWE-HIPHOST -/interface gre disable [ find where name=gre-SWE-HIPHOST ] - -# Проверить что маршрут исчез из OSPF -/routing ospf route print - -# Включить туннель обратно -/interface gre enable [ find where name=gre-SWE-HIPHOST ] -``` - -### Проверка работы cost: - -```shell -# На HOME проверить OSPF маршруты с cost -/routing ospf route print - -# Должно показать что-то вроде: -# dst-address=0.0.0.0/0 gateway=10.100.2.2 cost=10 # Основной -# dst-address=0.0.0.0/0 gateway=10.100.5.2 cost=10 # Основной -# dst-address=0.0.0.0/0 gateway=10.100.1.2 cost=100 # Резервный -# dst-address=0.0.0.0/0 gateway=10.100.5.2 cost=100 # Резервный -``` - ---- - -## Настройка BFD для OSPF - -BFD (Bidirectional Forwarding Detection) обеспечивает быстрое обнаружение недоступности каналов и ускоряет failover OSPF. - -### Конфигурация BFD на HOME (RouterOS 7.14+): - -```shell -# 1. Настроить BFD параметры для GRE интерфейсов -/routing bfd -add interface=gre-SWE-HIPHOST-MTS interval=100ms multiplier=3 -add interface=gre-SWE-HIPHOST-RTK interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-MTS interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-RTK interval=100ms multiplier=3 -add interface=gre-MSK-IHOR-MTS interval=100ms multiplier=3 -add interface=gre-MSK-IHOR-RTK interval=100ms multiplier=3 - -# 2. Включить BFD для OSPF -/routing ospf interface-template -set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes -set [ find where interfaces=gre-SWE-HIPHOST-RTK ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-MTS ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-RTK ] bfd=yes -set [ find where interfaces=gre-MSK-IHOR-MTS ] bfd=yes -set [ find where interfaces=gre-MSK-IHOR-RTK ] bfd=yes -``` - -### Конфигурация BFD на SWE-HIPHOST (RouterOS 7.14+): - -```shell -# 1. Настроить BFD параметры для GRE интерфейсов -/routing bfd -add interface=gre-SWE-HIPHOST-MTS interval=100ms multiplier=3 -add interface=gre-SWE-HIPHOST-RTK interval=100ms multiplier=3 -add interface=gre-SWE-HIPHOST interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-IHOR interval=100ms multiplier=3 - -# 2. Включить BFD для OSPF -/routing ospf interface-template -set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes -set [ find where interfaces=gre-SWE-HIPHOST-RTK ] bfd=yes -set [ find where interfaces=gre-SWE-HIPHOST ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-IHOR ] bfd=yes -``` - -### Конфигурация BFD на MSK-VPSVILLE (RouterOS 7.14+): - -```shell -# 1. Настроить BFD параметры для GRE интерфейсов -/routing bfd -add interface=gre-MSK-VPSVILLE-MTS interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-RTK interval=100ms multiplier=3 -add interface=gre-SWE-HIPHOST interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-IHOR interval=100ms multiplier=3 - -# 2. Включить BFD для OSPF -/routing ospf interface-template -set [ find where interfaces=gre-MSK-VPSVILLE-MTS ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-RTK ] bfd=yes -set [ find where interfaces=gre-SWE-HIPHOST ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-IHOR ] bfd=yes -``` - -### Конфигурация BFD на MSK-IHOR (RouterOS 7.14+): - -```shell -# 1. Настроить BFD параметры для GRE интерфейсов -/routing bfd -add interface=gre-MSK-IHOR-MTS interval=100ms multiplier=3 -add interface=gre-MSK-IHOR-RTK interval=100ms multiplier=3 -add interface=gre-SWE-HIPHOST interval=100ms multiplier=3 -add interface=gre-MSK-VPSVILLE-IHOR interval=100ms multiplier=3 - -# 2. Включить BFD для OSPF -/routing ospf interface-template -set [ find where interfaces=gre-MSK-IHOR-MTS ] bfd=yes -set [ find where interfaces=gre-MSK-IHOR-RTK ] bfd=yes -set [ find where interfaces=gre-SWE-HIPHOST ] bfd=yes -set [ find where interfaces=gre-MSK-VPSVILLE-IHOR ] bfd=yes -``` - -### Параметры BFD: - -| Параметр | Значение | Описание | -|----------|----------|----------| -| **interval** | 100ms | Интервал отправки BFD пакетов (быстрое обнаружение) | -| **multiplier** | 3 | Количество пропущенных пакетов для объявления недоступности | -| **Время обнаружения** | 300ms | interval × multiplier = 100ms × 3 = 300ms | - -### Полная конфигурация BFD для всех серверов: - -| Сервер | GRE интерфейсы с BFD | BFD параметры | OSPF интеграция | -|--------|---------------------|---------------|-----------------| -| **HOME** | gre-SWE-HIPHOST-MTS, gre-SWE-HIPHOST-RTK, gre-MSK-VPSVILLE-MTS, gre-MSK-VPSVILLE-RTK, gre-MSK-IHOR-MTS, gre-MSK-IHOR-RTK | interval=100ms, multiplier=3 | Все интерфейсы в OSPF с bfd=yes | -| **MSK-VPSVILLE** | gre-MSK-VPSVILLE-MTS, gre-MSK-VPSVILLE-RTK, gre-SWE-HIPHOST, gre-MSK-VPSVILLE-IHOR | interval=100ms, multiplier=3 | Все интерфейсы в OSPF с bfd=yes | -| **MSK-IHOR** | gre-MSK-IHOR-MTS, gre-MSK-IHOR-RTK, gre-SWE-HIPHOST, gre-MSK-VPSVILLE-IHOR | interval=100ms, multiplier=3 | Все интерфейсы в OSPF с bfd=yes | -| **SWE-HIPHOST** | gre-SWE-HIPHOST-MTS, gre-SWE-HIPHOST-RTK, gre-SWE-HIPHOST, gre-MSK-VPSVILLE-IHOR | interval=100ms, multiplier=3 | Все интерфейсы в OSPF с bfd=yes | - -### Альтернативные настройки BFD: - -| Тип обнаружения | Interval | Multiplier | Время обнаружения | Применение | -|----------------|----------|------------|-------------------|------------| -| Быстрое | 50ms | 3 | 150ms | Критичные каналы | -| Стандартное | 200ms | 3 | 600ms | Обычные каналы | -| Медленное | 500ms | 3 | 1.5s | Экономия ресурсов | - -### Проверка BFD (RouterOS 7.14+): - -```shell -# Проверить статус BFD сессий -/routing bfd print - -# Проверить детали BFD сессий -/routing bfd print detail - -# Проверить BFD на интерфейсах -/interface gre print - -# Проверить OSPF с BFD -/routing ospf interface-template print where bfd=yes - -# Проверить BFD логи -/log print where topics~"bfd" - -# Проверить BFD статистику -/routing bfd print stats -``` - -### Преимущества BFD: - -1. **Быстрое обнаружение**: 300ms вместо нескольких секунд OSPF -2. **Надежность**: Независимое от OSPF обнаружение недоступности -3. **Гибкость**: Настраиваемые параметры для разных каналов -4. **Совместимость**: Работает с любыми протоколами маршрутизации - ---- - -## Диагностика проблем с BFD - -### Проблема: BFD session в статусе "init" и "down" - -Если BFD сессия не устанавливается, это может быть связано с несколькими причинами: - -#### 1. Проверка базовой связности - -```shell -# Проверить что GRE туннель работает -/interface gre print - -# Проверить ping через GRE туннель -ping 10.100.5.2 count=5 - -# Проверить что OSPF соседи установлены -/routing ospf neighbor print -``` - -#### 2. Проверка BFD конфигурации - -```shell -# Проверить BFD сессии -/routing bfd print - -# Проверить детали BFD -/routing bfd print detail - -# Проверить что BFD включен в OSPF -/routing ospf interface-template print where bfd=yes -``` - -#### 3. Возможные решения - -##### Решение 1: Проверить параметры BFD - -```shell -# Убедиться что параметры одинаковые на обеих сторонах -/routing bfd print - -# Если параметры разные, исправить: -/routing bfd set [ find where interface=gre-SWE-HIPHOST-MTS ] interval=100ms multiplier=3 -``` - -##### Решение 2: Перезапустить BFD сессию - -```shell -# Удалить и пересоздать BFD сессию -/routing bfd remove [ find where interface=gre-SWE-HIPHOST-MTS ] -/routing bfd add interface=gre-SWE-HIPHOST-MTS interval=100ms multiplier=3 -``` - -##### Решение 3: Проверить firewall - -```shell -# Проверить что BFD пакеты не блокируются -/ip firewall filter print where protocol=udp - -# BFD использует UDP порт 3784, убедиться что он не заблокирован -``` - -##### Решение 4: Использовать более медленные параметры - -```shell -# Попробовать более медленные параметры для стабильности -/routing bfd set [ find where interface=gre-SWE-HIPHOST-MTS ] interval=200ms multiplier=3 -``` - -#### 4. Пошаговая диагностика - -```shell -# Шаг 1: Проверить GRE туннель -/interface gre print - -# Шаг 2: Проверить OSPF соседей -/routing ospf neighbor print - -# Шаг 3: Проверить BFD сессии -/routing bfd print - -# Шаг 4: Проверить BFD детали -/routing bfd print detail - -# Шаг 5: Проверить логи -/log print where topics~"bfd" -``` - -#### 5. Альтернатива: Отключить BFD временно - -```shell -# Если BFD не работает, можно временно отключить -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no - -# OSPF будет работать без BFD, но медленнее -``` - -#### 6. Специфичные проблемы и решения - -##### Проблема: BFD не работает на GRE туннелях - -Некоторые версии RouterOS могут иметь проблемы с BFD на GRE туннелях. В этом случае: - -```shell -# Проверить версию RouterOS -/system resource print - -# Если версия < 7.14, BFD может не работать на GRE -# В этом случае лучше отключить BFD -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no -``` - -##### Проблема: BFD конфликтует с OSPF - -```shell -# Проверить OSPF соседей -/routing ospf neighbor print - -# Если OSPF работает, но BFD нет - отключить BFD -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no -``` - -##### Проблема: Неправильные параметры BFD - -```shell -# Проверить текущие параметры -/routing bfd print detail - -# Установить стандартные параметры -/routing bfd set [ find where interface=gre-SWE-HIPHOST-MTS ] interval=200ms multiplier=3 -``` - -#### 7. Рекомендуемая последовательность настройки BFD - -```shell -# Шаг 1: Убедиться что OSPF работает -/routing ospf neighbor print - -# Шаг 2: Настроить BFD с медленными параметрами -/routing bfd add interface=gre-SWE-HIPHOST-MTS interval=200ms multiplier=3 - -# Шаг 3: Включить BFD в OSPF -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes - -# Шаг 4: Проверить статус -/routing bfd print - -# Шаг 5: Если работает, ускорить параметры -/routing bfd set [ find where interface=gre-SWE-HIPHOST-MTS ] interval=100ms multiplier=3 -``` - -#### 8. Мониторинг BFD - -```shell -# Добавить логирование BFD -/system logging -add topics=bfd - -# Проверить логи BFD -/log print where topics~"bfd" - -# Мониторинг BFD сессий -:put "BFD Status:" -/routing bfd print -``` - -#### 9. Быстрая диагностика для вашего случая - -Выполните эти команды на обеих сторонах (gateway и сервер): - -```shell -# На gateway (HOME): -# 1. Проверить GRE туннель -/interface gre print where name~"SWE-HIPHOST" - -# 2. Проверить ping до сервера -ping 10.100.5.2 count=5 - -# 3. Проверить OSPF соседей -/routing ospf neighbor print - -# 4. Проверить BFD сессии -/routing bfd print - -# 5. Проверить детали BFD -/routing bfd print detail - -# На сервере (SWE-HIPHOST): -# 1. Проверить GRE туннель -/interface gre print where name~"SWE-HIPHOST" - -# 2. Проверить ping до gateway -ping 10.100.5.1 count=5 - -# 3. Проверить OSPF соседей -/routing ospf neighbor print - -# 4. Проверить BFD сессии -/routing bfd print - -# 5. Проверить детали BFD -/routing bfd print detail -``` - -#### 10. Быстрое решение - -Если диагностика показывает проблемы с BFD: - -```shell -# Временно отключить BFD на обеих сторонах -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no - -# Удалить BFD сессии -/routing bfd remove [ find where interface=gre-SWE-HIPHOST-MTS ] - -# Проверить что OSPF работает -/routing ospf neighbor print -``` - -#### 11. Проблема: BFD packets Rx = 0 - -Если на gateway BFD session показывает `packets Rx = 0`, это означает что BFD пакеты не доходят от сервера до gateway. - -##### Диагностика проблемы с BFD пакетами - -```shell -# На gateway проверить детали BFD сессии -/routing bfd print detail - -# Должно показать что-то вроде: -# packets-tx: 1234 -# packets-rx: 0 # ← Проблема здесь -# state: down -``` - -##### Возможные причины и решения: - -###### Причина 1: BFD не настроен на сервере - -```shell -# На сервере проверить BFD конфигурацию -/routing bfd print - -# Если BFD не настроен, добавить: -/routing bfd add interface=gre-SWE-HIPHOST-MTS interval=200ms multiplier=3 - -# И включить в OSPF: -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes -``` - -###### Причина 2: Firewall блокирует BFD пакеты - -```shell -# На сервере проверить firewall правила -/ip firewall filter print where protocol=udp - -# BFD использует UDP порт 3784, проверить что он не заблокирован -# Добавить правило для разрешения BFD (если нужно): -/ip firewall filter add chain=forward protocol=udp dst-port=3784 action=accept comment="BFD" -``` - -###### Причина 3: Разные параметры BFD - -```shell -# На обеих сторонах проверить параметры BFD -/routing bfd print detail - -# Убедиться что interval и multiplier одинаковые -# Если разные - исправить на сервере: -/routing bfd set [ find where interface=gre-SWE-HIPHOST-MTS ] interval=200ms multiplier=3 -``` - -###### Причина 4: GRE туннель нестабилен - -```shell -# Проверить стабильность GRE туннеля -/interface gre print - -# Проверить ping через туннель -ping 10.100.5.2 count=10 interval=100ms - -# Если есть потери пакетов, BFD может не работать -``` - -##### Быстрое решение для диагностики: - -```shell -# На сервере временно отключить BFD -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no -/routing bfd remove [ find where interface=gre-SWE-HIPHOST-MTS ] - -# На gateway тоже отключить -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=no -/routing bfd remove [ find where interface=gre-SWE-HIPHOST-MTS ] - -# Проверить что OSPF работает без BFD -/routing ospf neighbor print -``` - -##### Пошаговая настройка BFD заново: - -```shell -# Шаг 1: Убедиться что OSPF работает -/routing ospf neighbor print - -# Шаг 2: Настроить BFD на сервере с медленными параметрами -/routing bfd add interface=gre-SWE-HIPHOST-MTS interval=500ms multiplier=3 - -# Шаг 3: Включить BFD в OSPF на сервере -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes - -# Шаг 4: Настроить BFD на gateway с теми же параметрами -/routing bfd add interface=gre-SWE-HIPHOST-MTS interval=500ms multiplier=3 - -# Шаг 5: Включить BFD в OSPF на gateway -/routing ospf interface-template set [ find where interfaces=gre-SWE-HIPHOST-MTS ] bfd=yes - -# Шаг 6: Проверить статус -/routing bfd print -``` - -### Пояснение: DHCP маршруты в OSPF - -- **DHCP маршруты** считаются как `connected` в RouterOS -- **redistribute=connected** анонсирует все connected маршруты, включая DHCP -- **redistribute=static** анонсирует только статические маршруты -- **DHCP маршрут 0.0.0.0/0** автоматически попадет в OSPF при `redistribute=connected` -- **AWS metadata service (169.254.169.254)** тоже может анонсироваться и мешать - -### Проблема с AWS metadata service - -AWS автоматически добавляет маршрут к 169.254.169.254 (metadata service), который тоже будет анонсироваться через OSPF при `redistribute=connected`. Это может создавать нежелательные маршруты. - -### Проверка DHCP маршрута и AWS metadata: - -```shell -# Проверить тип маршрута 0.0.0.0/0 -/ip route print where dst-address=0.0.0.0/0 - -# Проверить AWS metadata маршрут -/ip route print where dst-address=169.254.169.254/32 - -# Должно показать что-то вроде: -# Flags: D - DYNAMIC; A - ACTIVE; c - CONNECT, s - STATIC -# Маршрут от DHCP будет помечен как DYNAMIC -# AWS metadata маршрут тоже будет DYNAMIC -``` - ---- \ No newline at end of file diff --git a/UX_UI_IMPROVEMENTS.md b/UX_UI_IMPROVEMENTS.md deleted file mode 100644 index 88d8e81..0000000 --- a/UX_UI_IMPROVEMENTS.md +++ /dev/null @@ -1,531 +0,0 @@ -# 🎨 UX/UI Улучшения Router Lists UI - 2025 - -## 📋 Обзор - -Данный документ описывает комплексные UX/UI улучшения, внесенные в проект Router Lists UI. Все компоненты разработаны **исключительно на стилях Tabler UI** без использования сторонних UI библиотек. - ---- - -## ✅ РЕАЛИЗОВАННЫЕ УЛУЧШЕНИЯ - -### 🎯 ВЫСОКИЙ ПРИОРИТЕТ - -#### 1. Оптимизация навигации и информационной архитектуры - -##### 📍 Улучшенный компонент Breadcrumbs -**Файл**: `frontend/src/components/Breadcrumbs.jsx` - -**Новые возможности**: -- ✅ Кнопка "Назад" с сохранением состояния навигации -- ✅ Динамические счетчики результатов в breadcrumbs -- ✅ Отображение информации об активных фильтрах -- ✅ Автоматический подсчет записей с правильными склонениями - -**Использование**: -```jsx - -``` - ---- - -#### 2. Улучшения таблиц и списков данных - -##### 🔖 SavedFilters - Сохраненные фильтры -**Файл**: `frontend/src/components/SavedFilters.jsx` - -**Возможности**: -- ✅ Сохранение комбинаций фильтров в localStorage -- ✅ Быстрое применение сохраненных фильтров -- ✅ Удаление неактуальных фильтров -- ✅ Отображение даты создания фильтра - -**Использование**: -```jsx - { - setSearchTerm(filters.searchTerm) - setFilterCommunity(filters.filterCommunity) - }} -/> -``` - -##### ☑️ BulkActionsBar - Массовые операции -**Файл**: `frontend/src/components/BulkActionsBar.jsx` - -**Возможности**: -- ✅ Выбор множества элементов через чекбоксы -- ✅ Кнопка "Выбрать все" / "Снять выбор" -- ✅ Массовое удаление, редактирование, экспорт -- ✅ Sticky позиционирование при скролле -- ✅ Кастомные действия через props - -**Использование**: -```jsx - selectAll()} - onDeselectAll={() => clearSelection()} - onDelete={() => handleBulkDelete()} - onExport={() => handleBulkExport()} - customActions={[ - { - icon: IconEdit, - label: 'Изменить community', - onClick: handleBulkEditCommunity - } - ]} -/> -``` - ---- - -#### 3. Формы и валидация - -##### ✓ ValidatedInput - Input с inline валидацией -**Файл**: `frontend/src/components/ValidatedInput.jsx` - -**Возможности**: -- ✅ Real-time валидация с debounce -- ✅ Визуальные индикаторы (галочка/крестик) -- ✅ Подсказки при ошибках -- ✅ Поддержка required полей -- ✅ Автофокус - -**Использование**: -```jsx - { - const valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) - return { - valid, - message: valid - ? 'IP адрес корректен' - : 'Введите IPv4 (например: 1.1.1.1)' - } - }} - helpText="Введите IPv4 адрес" - required -/> -``` - ---- - -#### 4. Feedback и уведомления - -##### 🔔 ToastContainer - Система toast уведомлений -**Файлы**: -- `frontend/src/components/ToastContainer.jsx` -- Интеграция в `frontend/src/App.jsx` - -**Возможности**: -- ✅ 4 типа уведомлений: success, error, warning, info -- ✅ Автозакрытие с настраиваемой длительностью -- ✅ Действия в уведомлениях (кнопки "Отменить", "Повторить") -- ✅ Анимированное появление/исчезновение -- ✅ Глобальный доступ через `window.toast` - -**Использование**: -```jsx -// Через hook -const toast = useToast() -toast.success('Данные сохранены') -toast.error('Ошибка соединения', { duration: 0 }) // не закроется автоматически -toast.warning('Проверьте данные', { duration: 5000 }) -toast.info('Обновление доступно', { - actions: [ - { label: 'Обновить', onClick: () => window.location.reload() } - ] -}) - -// Через window (обратная совместимость) -window.toast.success('Успех!') -``` - -##### ⏳ ProgressIndicator - Индикаторы прогресса -**Файл**: `frontend/src/components/ProgressIndicator.jsx` - -**Компоненты**: -1. **ProgressIndicator** - полный индикатор с кнопкой отмены -2. **LinearProgress** - компактный линейный -3. **CircularProgress** - кольцевой индикатор - -**Использование**: -```jsx -// Полный индикатор - cancelOperation()} -/> - -// Линейный - - -// Кольцевой - -``` - -##### 💀 Skeleton Loaders - Улучшенные загрузчики -**Файл**: `frontend/src/components/TableSkeleton.jsx` - -**Компоненты**: -1. **TableSkeleton** - для таблиц (с поддержкой чекбоксов) -2. **CardSkeleton** - для карточек статистики -3. **ListSkeleton** - для списков -4. **FormSkeleton** - для форм - -**Использование**: -```jsx -import TableSkeleton, { CardSkeleton, ListSkeleton, FormSkeleton } from './components/TableSkeleton' - -{loading ? ( - -) : ( - ...
-)} -``` - ---- - -#### 5. Keyboard Shortcuts и Command Palette - -##### ⌨️ CommandPalette - Глобальный поиск (Ctrl+K) -**Файлы**: -- `frontend/src/components/CommandPalette.jsx` -- Интеграция в `frontend/src/App.jsx` - -**Возможности**: -- ✅ Открытие по Ctrl+K / Cmd+K -- ✅ Навигация стрелками ↑↓ -- ✅ Фильтрация команд по keywords -- ✅ Быстрый переход по страницам -- ✅ Подсветка активной команды - -**Горячие клавиши**: -- `Ctrl+K` - Открыть command palette -- `↑` `↓` - Навигация по списку -- `Enter` - Выполнить команду -- `Esc` - Закрыть - -##### 📋 KeyboardShortcutsButton - Справка по горячим клавишам -**Использование**: Добавлена кнопка в навбар - -**Список горячих клавиш**: -- `Ctrl+K` - Поиск команд -- `Ctrl+N` - Добавить новую запись -- `Ctrl+S` - Сохранить -- `Ctrl+F` - Поиск по таблице -- `Esc` - Закрыть / Отменить -- `↑` `↓` - Навигация -- `Enter` - Подтвердить - ---- - -### 🎨 СРЕДНИЙ ПРИОРИТЕТ - -#### 6. Визуальная иерархия - -##### 📊 Sparkline - Мини-графики -**Файл**: `frontend/src/components/Sparkline.jsx` - -**Компоненты**: -1. **Sparkline** - линейный график -2. **SparklineWithTrend** - с процентом изменения -3. **SparklineBar** - столбчатый график - -**Возможности**: -- ✅ Чистый SVG без библиотек -- ✅ Настраиваемые цвета и размеры -- ✅ Опциональная заливка под графиком -- ✅ Отображение точек на графике - -**Использование**: -```jsx -// Простой sparkline - - -// С трендом - - -// Столбчатый - -``` - ---- - -## 🚀 КАК ИСПОЛЬЗОВАТЬ - -### Интеграция в существующие страницы - -#### Пример: DomainsNewManager с новыми компонентами - -```jsx -import { useState } from 'react' -import Breadcrumbs from './components/Breadcrumbs' -import SavedFilters from './components/SavedFilters' -import BulkActionsBar from './components/BulkActionsBar' -import ValidatedInput from './components/ValidatedInput' -import TableSkeleton from './components/TableSkeleton' -import { useToast } from './components/ToastContainer' - -function DomainsNewManager() { - const [selectedItems, setSelectedItems] = useState(new Set()) - const [loading, setLoading] = useState(false) - const [filters, setFilters] = useState({ searchTerm: '', community: '' }) - const toast = useToast() - - const handleBulkDelete = async () => { - try { - await api.delete('/domains-new/bulk', { ids: Array.from(selectedItems) }) - toast.success(`Удалено ${selectedItems.size} доменов`) - setSelectedItems(new Set()) - } catch (e) { - toast.error('Ошибка удаления', { duration: 0 }) - } - } - - return ( -
- {/* Breadcrumbs с кнопкой назад */} - - - {/* Сохраненные фильтры */} -
- -
- - {/* Bulk actions */} - setSelectedItems(new Set(items.map(i => i.id)))} - onDeselectAll={() => setSelectedItems(new Set())} - onDelete={handleBulkDelete} - onExport={handleBulkExport} - /> - - {/* Таблица с skeleton loader */} - {loading ? ( - - ) : ( - - {/* таблица */} -
- )} -
- ) -} -``` - ---- - -## 📐 ДИЗАЙН-СИСТЕМА - -### Цветовая палитра Tabler - -```scss -// Основные цвета -$blue: #206bc4; // primary -$azure: #4299e1; // info -$indigo: #4263eb; -$purple: #ae3ec9; -$pink: #d6336c; -$red: #d63939; // danger -$orange: #f76707; // warning -$yellow: #f59f00; -$lime: #74b816; -$green: #2fb344; // success -$teal: #0ca678; -$cyan: #17a2b8; - -// Semantic colors -$primary: $blue; -$success: $green; -$warning: $orange; -$danger: $red; -$info: $azure; -``` - -### Использование badges - -```jsx -// Успех -Активен - -// Ошибка -Офлайн - -// Предупреждение -Внимание - -// Информация -123 -``` - -### Иконки (Tabler Icons) - -Все иконки из `@tabler/icons-react`: - -```jsx -import { - IconCheck, - IconX, - IconAlertTriangle, - IconWorld, - IconServer, - IconFilter -} from '@tabler/icons-react' - - -``` - ---- - -## 📊 СТАТИСТИКА УЛУЧШЕНИЙ - -### Созданные компоненты - -| Компонент | Файл | Строк кода | Статус | -|-----------|------|------------|--------| -| Breadcrumbs | `Breadcrumbs.jsx` | 82 | ✅ | -| SavedFilters | `SavedFilters.jsx` | 170 | ✅ | -| BulkActionsBar | `BulkActionsBar.jsx` | 110 | ✅ | -| ToastContainer | `ToastContainer.jsx` | 160 | ✅ | -| ValidatedInput | `ValidatedInput.jsx` | 120 | ✅ | -| ProgressIndicator | `ProgressIndicator.jsx` | 180 | ✅ | -| Sparkline | `Sparkline.jsx` | 150 | ✅ | -| CommandPalette | `CommandPalette.jsx` | 250 | ✅ | -| **Итого** | **8 компонентов** | **~1222 строки** | **100%** | - -### Улучшенные компоненты - -- `TableSkeleton.jsx` - добавлены CardSkeleton, ListSkeleton, FormSkeleton (+70 строк) -- `App.jsx` - интеграция ToastContainer и CommandPalette - ---- - -## 🎯 СЛЕДУЮЩИЕ ШАГИ (pending) - -### 7. Графики и визуализация данных -- [ ] Timeline chart для Dashboard (динамика за месяц) -- [ ] Pie chart для распределения по community -- [ ] Heatmap активности -- [ ] Улучшенный GraphView с zoom controls - -### 8. Мобильная адаптивность -- [ ] Card view для таблиц на мобильных -- [ ] Swipe actions (свайп для удаления/редактирования) -- [ ] Bottom navigation для мобилок -- [ ] Адаптивные модалки (bottom sheet) - ---- - -## 💡 РЕКОМЕНДАЦИИ ПО ИСПОЛЬЗОВАНИЮ - -### 1. Замена старых alert'ов на Toast -```jsx -// Было -setSuccess('Данные сохранены') -setError('Ошибка') - -// Стало -toast.success('Данные сохранены') -toast.error('Ошибка') -``` - -### 2. Добавление bulk operations в таблицы -1. Добавить состояние `const [selected, setSelected] = useState(new Set())` -2. Добавить чекбоксы в `` и `` -3. Добавить `` над таблицей - -### 3. Использование ValidatedInput вместо обычного input -Заменить все критичные input'ы (IP, email, домены) на `` - -### 4. Skeleton loaders вместо спиннеров -```jsx -// Вместо -{loading &&
} - -// Использовать -{loading ? : } -``` - ---- - -## 🐛 ИЗВЕСТНЫЕ ОГРАНИЧЕНИЯ - -1. **SavedFilters** хранит данные в localStorage (ограничение 5-10MB) -2. **BulkActionsBar** sticky позиция может конфликтовать с другими sticky элементами -3. **CommandPalette** не поддерживает вложенные команды -4. **Sparkline** не имеет интерактивности (нет hover tooltips) - ---- - -## 📝 ЗАКЛЮЧЕНИЕ - -Все компоненты разработаны с использованием **чистого Tabler UI** без сторонних библиотек. Компоненты: -- ✅ Полностью адаптивны -- ✅ Поддерживают темную тему -- ✅ Доступны (ARIA labels, keyboard navigation) -- ✅ Легковесны (нет зависимостей) -- ✅ Расширяемы через props - -**Итого внедрено**: 8 новых компонентов, улучшено 3 существующих, добавлено ~1300 строк кода. - ---- - -**Версия документа**: 1.0 -**Дата**: 2 октября 2025 -**Автор**: AI Assistant (Claude Sonnet 4.5) - diff --git a/backend/server.new.js b/backend/server.new.js deleted file mode 100644 index f1c60d7..0000000 --- a/backend/server.new.js +++ /dev/null @@ -1,331 +0,0 @@ -// Оптимизированная версия server.js с модульной структурой -require('dotenv').config(); -const express = require('express'); -const cors = require('cors'); -const path = require('path'); -const compression = require('compression'); -const Ajv = require('ajv'); -const helmet = require('helmet'); -const rateLimit = require('express-rate-limit'); -const pino = require('pino'); -const pinoHttp = require('pino-http'); -const promClient = require('prom-client'); -const crypto = require('crypto'); - -// Импорт модулей -const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler'); -const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager'); -const { createTextDataRoutes } = require('./routes/textDataRoutes'); -const { createJsonDataRoutes } = require('./routes/jsonDataRoutes'); -const { splitWhitespace, resourceToKey, toIso, buildNestedGatewayBlocks } = require('./utils/helpers'); -const validators = require('./lib/validators'); -const mikrotikValidator = require('./lib/mikrotik-validator'); -const s3Service = require('./services/s3Service'); - -const app = express(); -const port = Number(process.env.PORT) || 3001; - -// === Logger === -const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); -app.use(pinoHttp({ - logger, - genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'), - serializers: { - req(req) { return { id: req.id, method: req.method, url: req.url }; }, - res(res) { return { statusCode: res.statusCode }; }, - }, -})); - -// === CORS === -const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean); -if (allowed.length > 0) { - app.use(cors({ - origin: (origin, cb) => { - if (!origin || allowed.includes(origin)) return cb(null, true); - return cb(new Error('CORS blocked')); - }, - credentials: true, - })); -} else { - app.use(cors({ origin: true, credentials: true })); -} - -app.options('*', cors()); - -// === Security === -app.use(helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, - crossOriginResourcePolicy: { policy: 'cross-origin' }, -})); -app.set('trust proxy', 1); -app.disable('x-powered-by'); - -// === Rate Limiting === -const createRateLimiter = (windowMs, max, message) => rateLimit({ - windowMs, - max, - standardHeaders: true, - legacyHeaders: false, - message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов' }, -}); - -const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000); -const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи'); -const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления'); - -app.use(generalLimiter); -app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' })); -app.use(compression()); -app.set('etag', false); - -app.use((req, res, next) => { - res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source'); - next(); -}); - -// === Metrics === -promClient.collectDefaultMetrics(); -const httpDuration = new promClient.Histogram({ - name: 'http_request_duration_seconds', - help: 'HTTP request duration', - labelNames: ['method', 'route', 'code'], - buckets: [0.05,0.1,0.2,0.5,1,2,5] -}); -const httpErrors = new promClient.Counter({ - name: 'http_errors_total', - help: 'HTTP error count', - labelNames: ['route','code'] -}); - -app.use((req, res, next) => { - const start = process.hrtime.bigint(); - res.on('finish', () => { - try { - const dur = Number(process.hrtime.bigint() - start) / 1e9; - httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur); - if (res.statusCode >= 400) { - httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc(); - } - } catch {} - }); - next(); -}); - -// === Health & Metrics === -app.get('/health', (req, res) => res.json({ ok: true })); -app.get('/ready', (req, res) => res.json({ ok: true })); -app.get('/metrics', async (req, res) => { - try { - res.set('Content-Type', promClient.register.contentType); - res.end(await promClient.register.metrics()); - } catch (e) { - res.status(500).end(String(e?.message || e)); - } -}); - -app.get('/api/version', (req, res) => { - res.json({ - version: process.env.APP_VERSION || null, - gitSha: process.env.GIT_SHA || null, - buildAt: process.env.BUILD_AT || null - }); -}); - -// === Cache TTL === -const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30)); -app.use((req, res, next) => { - if (req.method === 'GET') { - res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`); - } - next(); -}); - -app.use(express.static(path.join(__dirname, 'public'))); - -// === AJV Schemas === -const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' }); - -const schemaDomainsNew = { - type: 'array', - items: { - type: 'object', - required: ['domain', 'community'], - additionalProperties: false, - properties: { - domain: { type: 'string' }, - community: { type: 'string' } - } - } -}; - -const schemaAsns = { - type: 'array', - items: { - type: 'object', - required: ['domain', 'type'], - additionalProperties: false, - properties: { - domain: { type: 'string' }, - type: { type: 'string' } - } - } -}; - -const schemaIpRanges = { - type: 'array', - items: { - type: 'object', - required: ['ipRange', 'community'], - additionalProperties: false, - properties: { - ipRange: { type: 'string' }, - community: { type: 'string' } - } - } -}; - -const validateDomainsNew = ajv.compile(schemaDomainsNew); -const validateAsns = ajv.compile(schemaAsns); -const validateIpRanges = ajv.compile(schemaIpRanges); - -// === Domains (старая версия) === -const domainsOldRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/domains.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', type: parts[1] || '' }; - }, - formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(), - validate: validateAsns, - cachePrefix: 'domains' -}); - -app.get('/api/domains', domainsOldRoutes.get); -app.post('/api/domains', domainsOldRoutes.post); - -// === ASNs === -const asnsRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/asns.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', type: parts[1] || '' }; - }, - formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(), - validate: validateAsns, - validateItem: (asn, i) => { - const errors = []; - if (!validators.isValidASN(asn.domain)) { - errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`); - } - if (!validators.isValidCommunity(asn.type)) { - errors.push(`Элемент ${i}: неверный community "${asn.type}"`); - } - return errors; - }, - cachePrefix: 'asns' -}); - -app.get('/api/asns', asnsRoutes.get); -app.post('/api/asns', writeLimiter, asnsRoutes.post); - -// === Domains New === -const domainsNewRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/domains_community.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', community: parts[1] || '' }; - }, - formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(), - validate: validateDomainsNew, - validateItem: (d, i) => { - const errors = []; - if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { - errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); - } - if (!validators.isValidCommunity(d.community)) { - errors.push(`Элемент ${i}: неверный community "${d.community}"`); - } - if (!validators.isSafeXSSString(d.domain)) { - errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); - } - return errors; - }, - cachePrefix: 'domains-new' -}); - -app.get('/api/domains-new', domainsNewRoutes.get); -app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post); - -// === IP Ranges === -const ipRangesRoutes = createTextDataRoutes({ - s3Key: 'bgp_data/ips.txt', - mapLine: (line) => { - const parts = splitWhitespace(line); - return { ipRange: parts[0] || '', community: parts[1] || '' }; - }, - formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(), - validate: validateIpRanges, - validateItem: (ip, i) => { - const errors = []; - const ipStr = String(ip.ipRange || '').trim(); - const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); - const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); - - if (!isValidCIDR && !isValidIP) { - errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); - } - if (!validators.isValidCommunity(ip.community)) { - errors.push(`Элемент ${i}: неверный community "${ip.community}"`); - } - return errors; - }, - cachePrefix: 'ip-ranges' -}); - -app.get('/api/ip-ranges', ipRangesRoutes.get); -app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); - -// === Остальные роуты из старого server.js === -// Для экономии места и времени, остальные роуты могут быть вынесены позже -// Но основная логика уже оптимизирована - -// Locks -app.get('/api/locks/:resource', (req, res) => { - const status = getLockStatus(req.params.resource); - res.json(status); -}); - -app.post('/api/locks/:resource', (req, res) => { - const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {}; - const result = acquireLock(req.params.resource, owner, ttlSeconds); - if (!result.success) { - return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { - owner: result.owner, - expiresAt: result.expiresAt - }); - } - res.json(result); -}); - -app.delete('/api/locks/:resource', (req, res) => { - const result = releaseLock(req.params.resource); - res.json(result); -}); - -// Fallback для остальных роутов - временно используем старую логику из server.js -// В будущем можно вынести и оптимизировать остальные эндпоинты аналогично - -// Catchall -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -// Error handler -app.use(errorHandler); - -app.listen(port, () => { - console.log(`Server is running on http://localhost:${port}`); -}); - diff --git a/backend/server.old.js b/backend/server.old.js deleted file mode 100644 index 3a79325..0000000 --- a/backend/server.old.js +++ /dev/null @@ -1,2486 +0,0 @@ -// History endpoints are registered below after app is initialized -require('dotenv').config(); -const express = require('express'); -const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectVersionsCommand } = require('@aws-sdk/client-s3'); -const { NodeHttpHandler } = require('@smithy/node-http-handler'); -const cors = require('cors'); -const path = require('path'); -const compression = require('compression'); -const Ajv = require('ajv'); -const net = require('net'); -const crypto = require('crypto'); -const helmet = require('helmet'); -const rateLimit = require('express-rate-limit'); -const pino = require('pino'); -const pinoHttp = require('pino-http'); -const promClient = require('prom-client'); -const http = require('http'); -const https = require('https'); -const { URL } = require('url'); -const validators = require('./lib/validators'); -const mikrotikValidator = require('./lib/mikrotik-validator'); - -const app = express(); -const port = Number(process.env.PORT) || 3001; - -// Logger with requestId -const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); -app.use(pinoHttp({ - logger, - genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'), - serializers: { - req(req) { return { id: req.id, method: req.method, url: req.url }; }, - res(res) { return { statusCode: res.statusCode }; }, - }, -})); - -// CORS: по умолчанию максимально разрешаем, можно сузить через CORS_ORIGINS -const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean); -if (allowed.length > 0) { - app.use(cors({ - origin: (origin, cb) => { - if (!origin || allowed.includes(origin)) return cb(null, true); - return cb(new Error('CORS blocked')); - }, - credentials: true, - })); -} else { - app.use(cors({ origin: true, credentials: true })); -} -// Разрешаем preflight для всех путей -app.options('*', cors()); -app.use(helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, - crossOriginResourcePolicy: { policy: 'cross-origin' }, -})); -// Если приложение работает за прокси/ингрессом (Docker/NGINX), доверяем первому прокси для корректной работы rate-limit -app.set('trust proxy', 1); -app.disable('x-powered-by'); - -// Улучшенный rate limiting с разными лимитами для разных операций -const createRateLimiter = (windowMs, max, message) => rateLimit({ - windowMs, - max, - standardHeaders: true, - legacyHeaders: false, - message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов, попробуйте позже' }, -}); - -// Общий лимитер для всех запросов -const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000); -app.use(generalLimiter); - -// Строгий лимитер для операций записи -const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи'); - -// Очень строгий лимитер для BGP обновлений -const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления'); -app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' })); -app.use(compression()); -// Disable Express auto-ETag to avoid weak ETags on JSON bodies -app.set('etag', false); - -// Expose important headers to browser JS (for CORS) -app.use((req, res, next) => { - res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source'); - next(); -}); - -// --- Metrics --- -promClient.collectDefaultMetrics(); -const httpDuration = new promClient.Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'route', 'code'], buckets: [0.05,0.1,0.2,0.5,1,2,5] }); -const httpErrors = new promClient.Counter({ name: 'http_errors_total', help: 'HTTP error count', labelNames: ['route','code'] }); -const s3Duration = new promClient.Histogram({ name: 's3_request_duration_seconds', help: 'S3 request duration', labelNames: ['op'], buckets: [0.01,0.05,0.1,0.2,0.5,1,2] }); -const http304 = new promClient.Counter({ name: 'http_304_total', help: 'HTTP 304 responses' }); -const http412 = new promClient.Counter({ name: 'http_412_total', help: 'HTTP 412 responses' }); -const http423 = new promClient.Counter({ name: 'http_423_total', help: 'HTTP 423 responses' }); -app.use((req, res, next) => { - const start = process.hrtime.bigint(); - res.on('finish', () => { - try { - const dur = Number(process.hrtime.bigint() - start) / 1e9; - httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur); - if (res.statusCode >= 400) httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc(); - } catch {} - }); - next(); -}); - -app.get('/health', (req, res) => res.json({ ok: true })); -app.get('/ready', (req, res) => res.json({ ok: true })); -app.get('/metrics', async (req, res) => { - try { - res.set('Content-Type', promClient.register.contentType); - res.end(await promClient.register.metrics()); - } catch (e) { - res.status(500).end(String(e?.message || e)); - } -}); - -// Version endpoint (static env-based) -app.get('/api/version', (req, res) => { - res.json({ - version: process.env.APP_VERSION || null, - gitSha: process.env.GIT_SHA || null, - buildAt: process.env.BUILD_AT || null - }); -}); - -// Централизованный обработчик ошибок (должен быть подключён ПОСЛЕ роутов — см. ниже второе use) - -// Helpers: meta and responses -function toIso(x) { - try { return new Date(x).toISOString(); } catch { return null; } -} - -async function headMeta(key) { - try { - const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - return { - etag: h.ETag || null, - lastModified: h.LastModified ? toIso(h.LastModified) : null, - contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, - }; - } catch (e) { - return { etag: null, lastModified: null, contentLength: null }; - } -} - -function sendOk(res, meta) { - if (meta?.etag) res.set('ETag', String(meta.etag)); - if (meta?.lastModified) res.set('Last-Modified', new Date(meta.lastModified).toUTCString()); - if (typeof meta?.contentLength === 'number') res.set('Content-Length-Source', String(meta.contentLength)); - return res.json({ ok: true, etag: meta?.etag || null, lastModified: meta?.lastModified || null, contentLength: meta?.contentLength ?? null }); -} - -function sendError(res, status, message, code, details) { - const requestId = res.req?.id; - try { if (status === 304) http304.inc(); if (status === 412) http412.inc(); if (status === 423) http423.inc(); } catch {} - return res.status(status).json({ code, message, details, requestId }); -} - -// Map UI resource -> S3 key (for history endpoints) -function resourceToKey(resource) { - switch (resource) { - case 'domains-new': return 'bgp_data/domains_community.txt'; - case 'ip-ranges': return 'bgp_data/ips.txt'; - case 'asns': return 'bgp_data/asns.txt'; - default: return null; - } -} - -// Compute sha256 of string -function sha256OfString(s) { - return crypto.createHash('sha256').update(Buffer.from(String(s), 'utf-8')).digest('hex'); -} - -function mapAjvErrors(errors) { - if (!Array.isArray(errors)) return []; - return errors.map((e) => ({ - message: e.message, - instancePath: e.instancePath, - keyword: e.keyword, - params: e.params, - })); -} - -// Helpers -function splitWhitespace(line) { - return String(line || '').trim().split(/\s+/); -} - -// Serve static files from the React app -app.use(express.static(path.join(__dirname, 'public'))); - -// Configure AWS S3 (SDK v3) -const s3 = new S3Client({ - endpoint: 'https://storage.yandexcloud.net', - region: process.env.AWS_REGION, - forcePathStyle: true, - maxAttempts: 3, - requestHandler: new NodeHttpHandler({ - httpAgent: new http.Agent({ keepAlive: true }), - httpsAgent: new https.Agent({ keepAlive: true }) - }), - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY, - } -}); -// Default Cache-Control for GETs -const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30)); -app.use((req, res, next) => { - if (req.method === 'GET') { - res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`); - } - next(); -}); - - -const BUCKET_NAME = process.env.S3_BUCKET_NAME; -const FILE_KEY = 'bgp_data/domains.txt'; - -// AJV setup and schemas -const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' }); - -const schemaDomainsNew = { - type: 'array', - items: { - type: 'object', - required: ['domain', 'community'], - additionalProperties: false, - properties: { - domain: { type: 'string' }, - community: { type: 'string' } - } - } -}; -const schemaAsns = { - type: 'array', - items: { - type: 'object', - required: ['domain', 'type'], - additionalProperties: false, - properties: { - domain: { type: 'string' }, - type: { type: 'string' } - } - } -}; -const schemaIpRanges = { - type: 'array', - items: { - type: 'object', - required: ['ipRange', 'community'], - additionalProperties: false, - properties: { - ipRange: { type: 'string' }, - community: { type: 'string' } - } - } -}; -const schemaFilters = { - type: 'array', - items: { - type: 'object', - required: ['community', 'gateway'], - additionalProperties: true, - properties: { - community: { type: 'string' }, - gateway: { type: 'string' }, - description: { type: 'string' } - } - } -}; -const schemaServers = { - type: 'array', - items: { - type: 'object', - required: ['ip', 'dns', 'country', 'provider', 'tunnel'], - additionalProperties: true, - properties: { - ip: { type: 'string' }, - dns: { type: 'string' }, - country: { type: 'string' }, - provider: { type: 'string' }, - tunnel: { type: 'string' }, - gateway: { type: 'string' } - } - } -}; -const schemaBilling = { - type: 'array', - items: { - type: 'object', - required: ['hostName', 'country', 'provider'], - additionalProperties: true, - properties: { - hostName: { type: 'string' }, - country: { type: 'string' }, - provider: { type: 'string' } - } - } -}; - -const validateDomainsNew = ajv.compile(schemaDomainsNew); -const validateAsns = ajv.compile(schemaAsns); -const validateIpRanges = ajv.compile(schemaIpRanges); -const validateFilters = ajv.compile(schemaFilters); -const validateServers = ajv.compile(schemaServers); -const validateBilling = ajv.compile(schemaBilling); - -// Helper: build MikroTik nested if/else blocks (RouterOS v7 filter language does not support 'else if') -function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) { - const indent = (n) => ' '.repeat(n); - const entries = Object.entries(gatewayGroups); - if (entries.length === 0) return ''; - - function buildAt(index, pad) { - const [gateway, communities] = entries[index]; - let s = ''; - s += `${indent(pad)}if (\n`; - communities.forEach((community, i) => { - s += `${indent(pad + 4)}(bgp-communities includes ${community})`; - if (i < communities.length - 1) s += ' or \n'; - }); - s += `\n${indent(pad)})\n`; - s += `${indent(pad)}{\n${indent(pad + 8 - 4)}set gw ${gateway}; accept;\n${indent(pad)}}\n`; - if (index < entries.length - 1) { - s += `${indent(pad)}else\n${indent(pad)}{\n`; - s += buildAt(index + 1, pad + 4); - s += `\n${indent(pad)}}`; - } else { - s += `${indent(pad)}else\n${indent(pad)}{\n${indent(pad + 4)}reject;\n${indent(pad)}}`; - } - return s; - } - - return buildAt(0, baseIndentSpaces); -} - -// In-memory LRU-ish cache for small texts and head meta -const s3Cache = { text: new Map(), head: new Map(), max: 100, ttlMs: 30_000 }; -const countOnlyCache = { map: new Map(), ttlMs: 10_000 }; -function getCache(map, key) { - const v = map.get(key); - if (!v) return null; - if (Date.now() > v.at + s3Cache.ttlMs) { map.delete(key); return null; } - return v.value; -} -function setCache(map, key, value) { - if (map.size >= s3Cache.max) { const firstKey = map.keys().next().value; if (firstKey) map.delete(firstKey); } - map.set(key, { value, at: Date.now() }); -} - -function invalidateCacheForKey(key) { - try { s3Cache.text.delete(key); } catch {} - try { s3Cache.head.delete(key); } catch {} -} - -function getCountOnlyCache(cacheKey) { - const v = countOnlyCache.map.get(cacheKey); - if (!v) return null; - if (Date.now() > v.at + countOnlyCache.ttlMs) { countOnlyCache.map.delete(cacheKey); return null; } - return v.value; -} -function setCountOnlyCache(cacheKey, value) { - countOnlyCache.map.set(cacheKey, { value, at: Date.now() }); -} - -function checkIfNoneMatch(req, res, etag) { - const inm = req.headers && (req.headers['if-none-match'] || req.headers['If-None-Match']); - if (inm && etag && String(inm) === String(etag)) { - try { http304.inc(); } catch {} - res.status(304).end(); - return true; - } - return false; -} - -// Helper: read text file from S3 and return { body, etag, lastModified, contentLength } -async function streamToString(stream) { - if (!stream) return ''; - if (typeof stream.transformToString === 'function') { - return await stream.transformToString(); - } - return await new Promise((resolve, reject) => { - let chunks = []; - stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c)))); - stream.once('error', reject); - stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - }); -} - -async function readS3TextObject(key) { - const cached = getCache(s3Cache.text, key); - if (cached) return cached; - const s3Start = Date.now(); - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {} - const out = { - body: await streamToString(data.Body), - etag: data.ETag || undefined, - lastModified: data.LastModified ? data.LastModified.toISOString() : undefined, - contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined - }; - setCache(s3Cache.text, key, out); - return out; -} - -// Helper: head object and return current ETag -async function headS3ObjectEtag(key) { - const cached = getCache(s3Cache.head, key); - if (cached && cached.etag) return cached.etag; - const s3Start = Date.now(); - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {} - setCache(s3Cache.head, key, { etag: head.ETag || undefined }); - return head.ETag || undefined; -} - -// Helper: stream and paginate big text files (line-based) -async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { - return new Promise(async (resolve, reject) => { - let total = 0; - const items = []; - let sent = 0; - let buffered = ''; - const matchesQuery = (line) => { - if (!q) return true; - return line.toLowerCase().includes(String(q).toLowerCase()); - }; - try { - const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key })); - const stream = resp.Body; - if (!stream || typeof stream.on !== 'function') { - const text = await streamToString(resp.Body); - const lines = text.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = String(lines[i] || '').trim(); - if (!line) continue; - if (!matchesQuery(line)) continue; - total++; - const pos = total - 1; - if (limit > 0) { - if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; } - } else { - items.push(mapLine(line)); - } - } - return resolve({ items, total }); - } - stream.on('data', (chunk) => { - buffered += chunk.toString('utf-8'); - let lines = buffered.split('\n'); - buffered = lines.pop(); - for (const lnRaw of lines) { - const line = lnRaw.trim(); - if (!line) continue; - if (!matchesQuery(line)) continue; - total++; - const pos = total - 1; - if (limit > 0) { - if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; } - } else { - items.push(mapLine(line)); - } - // Не прерываем поток раньше конца, чтобы корректно посчитать total - } - }); - stream.on('end', () => { - const last = (buffered || '').trim(); - if (last) { - if (!q || last.toLowerCase().includes(String(q).toLowerCase())) { - total++; - if (limit > 0) { - const pos = total - 1; - if (pos >= offset && items.length < limit) items.push(mapLine(last)); - } else { - items.push(mapLine(last)); - } - } - } - resolve({ items, total }); - }); - stream.on('error', reject); - } catch (e) { - reject(e); - } - }); -} - -// Simple in-memory soft locks with TTL -const locks = new Map(); // key -> { owner, expiresAt } -function cleanupExpiredLocks() { - const now = Date.now(); - for (const [k, v] of locks.entries()) { - if (!v || typeof v.expiresAt !== 'number' || v.expiresAt <= now) { - locks.delete(k); - } - } -} -setInterval(cleanupExpiredLocks, 30_000); - -// Get domains from S3 (supports countOnly/std and If-None-Match) -app.get('/api/domains', async (req, res) => { - const { q = '', offset, limit, countOnly, format } = req.query || {}; - try { - if (countOnly === 'true') { - const cacheKey = `domains:count:${q}`; - const cached = getCountOnlyCache(cacheKey); - if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); - const { total } = await streamPaginatedText({ key: FILE_KEY, q, offset: 0, limit: 0, mapLine: () => ({}) }); - setCountOnlyCache(cacheKey, total); - return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); - } else if (Number(limit) > 0) { - const { items, total } = await streamPaginatedText({ - key: FILE_KEY, - q, - offset: Number(offset) || 0, - limit: Number(limit) || 0, - mapLine: (line) => { - const parts = splitWhitespace(line); - return { domain: parts[0] || '', type: parts[1] || '' }; - } - }); - if (!validateAsns(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); - } else { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: FILE_KEY })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; // 304 - - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: FILE_KEY })); - const fileContent = await streamToString(data.Body); - const domains = fileContent.split('\n').filter(line => line).map(line => { - const parts = splitWhitespace(line); - const domain = parts[0] || ''; - const type = parts[1] || ''; - return { domain, type }; - }); - if (format === 'std') return res.json({ items: domains, total: domains.length, meta: {} }); - res.json(domains); - } - } catch (error) { - if (error?.name === 'NotFound' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) }); - } - } -}); - -// Update domains in S3 with optimistic concurrency via ETag check -app.post('/api/domains', async (req, res) => { - const { domains, etag } = req.body; - const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim()).filter(Boolean).join('\n'); - - // Concurrency guard: if client sent etag, ensure current ETag matches - try { - if (etag) { - const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined); - if (current && current.replace(/\"/g, '"') !== String(etag)) { - const meta = await headMeta(FILE_KEY); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); - } - } - } catch (e) { - // ignore if head fails due to NoSuchKey; proceed to create - } - - const params = { - Bucket: BUCKET_NAME, - Key: FILE_KEY, - Body: fileContent, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey(FILE_KEY); - const meta = await headMeta(FILE_KEY); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3'); - } -}); - -// --- ASNs Routes --- - -// Get ASNs from S3 -app.get('/api/asns', async (req, res) => { - const { q = '', offset, limit, countOnly, format } = req.query || {}; - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/asns.txt', - }; - - try { - if (countOnly === 'true') { - const cacheKey = `asns:count:${q}`; - const cached = getCountOnlyCache(cacheKey); - if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); - const { total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); - setCountOnlyCache(cacheKey, total); - return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); - } else if (Number(limit) > 0) { - const { items, total } = await streamPaginatedText({ - key: 'bgp_data/asns.txt', - q, - offset: Number(offset) || 0, - limit: Number(limit) || 0, - mapLine: (line) => { - const parts = line.trim().split(/\s+/); - return { domain: parts[0] || '', type: parts[1] || '' }; - } - }); - if (!validateAsns(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); - } else { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - const asns = fileContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - const domain = parts[0] || ''; - const type = parts[1] || ''; - return { domain, type }; - }); - if (!validateAsns(asns)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - if (format === 'std') return res.json({ items: asns, total: asns.length, meta: {} }); - res.json(asns); - } - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update ASNs in S3 -app.post('/api/asns', writeLimiter, async (req, res) => { - const { domains: asns, etag } = req.body; // Keep name 'domains' for consistency - - // Валидация входных данных - if (!Array.isArray(asns)) { - return sendError(res, 400, 'ASNs должен быть массивом', 'E_BAD_REQUEST'); - } - - // Проверка каждого ASN - const validationErrors = []; - for (let i = 0; i < asns.length; i++) { - const asn = asns[i]; - if (!asn || typeof asn !== 'object') { - validationErrors.push(`Элемент ${i}: неверный формат`); - continue; - } - - if (!validators.isValidASN(asn.domain)) { - validationErrors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`); - } - - if (!validators.isValidCommunity(asn.type)) { - validationErrors.push(`Элемент ${i}: неверный community "${asn.type}"`); - } - } - - if (validationErrors.length > 0) { - return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) }); - } - - const fileContent = (asns || []).map(a => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n'); - - try { - if (!validateAsns(asns || [])) { - return sendError(res, 400, 'Invalid payload format for asns', 'E_SCHEMA'); - } - let current = null; - const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; - try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {} - if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { - const meta = await headMeta('bgp_data/asns.txt'); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); - } - } catch {} - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/asns.txt', - Body: fileContent, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('bgp_data/asns.txt'); - const meta = await headMeta('bgp_data/asns.txt'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); - } -}); - -// --- Domains New Routes --- - -// Get domains-new from S3 -app.get('/api/domains-new', async (req, res) => { - const { q = '', offset, limit, countOnly, format } = req.query || {}; - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/domains_community.txt', - }; - - try { - if (countOnly === 'true') { - const cacheKey = `domains-new:count:${q}`; - const cached = getCountOnlyCache(cacheKey); - if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); - const { total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); - setCountOnlyCache(cacheKey, total); - return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); - } else if (Number(limit) > 0) { - const { items, total } = await streamPaginatedText({ - key: 'bgp_data/domains_community.txt', - q, - offset: Number(offset) || 0, - limit: Number(limit) || 0, - mapLine: (line) => { - const parts = line.trim().split(/\s+/); - return { domain: parts[0] || '', community: parts[1] || '' }; - } - }); - if (!validateDomainsNew(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); - } else { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - const domains = fileContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - const domain = parts[0] || ''; - const community = parts[1] || ''; - return { domain, community }; - }); - if (!validateDomainsNew(domains)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - if (format === 'std') return res.json({ items: domains, total: domains.length, meta: {} }); - res.json(domains); - } - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update domains-new in S3 -app.post('/api/domains-new', writeLimiter, async (req, res) => { - const { domains, etag } = req.body; - - // Валидация входных данных - if (!Array.isArray(domains)) { - return sendError(res, 400, 'domains должен быть массивом', 'E_BAD_REQUEST'); - } - - // Проверка каждого домена - const validationErrors = []; - for (let i = 0; i < domains.length; i++) { - const d = domains[i]; - if (!d || typeof d !== 'object') { - validationErrors.push(`Элемент ${i}: неверный формат`); - continue; - } - - if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { - validationErrors.push(`Элемент ${i}: неверный домен "${d.domain}"`); - } - - if (!validators.isValidCommunity(d.community)) { - validationErrors.push(`Элемент ${i}: неверный community "${d.community}"`); - } - - // Проверка на XSS - if (!validators.isSafeXSSString(d.domain)) { - validationErrors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); - } - } - - if (validationErrors.length > 0) { - return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) }); - } - - const fileContent = (domains || []).map(d => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n'); - - try { - if (!validateDomainsNew(domains || [])) { - return sendError(res, 400, 'Invalid payload format for domains-new', 'E_SCHEMA'); - } - let current = null; - const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; - try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {} - if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { - const meta = await headMeta('bgp_data/domains_community.txt'); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); - } - } catch {} - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/domains_community.txt', - Body: fileContent, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('bgp_data/domains_community.txt'); - const meta = await headMeta('bgp_data/domains_community.txt'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); - } -}); - -// --- IP Ranges Routes --- - -// Get IP ranges from S3 -app.get('/api/ip-ranges', async (req, res) => { - const { q = '', offset, limit, countOnly, format } = req.query || {}; - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/ips.txt', - }; - - try { - if (countOnly === 'true') { - const cacheKey = `ip-ranges:count:${q}`; - const cached = getCountOnlyCache(cacheKey); - if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); - const { total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); - setCountOnlyCache(cacheKey, total); - return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); - } else if (Number(limit) > 0) { - const { items, total } = await streamPaginatedText({ - key: 'bgp_data/ips.txt', - q, - offset: Number(offset) || 0, - limit: Number(limit) || 0, - mapLine: (line) => { - const parts = line.trim().split(/\s+/); - return { ipRange: parts[0] || '', community: parts[1] || '' }; - } - }); - if (!validateIpRanges(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); - } else { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - const ipRanges = fileContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - const ipRange = parts[0] || ''; - const community = parts[1] || ''; - return { ipRange, community }; - }); - if (!validateIpRanges(ipRanges)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); - if (format === 'std') return res.json({ items: ipRanges, total: ipRanges.length, meta: {} }); - res.json(ipRanges); - } - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update IP ranges in S3 -app.post('/api/ip-ranges', writeLimiter, async (req, res) => { - const { ipRanges, etag } = req.body; - - // Валидация входных данных - if (!Array.isArray(ipRanges)) { - return sendError(res, 400, 'ipRanges должен быть массивом', 'E_BAD_REQUEST'); - } - - // Проверка каждого IP range - const validationErrors = []; - for (let i = 0; i < ipRanges.length; i++) { - const ip = ipRanges[i]; - if (!ip || typeof ip !== 'object') { - validationErrors.push(`Элемент ${i}: неверный формат`); - continue; - } - - // Проверяем CIDR или IP адрес - const ipStr = String(ip.ipRange || '').trim(); - const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); - const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); - - if (!isValidCIDR && !isValidIP) { - validationErrors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); - } - - if (!validators.isValidCommunity(ip.community)) { - validationErrors.push(`Элемент ${i}: неверный community "${ip.community}"`); - } - } - - if (validationErrors.length > 0) { - return sendError(res, 400, 'Ошибки валидации', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) }); - } - - const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n'); - - try { - if (!validateIpRanges(ipRanges || [])) { - return sendError(res, 400, 'Invalid payload format for ip-ranges', 'E_SCHEMA'); - } - let current = null; - const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; - try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {} - if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { - const meta = await headMeta('bgp_data/ips.txt'); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); - } - } catch {} - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/ips.txt', - Body: fileContent, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('bgp_data/ips.txt'); - const meta = await headMeta('bgp_data/ips.txt'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) }); - } -}); - -// --- Communities Directory Routes --- - -// Get communities from S3 -app.get('/api/communities', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/communities.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/communities.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let communities = []; - - try { - const parsed = JSON.parse(fileContent); - communities = Array.isArray(parsed) ? parsed : []; - } catch (parseError) { - console.error('Error parsing communities.json:', parseError); - communities = []; - } - - // Basic normalization - communities = communities - .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) - .map((c) => ({ - value: String(c.value).trim(), - name: c.name ? String(c.name) : '', - description: c.description ? String(c.description) : '', - tags: Array.isArray(c.tags) ? c.tags.map(String) : [], - gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '', - color: c.color ? String(c.color) : '' - })); - - res.json(communities); - } catch (error) { - if (error.code === 'NoSuchKey') { - // If file missing, start with empty list - return res.json([]); - } - console.error('Error reading communities from S3:', error); - return sendError(res, 500, 'Error reading communities from S3', 'E_S3'); - } -}); - -// Update communities in S3 -app.post('/api/communities', writeLimiter, async (req, res) => { - const { communities } = req.body; - - if (!Array.isArray(communities)) { - return sendError(res, 400, 'communities must be an array', 'E_BAD_REQUEST'); - } - - // Validate entries and ensure unique values - const seen = new Set(); - const normalized = []; - const validationErrors = []; - - for (let i = 0; i < communities.length; i++) { - const entry = communities[i] || {}; - const value = typeof entry.value === 'string' ? entry.value.trim() : ''; - if (!value) { - validationErrors.push(`Community at index ${i} is missing required field: value`); - continue; - } - - // Валидация community значения - if (!validators.isValidCommunity(value)) { - validationErrors.push(`Community at index ${i} has invalid value: ${value}`); - continue; - } - - if (seen.has(value)) { - validationErrors.push(`Duplicate community value at index ${i}: ${value}`); - continue; - } - - seen.add(value); - normalized.push({ - value, - name: entry.name ? String(entry.name) : '', - description: entry.description ? String(entry.description) : '', - tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], - gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '', - color: entry.color ? String(entry.color) : '', - // Новые расширенные поля - category: entry.category ? String(entry.category) : '', - priority: typeof entry.priority === 'number' ? entry.priority : 0, - enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true, - }); - } - - if (validationErrors.length > 0) { - return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors }); - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/communities.json', - Body: JSON.stringify(normalized, null, 2), - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('bgp_data/communities.json'); - const meta = await headMeta('bgp_data/communities.json'); - return sendOk(res, meta); - } catch (error) { - console.error('Error writing communities to S3:', error); - return sendError(res, 500, 'Error writing communities to S3', 'E_S3'); - } -}); - -// Get community usage statistics -app.get('/api/communities/stats', async (req, res) => { - try { - // Загружаем все данные для подсчета использования - const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([ - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })), - s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })), - ]); - - const communityUsage = new Map(); // community -> count - - // Считаем использование в доменах - if (domainsRes.status === 'fulfilled') { - const text = await streamToString(domainsRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); - } - - // Считаем использование в IP ranges - if (ipRangesRes.status === 'fulfilled') { - const text = await streamToString(ipRangesRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); - } - - // Считаем использование в ASNs - if (asnsRes.status === 'fulfilled') { - const text = await streamToString(asnsRes.value.Body); - text.split('\n').filter(Boolean).forEach(line => { - const parts = line.trim().split(/\s+/); - if (parts[1]) { - communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1); - } - }); - } - - // Считаем использование в фильтрах - if (filtersRes.status === 'fulfilled') { - try { - const text = await streamToString(filtersRes.value.Body); - const filters = JSON.parse(text); - if (Array.isArray(filters)) { - filters.forEach(f => { - if (f.community) { - communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1); - } - }); - } - } catch {} - } - - // Преобразуем Map в массив объектов - const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({ - community, - count, - })).sort((a, b) => b.count - a.count); // Сортируем по убыванию использования - - res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) }); - } catch (error) { - console.error('Error getting community stats:', error); - return sendError(res, 500, 'Error getting community stats', 'E_S3'); - } -}); - -// --- Servers Routes (JSON format) --- - -// Get servers from S3 -app.get('/api/servers', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'servers.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let servers = []; - - try { - servers = JSON.parse(fileContent); - // Ensure it's an array - if (!Array.isArray(servers)) { - servers = []; - } - } catch (parseError) { - console.error('Error parsing servers.json:', parseError); - servers = []; - } - - res.json(servers); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update servers in S3 -app.post('/api/servers', async (req, res) => { - const { domains: servers } = req.body; // Keep name 'domains' for consistency - - // Validate servers structure - if (!Array.isArray(servers)) { - return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST'); - } - - // Validate each server has required fields - for (let i = 0; i < servers.length; i++) { - const server = servers[i]; - if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) { - return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'servers.json', - Body: JSON.stringify(servers, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('servers.json'); - const meta = await headMeta('servers.json'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3'); - } -}); - -// --- Billing Routes --- - -// Get billing data from S3 -app.get('/api/billing', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'servers-billing.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers-billing.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let billingData = []; - - try { - billingData = JSON.parse(fileContent); - // Ensure it's an array - if (!Array.isArray(billingData)) { - billingData = []; - } - } catch (parseError) { - console.error('Error parsing servers-billing.json:', parseError); - billingData = []; - } - - res.json(billingData); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update billing data in S3 -app.post('/api/billing', async (req, res) => { - const { domains: billingData } = req.body; // Keep name 'domains' for consistency - - // Validate billing data structure - if (!Array.isArray(billingData)) { - return sendError(res, 400, 'Billing data must be an array', 'E_BAD_REQUEST'); - } - - // Validate each billing item has required fields - for (let i = 0; i < billingData.length; i++) { - const item = billingData[i]; - if (!item.hostName || !item.country || !item.provider) { - return sendError(res, 400, `Billing item at index ${i} is missing required fields: hostName, country, provider`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'servers-billing.json', - Body: JSON.stringify(billingData, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('servers-billing.json'); - const meta = await headMeta('servers-billing.json'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3'); - } -}); - -// --- Filters Routes (JSON format) --- - -// Get filters from S3 -app.get('/api/filters', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'filters.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - // Ensure it's an array - if (!Array.isArray(filters)) { - filters = []; - } - } catch (parseError) { - console.error('Error parsing filters.json:', parseError); - filters = []; - } - - res.json(filters); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading from S3', 'E_S3'); - } - } -}); - -// Update filters in S3 -app.post('/api/filters', async (req, res) => { - const { domains: filters } = req.body; // Keep name 'domains' for consistency - - // Validate filters structure - if (!Array.isArray(filters)) { - return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); - } - - // Validate each filter has required fields - for (let i = 0; i < filters.length; i++) { - const filter = filters[i]; - if (!filter.community || !filter.gateway) { - return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'filters.json', - Body: JSON.stringify(filters, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('filters.json'); - const meta = await headMeta('filters.json'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing to S3', 'E_S3'); - } -}); - -// Эндпоинт метаданных S3 по ключевым файлам (Last-Modified, ETag, Content-Length) -app.get('/api/s3/last-modified', async (req, res) => { - try { - const keys = [ - { name: 'domainsNew', key: 'bgp_data/domains_community.txt' }, - { name: 'asns', key: 'bgp_data/asns.txt' }, - { name: 'servers', key: 'servers.json' }, - { name: 'filters', key: 'filters.json' }, - { name: 'ipRanges', key: 'bgp_data/ips.txt' }, - { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } - ]; - const results = await Promise.allSettled( - keys.map(k => s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: k.key }))) - ); - const out = {}; - results.forEach((r, idx) => { - const name = keys[idx].name; - if (r.status === 'fulfilled') { - out[name] = { - lastModified: r.value.LastModified ? r.value.LastModified.toISOString() : null, - etag: r.value.ETag || null, - contentLength: typeof r.value.ContentLength === 'number' ? r.value.ContentLength : null - }; - } else { - out[name] = null; - } - }); - res.json(out); - } catch (error) { - console.error('Error fetching last modified dates from S3:', error); - return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3'); - } -}); - -// Soft-lock endpoints -// GET lock status -app.get('/api/locks/:resource', (req, res) => { - cleanupExpiredLocks(); - const { resource } = req.params; - const info = locks.get(resource); - if (!info) return res.json({ locked: false }); - res.json({ locked: true, owner: info.owner, expiresAt: info.expiresAt }); -}); - -// POST acquire/refresh lock -app.post('/api/locks/:resource', (req, res) => { - cleanupExpiredLocks(); - const { resource } = req.params; - const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {}; - const now = Date.now(); - const existing = locks.get(resource); - if (existing && existing.expiresAt > now && existing.owner !== owner) { - return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { owner: existing.owner, expiresAt: existing.expiresAt }); - } - const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000; - locks.set(resource, { owner, expiresAt }); - res.json({ locked: true, owner, expiresAt }); -}); - -// DELETE release lock -app.delete('/api/locks/:resource', (req, res) => { - const { resource } = req.params; - locks.delete(resource); - res.json({ released: true }); -}); - -// History endpoints (require bucket versioning to be enabled). If versioning disabled, best effort. -app.get('/api/history/:resource', async (req, res) => { - const { resource } = req.params; - const { countOnly, format } = req.query || {}; - const key = resourceToKey(resource); - if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); - try { - const out = await s3.send(new ListObjectVersionsCommand({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 50 })); - const versionsAll = (out.Versions || []).filter(v => v.Key === key); - if (countOnly === 'true') { - const total = versionsAll.length; - return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); - } - const versions = versionsAll.slice(0, 10).map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag })); - return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions }); - } catch (e) { - console.error('history error', e); - return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) }); - } -}); - -app.post('/api/history/:resource/rollback', async (req, res) => { - const { resource } = req.params; - const { versionId } = req.body || {}; - const key = resourceToKey(resource); - if (!key || !versionId) return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST'); - try { - // Copy specific version over same key to rollback - await s3.send(new CopyObjectCommand({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key })); - const meta = await headMeta(key); - return sendOk(res, meta); - } catch (e) { - console.error('rollback error', e); - return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) }); - } -}); - -// Generate MikroTik configuration from filters -app.get('/api/filters/generate-config', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'filters.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'server-configs.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - if (!Array.isArray(filters)) { - filters = []; - } - } catch (parseError) { - console.error('Error parsing filters.json:', parseError); - filters = []; - } - - if (filters.length === 0) { - return res.json({ config: '// No filters to generate configuration' }); - } - - // Group filters by gateway - const gatewayGroups = {}; - filters.forEach(filter => { - if (!gatewayGroups[filter.gateway]) { - gatewayGroups[filter.gateway] = []; - } - gatewayGroups[filter.gateway].push(filter.community); - }); - - let config = '// Frouting filter configuration for MikroTik 7.14+\n'; - config += '// Generated automatically\n'; - config += `// Date: ${new Date().toISOString()}\n\n`; - config += '/routing filter bgp-in-tmp {\n'; - - // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) - config += buildNestedGatewayBlocks(gatewayGroups, 4); - - config += '}\n'; - - res.json({ config }); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json({ config: '// filters.json file not found' }); - } else { - console.error(error); - return sendError(res, 500, 'Error generating configuration', 'E_S3'); - } - } -}); - -// Export MikroTik configuration to S3 -app.post('/api/filters/export-config', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'filters.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: `filter-manager/server-filters-${serverId}.json` })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - if (!Array.isArray(filters)) { - filters = []; - } - } catch (parseError) { - console.error('Error parsing filters.json:', parseError); - filters = []; - } - - if (filters.length === 0) { - return res.json({ success: false, message: 'Нет фильтров для экспорта' }); - } - - // Group filters by gateway - const gatewayGroups = {}; - filters.forEach(filter => { - if (!gatewayGroups[filter.gateway]) { - gatewayGroups[filter.gateway] = []; - } - gatewayGroups[filter.gateway].push(filter.community); - }); - - let config = '// Frouting filter configuration for MikroTik 7.14+\n'; - config += '// Generated automatically\n'; - config += `// Date: ${new Date().toISOString()}\n\n`; - config += '/routing filter bgp-in-tmp {\n'; - - // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) - config += buildNestedGatewayBlocks(gatewayGroups, 4); - - config += '}\n'; - - // Save configuration to S3 - const exportParams = { - Bucket: BUCKET_NAME, - Key: 'mikrotik-frouting-config.txt', - Body: config, - ContentType: 'text/plain', - }; - - await s3.send(new PutObjectCommand(exportParams)); - res.json({ success: true, message: 'Конфигурация экспортирована в S3' }); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error exporting configuration', 'E_S3'); - } -}); - -// --- Server Configs Routes --- - -// Get server configs list -app.get('/api/server-configs', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'server-configs.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let servers = []; - - try { - servers = JSON.parse(fileContent); - if (!Array.isArray(servers)) { - servers = []; - } - } catch (parseError) { - console.error('Error parsing server-configs.json:', parseError); - servers = []; - } - - res.json(servers); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading server configs from S3', 'E_S3'); - } - } -}); - -// Update server configs list -app.post('/api/server-configs', async (req, res) => { - const { servers } = req.body; - - // Validate servers structure - if (!Array.isArray(servers)) { - return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST'); - } - - // Validate each server has required fields - for (let i = 0; i < servers.length; i++) { - const server = servers[i]; - if (!server.id || !server.name) { - return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'server-configs.json', - Body: JSON.stringify(servers, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('server-configs.json'); - const meta = await headMeta('server-configs.json'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing server configs to S3', 'E_S3'); - } -}); - -// Get specific server config -app.get('/api/server-configs/:serverId', async (req, res) => { - const { serverId } = req.params; - const params = { - Bucket: BUCKET_NAME, - Key: `filter-manager/config-${serverId}.txt`, - }; - - try { - const head = await s3.send(new HeadObjectCommand(params)).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const config = await streamToString(data.Body); - res.json({ config }); - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json({ config: '// Конфигурация не найдена' }); - } else { - console.error(error); - return sendError(res, 500, 'Error reading server config from S3', 'E_S3'); - } - } -}); - -// Save specific server config -app.post('/api/server-configs/:serverId', async (req, res) => { - const { serverId } = req.params; - const { config } = req.body; - - if (!config) { - return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST'); - } - - const params = { - Bucket: BUCKET_NAME, - Key: `filter-manager/config-${serverId}.txt`, - Body: config, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey(`filter-manager/config-${serverId}.txt`); - const meta = await headMeta(`filter-manager/config-${serverId}.txt`); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing server config to S3', 'E_S3'); - } -}); - -// Delete specific server config -app.delete('/api/server-configs/:serverId', async (req, res) => { - const { serverId } = req.params; - const params = { - Bucket: BUCKET_NAME, - Key: `filter-manager/config-${serverId}.txt`, - }; - - try { - await s3.send(new DeleteObjectCommand(params)); - invalidateCacheForKey(`filter-manager/config-${serverId}.txt`); - const meta = await headMeta(`filter-manager/config-${serverId}.txt`); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error deleting server config from S3', 'E_S3'); - } -}); - -// Delete server completely (config + filters) -app.delete('/api/server-configs/:serverId/complete', async (req, res) => { - const { serverId } = req.params; - - try { - // Удаляем конфигурацию сервера - const configParams = { - Bucket: BUCKET_NAME, - Key: `filter-manager/config-${serverId}.txt`, - }; - - // Удаляем фильтры сервера - const filtersParams = { - Bucket: BUCKET_NAME, - Key: `filter-manager/server-filters-${serverId}.json`, - }; - - // Удаляем оба файла параллельно - await Promise.allSettled([ - s3.send(new DeleteObjectCommand(configParams)), - s3.send(new DeleteObjectCommand(filtersParams)) - ]); - invalidateCacheForKey(configParams.Key); - invalidateCacheForKey(filtersParams.Key); - return res.json({ ok: true, etag: null, lastModified: null, contentLength: null }); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error deleting server files from S3', 'E_S3'); - } -}); - -// --- Server Filters Routes --- - -// Get server filters -app.get('/api/server-filters/:serverId', async (req, res) => { - const { serverId } = req.params; - const params = { - Bucket: BUCKET_NAME, - Key: `filter-manager/server-filters-${serverId}.json`, - }; - - try { - const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt' })).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - if (!Array.isArray(filters)) { - filters = []; - } - } catch (parseError) { - console.error('Error parsing server filters:', parseError); - filters = []; - } - - res.json(filters); - } catch (error) { - if (error.code === 'NoSuchKey') { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading server filters from S3', 'E_S3'); - } - } -}); - -// Validate MikroTik configuration syntax -app.post('/api/mikrotik/validate', async (req, res) => { - const { config } = req.body; - - if (!config || typeof config !== 'string') { - return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST'); - } - - try { - const validation = mikrotikValidator.validateMikrotikConfig(config); - return res.json(validation); - } catch (error) { - console.error('Error validating config:', error); - return sendError(res, 500, 'Error validating configuration', 'E_VALIDATION'); - } -}); - -// Generate MikroTik configuration from server filters -app.post('/api/server-filters/generate-config', async (req, res) => { - console.log('Received generate-config request with filters:', req.body); - const { filters } = req.body; - - if (!Array.isArray(filters) || filters.length === 0) { - console.log('No filters provided, returning empty config'); - return res.json({ config: '// No filters to generate configuration' }); - } - - // Group filters by gateway - const gatewayGroups = {}; - filters.forEach(filter => { - if (!gatewayGroups[filter.gateway]) { - gatewayGroups[filter.gateway] = []; - } - gatewayGroups[filter.gateway].push(filter.community); - }); - - console.log('Grouped filters by gateway:', gatewayGroups); - - let config = '// Frouting filter configuration for MikroTik 7.14+\n'; - config += '// Generated automatically\n'; - config += `// Date: ${new Date().toISOString()}\n\n`; - config += '/routing filter bgp-in-tmp {\n'; - - // Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте) - config += buildNestedGatewayBlocks(gatewayGroups, 4); - - config += '}\n'; - - console.log('Generated config:', config); - console.log('Sending response:', { config }); - res.json({ config }); -}); - -// Update server filters -app.post('/api/server-filters/:serverId', async (req, res) => { - const { serverId } = req.params; - const { filters } = req.body; - - // Validate filters structure - if (!Array.isArray(filters)) { - return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); - } - - // Validate each filter has required fields - for (let i = 0; i < filters.length; i++) { - const filter = filters[i]; - if (!filter.community || !filter.gateway) { - return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: `filter-manager/server-filters-${serverId}.json`, - Body: JSON.stringify(filters, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey(`filter-manager/server-filters-${serverId}.json`); - const meta = await headMeta(`filter-manager/server-filters-${serverId}.json`); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing server filters to S3', 'E_S3'); - } -}); - -// --- Simple Filters Routes --- - -// Get simple filters -app.get('/api/simple-filters', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'filter-manager/simple-filters.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand(params)).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - let filters = []; - - try { - filters = JSON.parse(fileContent); - if (!Array.isArray(filters)) { - filters = []; - } - } catch (parseError) { - console.error('Error parsing simple filters:', parseError); - filters = []; - } - - res.json(filters); - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading simple filters from S3', 'E_S3'); - } - } -}); - -// Update simple filters -app.post('/api/simple-filters', async (req, res) => { - const { filters } = req.body; - - // Validate filters structure - if (!Array.isArray(filters)) { - return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST'); - } - - // Validate each filter has required fields - for (let i = 0; i < filters.length; i++) { - const filter = filters[i]; - if (!filter.community || !filter.gateway) { - return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA'); - } - } - - const params = { - Bucket: BUCKET_NAME, - Key: 'filter-manager/simple-filters.json', - Body: JSON.stringify(filters, null, 2), // Pretty print JSON - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('filter-manager/simple-filters.json'); - const meta = await headMeta('filter-manager/simple-filters.json'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing simple filters to S3', 'E_S3'); - } -}); - -// --- Auto URL Routes --- - -// Get auto URLs from S3 -app.get('/api/auto-urls', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt', - }; - - try { - const head = await s3.send(new HeadObjectCommand(params)).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const fileContent = await streamToString(data.Body); - const urls = fileContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - const url = parts[0] || ''; - const community = parts[1] || ''; - return { url, community }; - }); - res.json(urls); - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - res.json([]); // Return empty array if file does not exist - } else { - console.error(error); - return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3'); - } - } -}); - -// --- UI Settings (rt_ui_settings.json in bgp_data) --- -// Get UI settings -app.get('/api/ui-settings', async (req, res) => { - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/rt_ui_settings.json', - }; - - try { - const head = await s3.send(new HeadObjectCommand(params)).catch(() => null); - const etag = head?.ETag || null; - if (etag) res.set('ETag', String(etag)); - if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); - if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); - if (checkIfNoneMatch(req, res, etag)) return; - - const data = await s3.send(new GetObjectCommand(params)); - const jsonText = await streamToString(data.Body); - let settings = {}; - try { - const parsed = JSON.parse(jsonText); - if (parsed && typeof parsed === 'object') settings = parsed; - } catch (parseError) { - settings = {}; - } - return res.json(settings); - } catch (error) { - if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { - return res.json({}); - } - console.error('Error reading ui settings from S3:', error); - return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); - } -}); - -// Update UI settings -app.post('/api/ui-settings', async (req, res) => { - const { settings, etag } = req.body || {}; - const payload = (settings && typeof settings === 'object') ? settings : {}; - - try { - // optimistic concurrency if ETag provided (or If-Match header) - let current = null; - const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; - try { current = await headS3ObjectEtag('bgp_data/rt_ui_settings.json'); } catch {} - if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { - const meta = await headMeta('bgp_data/rt_ui_settings.json'); - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); - } - } catch {} - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/rt_ui_settings.json', - Body: JSON.stringify(payload, null, 2), - ContentType: 'application/json', - }; - - try { - await s3.send(new PutObjectCommand(params)); - const meta = await headMeta('bgp_data/rt_ui_settings.json'); - return sendOk(res, meta); - } catch (error) { - console.error('Error writing UI settings to S3:', error); - return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); - } -}); - -// --- Servers availability check --- -// Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache -function tcpCheck(host, port, timeoutMs) { - return new Promise((resolve) => { - const socket = new net.Socket(); - let settled = false; - const settle = (ok) => { if (!settled) { settled = true; try { socket.destroy(); } catch {} resolve(ok); } }; - socket.setTimeout(timeoutMs, () => settle(false)); - socket.once('error', () => settle(false)); - socket.connect(port, host, () => settle(true)); - }); -} - -function anyTrue(promises) { - return new Promise((resolve) => { - if (!Array.isArray(promises) || promises.length === 0) return resolve(false); - let remaining = promises.length; - let resolved = false; - for (const p of promises) { - Promise.resolve(p).then((v) => { - if (v && !resolved) { resolved = true; resolve(true); } - }).finally(() => { - remaining -= 1; - if (remaining === 0 && !resolved) resolve(false); - }); - } - }); -} - -async function checkOneServerFast(srv, perSocketTimeoutMs = 800, perServerBudgetMs = 1000) { - const hosts = []; - if (srv.ip) hosts.push(String(srv.ip)); - if (srv.dns) hosts.push(String(srv.dns)); - const tryOneHost = (host) => anyTrue([ - // try common ports simultaneously - tcpCheck(host, 443, perSocketTimeoutMs), - tcpCheck(host, 80, perSocketTimeoutMs), - ]); - const run = anyTrue(hosts.map((h) => tryOneHost(h))); - // Per-server overall budget - const timeout = new Promise((resolve) => setTimeout(() => resolve(false), perServerBudgetMs)); - return Promise.race([run, timeout]); -} - -const availabilityCache = { at: 0, data: null }; - -app.get('/api/servers/availability', async (req, res) => { - try { - const ttlSeconds = Math.max(0, Math.min(300, Number(req.query.ttlSeconds) || 30)); - const now = Date.now(); - if (availabilityCache.data && (now - availabilityCache.at) < ttlSeconds * 1000) { - return res.json({ ...availabilityCache.data, cached: true }); - } - - const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' })); - let servers = []; - try { - servers = JSON.parse(await streamToString(data.Body)); - if (!Array.isArray(servers)) servers = []; - } catch { - servers = []; - } - - const checks = await Promise.allSettled(servers.map((s) => checkOneServerFast(s))); - const statuses = servers.map((s, i) => ({ ip: s.ip, dns: s.dns, online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false })); - const online = statuses.filter((x) => x.online).length; - const payload = { online, total: servers.length, statuses }; - availabilityCache.at = Date.now(); - availabilityCache.data = payload; - res.json(payload); - } catch (e) { - console.error('availability error', e); - res.status(500).json({ online: 0, total: 0, statuses: [] }); - } -}); - -// Update auto URLs in S3 -app.post('/api/auto-urls', async (req, res) => { - const { urls } = req.body; - const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n'); - - const params = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt', - Body: fileContent, - ContentType: 'text/plain', - }; - - try { - await s3.send(new PutObjectCommand(params)); - invalidateCacheForKey('bgp_data/auto_url/urls.txt'); - const meta = await headMeta('bgp_data/auto_url/urls.txt'); - return sendOk(res, meta); - } catch (error) { - console.error(error); - return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3'); - } -}); - -// Process auto URLs and update IPs -app.post('/api/auto-urls/process', async (req, res) => { - const https = require('https'); - const http = require('http'); - - // Helpers - const isValidIPv4 = (ip) => { - const octets = String(ip || '').trim().split('.'); - if (octets.length !== 4) return false; - return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255); - }; - const isValidCidrV4 = (value) => { - const v = String(value || '').trim(); - const parts = v.split('/'); - if (parts.length !== 2) return false; - const [ip, mask] = parts; - if (!isValidIPv4(ip)) return false; - if (!/^\d{1,2}$/.test(mask)) return false; - const m = Number(mask); - return m >= 0 && m <= 32; - }; - const isValidDomain = (value) => { - const v = String(value || '').trim().toLowerCase(); - if (v.startsWith('#')) return false; // comment line - return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v); - }; - - try { - // Load configured auto URLs - const urlsParams = { - Bucket: BUCKET_NAME, - Key: 'bgp_data/auto_url/urls.txt', - }; - - let urls = []; - try { - const urlsData = await s3.send(new GetObjectCommand(urlsParams)); - const urlsContent = await streamToString(urlsData.Body); - urls = urlsContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { url: parts[0] || '', community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') { - throw error; - } - } - - if (urls.length === 0) { - return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); - } - - // Load current IP ranges - const ipsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }; - let currentIps = []; - try { - const ipsData = await s3.send(new GetObjectCommand(ipsParams)); - const ipsContent = await streamToString(ipsData.Body); - currentIps = ipsContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { ipRange: parts[0] || '', community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') throw error; - } - - // Load current domains - const domainsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }; - let currentDomains = []; - try { - const dData = await s3.send(new GetObjectCommand(domainsParams)); - const dContent = await streamToString(dData.Body); - currentDomains = dContent.split('\n').filter(line => line).map(line => { - const parts = line.trim().split(/\s+/); - return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' }; - }); - } catch (error) { - if (error.code !== 'NoSuchKey') throw error; - } - - // Process each URL - const newIps = []; - const newDomains = []; - for (const urlData of urls) { - try { - const url = String(urlData.url || '').trim(); - const community = String(urlData.community || '').trim(); - if (!url || !community) continue; - - // Download content - const content = await new Promise((resolve, reject) => { - const protocol = url.startsWith('https:') ? https : http; - const req = protocol.get(url, (r) => { - let data = ''; - r.on('data', (chunk) => { data += chunk; }); - r.on('end', () => resolve(data)); - }); - req.on('error', reject); - req.setTimeout(15000, () => req.destroy()); - }); - - const lines = content.split('\n'); - for (const raw of lines) { - const line = String(raw || '').trim(); - if (!line) continue; - if (line.startsWith('#') || line.startsWith('//')) continue; - const token = line.split(/\s+/)[0]?.trim(); - if (!token) continue; - - // Decide destination - if (isValidCidrV4(token)) { - newIps.push({ ipRange: token, community }); - continue; - } - if (isValidIPv4(token)) { - // single IPv4 → normalize to /32 - newIps.push({ ipRange: `${token}/32`, community }); - continue; - } - if (isValidDomain(token)) { - newDomains.push({ domain: token.toLowerCase(), community }); - continue; - } - // ignore everything else - } - } catch (error) { - console.error(`Error processing URL ${urlData.url}:`, error); - } - } - - // Merge & deduplicate - const existingIpRanges = new Set(currentIps.map(i => i.ipRange)); - const uniqueNewIps = newIps.filter(i => !existingIpRanges.has(i.ipRange)); - const allIps = [...currentIps, ...uniqueNewIps]; - - const existingDomains = new Set(currentDomains.map(d => d.domain)); - const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain)); - const allDomains = [...currentDomains, ...uniqueNewDomains]; - - // Save updated IPs - const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n'); - await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', Body: updatedIpsContent, ContentType: 'text/plain' })); - - // Save updated Domains - const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n'); - await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', Body: updatedDomainsContent, ContentType: 'text/plain' })); - - const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`; - return res.json({ - success: true, - message: msg, - processedUrls: urls.length, - newIpsCount: uniqueNewIps.length, - newDomainsCount: uniqueNewDomains.length, - totalIpsCount: allIps.length, - totalDomainsCount: allDomains.length - }); - } catch (error) { - console.error('Error processing auto URLs:', error); - return sendError(res, 500, 'Error processing auto URLs', 'E_S3'); - } -}); - -// --- Proxy: Background BGP Update (avoids CORS from browser) --- -app.post('/api/update-bgp/background', bgpUpdateLimiter, async (req, res) => { - try { - const targetUrl = process.env.BGP_BACKGROUND_URL; - if (!targetUrl) { - return sendError(res, 500, 'BGP_BACKGROUND_URL is not configured', 'E_CONFIG'); - } - const u = new URL(targetUrl); - const client = u.protocol === 'https:' ? https : http; - const options = { - method: 'POST', - hostname: u.hostname, - port: u.port || (u.protocol === 'https:' ? 443 : 80), - path: `${u.pathname}${u.search || ''}`, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - timeout: 15000, - }; - - const body = req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : ''; - - const upstream = client.request(options, (r) => { - let data = ''; - r.setEncoding('utf8'); - r.on('data', (chunk) => { data += chunk; }); - r.on('end', () => { - const status = r.statusCode || 502; - // Try to parse JSON; fallback to text - try { - const json = data ? JSON.parse(data) : {}; - return res.status(status).json(json); - } catch (_) { - return res.status(status).json({ ok: status >= 200 && status < 300, data }); - } - }); - }); - upstream.on('timeout', () => { - try { upstream.destroy(); } catch {} - return sendError(res, 504, 'Upstream timeout', 'E_UPSTREAM_TIMEOUT'); - }); - upstream.on('error', (e) => { - return sendError(res, 502, 'Upstream error', 'E_UPSTREAM', { error: String(e?.message || e) }); - }); - if (body) upstream.write(body); - upstream.end(); - } catch (e) { - return sendError(res, 500, 'Proxy error', 'E_PROXY', { error: String(e?.message || e) }); - } -}); - -// Provide ws url to UI from settings/env to avoid exposing keys in bundle -app.get('/api/ws/url', async (req, res) => { - try { - const settings = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })).then(async (d) => { - try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; } - }).catch(() => ({})); - const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || ''; - return res.json({ url }); - } catch (e) { - return res.json({ url: '' }); - } -}); - -// The "catchall" handler: for any request that doesn't -// match one above, send back React's index.html file. -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -// Централизованный error handler (последний middleware) -// eslint-disable-next-line no-unused-vars -app.use((err, req, res, next) => { - const status = typeof err?.status === 'number' ? err.status : 500; - const code = err?.code || 'E_INTERNAL'; - const message = status === 500 && process.env.NODE_ENV === 'production' ? 'Internal Server Error' : (err?.message || 'Error'); - const details = err?.details; - const requestId = req?.id; - try { req.log?.error({ err, code, requestId }, 'request error'); } catch {} - res.status(status).json({ code, message, details, requestId }); -}); - -app.listen(port, () => { - console.log(`Server is running on http://localhost:${port}`); -}); \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index ef0f31a..d597b72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,9 @@ "@tabler/icons-react": "^3.34.0", "axios": "^1.10.0", "@tanstack/react-query": "^5.56.2", - "html-to-image": "^1.11.11", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", - "react-sigma": "^1.2.35", "@xyflow/react": "^12.3.4" }, "devDependencies": { diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index 3aaf507..34dd7e5 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -38,7 +38,7 @@ import QuickAddBar from './components/QuickAddBar.jsx'; import AccordionCard from './components/AccordionCard.jsx'; import SavedFilters from './components/SavedFilters.jsx'; import BulkActionsBar from './components/BulkActionsBar.jsx'; -import ValidatedInput from './components/ValidatedInput.jsx'; +import FormField from './components/FormField.jsx'; import { useToast } from './components/ToastContainer.jsx'; const API_URL = '/api'; diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx index d83838e..0aef316 100644 --- a/frontend/src/components/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog.jsx @@ -1,41 +1,67 @@ -import { useEffect, useRef } from 'react' +import Modal from './Modal'; +import { IconAlertTriangle } from '@tabler/icons-react'; + +/** + * ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений + * Рефакторинг: теперь использует базовый Modal компонент + */ +export default function ConfirmDialog({ + open, + title = 'Подтверждение', + message, + confirmText = 'Подтвердить', + cancelText = 'Отмена', + onConfirm, + onCancel, + destructive = false, + size = 'sm', + loading = false +}) { + const handleConfirm = () => { + onConfirm?.(); + }; -export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) { - const ref = useRef(null) - useEffect(() => { - if (open && ref.current) { - try { ref.current.querySelector('button[data-primary]')?.focus() } catch {} - } - }, [open]) - if (!open) return null return ( -
{ if (e.key === 'Escape') onCancel?.() }}> -
-
{ - if (e.key === 'Tab') { - const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - if (!focusable || focusable.length === 0) return - const first = focusable[0] - const last = focusable[focusable.length - 1] - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } - } - }}> -
-
{title || 'Подтверждение'}
- -
-
-

{message}

-
-
- - + + + + + } + > +
+ {destructive && ( +
+
+ )} +
+

{message}

-
- ) + + ); } - - diff --git a/frontend/src/components/ConfirmDiffModal.jsx b/frontend/src/components/ConfirmDiffModal.jsx index 020a82c..5b00b53 100644 --- a/frontend/src/components/ConfirmDiffModal.jsx +++ b/frontend/src/components/ConfirmDiffModal.jsx @@ -1,49 +1,73 @@ -function ConfirmDiffModal({ show, diff, onConfirm, onClose }) { - if (!show) return null; +import Modal from './Modal'; + +/** + * ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики + * Рефакторинг: теперь использует базовый Modal компонент + */ +function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) { const added = diff?.added?.length || 0; const removed = diff?.removed?.length || 0; const changed = diff?.changed?.length || 0; + return ( -
{ if (e.key === 'Escape') onClose?.() }}> -
-
{ - if (e.key === 'Tab') { - const c = e.currentTarget - const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - if (!focusable || focusable.length === 0) return - const first = focusable[0] - const last = focusable[focusable.length - 1] - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } - } - }}> -
-
Подтвердить сохранение
- -
-
-
-
-
Добавлено
{added}
-
-
-
Удалено
{removed}
-
-
-
Изменено
{changed}
-
+ + + + + } + > +
+
+
+
+ Добавлено +
{added}
-
- - +
+
+
+
+ Удалено +
{removed}
+
+
+
+
+
+
+ Изменено +
{changed}
+
-
+
); } export default ConfirmDiffModal; - - diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx index 839c79b..8d0212b 100644 --- a/frontend/src/components/EmptyState.jsx +++ b/frontend/src/components/EmptyState.jsx @@ -1,5 +1,7 @@ -import React from 'react' - +/** + * EmptyState - компонент для отображения пустого состояния + * Оптимизирован: убран неиспользуемый импорт React + */ function EmptyState({ icon: Icon, title = 'Нет данных', diff --git a/frontend/src/components/ErrorAlert.jsx b/frontend/src/components/ErrorAlert.jsx index 7c86202..c81bf98 100644 --- a/frontend/src/components/ErrorAlert.jsx +++ b/frontend/src/components/ErrorAlert.jsx @@ -1,7 +1,12 @@ -import { IconAlertTriangle, IconX } from '@tabler/icons-react'; +import { IconAlertTriangle } from '@tabler/icons-react'; +/** + * ErrorAlert - компонент для отображения ошибок + * Оптимизирован: убрано дублирование кода + */ function ErrorAlert({ message, details, onClose }) { if (!message) return null; + return (
@@ -10,24 +15,26 @@ function ErrorAlert({ message, details, onClose }) {
{String(message)} - {details ? ( + {details && (
Показать детали
                 {typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
               
- ) : null} + )}
- + {onClose && ( +
); } export default ErrorAlert; - - - diff --git a/frontend/src/components/FormField.jsx b/frontend/src/components/FormField.jsx index d2c014c..e693aa3 100644 --- a/frontend/src/components/FormField.jsx +++ b/frontend/src/components/FormField.jsx @@ -1,6 +1,10 @@ +import { useState, useEffect, useRef } from 'react'; +import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'; + /** * FormField - универсальное поле формы с валидацией - * Поддерживает иконки, подсказки, ошибки и success состояния + * Объединяет функциональность FormField и ValidatedInput + * Поддерживает: иконки, подсказки, ошибки, success состояния, debounce валидацию */ function FormField({ label, @@ -9,32 +13,76 @@ function FormField({ value, onChange, onBlur, + onValidate, // функция валидации (опционально): (value) => { valid: boolean, message: string } error, success, helpText, required, disabled, placeholder, + autoFocus, icon: Icon, className = '', inputClassName = '', rows, // для textarea options, // для select + debounceMs = 300, // debounce для валидации + showValidationIcon = true, ...inputProps }) { const inputId = `field-${name}`; - const hasError = !!error; - const hasSuccess = !!success && !error; + const [localValue, setLocalValue] = useState(value || ''); + const [validation, setValidation] = useState({ valid: null, message: '' }); + const [isDirty, setIsDirty] = useState(false); + const timerRef = useRef(null); + + const hasExternalError = !!error; + const hasValidationError = isDirty && validation.valid === false; + const hasError = hasExternalError || hasValidationError; + + const hasExternalSuccess = !!success && !hasExternalError; + const hasValidationSuccess = isDirty && validation.valid === true && !hasExternalError; + const hasSuccess = hasExternalSuccess || hasValidationSuccess; + const isTextarea = type === 'textarea'; const isSelect = type === 'select'; + // Синхронизация с внешним value + useEffect(() => { + setLocalValue(value || ''); + }, [value]); + + // Валидация с debounce + useEffect(() => { + if (!isDirty || !onValidate) return; + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + const result = onValidate(localValue); + setValidation(result); + }, debounceMs); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [localValue, isDirty, onValidate, debounceMs]); + const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`; const handleChange = (e) => { - onChange?.(e.target.value, e); + const newValue = e.target.value; + setLocalValue(newValue); + setIsDirty(true); + onChange?.(newValue, e); }; const handleBlur = (e) => { + setIsDirty(true); onBlur?.(e); }; @@ -45,13 +93,14 @@ function FormField({ id={inputId} name={name} className={inputClasses} - value={value} + value={localValue} onChange={handleChange} onBlur={handleBlur} placeholder={placeholder} required={required} disabled={disabled} rows={rows || 3} + autoFocus={autoFocus} aria-invalid={hasError} aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined} {...inputProps} @@ -65,11 +114,12 @@ function FormField({ id={inputId} name={name} className={inputClasses.replace('form-control', 'form-select')} - value={value} + value={localValue} onChange={handleChange} onBlur={handleBlur} required={required} disabled={disabled} + autoFocus={autoFocus} aria-invalid={hasError} aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined} {...inputProps} @@ -89,12 +139,13 @@ function FormField({ name={name} type={type} className={inputClasses} - value={value} + value={localValue} onChange={handleChange} onBlur={handleBlur} placeholder={placeholder} required={required} disabled={disabled} + autoFocus={autoFocus} aria-invalid={hasError} aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined} {...inputProps} @@ -102,6 +153,53 @@ function FormField({ ); }; + // Рендер с иконкой (слева или справа для валидации) + const renderInputWithIcon = () => { + const hasValidationIcon = showValidationIcon && isDirty && validation.valid !== null; + + if (Icon) { + // Иконка слева (переданная через prop) + return ( +
+ + + + {renderInput()} + {hasValidationIcon && ( + + {validation.valid === true ? ( + + ) : ( + + )} + + )} +
+ ); + } + + if (hasValidationIcon) { + // Только иконка валидации справа + return ( +
+ {renderInput()} + + {validation.valid === true ? ( + + ) : ( + + )} + +
+ ); + } + + return renderInput(); + }; + + const displayError = error || (hasValidationError ? validation.message : null); + const displaySuccess = success || (hasValidationSuccess ? validation.message : null); + return (
{label && ( @@ -111,31 +209,23 @@ function FormField({ )} - {Icon ? ( -
- - - - {renderInput()} -
- ) : ( - renderInput() - )} + {renderInputWithIcon()} - {error && ( + {displayError && ( )} - {hasSuccess && ( + {displaySuccess && !displayError && (
- {success} + {displaySuccess}
)} - {helpText && !error && !success && ( + {helpText && !displayError && !displaySuccess && (
+ {helpText}
)} @@ -144,4 +234,3 @@ function FormField({ } export default FormField; - diff --git a/frontend/src/components/LockBanner.jsx b/frontend/src/components/LockBanner.jsx deleted file mode 100644 index 5dde365..0000000 --- a/frontend/src/components/LockBanner.jsx +++ /dev/null @@ -1 +0,0 @@ -export default function LockBanner() { return null } diff --git a/frontend/src/components/Pagination.jsx b/frontend/src/components/Pagination.jsx index 2aa2539..da641fd 100644 --- a/frontend/src/components/Pagination.jsx +++ b/frontend/src/components/Pagination.jsx @@ -1,13 +1,14 @@ +import { useMemo } from 'react'; + /** * Универсальный компонент пагинации - * Устраняет дублирование кода пагинации во всех менеджерах + * Оптимизирован: useMemo для вычисления страниц */ - function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) { if (totalPages <= 1) return null; - const renderPages = () => { - const pages = []; + const pages = useMemo(() => { + const result = []; let start = Math.max(1, currentPage - 2); let end = Math.min(totalPages, currentPage + 2); @@ -18,25 +19,14 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang start = Math.max(1, totalPages - 4); } - if (start > 1) pages.push('start-ellipsis'); - for (let p = start; p <= end; p++) pages.push(p); - if (end < totalPages) pages.push('end-ellipsis'); + if (start > 1) result.push({ type: 'ellipsis', key: 'start-ellipsis' }); + for (let p = start; p <= end; p++) { + result.push({ type: 'page', page: p, key: p }); + } + if (end < totalPages) result.push({ type: 'ellipsis', key: 'end-ellipsis' }); - return pages.map((p) => { - if (p === 'start-ellipsis' || p === 'end-ellipsis') { - return ( -
  • - -
  • - ); - } - return ( -
  • - -
  • - ); - }); - }; + return result; + }, [currentPage, totalPages]); const startItem = (currentPage - 1) * pageSize + 1; const endItem = Math.min(currentPage * pageSize, totalItems); @@ -52,6 +42,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang className="page-link" onClick={() => onPageChange(1)} disabled={currentPage === 1} + aria-label="Первая страница" > Первая @@ -61,16 +52,38 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang className="page-link" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} + aria-label="Предыдущая страница" > Назад - {renderPages()} + {pages.map((item) => { + if (item.type === 'ellipsis') { + return ( +
  • + +
  • + ); + } + return ( +
  • + +
  • + ); + })}
  • @@ -80,6 +93,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang className="page-link" onClick={() => onPageChange(totalPages)} disabled={currentPage === totalPages} + aria-label="Последняя страница" > Последняя @@ -90,4 +104,3 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang } export default Pagination; - diff --git a/frontend/src/components/ValidatedInput.jsx b/frontend/src/components/ValidatedInput.jsx deleted file mode 100644 index 7fcb313..0000000 --- a/frontend/src/components/ValidatedInput.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react' - -/** - * Компонент input с inline валидацией и подсказками (Tabler UI стили) - */ -function ValidatedInput({ - type = 'text', - value, - onChange, - onValidate, // функция валидации: (value) => { valid: boolean, message: string } - placeholder = '', - label = '', - required = false, - disabled = false, - className = '', - helpText = '', - debounceMs = 300, - showSuccessIcon = true, - autoFocus = false, - ...rest -}) { - const [localValue, setLocalValue] = useState(value || '') - const [validation, setValidation] = useState({ valid: null, message: '' }) - const [isDirty, setIsDirty] = useState(false) - const timerRef = useRef(null) - - // Синхронизация с внешним value - useEffect(() => { - setLocalValue(value || '') - }, [value]) - - // Валидация с debounce - useEffect(() => { - if (!isDirty || !onValidate) return - - if (timerRef.current) { - clearTimeout(timerRef.current) - } - - timerRef.current = setTimeout(() => { - const result = onValidate(localValue) - setValidation(result) - }, debounceMs) - - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current) - } - } - }, [localValue, isDirty, onValidate, debounceMs]) - - const handleChange = (e) => { - const newValue = e.target.value - setLocalValue(newValue) - setIsDirty(true) - onChange?.(newValue) - } - - const getInputClass = () => { - if (!isDirty) return '' - if (validation.valid === true) return 'is-valid' - if (validation.valid === false) return 'is-invalid' - return '' - } - - return ( -
    - {/* Label */} - {label && ( - - )} - - {/* Input с иконкой валидации */} -
    - - - {/* Иконка статуса валидации */} - {isDirty && validation.valid !== null && ( - - {validation.valid === true && showSuccessIcon ? ( - - ) : validation.valid === false ? ( - - ) : null} - - )} -
    - - {/* Help text или сообщение валидации */} - {isDirty && validation.message ? ( -
    - {validation.message} -
    - ) : helpText ? ( -
    - - {helpText} -
    - ) : null} -
    - ) -} - -export default ValidatedInput - diff --git a/frontend/src/hooks/useModal.js b/frontend/src/hooks/useModal.js new file mode 100644 index 0000000..57d7347 --- /dev/null +++ b/frontend/src/hooks/useModal.js @@ -0,0 +1,126 @@ +import { useState, useCallback, useEffect } from 'react'; + +/** + * Хук для управления состоянием модального окна + * Упрощает работу с открытием/закрытием модалок + */ +export function useModal(initialState = false) { + const [isOpen, setIsOpen] = useState(initialState); + + const open = useCallback(() => { + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const toggle = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + + return { + isOpen, + open, + close, + toggle, + setIsOpen + }; +} + +/** + * Хук для управления ESC и клавиатурной навигацией в модалках + */ +export function useModalKeyboard(isOpen, onClose) { + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e) => { + if (e.key === 'Escape') { + onClose?.(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); +} + +/** + * Хук для управления классом modal-open на body + */ +export function useModalBodyClass(isOpen) { + useEffect(() => { + if (isOpen) { + document.body.classList.add('modal-open'); + } else { + document.body.classList.remove('modal-open'); + } + + return () => { + document.body.classList.remove('modal-open'); + }; + }, [isOpen]); +} + +/** + * Хук для управления focus trap внутри модалки + */ +export function useFocusTrap(isOpen, containerRef) { + useEffect(() => { + if (!isOpen || !containerRef.current) return; + + const container = containerRef.current; + const focusableElements = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + // Фокус на первый элемент при открытии + firstElement?.focus(); + + const handleTabKey = (e) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + // Shift + Tab + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + // Tab + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } + } + }; + + container.addEventListener('keydown', handleTabKey); + return () => container.removeEventListener('keydown', handleTabKey); + }, [isOpen, containerRef]); +} + +/** + * Комплексный хук, объединяющий все хуки для модалок + */ +export function useModalManager(initialState = false) { + const modal = useModal(initialState); + + return { + ...modal, + // Дополнительные утилиты + openWithData: (data) => { + modal.setData?.(data); + modal.open(); + }, + }; +} + +export default useModal; + diff --git a/package.json b/package.json index 9de5cf2..a805977 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,5 @@ "license": "ISC", "devDependencies": { "concurrently": "^9.2.0" - }, - "dependencies": { - "@tabler/core": "^1.3.2" } }