12 Commits
Author SHA1 Message Date
denozord e8ba6d47e3 Refactor project structure and improve component organization
Publish Docker image / build-and-push (push) Successful in 1m0s
- Restructured project files for better maintainability and clarity.
- Enhanced component organization to streamline imports and improve readability.
- Updated relevant documentation to reflect changes in project structure.

Made-with: Cursor
2026-04-26 15:26:04 +07:00
denozord fe7acd655e Rollback frontend and config changes to commit a9a7e98 state.
Publish Docker image / build-and-push (push) Successful in 2m12s
Restore project files to match the requested baseline before subsequent updates.

Made-with: Cursor
2026-04-26 14:05:50 +07:00
denozord d86434e9c9 Remove unused dependencies from package-lock.json to streamline project and reduce bundle size.
Publish Docker image / build-and-push (push) Successful in 1m2s
2026-04-26 14:02:46 +07:00
denozord f290288f43 Enhance Tailwind CSS configuration and clean up CSS imports
Publish Docker image / build-and-push (push) Successful in 1m56s
- Extended Tailwind CSS configuration to include new color schemes, border radius settings, and font family for improved theming.
- Removed unnecessary CSS import for shadcn/tailwind.css to streamline styles.

Made-with: Cursor
2026-04-26 14:00:42 +07:00
denozord e6dc99f403 Refactor Dashboard and App components to utilize new UI elements
Publish Docker image / build-and-push (push) Failing after 1m3s
- Updated App.jsx to enhance the SidebarInset and main content layout with improved styling.
- Refactored Dashboard.jsx to replace traditional HTML elements with Card, CardContent, Button, and Badge components for a more consistent UI.
- Improved error handling display and button interactions for better user experience.

Made-with: Cursor
2026-04-26 13:56:10 +07:00
denozord 58ee09b70e Update frontend dependencies, enhance Vite configuration, and refactor components
Publish Docker image / build-and-push (push) Successful in 2m24s
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling.
- Updated Vite configuration to include path aliasing for easier imports.
- Refactored App.jsx to implement a theme context and improved routing structure.
- Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience.
- Cleaned up CSS imports and ensured proper styling integration.

Made-with: Cursor
2026-04-26 13:48:59 +07:00
denozord a9a7e98be5 Enhance SQLite database initialization in sqliteDb.js
Publish Docker image / build-and-push (push) Successful in 1m27s
- Added checks to ensure the SQLite database file exists before opening, improving resilience during container startup.
- Implemented error handling for file accessibility, providing clearer error messages if the database file is not readable or writable.

Made-with: Cursor
2026-04-26 02:07:35 +07:00
denozord cf3437c70c Update Docker documentation and README for SQLite storage and environment variables
Publish Docker image / build-and-push (push) Successful in 2m13s
- Enhanced Docker.md with detailed instructions on building and running Docker images, including environment variable requirements and tag usage.
- Updated README.md to reflect the current architecture using SQLite for data storage and clarified environment variable settings for backend and frontend operations.
- Removed references to AWS S3, emphasizing local storage for MikroTik backups and SQLite database management.

Made-with: Cursor
2026-04-26 01:45:43 +07:00
denozord 50f9c91843 Docker: include backend/db in runtime image
Publish Docker image / build-and-push (push) Successful in 1m30s
Made-with: Cursor
2026-04-20 02:04:08 +07:00
denozord 0d0ac2936a CI: build v5 branch with Docker tags v5 only
Publish Docker image / build-and-push (push) Successful in 5m3s
Made-with: Cursor
2026-04-20 01:36:21 +07:00
denozord 1c6e6ab24a Backend: SQLite storage, EvoBGP integration, filters in SQL
Made-with: Cursor
2026-04-20 01:17:00 +07:00
denozord 7f3eaea40a chore(git): create v5 branch from v4
Made-with: Cursor
2026-04-19 22:27:09 +07:00
32 changed files with 3134 additions and 3017 deletions
+26 -1
View File
@@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
- v4 - v4
- v5
- tabler - tabler
- v3 - v3
workflow_dispatch: workflow_dispatch:
@@ -29,7 +30,31 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }} password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push Docker image # Ветка v5: в реестр только теги v5 / v5-<sha> (без latest)
- name: Build and push Docker image (v5)
if: ${{ gitea.ref_name == 'v5' }}
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.fast
push: true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
git.shts.su/${{ gitea.repository }}:v5
git.shts.su/${{ gitea.repository }}:v5-${{ gitea.sha }}
labels: |
org.opencontainers.image.title=RouterOS Manager
org.opencontainers.image.description=RouterOS Manager for domains and AS management
org.opencontainers.image.version=v5
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Build and push Docker image (main и прочие ветки)
if: ${{ gitea.ref_name != 'v5' }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
+482
View File
@@ -0,0 +1,482 @@
# AI-READY DEVELOPMENT SPEC
## 1. SYSTEM OVERVIEW (СИСТЕМНЫЙ КОНТЕКСТ)
Система `router-lists-ui` управляет сетевыми данными и конфигурациями RouterOS/MikroTik: домены, ASN, IP-диапазоны, фильтры, серверные конфиги, бэкапы и мониторинг доступности/ресурсов. UI (`React + Vite`) работает с API (`Express`) через `/api/*`.
Фактическая модель хранения гибридная:
- справочники BGP (domains/asns/ip-ranges/communities/auto-urls) обслуживаются через EvoBGP API (`evobgpClient`);
- локальные конфиги/кеш/версии хранятся в SQLite (`better-sqlite3`) через слой совместимости `s3Service`/`blobStorage`;
- `.rsc` backup MikroTik хранится в локальной ФС (`MIKROTIK_BACKUP_DIR`).
### Ключевые бизнес-процессы
- Управление BGP-справочниками (CRUD через EvoBGP).
- Управление фильтрами (`global/simple`) в `app_filter_rules`.
- Управление серверами, server-configs, server-filters.
- Генерация/применение MikroTik-конфигов и диагностические операции (ping/traceroute/speed-test).
- Планировщики: network-map, ping-services, uptime-monitor, backups.
- История/rollback для versioned blob-объектов.
### Основные сущности
- `DomainCommunity`: `{ domain, community }`
- `AsnCommunity`: `{ domain(asn), type(community) }`
- `IpRangeCommunity`: `{ ipRange, community }`
- `Server`: jumphost/exit + gateways + creds
- `FilterRule`: `{ community, gateway, description, ...extra }` + `scope`
- `BlobObject`: logical key + body + etag + versions
- `UiSettings`
- `MikrotikBackup`
### Критические ограничения (НЕ НАРУШАТЬ)
- Нельзя заменять EvoBGP-данные локальной SQLite-логикой для `domains-new/asns/ip-ranges/communities/auto-urls`.
- Нельзя удалять/ломать ETag-конкурентность (`If-None-Match`, `If-Match`, `412`) в read/write API.
- Нельзя ломать namespace/ключи blob-объектов (`servers.json`, `filter-manager/*`, `bgp_data/rt_ui_settings.json` и т.д.) без миграционного плана.
- Нельзя отключать `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout`.
- Нельзя менять формат ответа ошибок `{ code, message, details, requestId }`.
---
## 2. REPOSITORY MAP (КАРТА РЕПОЗИТОРИЯ)
> Примечание: в текущем репозитории нет отдельных top-level директорий `/db`, `/services`, `/api`, `/agents`; их роль реализована внутри `backend/`.
### `/frontend`
- Назначение: UI-приложение (`React 19`, `react-router-dom`, `@tanstack/react-query`, `axios`, Tabler).
- Ключевые зоны:
- `src/App.jsx`: роутинг, layout, providers (`QueryClientProvider`, `NotifyProvider`, `PingProvider`, `AlertsProvider`).
- `src/lib/api.js`: единый HTTP-клиент, retry, ETag-based GET-cache, нормализация ошибок.
- `src/hooks/useApiQuery.js`: реактивный data-layer + optimistic updates.
- Правила изменений:
- Любой новый экран регистрировать в `App.jsx` routes + navigation.
- Новые API-вызовы делать только через `src/lib/api.js`.
- Мутации должны инвалидацировать/обновлять react-query cache.
- Зависимости: backend `/api`, browser localStorage (theme/lang/layout), глобальный notifier.
### `/backend`
- Назначение: API, интеграции, storage abstraction, schedulers.
- Ключевые зоны:
- `server.js`: composition root, middleware, endpoint registration.
- `routes/*.js`: endpoint-level orchestration.
- `services/*.js`: storage/EvoBGP/MikroTik/scheduler логика.
- `db/sqliteDb.js` + `db/migrations/*.sql`: DB init/migrations.
- `middleware/*`: unified errors + soft locks.
- Правила изменений:
- Новый endpoint добавлять через route/service, не встраивать бизнес-логику в `server.js`.
- Для persistent данных использовать `s3Service`/`filterRulesStorage`/`backupFsService` по назначению.
- Сохранять совместимость legacy-имен (`s3*`) пока фронт/интеграции их используют.
- Зависимости: SQLite, EvoBGP HTTP API, локальная ФС для backups, env-конфиг.
### `/backend/db` (внутренний `/db`)
- Назначение: схема и поведение SQLite.
- Состав:
- `migrations/001_init.sql`: blobs/meta/cache/versioning.
- `migrations/002_filter_rules.sql`: `app_filter_rules`.
- Правила изменений:
- Только миграциями; прямые ручные правки существующих таблиц запрещены.
- Миграции должны быть backward-compatible и idempotent.
### `/backend/services` (внутренний `/services`)
- Назначение: слой бизнес-операций и инфраструктурных адаптеров.
- Ключевые модули:
- `evobgpClient.js` (внешний источник правды для BGP reference).
- `s3Service.js` + `blobStorage.js` (SQLite-backed blob API).
- `filterRulesStorage.js` (SQL-таблица фильтров).
- `mikrotik*`, `*Scheduler.js` (операции и периодические задачи).
- Правила изменений:
- Не смешивать transport-логику endpoint с data-access.
- Любая write-операция должна возвращать meta для `sendOk`.
### `/backend/routes` (внутренний `/api`)
- Назначение: HTTP contracts + валидация входа + orchestrator.
- Правила изменений:
- Вход валидируется до вызова сервиса.
- Ошибки наружу только через `sendError`.
- ETag/Last-Modified сохранять в GET/POST где уже реализовано.
### `/agents`
- В репозитории отсутствует.
---
## 3. ARCHITECTURE RULES (АРХИТЕКТУРНЫЕ ПРАВИЛА)
### Слои и границы
- DO: `Route -> Service -> Storage/External API`.
- DO: хранить валидацию запроса на route-слое, а доменную обработку в service.
- DON'T: вызывать SQLite напрямую из UI.
- DON'T: переносить доменную логику в `server.js`.
### Бизнес-логика
- DO: для filters использовать только `filterRulesStorage` (`app_filter_rules`).
- DO: для BGP-reference использовать только `evobgpClient`.
- DON'T: писать domains/asns/ip-ranges в `blobs`.
### State management (frontend)
- DO: network-state через React Query.
- DO: optimistic updates + rollback на error.
- DO: invalidation query keys после мутаций.
- DON'T: дублировать fetch/caching logic вне `api.js`/`useApiQuery.js`.
### Доступ к БД
- DO: через `s3Service`/`blobStorage`/`filterRulesStorage`.
- DO: учитывать `etag`, `lastModified`, `contentLength`.
- DON'T: менять ключевые `object_key` без миграции и impact map.
### Изменения высокой опасности
- Запрещено менять без полной цепочки рефакторинга:
- форматы payload для `/api/domains-new`, `/api/asns`, `/api/ip-ranges`;
- schema ключей blob-хранилища;
- `sendOk/sendError` формат;
- scheduler settings endpoints и их JSON-структуры;
- semantics `server-configs` (только jumphost в листинге).
---
## 4. BUSINESS LOGIC SPECIFICATION
### 4.1 Процесс: BGP Reference Sync (domains/asns/ip-ranges)
- Inputs: HTTP GET/POST, EvoBGP env (`EVOBGP_API_URL`, `EVOBGP_API_TOKEN`), payload массив.
- Outputs: данные справочников + `sendOk` metadata.
- States:
- `IDLE` -> `VALIDATE_INPUT` -> `CALL_EVOBGP` -> `NORMALIZE` -> `RESPOND_OK`
- ветка ошибки: `VALIDATE_INPUT -> RESPOND_400` или `CALL_EVOBGP -> RESPOND_5XX`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_EVOBGP`, `E_EVOBGP_NOT_CONFIGURED`.
- Side effects: запись в EvoBGP, обновление remote reference set.
### 4.2 Процесс: Filter Rules Replace (`global/simple`)
- Inputs: `scope`, массив правил `{community,gateway,...}`.
- Outputs: новый snapshot правил + meta (`etag/lastModified`).
- States:
- `READ_SCOPE_META` -> `VALIDATE_ARRAY` -> `TX_BEGIN` -> `DELETE_SCOPE_ROWS` -> `INSERT_ROWS_ORDERED` -> `UPDATE_META_MTIME` -> `TX_COMMIT` -> `RESPOND_OK`
- rollback: `TX_BEGIN -> ERROR -> TX_ROLLBACK`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_STORAGE`.
- Side effects: перезапись всех правил scope (full replace).
### 4.3 Процесс: Blob Object Write (legacy s3Service API)
- Inputs: `object_key`, `content`.
- Outputs: `sendOk(meta)`.
- States:
- `VALIDATE_KEY` -> `READ_EXISTING` -> `IF_VERSIONED_SAVE_OLD_VERSION` -> `UPSERT_BLOB` -> `TRIM_VERSIONS` -> `INVALIDATE_MEMORY_CACHE` -> `RESPOND_OK`.
- Errors: `E_STORAGE`.
- Side effects: изменение `blobs`, опционально `blob_versions`.
### 4.4 Процесс: Server Config Management
- Inputs: `serverId`, config text / filters json.
- Outputs: per-server config/filter data.
- States:
- `VALIDATE_SERVER_ID` -> `READ_OR_WRITE_BLOB_KEY` -> `RESPOND`.
- Для `/api/server-configs` list: `READ_SERVERS` -> `FILTER_JUMPHOST` -> `MAP_ID_NAME` -> `RESPOND`.
- Errors: `E_BAD_REQUEST`, `E_SCHEMA`, `E_S3/E_STORAGE`.
- Side effects: запись в `filter-manager/config-*.txt` и `filter-manager/server-filters-*.json`.
### 4.5 Процесс: Auto URLs Ingestion
- Inputs: список URL + community.
- Outputs: summary counters.
- States:
- `LOAD_URLS` -> `FETCH_CONTENT` -> `PARSE_LINES` -> `CLASSIFY(IP/CIDR/DOMAIN)` -> `DEDUP_WITH_CURRENT` -> `SAVE_TO_EVOBGP` -> `RESPOND`.
- Errors: `E_BAD_REQUEST`, `E_EVOBGP`.
- Side effects: расширение списков IP/domains в EvoBGP.
### 4.6 Процесс: Ping Services Cache Refresh
- Inputs: UI settings (`pingServicesSource`, cache TTL), services list.
- Outputs: `{ byId, history }`.
- States:
- `LOAD_UI_SETTINGS` -> (`ROUTER_MODE` | `WEB_MODE`) -> `MEASURE_RTT` -> `WRITE_CACHE` -> `APPEND_HISTORY` -> `RESPOND`.
- Errors: fallback на `ms: null` payload при деградации.
- Side effects: запись в blob-ключи `ping-services/cache_*`, `ping-services/history_*`.
### 4.7 Процесс: History Rollback
- Inputs: `resource`, `versionId`.
- Outputs: `sendOk(meta)` после rollback.
- States:
- `MAP_RESOURCE_TO_KEY` -> `VALIDATE_SUPPORT` -> `ROLLBACK_VERSION` -> `RESPOND`.
- Errors: `E_RESOURCE`, `E_HISTORY_EV`, `E_HISTORY_FILTERS_SQL`, `E_STORAGE`.
- Side effects: восстановление blob из `blob_versions`.
---
## 5. API CONTRACTS (ФОРМАЛЬНЫЕ КОНТРАКТЫ)
### 5.1 Глобальные правила API
- Base path: `/api`.
- Auth level: явной auth-схемы нет (network-level trust, rate-limit, CORS, security headers).
- Success write format: `{ ok, etag, lastModified, contentLength }`.
- Error format: `{ code, message, details, requestId }`.
- Конкурентность: `If-None-Match` для GET, `If-Match`/`etag` для отдельных write.
### 5.2 Контракты endpoint’ов
| Route | Method | Auth | Input schema (JSON) | Output schema | Side effects | Таблицы/ключи | Сервисы |
|---|---|---|---|---|---|---|---|
| `/health` | GET | public | - | `{ok:true}` | none | - | - |
| `/ready` | GET | public | - | `{ok:true}` | none | - | - |
| `/metrics` | GET | internal | - | Prometheus text | none | - | prom-client |
| `/api/version` | GET | public | - | `{version,gitSha,buildAt}` | none | - | env |
| `/api/domains` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET std/raw list, POST `sendOk` | remote write/read | EvoBGP | evobgpClient |
| `/api/asns` | GET/POST | public | POST: `{domains:[{domain,type}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/domains-new` | GET/POST | public | POST: `{domains:[{domain,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/ip-ranges` | GET/POST | public | POST: `{ipRanges:[{ipRange,community}],etag?}` | GET/POST аналогично | remote write/read | EvoBGP | evobgpClient |
| `/api/servers` | GET/POST | public | POST: `{servers:[...],etag?}` | JSON list / `sendOk` | persist servers | `blobs:servers.json` | serversRoutes,s3Service |
| `/api/server-connections` | GET/POST | public | POST: `{domains:[{from,to,tunnelType,...}]}` | list / `sendOk` | persist graph links | blob key `server-connections.json` | jsonDataRoutes |
| `/api/billing` | GET/POST | public | POST: `{items:[...],etag?}` (route expects `domains` in factory; adapters normalize) | list / `sendOk` | persist billing | blob key `servers-billing.json` | jsonDataRoutes |
| `/api/filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=global)` | filterRulesStorage |
| `/api/simple-filters` | GET/POST | public | POST: `{domains:[{community,gateway,...}]}` | list / `sendOk` | replace scope rules | `app_filter_rules(scope=simple)` | filterRulesStorage |
| `/api/network-config` | GET/POST | public | POST: `{domains:{gateways?,tunnelInterfaces?,ipPools?}}` | object / `sendOk` | persist network config | blob key `network-config.json` | jsonDataRoutes |
| `/api/communities` | GET/POST | public | POST: `{communities:[{value,name?,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | communitiesRoutes |
| `/api/communities/stats` | GET | public | - | `{stats:[{community,count}],total}` | none | EvoBGP + `app_filter_rules` | communitiesRoutes |
| `/api/filters/generate-config` | GET | public | query params | generated config | none/write temp | derived blobs | filtersRoutes |
| `/api/filters/export-config` | POST | public | export params | file/content json | write export | derived blobs/fs | filtersRoutes |
| `/api/server-filters/generate-config` | POST | public | `{serverId,...}` | generated config | none | per-server blobs | filtersRoutes |
| `/api/server-configs` | GET/POST | public | POST: `{servers:[jumphost...]}` | jumphost list / `sendOk` | rewrite servers subset | `servers.json` | serverConfigsRoutes |
| `/api/server-configs/:serverId` | GET/POST/DELETE | public | POST: `{config:string}` | `{config}` / `sendOk` | write/delete server config | `filter-manager/config-<id>.txt` | serverConfigsRoutes |
| `/api/server-configs/:serverId/complete` | DELETE | public | - | `{ok:true,...}` | delete config+filters | `filter-manager/config-*`, `server-filters-*` | serverConfigsRoutes |
| `/api/server-filters/:serverId` | GET/POST | public | POST: `{filters:[{community,gateway,...}],etag?}` | `{filters}` / `sendOk` | write server filters | `filter-manager/server-filters-<id>.json` | serverConfigsRoutes |
| `/api/locks/:resource` | GET/POST/DELETE | public | POST:`{owner?,ttlSeconds?}` | lock status/result | in-memory lock mutate | RAM only | lockManager |
| `/api/history/:resource` | GET | public | query:`countOnly,format` | versions list | none | `blob_versions` | miscRoutes+s3Service |
| `/api/history/:resource/rollback` | POST | public | `{versionId}` | `sendOk` | rollback snapshot | `blobs`,`blob_versions` | miscRoutes+s3Service |
| `/api/s3/last-modified` | GET | public | - | map metas | none | blobs/filter_rules | miscRoutes |
| `/api/auto-urls` | GET/POST | public | POST:`{urls:[{url,community,...}]}` | list / `sendOk` | EvoBGP write | EvoBGP | miscRoutes |
| `/api/auto-urls/process` | POST | public | optional params | process summary | pulls URLs, writes lists | EvoBGP | miscRoutes |
| `/api/servers/availability` | GET | public | query:`ttlSeconds?` | `{online,total,statuses}` | refresh memory cache | RAM | miscRoutes |
| `/api/update-bgp/background` | POST | public (rate limited) | passthrough body | upstream response | outbound HTTP POST | - | miscRoutes |
| `/api/ws/url` | GET | public | - | `{url}` | none | `bgp_data/rt_ui_settings.json` | miscRoutes |
| `/api/ui-settings` | GET/POST | public | POST:`{settings,etag?}` | settings / `sendOk` | write ui settings | `bgp_data/rt_ui_settings.json` | miscRoutes |
| `/api/ping-services-list` | GET | public | - | `{list:[...]}` | none | ui settings | miscRoutes |
| `/api/ping-services` | GET | public | query:`refresh|nocache` | `{byId,history}` | cache+history writes | `ping-services/cache_*`,`history_*` | miscRoutes |
| `/api/ipsec-passwords` | GET/POST | public | POST DTO ipsec secret | list / item / `sendOk` | secret storage update | blobs/config | ipsecPasswordsRoutes |
| `/api/ipsec-passwords/:id` | GET/PUT/DELETE | public | PUT DTO | item / `sendOk` | update/delete secret | blobs/config | ipsecPasswordsRoutes |
| `/api/mikrotik/generate` | POST | public | generation payload | config text/json | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/generate-interfaces` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/generate-recursive-routes` | GET | public | query | generated config | none | derived | mikrotikConfigRoutes |
| `/api/mikrotik/test-connection` | POST | public | connection payload | test result | network probe | - | mikrotikConfigRoutes |
| `/api/mikrotik/ping` | GET/POST | public | POST:`{serverId,gateway,target,...}` | health/ping result | RouterOS ping | - | mikrotikConfigRoutes |
| `/api/mikrotik/traceroute` | POST | public | traceroute payload | trace result | RouterOS command | - | mikrotikConfigRoutes |
| `/api/mikrotik/speed-test` | POST | public | speed-test payload | throughput metrics | RouterOS command | speed cache blobs | mikrotikConfigRoutes |
| `/api/mikrotik/apply` | POST | public | apply payload | apply report | config apply + backup | fs+blobs | mikrotikConfigRoutes |
| `/api/mikrotik/run-script` | POST | public | script payload | execution output | RouterOS script execution | - | mikrotikConfigRoutes |
| `/api/mikrotik/address-lists` | GET | public | query | list data | none | EvoBGP/derived | mikrotikConfigRoutes |
| `/api/mikrotik/ospf-interface-templates` | GET | public | query | templates | none | blobs/config | mikrotikConfigRoutes |
| `/api/mikrotik/ospf-interface-templates/apply` | POST | public | apply payload | `sendOk`/report | RouterOS mutation | - | mikrotikConfigRoutes |
| `/api/mikrotik/address-lists/apply-summary` | POST | public | summary payload | report | RouterOS mutation | - | mikrotikConfigRoutes |
| `/api/uptime/cache` | GET | public | query | uptime snapshot | none | uptime cache blob | mikrotikConfigRoutes |
| `/api/uptime/check` | POST | public | check payload | status report | ping/http checks | uptime cache blob | mikrotikConfigRoutes |
| `/api/traffic/interface-stats` | GET | public | query | traffic stats | optional cache update | cache blobs | trafficRoutes |
| `/api/resources/stats` | GET | public | query | cpu/ram/hdd stats | optional cache update | cache blobs | resourceStatsRoutes |
| `/api/alerts` | GET | public | query | alerts aggregate | none | combines caches | alertsRoutes |
| `/api/mikrotik/backups` | GET/POST | public | POST backup payload | list / backup result | write/read `.rsc` | local FS + metadata | mikrotikBackupRoutes |
| `/api/mikrotik/backups/item` | GET | public | query id/path | backup item | read FS | local FS | mikrotikBackupRoutes |
| `/api/mikrotik/backups/diff` | POST | public | `{from,to,...}` | diff result | none | local FS | mikrotikBackupRoutes |
| `/api/mikrotik/backups/run` | POST | public | optional | run-now result | scheduler trigger | local FS | mikrotikBackupRoutes |
| `/api/network-map-cache` | GET | public | query | cached map | none | cache blobs | schedulerRoutes |
| `/api/route-optimizer` | GET | public | query topology | optimization result | none | derived/cache | routeOptimizerRoutes |
| `/api/scheduler/network-map/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | blobs/ui settings | schedulerRoutes |
| `/api/scheduler/network-map/logs` | GET | public | query | logs | none | blobs/logs | schedulerRoutes |
| `/api/scheduler/network-map/run-now` | POST | public | optional | trigger result | immediate job | cache update | schedulerRoutes |
| `/api/scheduler/ping-services/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
| `/api/scheduler/ping-services/run-now` | POST | public | optional | trigger result | immediate cache refresh | ping-services cache | schedulerRoutes |
| `/api/scheduler/uptime-monitor/settings` | GET/PATCH | public | PATCH settings DTO | settings/result | update scheduler config | ui settings | schedulerRoutes |
| `/api/scheduler/uptime-monitor/run-now` | POST | public | optional | trigger result | immediate uptime job | uptime cache | schedulerRoutes |
| `/api/mikrotik/validate` | POST | public | `{config:string}` | validation report | none | - | mikrotik-validator |
---
## 6. DATABASE BEHAVIOR MODEL
### 6.1 Критичные таблицы
| Таблица | Назначение | Критичность | Можно менять? |
|---|---|---|---|
| `blobs` | текущее состояние logical objects | very high | только через миграцию + compatibility layer |
| `blob_versions` | история версий для rollback | high | нельзя удалять без замены rollback механизма |
| `versioned_keys` | whitelist ключей с версионированием | high | расширять можно, удаление рискованно |
| `app_filter_rules` | глобальные/simple фильтры | very high | менять с миграцией + route/service sync |
| `app_meta` | meta timestamps/flags | medium | осторожно; используется для etag/mtime |
| `cache_entries` | TTL-кеш | medium | можно расширять, не ломая purge logic |
| `schema_migrations` | applied versions | critical | не редактировать вручную |
### 6.2 Cascade/relations
- `blob_versions.object_key -> blobs.object_key ON DELETE CASCADE`.
- Удаление blob удаляет историю версий автоматически.
### 6.3 Transactional boundaries
- `blobStorage.writeBlobTx`: атомарно сохраняет новую версию + snapshot old version + trim.
- `filterRulesStorage.replaceRules`: атомарно `DELETE scope` + `INSERT new set` + update mtime.
- Rollback версии выполняется внутри DB write flow.
### 6.4 Consistency rules
- `etag` вычисляется по SHA-256 body; должен соответствовать текущему blob содержимому.
- `lastModified` и `contentLength` обязательны для корректной cache-конкурентности UI/API.
- `app_filter_rules.position` определяет порядок; запись всегда full-replace.
- `SQLITE_PATH` должен быть persistent volume в production.
- `journal_mode=WAL` обязателен для конкурентного I/O.
---
## 7. FRONTEND RULES (UI ENGINEERING RULESET)
### Структура страниц
- Все маршруты регистрируются централизованно в `App.jsx`.
- Экран = manager/page компонент + общие UI-компоненты (`components/*`).
### State management rules
- Server-state: только React Query (`useQuery/useMutation`).
- UI-state: local component state + Context (`Language`, `Theme`, `Ping`, `Alerts`).
- Запрещено хранить API snapshot в произвольных singleton-объектах вне React Query.
### Data fetching rules
- Только через `src/lib/api.js`.
- Для GET использовать `If-None-Match`/ETag-механику клиента.
- Для write-путей с поддержкой ETag передавать `If-Match`/`etag`.
### Caching strategy
- React Query `staleTime` default 30s.
- Axios in-memory response cache для GET.
- Для `/communities` отключён условный GET (всегда fresh).
### Routing dependencies
- Навигация и route paths должны совпадать (sidebar/horizontal menu).
- Любой новый route требует:
- компонент страницы;
- запись в nav categories;
- route registration (оба layout блока).
### Запрещённые паттерны
- Прямой `fetch` в компонентах (мимо `api.js`).
- Изменение `queryClient` политики точечно без общего обоснования.
- UI-изменения, которые ломают optimistic rollback сценарии.
---
## 8. AI AGENT OPERATING PROTOCOL
### 8.1 Роли агентов
- `architect agent`: проверяет архитектурные границы, планирует миграции/контракты.
- `backend agent`: реализует route/service/storage изменения с сохранением backward compatibility.
- `frontend agent`: реализует UI/state/data-fetch изменения по правилам React Query.
- `refactor agent`: выполняет безопасные структурные изменения с impact map.
- `debug agent`: локализует баги, подтверждает root cause, готовит минимальный fix.
### 8.2 Правила работы агентов
- Перед правками читать: `README.md`, `backend/server.js`, целевой route/service, соответствующий frontend hook/page.
- Изменения планировать от контракта к реализации: API -> service -> storage -> UI.
- При любом endpoint-change проверять:
- формат success/error;
- ETag/headers;
- влияние на React Query keys и optimistic updates.
- Избегать конфликтов:
- не менять shared utility без cross-feature impact check;
- при рефакторинге route переносить логику в service, не наоборот.
### 8.3 SAFE EDITING RULE (обязательный)
Перед изменением кода агент обязан:
1. Найти все зависимости (routes/services/hooks/components/schedulers).
2. Проверить влияние на API (input/output, status codes, headers).
3. Проверить влияние на DB (таблицы, ключи blobs, миграции, транзакции).
4. Проверить UI impact (query keys, route path, optimistic update behavior).
5. Сформировать `plan diff` (что меняется, что не меняется, как верифицируется).
---
## 9. CHANGE MANAGEMENT PROTOCOL
### STEP 1 — ANALYZE
- Определить feature/bug scope.
- Идентифицировать owner-слой (frontend/backend/storage/external).
- Собрать baseline контрактов.
### STEP 2 — IMPACT MAP
- Построить карту зависимостей:
- endpoint -> service -> storage/external;
- endpoint -> frontend hooks -> pages/components.
- Классифицировать риск: low/medium/high.
### STEP 3 — IMPLEMENT PLAN
- Реализовать минимально-инвазивные изменения.
- Сохранять backward-compatible payload где возможно.
- Для high-risk сначала добавить адаптерный слой, потом migration.
### STEP 4 — APPLY PATCH
- Вносить правки атомарными логическими блоками.
- Не смешивать функциональные и косметические изменения.
- Фиксировать invariants в комментариях только при необходимости.
### STEP 5 — VERIFY CONSISTENCY
- Проверить lint/type/build.
- Проверить ключевые API сценарии (read/write/etag/error).
- Проверить UI critical path (list load -> edit -> save -> refresh).
- Проверить scheduler/side effect пути, если затронуты.
---
## 10. DEBUGGING & RECOVERY RULES
### Диагностика ошибок
- Backend:
- проверять structured logs (`pino-http`) с `requestId`;
- проверять метрики (`http_errors_total`, `http_request_duration_seconds`);
- валидировать env для EvoBGP/MikroTik.
- Frontend:
- проверять network ошибки через `apiErrorHandler` + notify payload;
- проверять retry/noRetry и cache behavior.
### Откат изменений
- Код: откат через VCS commit revert.
- Данные:
- blob keys: `/api/history/:resource/rollback` (если поддерживается).
- filters/evobgp resources: rollback через локальный history API не поддерживается; нужен внешний restore plan.
### Изоляция багов
- Сначала локализовать слой (UI/API/Service/Storage/External).
- Воспроизвести минимальный сценарий.
- Проверить контракт на границе слоя перед deep refactor.
### Логирование проблем
- Каждый баг-репорт должен включать:
- requestId/endpoint;
- входной payload (без секретов);
- фактический vs ожидаемый ответ;
- impacted таблицы/ключи.
---
## 11. EXTENSION GUIDE
### Добавление новой фичи (общий шаблон)
1. Добавить/расширить service (доменная логика).
2. Добавить route с валидацией и contract-consistent ответом.
3. Добавить frontend hook в `useApiQuery.js` или отдельный data hook.
4. Подключить UI page/component + route entry.
5. Добавить проверку impact на ETag/cache/retry.
### Расширение API
- Предпочитать additive changes (новые поля/роуты) вместо breaking.
- Для breaking change:
- ввести transitional endpoint или feature flag;
- обновить frontend и consumers синхронно;
- подготовить rollback plan.
### Добавление новой таблицы
- Только новой миграцией `00X_*.sql`.
- Определить:
- primary key + indexes;
- relation/cascade policy;
- transaction boundaries;
- read/write service API.
### Как не ломать текущую систему
- Не удалять legacy aliases без full usage audit.
- Не менять ключи объектов в `blobs` без map миграции.
- Не менять `sendError/sendOk` contract.
- Не обходить `evobgpClient` для BGP-reference данных.
---
## OPERATIONAL CHECKLIST FOR LLM AGENTS
- [ ] Прочитан `README.md` и целевые route/service/frontend файлы.
- [ ] Сформирован `plan diff` и impact map.
- [ ] Проверены API contract invariants (status/errors/headers).
- [ ] Проверены DB invariants (transactions/etag/versioning).
- [ ] Проверен UI flow (query cache + optimistic updates).
- [ ] Выполнены lint/build/ручная smoke-валидация критических сценариев.
+128 -180
View File
@@ -1,118 +1,142 @@
# Docker Images # Docker Images
Этот проект предоставляет Docker образы для RouterOS Manager с двумя различными версиями интерфейса. Образ собирается из [`Dockerfile.fast`](Dockerfile.fast) (многостадийная сборка: Vite frontend + `npm ci` backend, рантайм **distroless** Node 20). В Gitea Actions используется тот же файл (см. [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml)).
## 🏷️ Доступные теги ## Теги в реестре `git.shts.su`
### Основная версия (main branch) Замените `[repository]` на путь репозитория в Gitea (например `denozord/router-lists-ui`).
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
### Tabler версия (tabler branch) | Ветка (триггер push) | Образы в реестре |
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI |----------------------|-------------------|
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом | **main** (и v4, v3 и т.д. из workflow) | `git.shts.su/[repository]:latest` и `git.shts.su/[repository]:<полный-sha>` |
| **v5** | **только** `git.shts.su/[repository]:v5` и `git.shts.su/[repository]:v5-<полный-sha>` — тег **`latest` с ветки v5 не публикуется** |
| **tabler** | см. отдельный workflow `docker-publish-tabler.yml`, если включён |
## 🚀 Запуск Пин на коммит: используйте тег с суффиксом `-<sha>`; «плавающий» тег ветки (`:v5` или `:latest`) всегда указывает на последнюю успешную сборку.
## Запуск (production)
### Общие рекомендации
1. **Данные на хосте** — смонтируйте том для SQLite и каталог бэкапов MikroTik, иначе при удалении контейнера пропадут БД и `.rsc`.
2. **Переменные** — минимум `ENCRYPTION_KEY`; для справочников и UI — `EVOBGP_API_URL` и `EVOBGP_API_TOKEN`; при необходимости `CORS_ORIGINS`, `PORT`.
3. **Рантайм distroless** — внутри образа **нет** `/bin/bash` и пакетного менеджера; отладка через `docker logs` / метрики. Интерактивная оболочка недоступна.
### Основная линия (`:latest`)
### Основная версия
```bash ```bash
docker run -d \ docker run -d \
--name s3-lists-manager \ --name router-lists-ui \
-p 3001:3001 \ -p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \ -e ENCRYPTION_KEY=<64-hex> \
-e EVOBGP_API_URL=https://evobgp.example/v1 \
-e EVOBGP_API_TOKEN=<token> \
-e SQLITE_PATH=/data/router-lists.db \
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
-v router-lists-data:/data \
git.shts.su/[repository]:latest git.shts.su/[repository]:latest
``` ```
### Tabler версия Каталог `/data` на томе должен быть доступен пользователю **nonroot** (образ distroless); при ошибках прав создайте том заранее или используйте bind-mount с `chown` под UID образа (обычно `65532` у nonroot).
### Ветка v5 (только теги `v5` / `v5-<sha>`)
```bash
docker pull git.shts.su/[repository]:v5
docker run -d \
--name router-lists-ui-v5 \
-p 3002:3001 \
-e ENCRYPTION_KEY=<64-hex> \
-e EVOBGP_API_URL=https://evobgp.example/v1 \
-e EVOBGP_API_TOKEN=<token> \
-e SQLITE_PATH=/data/router-lists.db \
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
-v router-lists-v5-data:/data \
git.shts.su/[repository]:v5
```
Чтобы зафиксировать конкретный коммит:
```bash
docker run -d ... git.shts.su/[repository]:v5-1c6e6ab...
```
### Tabler-ветка (если публикуется отдельным workflow)
```bash ```bash
docker run -d \ docker run -d \
--name s3-lists-manager-tabler \ --name router-lists-ui-tabler \
-p 3001:3001 \ -p 3003:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \ --env-file ./backend/.env \
-v router-lists-tabler-data:/data \
git.shts.su/[repository]:tabler git.shts.su/[repository]:tabler
``` ```
## 🔄 Обновление ## Обновление образа
### latest
### Основная версия
```bash ```bash
docker pull git.shts.su/[repository]:latest docker pull git.shts.su/[repository]:latest
docker stop s3-lists-manager docker stop router-lists-ui && docker rm router-lists-ui
docker rm s3-lists-manager # затем снова docker run ... как выше
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:latest
``` ```
### Tabler версия ### v5
```bash ```bash
docker pull git.shts.su/[repository]:tabler docker pull git.shts.su/[repository]:v5
docker stop s3-lists-manager-tabler docker stop router-lists-ui-v5 && docker rm router-lists-ui-v5
docker rm s3-lists-manager-tabler docker run -d ... git.shts.su/[repository]:v5
docker run -d \
--name s3-lists-manager-tabler \
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:tabler
``` ```
## 🐳 Docker Compose ## Docker Compose
Создайте файл `docker-compose.yml`: Пример **одной** службы на `latest` с томом данных (подставьте свой `image:` и при необходимости `env_file`):
```yaml ```yaml
version: '3.8'
services: services:
s3-lists-manager: router-lists-ui:
image: git.shts.su/[repository]:latest image: git.shts.su/[repository]:latest
container_name: s3-lists-manager container_name: router-lists-ui
ports: ports:
- "3001:3001" - "3001:3001"
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production NODE_ENV: production
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# Прокси EvoBGP для UI (Communities / Domains / …) — без CORS в браузере: EVOBGP_API_URL: ${EVOBGP_API_URL}
# - EVOBGP_API_URL=http://evobgp:8080 EVOBGP_API_TOKEN: ${EVOBGP_API_TOKEN}
# - EVOBGP_API_TOKEN=... SQLITE_PATH: /data/router-lists.db
MIKROTIK_BACKUP_DIR: /data/backups/mikrotik
volumes:
- router-lists-data:/data
s3-lists-manager-tabler: volumes:
image: git.shts.su/[repository]:tabler router-lists-data:
container_name: s3-lists-manager-tabler
ports:
- "3002:3001"
restart: unless-stopped
environment:
- NODE_ENV=production
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
``` ```
**Создайте файл `.env` в той же директории:** Отдельный сервис для **v5** — тот же фрагмент, но `image: git.shts.su/[repository]:v5`, другой `container_name` и при желании отдельный volume.
```bash
ENCRYPTION_KEY=your-64-hex-character-key-here
```
Запуск: ## Переменные окружения (контейнер)
```bash
docker-compose up -d
```
## 🔧 Переменные окружения | Переменная | Описание | Обязательная |
|------------|----------|----------------|
| `ENCRYPTION_KEY` | AES-256 для IPSec и паролей MikroTik (64 hex) | **Да** |
| `EVOBGP_API_URL` | Базовый URL API EvoBGP (серверный прокси `/api/evobgp` и бэкенд-справочники) | По сценарию |
| `EVOBGP_API_TOKEN` | Bearer к EvoBGP | По сценарию |
| `SQLITE_PATH` | Путь к файлу SQLite внутри контейнера (персистентный том) | Рекомендуется явно задать, напр. `/data/router-lists.db` |
| `MIKROTIK_BACKUP_DIR` | Каталог бэкапов `.rsc` на диске | Рекомендуется с томом, напр. `/data/backups/mikrotik` |
| `PORT` | Порт HTTP внутри контейнера | Нет (по умолчанию `3001`) |
| `CORS_ORIGINS` | Список Origin через запятую | Нет |
| `LOG_LEVEL` | Уровень логов pino | Нет |
| Переменная | Описание | По умолчанию | Обязательная | `ENCRYPTION_KEY` должен быть **один и тот же** между перезапусками, иначе не расшифровать уже сохранённые секреты.
|------------|----------|--------------|--------------|
| `NODE_ENV` | Окружение Node.js | `production` | Нет |
| `PORT` | Порт для запуска сервера | `3001` | Нет |
| `ENCRYPTION_KEY` | Ключ шифрования для IPSec паролей (64 hex символа) | - | **Да** |
| `EVOBGP_API_URL` | Базовый URL API EvoBGP для серверного прокси `/api/evobgp` (например `http://evobgp:8080` или `http://10.0.0.5:8080`; при необходимости к пути добавляется `/v1`) | - | Да, если используете интеграцию EvoBGP в UI |
| `EVOBGP_API_TOKEN` | Bearer-токен к API EvoBGP (только на сервере, не в браузере) | - | Да, если используете интеграцию EvoBGP в UI |
### EvoBGP и reverse proxy (nginx) ### EvoBGP и reverse proxy (nginx)
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI (иначе в upstream попадёт не тот путь и будет 404). Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI.
**Рекомендуемый вариант** (путь `/api/…` сохраняется): **Рекомендуемый вариант** (путь `/api/…` сохраняется):
@@ -126,140 +150,64 @@ location /api/ {
} }
``` ```
**Типичная ошибка**: внутри `location /api` указать `proxy_pass http://127.0.0.1:3001/;` — nginx **срезает** `/api`, в Node приходит `/evobgp/router-lists/catalog` вместо `/api/evobgp/router-lists/catalog`. В коде добавлен запасной маршрут на `/evobgp`, но лучше поправить nginx. **Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` (без хвоста) должен вернуть JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}` — если вместо этого HTML «404» или страница SPA, запрос **не доходит** до Node (только статика, другой порт или неверный `proxy_pass`). **Проверка**: `GET http://<хост>:<порт>/api/evobgp` JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
### ENCRYPTION_KEY ## Генерация `ENCRYPTION_KEY`
**Важно**: `ENCRYPTION_KEY` является обязательным параметром для работы с IPSec паролями. Этот ключ используется для шифрования/дешифрования паролей, хранящихся в S3.
**Генерация ключа:**
```bash ```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
``` ```
**Пример запуска с ENCRYPTION_KEY:** ## Мониторинг
```bash ```bash
docker run -d \ docker logs -f router-lists-ui
--name s3-lists-manager \ docker stats router-lists-ui
-p 3001:3001 \
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
git.shts.su/[repository]:latest
``` ```
**⚠️ Внимание**: Используйте один и тот же `ENCRYPTION_KEY` для всех запусков контейнера. При изменении ключа старые зашифрованные пароли не смогут быть расшифрованы. ## Локальная сборка (как в CI)
## 📊 Мониторинг
### Просмотр логов
```bash ```bash
# Основная версия docker build -f Dockerfile.fast -t router-lists-ui:local .
docker logs s3-lists-manager docker run --rm -p 3001:3001 --env-file ./backend/.env -v $(pwd)/data:/data \
-e SQLITE_PATH=/data/router-lists.db \
# Tabler версия -e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
docker logs s3-lists-manager-tabler router-lists-ui:local
``` ```
### Статистика контейнеров (PowerShell на Windows: вместо `$(pwd)` используйте путь к каталогу данных.)
## CI/CD (Gitea Actions)
- [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml) — пуш в перечисленные ветки (включая **v5**): сборка и push в `git.shts.su`.
- Для **v5** в реестр уходят **только** теги `v5` и `v5-<sha>` (без `latest`).
- Остальные ветки из списка workflow — `latest` и тег по SHA.
Шаг **Trigger container update webhook** (если задан `secrets.portainer_webhook`) выполняется после сборки; при ошибке шаг не валит job (`continue-on-error: true`).
## Безопасность и сеть
```bash ```bash
docker stats s3-lists-manager s3-lists-manager-tabler docker network create router-lists-net
docker run -d --network router-lists-net ... git.shts.su/[repository]:latest
``` ```
## 🔍 Отладка Ограничение ресурсов:
### Вход в контейнер
```bash ```bash
# Основная версия docker run -d --memory=512m --cpus=0.5 ... git.shts.su/[repository]:latest
docker exec -it s3-lists-manager /bin/bash
# Tabler версия
docker exec -it s3-lists-manager-tabler /bin/bash
``` ```
### Проверка процессов ## Устранение неполадок
```bash
docker exec s3-lists-manager ps aux
```
## 🏗️ Сборка локально
### Основная версия
```bash
git checkout main
docker build -t s3-lists-manager:local .
```
### Tabler версия
```bash
git checkout tabler
docker build -t s3-lists-manager-tabler:local .
```
## 📝 CI/CD
### Автоматическая сборка
- **Ветка `main`**: Автоматически создает образ с тегом `latest`
- **Ветка `tabler`**: Автоматически создает образ с тегом `tabler`
### Workflow файлы
- `.gitea/workflows/docker-publish.yml` - Сборка основной версии
- `.gitea/workflows/docker-publish-tabler.yml` - Сборка Tabler версии
## 🔒 Безопасность
### Проверка образа
```bash
docker scan git.shts.su/[repository]:latest
docker scan git.shts.su/[repository]:tabler
```
### Запуск в изолированной сети
```bash
docker network create s3-lists-network
docker run -d \
--name s3-lists-manager \
--network s3-lists-network \
-p 3001:3001 \
git.shts.su/[repository]:latest
```
## 📈 Производительность
### Ограничение ресурсов
```bash
docker run -d \
--name s3-lists-manager \
--memory=512m \
--cpus=0.5 \
-p 3001:3001 \
git.shts.su/[repository]:latest
```
### Мониторинг ресурсов
```bash
docker stats --no-stream s3-lists-manager
```
## 🆘 Устранение неполадок
### Проверка статуса контейнера
```bash
docker ps -a | grep s3-lists-manager
```
### Перезапуск контейнера
```bash
docker restart s3-lists-manager
```
### Очистка неиспользуемых образов
```bash ```bash
docker ps -a --filter name=router-lists-ui
docker restart router-lists-ui
docker image prune -f docker image prune -f
``` ```
--- ---
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea. **Замените `[repository]`** на актуальный путь репозитория в Gitea (например `denozord/router-lists-ui`).
+1
View File
@@ -48,6 +48,7 @@ COPY backend/middleware ./middleware
COPY backend/routes ./routes COPY backend/routes ./routes
COPY backend/services ./services COPY backend/services ./services
COPY backend/utils ./utils COPY backend/utils ./utils
COPY backend/db ./db
COPY --from=frontend-builder /app/frontend/dist ./public COPY --from=frontend-builder /app/frontend/dist ./public
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере): # EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
+76 -294
View File
@@ -1,347 +1,129 @@
# Router Lists UI # Router Lists UI
Полноценный UI/Backend для управления списками BGP (домены, IP-диапазоны, ASNs), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage). Интерфейс построен на Tabler, frontend — Vite + React, backend — Express. UI/Backend для управления BGP-списками, фильтрами и конфигурациями MikroTik.
Текущая модель хранения: **SQLite + локальная файловая система** (для `.rsc` backup MikroTik).
## Актуальная архитектура хранения
- Основные данные и объекты приложения хранятся в SQLite (через `better-sqlite3`).
- MikroTik backup-файлы (`.rsc`) хранятся в локальной ФС в каталоге `MIKROTIK_BACKUP_DIR`.
- AWS SDK/S3 в runtime не используются.
- Часть имен API/сервисов (`s3*`) сохранена для обратной совместимости, но фактический backend storage — SQLite.
## Быстрый старт ## Быстрый старт
### Требования ### Требования
- Node.js 18+ - Node.js 18+
- S3-совместимое хранилище (Yandex Object Storage) - npm
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
### Запуск backend ### Запуск backend
```powershell ```powershell
cd backend cd backend
npm i npm install
# создайте .env и заполните (пример ниже) # создайте .env и заполните (пример ниже)
npm start npm start
``` ```
Сервис поднимется на `http://localhost:3001`.
Backend поднимется на `http://localhost:3001`.
### Запуск frontend ### Запуск frontend
```bash
```powershell
cd frontend cd frontend
npm i npm install
npm run dev npm run dev
``` ```
Frontend доступен на `http://localhost:5173` (по умолчанию). Production-сборка: `npm run build`.
## Главные особенности Frontend доступен на `http://localhost:5173`.
- Единая UX/UI библиотека Tabler, адаптивные панели действий и навигация.
- Онлайн-обновление (WebSocket) BGP c потоковым логом и фоновое обновление (HTTP POST) из UI.
- Блокировки (soft-lock) ресурсов с TTL, чтобы избежать гонок при одновременном редактировании.
- Версионирование данных (история/откат), если включено версии в бакете.
- Генерация конфигурации MikroTik из фильтров (`/api/filters/generate-config`) и экспорт в S3.
- Метрики Prometheus: `/metrics`, health/ready: `/health`, `/ready`.
### Важные переменные окружения ## Важные переменные окружения
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей - `SQLITE_PATH` — путь к файлу SQLite (по умолчанию `backend/data/router-lists.db`).
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все) - `MIKROTIK_BACKUP_DIR` — каталог для локальных `.rsc` backup-файлов MikroTik.
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию) - `ENCRYPTION_KEY` — 64 hex-символа для шифрования секретов (IPSec/MikroTik).
- `PORT` — порт backend (по умолчанию 3001) - `EVOBGP_API_URL`, `EVOBGP_API_TOKEN` — если используете интеграцию EvoBGP.
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например: - `CORS_ORIGINS`, `LOG_LEVEL`, `PORT` — эксплуатационные настройки сервиса.
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...` - `BGP_BACKGROUND_URL` — URL фонового обновления BGP для прокси-эндпоинта `POST /api/update-bgp/background`.
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
Пример `.env`: Пример `.env`:
```dotenv ```dotenv
PORT=3001 PORT=3001
LOG_LEVEL=info LOG_LEVEL=info
AWS_ACCESS_KEY_ID=... SQLITE_PATH=./data/router-lists.db
AWS_SECRET_ACCESS_KEY=... MIKROTIK_BACKUP_DIR=./data/backups/mikrotik
AWS_REGION=ru-central1 ENCRYPTION_KEY=<64-hex>
S3_BUCKET_NAME=...
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=http://localhost:5173
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502 BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=...
``` ```
## API (backend) ## API (backend)
Все ответы об ошибке имеют единый формат: Формат ошибок:
```json ```json
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." } { "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
``` ```
Успешные POST/PUT возвращают:
Формат успешных `POST/PUT`:
```json ```json
{ "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 } { "ok": true, "etag": "...", "lastModified": "ISO", "contentLength": 123 }
``` ```
И всегда выставляют заголовки `ETag`, `Last-Modified`, `Content-Length-Source` (если есть). GET поддерживают `countOnly=true` там, где это логично.
### Данные ### Примечание по legacy-терминам
- GET `/api/domains-new` — список доменов `{ domain, community }`
- `?q=`, `?offset=`, `?limit=`, `?countOnly=true`, `?format=std`
- POST `/api/domains-new``{ domains: [{domain, community}], etag }`
- GET `/api/ip-ranges` — список `[{ ipRange, community }]` - `GET /api/s3/last-modified` и функции вида `readS3*`/`writeS3*` — это **legacy-названия**.
- POST `/api/ip-ranges``{ ipRanges: [{ipRange, community}], etag }` - Фактически эти операции работают с SQLite-backed хранилищем.
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community) ### Основные эндпоинты
- POST `/api/asns``{ domains: [{domain, type}], etag }`
- GET `/api/communities` — справочник community (JSON) - Данные: `/api/domains-new`, `/api/ip-ranges`, `/api/asns`, `/api/communities`
- POST `/api/communities` `{ communities: [...] }` (валидация уникальности value) - Фильтры/конфиги: `/api/filters`, `/api/server-configs`, `/api/server-filters`
- MikroTik: `/api/mikrotik/generate`, `/api/mikrotik/generate-interfaces`, `/api/mikrotik/generate-recursive-routes`, `/api/mikrotik/test-connection`, `/api/mikrotik/apply`
- Прочее: `/api/servers`, `/api/billing`, `/api/auto-urls`, `/api/servers/availability`, `/api/locks/:resource`, `/api/history/:resource`
### Фильтры и конфигурации ## Docker
- GET `/api/filters` / POST `/api/filters` — фильтры для всех серверов.
- GET `/api/filters/generate-config?format=text|json` — сгенерировать конфиг MikroTik из `filters.json`.
- POST `/api/filters/export-config` — сохранить сгенерированный конфиг в S3 (`mikrotik-frouting-config.txt`).
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...). Полная инструкция: [DOCKER.md](DOCKER.md).
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
- GET `/api/server-filters/:serverId` — фильтры сервера. Критично для production:
- POST `/api/server-filters/:serverId` — сохранить фильтры сервера.
- POST `/api/server-filters/generate-config` — сгенерировать конфиг MikroTik на лету из переданных `{ filters, format?: 'text'|'json' }`.
- POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json).
- GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы.
- GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты.
- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`.
- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost.
### Прочее - Обязательно смонтировать volume для пути с `SQLITE_PATH`.
- GET `/api/servers` / POST `/api/servers` — список серверов. - Обязательно смонтировать volume для `MIKROTIK_BACKUP_DIR`, иначе `.rsc` backup-файлы потеряются при пересоздании контейнера.
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.). - Использовать стабильный `ENCRYPTION_KEY` между перезапусками.
- GET `/api/auto-urls` / POST `/api/auto-urls` — список авто-URL.
- POST `/api/auto-urls/process` — обработать авто-URL и добавить IP в `bgp_data/ips.txt`.
- GET `/api/servers/availability?ttlSeconds=60` — быстрый TCP‑чек доступности нод.
- GET `/api/s3/last-modified` — метаданные S3 (etag/lastModified/contentLength) по ключевым файлам.
- POST `/api/update-bgp/background` — прокси к фоновой задаче обновления BGP. Требует `BGP_BACKGROUND_URL` в `.env`.
- Locks: GET `/api/locks/:resource`, POST `/api/locks/:resource`, DELETE `/api/locks/:resource` (опциональные soft-locks для редактирования; доступ к S3 и долгие операции вроде speed-test блокировок не используют).
- History: GET `/api/history/:resource`, POST `/api/history/:resource/rollback`.
## Frontend Пример запуска:
- Vite + React, Tabler CSS/JS (`@tabler/core`).
- Общий компонент `PageHeaderActions` — единый toolbar на страницах данных.
- Модалка `WsUpdateModal` — поток логов online-обновления (ws).
- Доступность: роли, подписи, фокус-кольца, hot-path без мыши.
### Скрипты ```powershell
```bash docker run -d `
npm run dev # dev-сервер --name router-lists-ui `
npm run build # продакшн сборка -p 3001:3001 `
``` --env-file ./backend/.env `
-e SQLITE_PATH=/data/router-lists.db `
## Структура репозитория -e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik `
``` -v router-lists-data:/data `
backend/ # Express API
frontend/ # Vite React UI
```
## Безопасность и эксплуатация
- Helmet, RateLimit, CORS, отключён слабый etag на JSON.
- Prometheus метрики по умолчанию.
- Для истории версий включите versioning в бакете S3.
## Лицензия
MIT
# 📂 RouterOS Manager
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
- **domains.txt** - список доменов и их шлюзов
- **asns.txt** - список AS номеров и их шлюзов
- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля)
## ✨ Возможности
- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки.
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
- **Сохранение в S3:** Все изменения сохраняются непосредственно в файлах в бакете Yandex Cloud.
- **Docker-контейнеризация:** Готовый `Dockerfile` для сборки и запуска приложения в изолированном окружении.
- **CI/CD с Gitea Actions:** Автоматическая сборка и публикация Docker-образа в Gitea Registry при пуше в `main`.
- **Современный интерфейс:** Полностью интегрированный Tabler UI с официальными компонентами.
## 🛠️ Технологический стек
| Область | Технология |
|--------------|-----------------------------------------------------------------------------------------------------------|
| **Фронтенд** | [**React**](https://reactjs.org/) + [**Vite**](https://vitejs.dev/) |
| | [**@tabler/core**](https://tabler.io/) (UI-компоненты для Tabler версии) |
| | [**Tabler Icons**](https://tabler-icons.io/) (иконки) |
| | [**Axios**](https://axios-http.com/) (HTTP-клиент) |
| **Бэкенд** | [**Node.js**](https://nodejs.org/) + [**Express**](https://expressjs.com/) |
| | [**AWS SDK for JS**](https://aws.amazon.com/sdk-for-javascript/) (для работы с Yandex Cloud S3) |
| **CI/CD** | [**Docker**](https://www.docker.com/), [**Gitea Actions**](https://gitea.com/blog/2022/10/01/gitea-actions/) |
## 🏗️ Архитектура
Приложение состоит из двух основных частей: фронтенд на React и бэкенд на Node.js/Express, которые взаимодействуют через REST API.
```mermaid
graph TD
subgraph Browser
A[React Frontend]
end
subgraph Server
B(Node.js/Express API)
end
subgraph Yandex Cloud
C{S3 Bucket}
D1[domains.txt]
D2[asns.txt]
D3[servers.json]
end
A -- HTTP Requests --> B
B -- AWS SDK --> C
C --- D1
C --- D2
```
## 🚀 Установка и запуск
### Предварительные требования
- [Node.js](https://nodejs.org/) (v20.x или выше)
- [npm](https://www.npmjs.com/) или [yarn](https://yarnpkg.com/)
- Доступ к бакету Yandex Cloud S3 и сервисный аккаунт с правами на чтение и запись.
### 1. Настройка бэкенда
1. Перейдите в директорию `backend`:
```bash
cd backend
```
2. Создайте файл `.env` на основе примера `.env.example`. Заполните его вашими учетными данными от Yandex Cloud S3:
```env
# .env
S3_ACCESS_KEY_ID=ВАШ_КЛЮЧ_ДОСТУПА
S3_SECRET_ACCESS_KEY=ВАШ_СЕКРЕТНЫЙ_КЛЮЧ
S3_BUCKET_NAME=ИМЯ_ВАШЕГО_БАКЕТА
```
3. Установите зависимости:
```bash
npm install
```
### 2. Настройка фронтенда
1. Перейдите в директорию `frontend`:
```bash
cd ../frontend
```
2. Установите зависимости:
```bash
npm install
```
### 3. Запуск приложения
1. **Запустите бэкенд-сервер.** В директории `backend` выполните:
```bash
npm start
```
Сервер запустится на `http://localhost:3001`.
2. **Запустите фронтенд.** В новой вкладке терминала, в директории `frontend`, выполните:
```bash
npm run dev
```
Приложение будет доступно по адресу `http://localhost:5173` и будет автоматически проксировать API-запросы на бэкенд.
## 🐳 Docker
Приложение полностью готово к запуску в Docker с двумя вариантами интерфейса.
### Доступные образы
#### Основная версия (main branch)
```bash
git.shts.su/[repository]:latest
```
#### Tabler версия (tabler branch)
```bash
git.shts.su/[repository]:tabler
```
### Быстрый запуск
#### Основная версия
```bash
docker run -d \
--name s3-lists-manager \
-p 3001:3001 \
--env-file ./backend/.env \
git.shts.su/[repository]:latest git.shts.su/[repository]:latest
``` ```
#### Tabler версия ## Чек-лист проверки соответствия (S3 -> SQLite)
```bash
docker run -d \ 1. В `backend/package.json` нет зависимостей AWS SDK.
--name s3-lists-manager-tabler \ 2. Приложение стартует с `SQLITE_PATH` и создает/использует файл БД.
-p 3002:3001 \ 3. CRUD по основным данным (`/api/domains-new`, `/api/ip-ranges`, `/api/asns`) работает после перезапуска контейнера с тем же volume.
--env-file ./backend/.env \ 4. Созданный MikroTik backup появляется как локальный `.rsc` файл в каталоге `MIKROTIK_BACKUP_DIR`.
git.shts.su/[repository]:tabler 5. После перезапуска контейнера с тем же volume backup-файл остается доступным.
6. `GET /api/s3/last-modified` возвращает метаданные, но интерпретируется как legacy endpoint поверх SQLite.
## Структура репозитория
```text
backend/ Express API
frontend/ Vite + React UI
``` ```
### Локальная сборка ## Лицензия
Для сборки образа выполните команду в корневой директории проекта: MIT
```bash
docker build -t s3-lists-manager .
```
### Запуск контейнера
Для запуска контейнера необходимо передать переменные окружения. Это можно сделать с помощью флага `-e` или через `--env-file`.
```bash
docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
```
После этого приложение будет доступно по адресу `http://localhost:3001`.
### Docker Compose
Создайте файл `docker-compose.yml`:
```yaml
version: '3.8'
services:
s3-lists-manager:
image: git.shts.su/[repository]:latest
container_name: s3-lists-manager
ports:
- "3001:3001"
restart: unless-stopped
env_file:
- ./backend/.env
s3-lists-manager-tabler:
image: git.shts.su/[repository]:tabler
container_name: s3-lists-manager-tabler
ports:
- "3002:3001"
restart: unless-stopped
env_file:
- ./backend/.env
```
Запуск:
```bash
docker-compose up -d
```
Подробная документация по Docker образам доступна в файле [DOCKER.md](DOCKER.md).
## ⚙️ API Endpoints
| Метод | Путь | Описание |
|--------|---------------|----------------------------------|
| `GET` | `/api/domains`| Получить список всех доменов. |
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
| `GET` | `/api/asns` | Получить список всех AS. |
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
| `GET` | `/api/servers`| Получить список всех серверов. |
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
-- Router-lists local storage (SQLite). EvoBGP holds reference lists; this DB holds config + caches.
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL UNIQUE,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS blobs (
object_key TEXT PRIMARY KEY,
namespace TEXT NOT NULL CHECK (namespace IN (
'config',
'derived',
'per_server',
'cache'
)),
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT NOT NULL,
updated_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_blobs_namespace_updated
ON blobs (namespace, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_blobs_prefix
ON blobs (object_key);
CREATE TABLE IF NOT EXISTS blob_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_key TEXT NOT NULL,
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (object_key) REFERENCES blobs(object_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_blob_versions_key_time
ON blob_versions (object_key, created_at DESC);
CREATE TABLE IF NOT EXISTS versioned_keys (
object_key TEXT PRIMARY KEY
);
INSERT OR IGNORE INTO versioned_keys (object_key) VALUES
('filters.json'),
('servers.json');
CREATE TABLE IF NOT EXISTS cache_entries (
cache_key TEXT PRIMARY KEY,
namespace TEXT NOT NULL DEFAULT 'cache',
body BLOB NOT NULL,
byte_size INTEGER NOT NULL,
etag TEXT,
updated_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_cache_expires
ON cache_entries (expires_at);
@@ -0,0 +1,16 @@
-- Нормализованные глобальные и simple-фильтры (вместо filters.json / simple-filters.json в blobs)
CREATE TABLE IF NOT EXISTS app_filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('global', 'simple')),
position INTEGER NOT NULL,
community TEXT NOT NULL,
gateway TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
extra_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_filter_rules_scope_pos ON app_filter_rules(scope, position);
DELETE FROM versioned_keys WHERE object_key = 'filters.json';
+155
View File
@@ -0,0 +1,155 @@
/**
* Single-process SQLite connection, migrations, PRAGMAs.
* Designed so the same API can later move to a dedicated storage service process.
*/
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
let _db = null;
let _cachePurgeTimer = null;
function defaultDbPath() {
return path.join(__dirname, '..', 'data', 'router-lists.db');
}
function getDbPath() {
const p = process.env.SQLITE_PATH || defaultDbPath();
const dir = path.dirname(p);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Ensure the file exists before better-sqlite3 opens it.
// This makes container startup resilient when SQLITE_PATH points to
// a persistent volume and database file is not created yet.
if (!fs.existsSync(p)) {
fs.closeSync(fs.openSync(p, 'a'));
}
try {
fs.accessSync(p, fs.constants.R_OK | fs.constants.W_OK);
} catch (err) {
const details = err && err.message ? err.message : String(err);
throw new Error(`SQLite file is not readable/writable at "${p}": ${details}`);
}
return p;
}
function applyMigration001(db) {
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8');
db.exec(sql);
}
function applyMigration002(db) {
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8');
db.exec(sql);
const migrateBlobToRules = (objectKey, scope) => {
const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey);
if (!row) return;
let bodyStr = '';
try {
bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body);
} catch {
return;
}
let arr = [];
try {
const parsed = JSON.parse(bodyStr);
arr = Array.isArray(parsed) ? parsed : [];
} catch {
return;
}
const now = Date.now();
const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
const ins = db.prepare(
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const run = () => {
delRules.run(scope);
for (let i = 0; i < arr.length; i++) {
const it = arr[i] || {};
const community = String(it.community || '').trim();
const gateway = String(it.gateway || '').trim();
const description = String(it.description || '').trim();
const extra = { ...it };
delete extra.community;
delete extra.gateway;
delete extra.description;
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
}
db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run(
`filter_rules_mtime_${scope}`,
String(now),
);
db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey);
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
};
db.transaction(run)();
};
migrateBlobToRules('filters.json', 'global');
migrateBlobToRules('filter-manager/simple-filters.json', 'simple');
}
/**
* @returns {import('better-sqlite3').Database}
*/
function openDatabase() {
if (_db) return _db;
const filePath = getDbPath();
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`);
const cacheKb = Number(process.env.SQLITE_CACHE_KB);
if (Number.isFinite(cacheKb) && cacheKb !== 0) {
db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`);
}
let userVersion = db.pragma('user_version', { simple: true });
if (userVersion < 1) {
applyMigration001(db);
db.pragma('user_version = 1');
const now = Date.now();
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now);
userVersion = 1;
}
if (userVersion < 2) {
applyMigration002(db);
db.pragma('user_version = 2');
const now2 = Date.now();
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2);
}
_db = db;
if (!_cachePurgeTimer) {
const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000);
_cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs);
_cachePurgeTimer.unref?.();
}
return db;
}
function purgeExpiredCacheEntries() {
const db = openDatabase();
const now = Date.now();
try {
db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
} catch (_) {}
}
module.exports = {
openDatabase,
getDbPath,
purgeExpiredCacheEntries,
};
+409 -1666
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,8 +16,8 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.687.0",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"better-sqlite3": "^11.6.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
+79 -85
View File
@@ -1,38 +1,52 @@
/** /**
* Роуты для работы с communities (справочник BGP Community) * Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API
*/ */
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service'); const evobgpClient = require('../services/evobgpClient');
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk } = require('../middleware/errorHandler'); const { sendError, sendOk } = require('../middleware/errorHandler');
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const validators = require('../lib/validators'); const validators = require('../lib/validators');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
const S3_KEY = 'bgp_data/communities.json'; function requireEvobgp(res) {
if (!evobgpClient.isConfigured()) {
return sendError(
res,
503,
'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN',
'E_EVOBGP_NOT_CONFIGURED',
);
}
return null;
}
// GET /api/communities // GET /api/communities
async function getCommunities(req, res) { async function getCommunities(req, res) {
await getS3JsonWithHeaders(S3_KEY, req, res, { const err = requireEvobgp(res);
transform: (communities) => { if (err) return err;
// Нормализация try {
return communities const communities = await evobgpClient.listCommunities();
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) const normalized = communities
.map((c) => ({ .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
value: String(c.value).trim(), .map((c) => ({
name: c.name ? String(c.name) : '', value: String(c.value).trim(),
description: c.description ? String(c.description) : '', name: c.name ? String(c.name) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [], description: c.description ? String(c.description) : '',
color: c.color ? String(c.color) : '', tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
icon: c.icon ? String(c.icon) : '' color: c.color ? String(c.color) : '',
})); icon: c.icon ? String(c.icon) : '',
}, }));
defaultValue: [] res.json(normalized);
}); } catch (e) {
console.error('getCommunities', e);
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
}
} }
// POST /api/communities // POST /api/communities
async function postCommunities(req, res) { async function postCommunities(req, res) {
const err = requireEvobgp(res);
if (err) return err;
const { communities } = req.body; const { communities } = req.body;
if (!Array.isArray(communities)) { if (!Array.isArray(communities)) {
@@ -81,91 +95,72 @@ async function postCommunities(req, res) {
} }
try { try {
await s3.send(new PutObjectCommand({ await evobgpClient.saveCommunitiesData(normalized);
Bucket: BUCKET_NAME, return sendOk(res, {
Key: S3_KEY, etag: null,
Body: JSON.stringify(normalized, null, 2), lastModified: new Date().toISOString(),
ContentType: 'application/json', contentLength: null,
})); });
invalidateCacheForKey(S3_KEY);
const { headMeta } = require('../services/s3Service');
const meta = await headMeta(S3_KEY);
return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error('Error writing communities to S3:', error); console.error('Error writing communities to EvoBGP:', error);
return sendError(res, 500, 'Error writing communities to S3', 'E_S3'); return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP');
} }
} }
function countCommunityInLines(rows, getCommunity) {
const usage = new Map();
for (const row of rows || []) {
const c = getCommunity(row);
if (c) usage.set(c, (usage.get(c) || 0) + 1);
}
return usage;
}
// GET /api/communities/stats // GET /api/communities/stats
async function getCommunityStats(req, res) { async function getCommunityStats(req, res) {
const err = requireEvobgp(res);
if (err) return err;
try { try {
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([ const [domains, ipRanges, asns] = await Promise.allSettled([
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })), evobgpClient.fetchDomainsData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })), evobgpClient.fetchIpRangesData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })), evobgpClient.fetchAsnsData(),
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
]); ]);
const communityUsage = new Map(); const communityUsage = new Map();
// Подсчет использования в доменах if (domains.status === 'fulfilled') {
if (domainsRes.status === 'fulfilled') { const m = countCommunityInLines(domains.value, (r) => r.community);
const text = await streamToString(domainsRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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 (ipRanges.status === 'fulfilled') {
if (ipRangesRes.status === 'fulfilled') { const m = countCommunityInLines(ipRanges.value, (r) => r.community);
const text = await streamToString(ipRangesRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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 (asns.status === 'fulfilled') {
if (asnsRes.status === 'fulfilled') { const m = countCommunityInLines(asns.value, (r) => r.community);
const text = await streamToString(asnsRes.value.Body); for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
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);
}
});
} }
// Подсчет в фильтрах try {
if (filtersRes.status === 'fulfilled') { const simpleFilters = filterRulesStorage.listRules('simple');
try { for (const f of simpleFilters) {
const text = await streamToString(filtersRes.value.Body); if (f.community) {
const filters = JSON.parse(text); communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
if (Array.isArray(filters)) {
filters.forEach(f => {
if (f.community) {
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
}
});
} }
} catch {} }
} } catch (_) {}
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({ const stats = Array.from(communityUsage.entries())
community, .map(([community, count]) => ({ community, count }))
count, .sort((a, b) => b.count - a.count);
})).sort((a, b) => b.count - a.count);
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) }); res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
} catch (error) { } catch (error) {
console.error('Error getting community stats:', error); console.error('Error getting community stats:', error);
return sendError(res, 500, 'Error getting community stats', 'E_S3'); return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP');
} }
} }
@@ -174,4 +169,3 @@ module.exports = {
postCommunities, postCommunities,
getCommunityStats, getCommunityStats,
}; };
+257
View File
@@ -0,0 +1,257 @@
/**
* GET/POST для справочников из EvoBGP (ранее textDataRoutes + S3).
*/
const crypto = require('crypto');
const evobgpClient = require('../services/evobgpClient');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
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 etagForJson(obj) {
const s = JSON.stringify(obj);
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
return `"${h}"`;
}
function filterPage(items, q, offset, limit) {
const qstr = q ? String(q).toLowerCase() : '';
const filtered = !qstr
? items
: items.filter((it) => JSON.stringify(it).toLowerCase().includes(qstr));
const total = filtered.length;
if (limit > 0) {
return { items: filtered.slice(offset, offset + limit), total };
}
return { items: filtered, total };
}
function createEvobgpListGET({ fetchItems, cachePrefix, validate }) {
return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {};
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
if (countOnly === 'true') {
const cacheKey = `${cachePrefix}:count:${q}`;
const cached = getCountOnlyCache(cacheKey);
if (cached != null) {
return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
}
const all = await fetchItems();
const { total } = filterPage(all, q, 0, 0);
setCountOnlyCache(cacheKey, total);
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
}
if (Number(limit) > 0) {
const all = await fetchItems();
if (!validate(all)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
const { items, total } = filterPage(all, q, Number(offset) || 0, Number(limit) || 0);
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
}
const all = await fetchItems();
if (!validate(all)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
}
const etag = etagForJson(all);
res.set('ETag', etag);
res.set('Last-Modified', new Date().toUTCString());
res.set('Content-Length-Source', String(Buffer.byteLength(JSON.stringify(all), 'utf8')));
if (checkIfNoneMatch(req, res, etag)) return;
if (format === 'std') {
return res.json({ items: all, total: all.length, meta: {} });
}
return res.json(all);
} catch (e) {
console.error(cachePrefix, e);
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
}
};
}
function readIfMatch(req) {
const raw = req.headers['if-match'] || req.headers['If-Match'];
if (!raw) return null;
return String(raw).trim();
}
function createEvobgpDomainsNewPOST({ validate, validateItem }) {
return async (req, res) => {
const { domains: items, etag: bodyEtag } = req.body || {};
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(items)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < items.length; i++) {
const errors = validateItem(items[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchDomainsData();
const currentEtag = etagForJson(current);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = items.map((d) => ({
domain: String(d.domain || '').trim().toLowerCase(),
community: String(d.community || '').trim(),
}));
await evobgpClient.saveDomainsData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
function createEvobgpAsnsPOST({ validate, validateItem }) {
return async (req, res) => {
const { domains: items, etag: bodyEtag } = req.body || {};
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(items)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < items.length; i++) {
const errors = validateItem(items[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchAsnsData();
const currentEtag = etagForJson(
current.map((a) => ({ domain: a.asn, type: a.community })),
);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = items.map((a) => ({
asn: String(a.domain || a.asn || '').trim(),
community: String(a.type || a.community || '').trim(),
}));
await evobgpClient.saveAsnsData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
function createEvobgpIpRangesPOST({ validate, validateItem }) {
return async (req, res) => {
const payload = Array.isArray(req.body?.domains)
? req.body.domains
: Array.isArray(req.body?.ipRanges)
? req.body.ipRanges
: null;
const { etag: bodyEtag } = req.body || {};
if (!Array.isArray(payload)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
if (!validate(payload)) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
}
if (validateItem) {
const validationErrors = [];
for (let i = 0; i < payload.length; i++) {
const errors = validateItem(payload[i], i);
if (errors.length > 0) validationErrors.push(...errors);
}
if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10),
});
}
}
if (!evobgpClient.isConfigured()) {
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
try {
const current = await evobgpClient.fetchIpRangesData();
const currentEtag = etagForJson(current);
const ifMatch = readIfMatch(req);
const expected = ifMatch || bodyEtag;
if (expected && String(expected).trim() !== currentEtag) {
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
}
const normalized = payload.map((ip) => ({
ipRange: String(ip.ipRange || '').trim(),
community: String(ip.community || '').trim(),
}));
await evobgpClient.saveIpRangesData(normalized, current);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (e) {
console.error(e);
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
}
};
}
/** Legacy /api/domains: тот же модуль ASN, ответ в полях domain + type. */
function createEvobgpLegacyDomainsGET({ validate }) {
return createEvobgpListGET({
cachePrefix: 'domains',
validate,
fetchItems: async () => {
const rows = await evobgpClient.fetchAsnsData();
return rows.map((r) => ({ domain: r.asn, type: r.community }));
},
});
}
module.exports = {
createEvobgpListGET,
createEvobgpDomainsNewPOST,
createEvobgpAsnsPOST,
createEvobgpIpRangesPOST,
createEvobgpLegacyDomainsGET,
};
+53
View File
@@ -0,0 +1,53 @@
/**
* GET/POST /api/filters и /api/simple-filters — данные в app_filter_rules, не в blobs.
*/
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
/**
* @param {'global'|'simple'} scope
* @param {(item: any, i: number) => string|null} validateItem
*/
function createFilterRulesRoutes(scope, validateItem) {
return {
get: async (req, res) => {
try {
const head = filterRulesStorage.headMetaForScope(scope);
if (head.etag) res.set('ETag', String(head.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, head.etag)) return;
res.json(filterRulesStorage.listRules(scope));
} catch (e) {
console.error('filterRules get', e);
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
}
},
post: async (req, res) => {
const { domains: items } = req.body;
if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
}
try {
const meta = filterRulesStorage.replaceRules(scope, items, { validateItem });
return sendOk(res, meta);
} catch (e) {
if (e.code === 'E_SCHEMA') {
return sendError(res, 400, e.message || 'Validation failed', 'E_SCHEMA');
}
console.error('filterRules post', e);
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
}
},
};
}
module.exports = {
createFilterRulesRoutes,
};
+16 -25
View File
@@ -2,12 +2,10 @@
* Роуты для фильтров и генерации конфигураций MikroTik * Роуты для фильтров и генерации конфигураций MikroTik
*/ */
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service'); const { writeS3TextObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler'); const filterRulesStorage = require('../services/filterRulesStorage');
const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
const { streamToString } = require('../services/s3Service');
const { generateMikrotikConfig } = require('../utils/mikrotikConfig'); const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/filters/generate-config?format=text|json // GET /api/filters/generate-config?format=text|json
async function generateConfig(req, res) { async function generateConfig(req, res) {
@@ -16,13 +14,16 @@ async function generateConfig(req, res) {
if (!['text', 'json'].includes(format)) { if (!['text', 'json'].includes(format)) {
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST'); return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
} }
await getS3JsonWithHeaders('filters.json', req, res, { const head = filterRulesStorage.headMetaForScope('global');
transform: (filters) => { if (head.etag) res.set('ETag', String(head.etag));
const result = generateMikrotikConfig(filters, { format }); if (head.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
return format === 'json' ? result : { config: result }; if (typeof head.contentLength === 'number') {
}, res.set('Content-Length-Source', String(head.contentLength));
defaultValue: [], }
}); if (checkIfNoneMatch(req, res, head.etag)) return;
const filters = filterRulesStorage.listRules('global');
const result = generateMikrotikConfig(filters, { format });
res.json(format === 'json' ? result : { config: result });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error generating configuration', 'E_S3'); return sendError(res, 500, 'Error generating configuration', 'E_S3');
@@ -32,17 +33,7 @@ async function generateConfig(req, res) {
// POST /api/filters/export-config // POST /api/filters/export-config
async function exportConfig(req, res) { async function exportConfig(req, res) {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })); const filters = filterRulesStorage.listRules('global');
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) { if (filters.length === 0) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' }); return res.json({ success: false, message: 'Нет фильтров для экспорта' });
@@ -50,10 +41,10 @@ async function exportConfig(req, res) {
const config = generateMikrotikConfig(filters); const config = generateMikrotikConfig(filters);
await writeS3TextObject('mikrotik-frouting-config.txt', config); await writeS3TextObject('mikrotik-frouting-config.txt', config);
res.json({ success: true, message: 'Конфигурация экспортирована в S3' }); res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error exporting configuration', 'E_S3'); return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
} }
} }
+23 -39
View File
@@ -2,14 +2,12 @@
* Общие роуты для JSON данных (servers, filters, billing и т.д.) * Общие роуты для JSON данных (servers, filters, billing и т.д.)
*/ */
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service'); const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
/** /**
* Создать GET эндпоинт для JSON данных * Создать GET эндпоинт для JSON данных
* @param {string} s3Key - Ключ в S3 * @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
* @param {Object} options - Опции * @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива * @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта) * @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
@@ -20,24 +18,31 @@ function createJsonDataGET(s3Key, options = {}) {
return async (req, res) => { return async (req, res) => {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let fileContent;
const fileContent = await streamToString(data.Body); try {
const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(fallback);
}
throw e;
}
let items = fallback; let items = fallback;
try { try {
const parsed = JSON.parse(fileContent); const parsed = JSON.parse(fileContent);
if (singleObject) { if (singleObject) {
// Для одиночного объекта items = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : fallback;
items = (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : fallback;
} else { } else {
// Для массива
items = Array.isArray(parsed) ? parsed : fallback; items = Array.isArray(parsed) ? parsed : fallback;
} }
} catch (parseError) { } catch (parseError) {
@@ -47,36 +52,26 @@ function createJsonDataGET(s3Key, options = {}) {
res.json(items); res.json(items);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(fallback); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
}; };
} }
/** /**
* Создать POST эндпоинт для JSON данных * Создать POST эндпоинт для JSON данных
* @param {string} s3Key - Ключ в S3
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
*/ */
function createJsonDataPOST(s3Key, validateItem = null, options = {}) { function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
const { singleObject = false } = options; const { singleObject = false } = options;
return async (req, res) => { return async (req, res) => {
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости const { domains: items } = req.body;
if (singleObject) { if (singleObject) {
// Для одиночного объекта
if (typeof items !== 'object' || items === null || Array.isArray(items)) { if (typeof items !== 'object' || items === null || Array.isArray(items)) {
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
} }
// Валидация объекта целиком
if (validateItem) { if (validateItem) {
const error = validateItem(items); const error = validateItem(items);
if (error) { if (error) {
@@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
} }
} }
} else { } else {
// Для массива
if (!Array.isArray(items)) { if (!Array.isArray(items)) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
} }
// Валидация каждого элемента массива
if (validateItem) { if (validateItem) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const error = validateItem(items[i], i); const error = validateItem(items[i], i);
@@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3'); return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
} }
}; };
} }
/**
* Создать роуты для JSON данных (GET + POST)
* @param {string} s3Key - Ключ в S3
* @param {Function} validateItem - Функция валидации
* @param {Object} options - Опции
* @param {boolean} options.singleObject - Если true, работает с объектом вместо массива
* @param {any} options.defaultValue - Значение по умолчанию
*/
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) { function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
return { return {
get: createJsonDataGET(s3Key, options), get: createJsonDataGET(s3Key, options),
post: createJsonDataPOST(s3Key, validateItem, options) post: createJsonDataPOST(s3Key, validateItem, options),
}; };
} }
@@ -130,4 +115,3 @@ module.exports = {
createJsonDataGET, createJsonDataGET,
createJsonDataPOST, createJsonDataPOST,
}; };
+18 -19
View File
@@ -1,8 +1,8 @@
/** /**
* Роуты для бэкапов конфигурации MikroTik * Роуты для бэкапов конфигурации MikroTik
* Хранят сырый текст конфигурации (например, вывод /export) в S3. * Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
* *
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc * Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
* *
* Задачи: * Задачи:
* - создать бэкап (ручной или из планировщика) * - создать бэкап (ручной или из планировщика)
@@ -13,7 +13,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { sendError, sendOk } = require('../middleware/errorHandler'); const { sendError, sendOk } = require('../middleware/errorHandler');
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service'); const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
const { readServersFromS3 } = require('./serversRoutes'); const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService'); const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
const { encrypt, decrypt } = require('../utils/encryption'); const { encrypt, decrypt } = require('../utils/encryption');
@@ -61,7 +61,7 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`; let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY // Шифруем содержимое файла, если задан ENCRYPTION_KEY
if (process.env.ENCRYPTION_KEY) { if (process.env.ENCRYPTION_KEY) {
try { try {
content = ENCRYPTED_PREFIX + encrypt(content); content = ENCRYPTED_PREFIX + encrypt(content);
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
} }
} }
const meta = await writeS3TextObject(key, content, 'text/plain'); const meta = await writeBackupFile(key, content, 'text/plain');
return { key, createdAt: now.toISOString(), s3: meta }; return { key, createdAt: now.toISOString(), storage: meta };
} }
/** /**
* POST /api/mikrotik/backups * POST /api/mikrotik/backups
* Body: { serverId: string, config: string, source?: string, comment?: string } * Body: { serverId: string, config: string, source?: string, comment?: string }
* *
* Сохраняет текст конфигурации (обычно вывод /export) в S3. * Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт. * Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
*/ */
async function createBackup(req, res) { async function createBackup(req, res) {
@@ -93,14 +93,14 @@ async function createBackup(req, res) {
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST'); return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
} }
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment }); const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
return res.json({ return res.json({
ok: true, ok: true,
key, key,
serverId, serverId,
createdAt, createdAt,
s3, storage,
}); });
} catch (error) { } catch (error) {
console.error('createBackup error:', error); console.error('createBackup error:', error);
@@ -120,12 +120,11 @@ async function listBackups(req, res) {
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
} }
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`; const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
const objects = await listS3Objects(prefix, { maxKeys: 200 }); const objects = await listBackupObjects(safeId, 200);
const items = objects const items = objects
.filter(o => o.key && o.key.startsWith(prefix)) .map((o) => ({
.map(o => ({
key: o.key, key: o.key,
serverId, serverId,
createdAt: o.lastModified, createdAt: o.lastModified,
@@ -156,7 +155,7 @@ async function getBackup(req, res) {
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST'); return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
} }
const data = await readS3TextObject(key); const data = await readBackupFile(key);
const config = decryptBackupContent(data.body || ''); const config = decryptBackupContent(data.body || '');
return res.json({ return res.json({
@@ -191,8 +190,8 @@ async function diffBackups(req, res) {
} }
const [a, b] = await Promise.all([ const [a, b] = await Promise.all([
readS3TextObject(keyA), readBackupFile(keyA),
readS3TextObject(keyB), readBackupFile(keyB),
]); ]);
const bodyA = decryptBackupContent(a.body || ''); const bodyA = decryptBackupContent(a.body || '');
@@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) {
* POST /api/mikrotik/backups/run * POST /api/mikrotik/backups/run
* Body: { serverId: string } * Body: { serverId: string }
* *
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3. * Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
*/ */
async function runBackupNow(req, res) { async function runBackupNow(req, res) {
try { try {
@@ -293,7 +292,7 @@ async function runBackupNow(req, res) {
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT'); return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
} }
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
source: 'manual', source: 'manual',
comment: 'Manual backup from UI (/api/mikrotik/backups/run)', comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
}); });
@@ -303,7 +302,7 @@ async function runBackupNow(req, res) {
key, key,
serverId, serverId,
createdAt, createdAt,
s3, storage,
}); });
} catch (error) { } catch (error) {
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data); console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
+4 -6
View File
@@ -4,8 +4,7 @@
*/ */
const crypto = require('crypto'); const crypto = require('crypto');
const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
const { sendError, sendOk } = require('../middleware/errorHandler'); const { sendError, sendOk } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption'); const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes'); const { readServersFromS3 } = require('./serversRoutes');
@@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) {
/** Загрузить network-config из S3 */ /** Загрузить network-config из S3 */
async function loadNetworkConfig() { async function loadNetworkConfig() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY })); const data = await readS3TextObject(NETWORK_CONFIG_KEY);
const body = await streamToString(data.Body); const parsed = JSON.parse(data.body || '{}');
const parsed = JSON.parse(body || '{}');
return parsed && typeof parsed === 'object' ? parsed : {}; return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) { } catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {}; if (e?.code === 'NoSuchKey') return {};
throw e; throw e;
} }
} }
+129 -156
View File
@@ -2,10 +2,18 @@
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.) * Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
*/ */
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service'); const {
writeS3TextObject,
writeS3JsonObject,
readS3TextObject,
headMeta,
listBlobVersionsForKey,
rollbackBlobVersion,
} = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const filterRulesStorage = require('../services/filterRulesStorage');
const { sendError, sendOk } = require('../middleware/errorHandler'); const { sendError, sendOk } = require('../middleware/errorHandler');
const { resourceToKey, toIso } = require('../utils/helpers'); const { resourceToKey, toIso } = require('../utils/helpers');
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const validators = require('../lib/validators'); const validators = require('../lib/validators');
const net = require('net'); const net = require('net');
const http = require('http'); const http = require('http');
@@ -19,66 +27,97 @@ async function getS3LastModified(req, res) {
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' }, { name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' }, { name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' }, { name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' }, { name: 'filters', key: null },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' }, { name: 'ipRanges', key: 'bgp_data/ips.txt' },
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
]; ];
const results = await Promise.allSettled(
keys.map(k => headMeta(k.key))
);
const out = {}; const out = {};
results.forEach((r, idx) => { for (const k of keys) {
const name = keys[idx].name; if (k.name === 'filters') {
if (r.status === 'fulfilled' && r.value) { try {
out[name] = r.value; const head = filterRulesStorage.headMetaForScope('global');
} else { out.filters = {
out[name] = null; etag: head.etag,
lastModified: head.lastModified,
contentLength: head.contentLength,
};
} catch {
out.filters = null;
}
continue;
} }
}); if (['domainsNew', 'asns', 'ipRanges'].includes(k.name) && evobgpClient.isConfigured()) {
try {
await evobgpClient.fetchRouterListsCatalog();
out[k.name] = { etag: null, lastModified: new Date().toISOString(), contentLength: null };
} catch {
out[k.name] = null;
}
continue;
}
out[k.name] = await headMeta(k.key).catch(() => null);
}
res.json(out); res.json(out);
} catch (error) { } catch (error) {
console.error('Error fetching last modified dates from S3:', error); console.error('Error fetching last modified dates:', error);
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3'); return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE');
} }
} }
const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']);
// GET /api/history/:resource // GET /api/history/:resource
async function getHistory(req, res) { async function getHistory(req, res) {
const { resource } = req.params; const { resource } = req.params;
const { countOnly, format } = req.query || {}; const { countOnly, format } = req.query || {};
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
const note =
resource === 'filters'
? 'Фильтры в SQL-таблице app_filter_rules; локальной истории отката через этот API нет'
: 'Версии хранятся в EvoBGP, локальной истории нет';
if (countOnly === 'true') {
return res.json(format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true } } : { total: 0 });
}
return res.json(
format === 'std'
? { items: [], total: 0, meta: { noBlobHistory: true, note } }
: { items: [], total: 0 },
);
}
const key = resourceToKey(resource); const key = resourceToKey(resource);
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
try { try {
const out = await s3.send(new ListObjectVersionsCommand({ const rows = listBlobVersionsForKey(key, 50);
Bucket: BUCKET_NAME, const versionsAll = rows.map((r) => ({
Prefix: key, id: r.id,
MaxKeys: 50 object_key: r.object_key,
byte_size: r.byte_size,
etag: r.etag,
created_at: r.created_at,
})); }));
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
if (countOnly === 'true') { if (countOnly === 'true') {
const total = versionsAll.length; const total = versionsAll.length;
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
} }
const versions = versionsAll.slice(0, 10).map(v => ({ const versions = versionsAll.slice(0, 10).map((v, idx) => ({
versionId: v.VersionId, versionId: String(v.id),
isLatest: v.IsLatest, isLatest: idx === 0,
lastModified: toIso(v.LastModified), lastModified: toIso(v.created_at),
size: v.Size, size: v.byte_size,
etag: v.ETag etag: v.etag,
})); }));
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions }); return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
} catch (e) { } catch (e) {
console.error('history error', e); console.error('history error', e);
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) }); return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) });
} }
} }
@@ -86,6 +125,14 @@ async function getHistory(req, res) {
async function postRollback(req, res) { async function postRollback(req, res) {
const { resource } = req.params; const { resource } = req.params;
const { versionId } = req.body || {}; const { versionId } = req.body || {};
if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) {
const msg =
resource === 'filters'
? 'Откат версий для фильтров не поддерживается (данные в SQL, не в blob_versions)'
: 'Откат версий для этого справочника не поддерживается (данные в EvoBGP)';
return sendError(res, 400, msg, resource === 'filters' ? 'E_HISTORY_FILTERS_SQL' : 'E_HISTORY_EV');
}
const key = resourceToKey(resource); const key = resourceToKey(resource);
if (!key || !versionId) { if (!key || !versionId) {
@@ -93,118 +140,61 @@ async function postRollback(req, res) {
} }
try { try {
await s3.send(new CopyObjectCommand({ rollbackBlobVersion(key, versionId);
Bucket: BUCKET_NAME,
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
Key: key
}));
const meta = await headMeta(key); const meta = await headMeta(key);
return sendOk(res, meta); return sendOk(res, meta);
} catch (e) { } catch (e) {
console.error('rollback error', e); console.error('rollback error', e);
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) }); return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) });
} }
} }
// GET /api/auto-urls // GET /api/auto-urls
async function getAutoUrls(req, res) { async function getAutoUrls(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler');
try { try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3'); if (!evobgpClient.isConfigured()) {
const head = await s3.send(new HeadObjectCommand({ return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
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({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
const fileContent = await streamToString(data.Body);
const urls = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
res.json(urls);
} catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
res.json([]);
} else {
console.error(error);
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
} }
const rows = await evobgpClient.fetchAutoUrlsData();
res.json(rows);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP');
} }
} }
// POST /api/auto-urls // POST /api/auto-urls
async function postAutoUrls(req, res) { async function postAutoUrls(req, res) {
const { urls } = req.body; const { urls } = req.body;
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n'); if (!Array.isArray(urls)) {
return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST');
}
try { try {
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent); if (!evobgpClient.isConfigured()) {
return sendOk(res, meta); return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
}
await evobgpClient.saveAutoUrlsData(urls);
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3'); return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP');
} }
} }
// POST /api/auto-urls/process // POST /api/auto-urls/process
async function processAutoUrls(req, res) { async function processAutoUrls(req, res) {
try { try {
// Загрузка URL списков if (!evobgpClient.isConfigured()) {
let urls = []; return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
try {
const urlsData = await s3.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt'
}));
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;
} }
const urls = await evobgpClient.fetchAutoUrlsData();
if (urls.length === 0) { if (urls.length === 0) {
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
} }
// Загрузка текущих IP и доменов const currentIps = await evobgpClient.fetchIpRangesData();
let currentIps = []; const currentDomains = await evobgpClient.fetchDomainsData();
try {
const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' }));
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;
}
let currentDomains = [];
try {
const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }));
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;
}
// Обработка URL // Обработка URL
const newIps = []; const newIps = [];
@@ -256,22 +246,8 @@ async function processAutoUrls(req, res) {
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain)); const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
const allDomains = [...currentDomains, ...uniqueNewDomains]; const allDomains = [...currentDomains, ...uniqueNewDomains];
// Сохранение await evobgpClient.saveIpRangesData(allIps, currentIps);
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n'); await evobgpClient.saveDomainsData(allDomains, currentDomains);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
Body: updatedIpsContent,
ContentType: 'text/plain'
}));
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} доменов`; const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
return res.json({ return res.json({
@@ -285,7 +261,7 @@ async function processAutoUrls(req, res) {
}); });
} catch (error) { } catch (error) {
console.error('Error processing auto URLs:', error); console.error('Error processing auto URLs:', error);
return sendError(res, 500, 'Error processing auto URLs', 'E_S3'); return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP');
} }
} }
@@ -357,9 +333,8 @@ function getPingServicesListFromSettings(uiSettings) {
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */ /** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
async function loadUiSettingsSync() { async function loadUiSettingsSync() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })); const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
const jsonText = await streamToString(data.Body); const parsed = JSON.parse(data.body || '{}');
const parsed = JSON.parse(jsonText || '{}');
return parsed && typeof parsed === 'object' ? parsed : {}; return parsed && typeof parsed === 'object' ? parsed : {};
} catch { } catch {
return {}; return {};
@@ -598,10 +573,10 @@ async function getServersAvailability(req, res) {
return res.json({ ...availabilityCache.data, cached: true }); return res.json({ ...availabilityCache.data, cached: true });
} }
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
let servers = []; let servers = [];
try { try {
servers = JSON.parse(await streamToString(data.Body)); const data = await readS3TextObject('servers.json');
servers = JSON.parse(data.body);
if (!Array.isArray(servers)) servers = []; if (!Array.isArray(servers)) servers = [];
} catch { } catch {
servers = []; servers = [];
@@ -683,12 +658,13 @@ async function updateBgpBackground(req, res) {
// GET /api/ws/url // GET /api/ws/url
async function getWsUrl(req, res) { async function getWsUrl(req, res) {
try { try {
const settings = await s3.send(new GetObjectCommand({ let settings = {};
Bucket: BUCKET_NAME, try {
Key: 'bgp_data/rt_ui_settings.json' const d = await readS3TextObject('bgp_data/rt_ui_settings.json');
})).then(async (d) => { settings = JSON.parse(d.body || '{}');
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; } } catch (_) {
}).catch(() => ({})); settings = {};
}
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || ''; const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
return res.json({ url }); return res.json({ url });
@@ -702,23 +678,20 @@ async function getUiSettings(req, res) {
const { checkIfNoneMatch } = require('../middleware/errorHandler'); const { checkIfNoneMatch } = require('../middleware/errorHandler');
try { try {
const { HeadObjectCommand } = require('@aws-sdk/client-s3'); const head = await headMeta('bgp_data/rt_ui_settings.json');
const head = await s3.send(new HeadObjectCommand({ const etag = head?.etag || null;
Bucket: BUCKET_NAME,
Key: 'bgp_data/rt_ui_settings.json'
})).catch(() => null);
const etag = head?.ETag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ let jsonText = '{}';
Bucket: BUCKET_NAME, try {
Key: 'bgp_data/rt_ui_settings.json' const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
})); jsonText = data.body;
const jsonText = await streamToString(data.Body); } catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let settings = {}; let settings = {};
try { try {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
@@ -728,11 +701,11 @@ async function getUiSettings(req, res) {
} }
return res.json(settings); return res.json(settings);
} catch (error) { } catch (error) {
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { if (error?.code === 'NoSuchKey') {
return res.json({}); return res.json({});
} }
console.error('Error reading ui settings from S3:', error); console.error('Error reading ui settings:', error);
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE');
} }
} }
@@ -756,8 +729,8 @@ async function postUiSettings(req, res) {
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload); const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error('Error writing UI settings to S3:', error); console.error('Error writing UI settings:', error);
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
} }
} }
+3 -3
View File
@@ -5,6 +5,7 @@
const { sendError } = require('../middleware/errorHandler'); const { sendError } = require('../middleware/errorHandler');
const { readS3TextObject, listS3Objects } = require('../services/s3Service'); const { readS3TextObject, listS3Objects } = require('../services/s3Service');
const evobgpClient = require('../services/evobgpClient');
const { readServersFromS3 } = require('./serversRoutes'); const { readServersFromS3 } = require('./serversRoutes');
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json'; const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
@@ -173,9 +174,8 @@ function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community
async function loadCommunitiesIndex() { async function loadCommunitiesIndex() {
try { try {
const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null); if (!evobgpClient.isConfigured()) return new Map();
if (!raw?.body) return new Map(); const arr = await evobgpClient.listCommunities();
const arr = JSON.parse(raw.body || '[]');
const m = new Map(); const m = new Map();
if (!Array.isArray(arr)) return m; if (!Array.isArray(arr)) return m;
for (const c of arr) { for (const c of arr) {
+14 -13
View File
@@ -5,10 +5,8 @@
* Это нужно для EasySwitch, который работает только с jumphost серверами * Это нужно для EasySwitch, который работает только с jumphost серверами
*/ */
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service'); const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers'); const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
const { isValidServerId } = require('../lib/validators'); const { isValidServerId } = require('../lib/validators');
@@ -41,15 +39,20 @@ function validateJumphostServer(server, i) {
// GET /api/server-configs (список только jumphost серверов) // GET /api/server-configs (список только jumphost серверов)
async function getServerConfigsList(req, res) { async function getServerConfigsList(req, res) {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); const head = await headMeta(S3_KEY);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); let fileContent = '[]';
const fileContent = await streamToString(data.Body); try {
const data = await readS3TextObject(S3_KEY);
fileContent = data.body;
} catch (e) {
if (e?.code !== 'NoSuchKey') throw e;
}
let allServers = []; let allServers = [];
try { try {
@@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) {
// Читаем текущие данные // Читаем текущие данные
let allServers = []; let allServers = [];
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); const data = await readS3TextObject(S3_KEY);
const fileContent = await streamToString(data.Body); allServers = JSON.parse(data.body);
allServers = JSON.parse(fileContent);
if (!Array.isArray(allServers)) allServers = []; if (!Array.isArray(allServers)) allServers = [];
} catch (e) { } catch (e) {
// Файл не существует или пустой - это нормально
allServers = []; allServers = [];
} }
+22 -29
View File
@@ -3,8 +3,7 @@
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword * Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
*/ */
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { encrypt, decrypt } = require('../utils/encryption'); const { encrypt, decrypt } = require('../utils/encryption');
@@ -28,7 +27,7 @@ function validateServer(server, i) {
if (!Array.isArray(server.gateways) || server.gateways.length === 0) { if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
return `Server at index ${i} must have gateways for type ${normalizedType}`; return `Server at index ${i} must have gateways for type ${normalizedType}`;
} }
const primaries = server.gateways.filter(g => g && g.primary); const primaries = server.gateways.filter((g) => g && g.primary);
if (primaries.length !== 1) { if (primaries.length !== 1) {
return `Server at index ${i} must have exactly one primary gateway`; return `Server at index ${i} must have exactly one primary gateway`;
} }
@@ -45,24 +44,17 @@ function validateServer(server, i) {
async function readServersFromS3() { async function readServersFromS3() {
try { try {
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); const data = await readS3TextObject(S3_KEY);
const body = await streamToString(data.Body); const parsed = JSON.parse(data.body || '[]');
const parsed = JSON.parse(body || '[]');
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch (e) { } catch (e) {
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return []; if (e?.code === 'NoSuchKey') return [];
throw e; throw e;
} }
} }
async function writeServersToS3(servers) { async function writeServersToS3(servers) {
const body = JSON.stringify(servers, null, 2); await writeS3JsonObject(S3_KEY, servers);
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: S3_KEY,
Body: body,
ContentType: 'application/json',
}));
} }
/** /**
@@ -70,15 +62,15 @@ async function writeServersToS3(servers) {
*/ */
async function getServers(req, res) { async function getServers(req, res) {
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); const head = await headMeta(S3_KEY);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const servers = await readServersFromS3(); const servers = await readServersFromS3();
const sanitized = servers.map(s => { const sanitized = servers.map((s) => {
const out = { ...s }; const out = { ...s };
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home'; const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
if (hasMikrotikApi && out.encryptedMikrotikPassword) { if (hasMikrotikApi && out.encryptedMikrotikPassword) {
@@ -91,11 +83,11 @@ async function getServers(req, res) {
}); });
res.json(sanitized); res.json(sanitized);
} catch (error) { } catch (error) {
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { if (error?.code === 'NoSuchKey') {
return res.json([]); return res.json([]);
} }
console.error(error); console.error(error);
return sendError(res, 500, 'Error reading servers', 'E_S3'); return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
} }
} }
@@ -115,9 +107,10 @@ async function postServers(req, res) {
try { try {
const currentServers = await readServersFromS3(); const currentServers = await readServersFromS3();
const findCurrent = (srv) => currentServers.find(c => const findCurrent = (srv) =>
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip) currentServers.find(
); (c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip),
);
const toSave = items.map((srv) => { const toSave = items.map((srv) => {
const current = findCurrent(srv); const current = findCurrent(srv);
@@ -140,15 +133,15 @@ async function postServers(req, res) {
}); });
await writeServersToS3(toSave); await writeServersToS3(toSave);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); const meta = await headMeta(S3_KEY);
return sendOk(res, { return sendOk(res, {
etag: head?.ETag || null, etag: meta?.etag || null,
lastModified: head?.LastModified ? head.LastModified.toISOString() : null, lastModified: meta?.lastModified || null,
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null, contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3'); return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
} }
} }
+26 -61
View File
@@ -1,14 +1,10 @@
/** /**
* Общие роуты для текстовых данных (domains, asns, ip-ranges) * Общие роуты для текстовых данных (domains, asns, ip-ranges)
* Уменьшают дублирование кода для похожих эндпоинтов
*/ */
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service'); const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
const { splitWhitespace } = require('../utils/helpers'); const { splitWhitespace } = require('../utils/helpers');
const { s3, BUCKET_NAME } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
// Кэш для countOnly запросов // Кэш для countOnly запросов
const countOnlyCache = { map: new Map(), ttlMs: 10_000 }; const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
@@ -27,13 +23,6 @@ function setCountOnlyCache(cacheKey, value) {
countOnlyCache.map.set(cacheKey, { value, at: Date.now() }); countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
} }
/**
* Создать GET эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} mapLine - Функция маппинга строки в объект
* @param {function} validate - Функция валидации данных
* @param {string} cachePrefix - Префикс для кэша
*/
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) { function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
return async (req, res) => { return async (req, res) => {
const { q = '', offset, limit, countOnly, format } = req.query || {}; const { q = '', offset, limit, countOnly, format } = req.query || {};
@@ -54,23 +43,31 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
q, q,
offset: Number(offset) || 0, offset: Number(offset) || 0,
limit: Number(limit) || 0, limit: Number(limit) || 0,
mapLine mapLine,
}); });
if (!validate(items)) { if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
} }
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total }); return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
} else { } else {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let fileContent;
const fileContent = await streamToString(data.Body); try {
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim())); const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json([]);
}
throw e;
}
const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim()));
if (!validate(items)) { if (!validate(items)) {
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA'); return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
res.json([]); res.json([]);
} else { } else {
console.error(error); console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) }); return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
} }
} }
}; };
} }
/**
* Создать POST эндпоинт для текстовых данных
* @param {string} s3Key - Ключ S3 файла
* @param {function} formatLine - Функция форматирования объекта в строку
* @param {function} validate - Функция валидации данных
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
*/
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) { function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return async (req, res) => { return async (req, res) => {
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные const { domains, ipRanges, items, etag } = req.body;
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
const payload = Array.isArray(domains) const payload = Array.isArray(domains)
? domains ? domains
: Array.isArray(ipRanges) : Array.isArray(ipRanges)
@@ -115,12 +104,10 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST'); return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
} }
// Валидация с помощью AJV
if (!validate(payload || [])) { if (!validate(payload || [])) {
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA'); return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
} }
// Дополнительная валидация элементов, если предоставлена
if (validateItem) { if (validateItem) {
const validationErrors = []; const validationErrors = [];
for (let i = 0; i < payload.length; i++) { for (let i = 0; i < payload.length; i++) {
@@ -132,7 +119,7 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
errors: validationErrors.slice(0, 10) errors: validationErrors.slice(0, 10),
}); });
} }
} }
@@ -140,51 +127,30 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n'); const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
try { try {
// Проверка ETag для optimistic concurrency
let current = null; let current = null;
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null;
try { try {
current = await headS3ObjectEtag(s3Key); current = await headS3ObjectEtag(s3Key);
} catch {} } catch (_) {}
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
const { headMeta } = require('../services/s3Service'); return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
const meta = await headMeta(s3Key);
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
currentEtag: current,
meta
});
} }
} catch {}
try {
const meta = await writeS3TextObject(s3Key, fileContent); const meta = await writeS3TextObject(s3Key, fileContent);
return sendOk(res, meta); return sendOk(res, meta);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', { return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
error: String(error?.message || error)
});
} }
}; };
} }
/**
* Создать роуты для текстовых данных (GET + POST)
*/
function createTextDataRoutes(config) { function createTextDataRoutes(config) {
const { const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
s3Key,
mapLine,
formatLine,
validate,
validateItem,
cachePrefix
} = config;
return { return {
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix), get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
post: createTextDataPOST(s3Key, formatLine, validate, validateItem) post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
}; };
} }
@@ -193,4 +159,3 @@ module.exports = {
createTextDataGET, createTextDataGET,
createTextDataPOST, createTextDataPOST,
}; };
+100 -95
View File
@@ -15,9 +15,16 @@ const crypto = require('crypto');
// Импорт модулей // Импорт модулей
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler'); const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager'); const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
const { createTextDataRoutes } = require('./routes/textDataRoutes');
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes'); const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
const { splitWhitespace } = require('./utils/helpers'); const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes');
const evobgpClient = require('./services/evobgpClient');
const {
createEvobgpListGET,
createEvobgpDomainsNewPOST,
createEvobgpAsnsPOST,
createEvobgpIpRangesPOST,
createEvobgpLegacyDomainsGET,
} = require('./routes/evobgpReferenceRoutes');
const validators = require('./lib/validators'); const validators = require('./lib/validators');
const mikrotikValidator = require('./lib/mikrotik-validator'); const mikrotikValidator = require('./lib/mikrotik-validator');
@@ -231,99 +238,100 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
const validateAsns = ajv.compile(schemaAsns); const validateAsns = ajv.compile(schemaAsns);
const validateIpRanges = ajv.compile(schemaIpRanges); const validateIpRanges = ajv.compile(schemaIpRanges);
// === Domains (старая версия) === // === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
const domainsOldRoutes = createTextDataRoutes({ const asnValidateItem = (asn, i) => {
s3Key: 'bgp_data/domains.txt', const errors = [];
mapLine: (line) => { if (!validators.isValidASN(asn.domain)) {
const parts = splitWhitespace(line); errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
return { domain: parts[0] || '', type: parts[1] || '' }; }
}, if (!validators.isValidCommunity(asn.type)) {
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(), errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
validate: validateAsns, }
cachePrefix: 'domains' return errors;
}); };
const domainsOldRoutes = {
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
};
app.get('/api/domains', domainsOldRoutes.get); app.get('/api/domains', domainsOldRoutes.get);
app.post('/api/domains', domainsOldRoutes.post); app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
// === ASNs === // === ASNs (EvoBGP) ===
const asnsRoutes = createTextDataRoutes({ const asnsRoutes = {
s3Key: 'bgp_data/asns.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'asns',
const parts = splitWhitespace(line); validate: validateAsns,
return { domain: parts[0] || '', type: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchAsnsData();
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(), return rows.map((r) => ({ domain: r.asn, type: r.community }));
validate: validateAsns, },
validateItem: (asn, i) => { }),
const errors = []; post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
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.get('/api/asns', asnsRoutes.get);
app.post('/api/asns', writeLimiter, asnsRoutes.post); app.post('/api/asns', writeLimiter, asnsRoutes.post);
// === Domains New === // === Domains New (EvoBGP) ===
const domainsNewRoutes = createTextDataRoutes({ const domainsNewRoutes = {
s3Key: 'bgp_data/domains_community.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'domains-new',
const parts = splitWhitespace(line); validate: validateDomainsNew,
return { domain: parts[0] || '', community: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchDomainsData();
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(), return rows.map(({ domain, community }) => ({ domain, community }));
validate: validateDomainsNew, },
validateItem: (d, i) => { }),
const errors = []; post: createEvobgpDomainsNewPOST({
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) { validate: validateDomainsNew,
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`); validateItem: (d, i) => {
} const errors = [];
if (!validators.isValidCommunity(d.community)) { if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
errors.push(`Элемент ${i}: неверный community "${d.community}"`); errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
} }
if (!validators.isSafeXSSString(d.domain)) { if (!validators.isValidCommunity(d.community)) {
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`); errors.push(`Элемент ${i}: неверный community "${d.community}"`);
} }
return errors; if (!validators.isSafeXSSString(d.domain)) {
}, errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
cachePrefix: 'domains-new' }
}); return errors;
},
}),
};
app.get('/api/domains-new', domainsNewRoutes.get); app.get('/api/domains-new', domainsNewRoutes.get);
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post); app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
// === IP Ranges === // === IP Ranges (EvoBGP) ===
const ipRangesRoutes = createTextDataRoutes({ const ipRangesRoutes = {
s3Key: 'bgp_data/ips.txt', get: createEvobgpListGET({
mapLine: (line) => { cachePrefix: 'ip-ranges',
const parts = splitWhitespace(line); validate: validateIpRanges,
return { ipRange: parts[0] || '', community: parts[1] || '' }; fetchItems: async () => {
}, const rows = await evobgpClient.fetchIpRangesData();
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(), return rows.map(({ ipRange, community }) => ({ ipRange, community }));
validate: validateIpRanges, },
validateItem: (ip, i) => { }),
const errors = []; post: createEvobgpIpRangesPOST({
const ipStr = String(ip.ipRange || '').trim(); validate: validateIpRanges,
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr); validateItem: (ip, i) => {
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr); const errors = [];
const ipStr = String(ip.ipRange || '').trim();
if (!isValidCIDR && !isValidIP) { const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`); const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
} if (!isValidCIDR && !isValidIP) {
if (!validators.isValidCommunity(ip.community)) { errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
errors.push(`Элемент ${i}: неверный community "${ip.community}"`); }
} if (!validators.isValidCommunity(ip.community)) {
return errors; errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
}, }
cachePrefix: 'ip-ranges' return errors;
}); },
}),
};
app.get('/api/ip-ranges', ipRangesRoutes.get); app.get('/api/ip-ranges', ipRangesRoutes.get);
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
@@ -360,24 +368,21 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) =>
app.get('/api/billing', billingRoutes.get); app.get('/api/billing', billingRoutes.get);
app.post('/api/billing', billingRoutes.post); app.post('/api/billing', billingRoutes.post);
// Filters // Filters (SQL app_filter_rules, scope global)
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => { const filterItemValidator = (filter, i) => {
if (!filter.community || !filter.gateway) { if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`; return `Filter at index ${i} is missing required fields`;
} }
return null; return null;
}); };
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
app.get('/api/filters', filtersRoutes2.get); app.get('/api/filters', filtersRoutes2.get);
app.post('/api/filters', filtersRoutes2.post); app.post('/api/filters', filtersRoutes2.post);
// Simple Filters // Simple Filters (app_filter_rules, scope simple)
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => { const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
if (!filter.community || !filter.gateway) {
return `Filter at index ${i} is missing required fields`;
}
return null;
});
app.get('/api/simple-filters', simpleFiltersRoutes.get); app.get('/api/simple-filters', simpleFiltersRoutes.get);
app.post('/api/simple-filters', simpleFiltersRoutes.post); app.post('/api/simple-filters', simpleFiltersRoutes.post);
@@ -508,7 +513,7 @@ app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) === // === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
app.get('/api/alerts', alertsRoutes.getAlerts); app.get('/api/alerts', alertsRoutes.getAlerts);
// === MIKROTIK BACKUPS (S3) === // === MIKROTIK BACKUPS (локальная ФС) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup); app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
+103
View File
@@ -0,0 +1,103 @@
/**
* Бэкапы MikroTik на локальной ФС (вместо S3).
* Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const BACKUP_PREFIX = 'backups/mikrotik';
function backupRootDir() {
const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik');
if (!fs.existsSync(raw)) {
fs.mkdirSync(raw, { recursive: true });
}
return raw;
}
function parseBackupKey(key) {
const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/);
if (!m) return null;
return { safeId: m[1], fileName: m[2] };
}
function fsPathForKey(key) {
const p = parseBackupKey(key);
if (!p) return null;
return path.join(backupRootDir(), p.safeId, p.fileName);
}
function buildBackupKey(serverId, createdAt, suffix) {
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
const rand = suffix || crypto.randomBytes(3).toString('hex');
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
}
function etagForContent(content) {
const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex');
return `"${h}"`;
}
async function writeBackupFile(key, content, contentType = 'text/plain') {
const p = parseBackupKey(key);
if (!p) throw new Error('Invalid backup key');
const dir = path.join(backupRootDir(), p.safeId);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fp = path.join(dir, p.fileName);
await fs.promises.writeFile(fp, String(content), 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(String(content));
return {
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function readBackupFile(key) {
const fp = fsPathForKey(key);
if (!fp) throw new Error('Invalid backup key');
const body = await fs.promises.readFile(fp, 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(body);
return {
body,
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function listBackupObjects(serverId, maxKeys = 200) {
const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
const dir = path.join(backupRootDir(), safeId);
if (!fs.existsSync(dir)) return [];
const names = await fs.promises.readdir(dir);
const out = [];
for (const name of names) {
if (out.length >= maxKeys) break;
const fp = path.join(dir, name);
const st = await fs.promises.stat(fp);
if (!st.isFile()) continue;
const key = `${BACKUP_PREFIX}/${safeId}/${name}`;
out.push({
key,
size: st.size,
lastModified: st.mtime.toISOString(),
etag: etagForContent(await fs.promises.readFile(fp, 'utf8')),
});
}
return out;
}
module.exports = {
BACKUP_PREFIX,
buildBackupKey,
writeBackupFile,
readBackupFile,
listBackupObjects,
backupRootDir,
};
+167
View File
@@ -0,0 +1,167 @@
/**
* SQLite blob store (logical S3 keys rows in `blobs`).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
function sha256Etag(buf) {
const h = crypto.createHash('sha256').update(buf).digest('hex');
return `"${h}"`;
}
function namespaceFromKey(objectKey) {
const k = String(objectKey || '');
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
return 'cache';
}
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
return 'per_server';
}
if (k === 'mikrotik-frouting-config.txt') {
return 'derived';
}
return 'config';
}
function isVersionedKey(db, objectKey) {
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
return Boolean(row);
}
function trimVersions(db, objectKey, keep = 50) {
db.prepare(
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
)`
).run(objectKey, objectKey, keep);
}
/**
* @param {Buffer|string} body
*/
function writeBlobTx(db, objectKey, body, contentType) {
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
const ns = namespaceFromKey(objectKey);
const etag = sha256Etag(buf);
const now = Date.now();
const size = buf.length;
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
const run = () => {
if (existing && isVersionedKey(db, objectKey)) {
const oldBuf = existing.body;
db.prepare(
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
VALUES (?, ?, ?, ?, ?)`
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
trimVersions(db, objectKey);
}
if (existing) {
db.prepare(
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
WHERE object_key = ?`
).run(ns, contentType, buf, size, etag, now, objectKey);
} else {
db.prepare(
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(objectKey, ns, contentType, buf, size, etag, now, now);
}
};
db.transaction(run)();
return {
etag,
lastModified: new Date(now).toISOString(),
contentLength: size,
};
}
function readBlob(objectKey) {
const db = openDatabase();
const row = db.prepare(
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
).get(objectKey);
if (!row) return null;
const bodyStr = row.body.toString('utf8');
return {
body: bodyStr,
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headMetaRow(objectKey) {
const db = openDatabase();
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
if (!row) {
return { etag: null, lastModified: null, contentLength: null };
}
return {
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headEtag(objectKey) {
const m = headMetaRow(objectKey);
return m.etag || undefined;
}
function deleteBlob(objectKey) {
const db = openDatabase();
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
}
function listByPrefix(prefix, maxKeys = 100) {
const db = openDatabase();
const rows = db.prepare(
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
).all(prefix, prefix + '\uffff', maxKeys);
return rows.map((r) => ({
key: r.key,
size: r.size,
lastModified: new Date(r.updated_at).toISOString(),
etag: r.etag,
}));
}
function listVersions(objectKey, maxKeys = 50) {
const db = openDatabase();
return db.prepare(
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
).all(objectKey, maxKeys);
}
function rollbackToVersionId(objectKey, versionId) {
const db = openDatabase();
const vid = Number(versionId);
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
if (!ver) throw new Error('Version not found');
const buf = ver.body;
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
writeBlobTx(db, objectKey, buf, contentType);
return headMetaRow(objectKey);
}
module.exports = {
namespaceFromKey,
writeBlobTx,
readBlob,
headMetaRow,
headEtag,
deleteBlob,
listByPrefix,
listVersions,
rollbackToVersionId,
sha256Etag,
};
+426
View File
@@ -0,0 +1,426 @@
/**
* Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js
*/
const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes');
const PAGE_LIMIT = 500;
const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000;
function getBaseAndToken() {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
return { base, token };
}
function isConfigured() {
const { base, token } = getBaseAndToken();
return Boolean(base && token);
}
async function evobgpRequest(method, pathname, { query, body } = {}) {
const { base, token } = getBaseAndToken();
if (!base || !token) {
const err = new Error('EvoBGP not configured');
err.code = 'E_EVOBGP_NOT_CONFIGURED';
throw err;
}
const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`);
if (query && typeof query === 'object') {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
}
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
try {
const res = await fetch(u.toString(), {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: ctrl.signal,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
if (!res.ok) {
const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText);
err.code = 'E_EVOBGP_HTTP';
err.status = res.status;
err.body = data;
throw err;
}
return data;
} finally {
clearTimeout(t);
}
}
async function paginate(path, params = {}) {
const items = [];
let cursor = null;
while (true) {
const res = await evobgpRequest('GET', path, {
query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
});
const chunk = Array.isArray(res?.items) ? res.items : [];
items.push(...chunk);
if (!res?.has_more || !res?.next_cursor) break;
cursor = res.next_cursor;
}
return items;
}
async function fetchRouterListsCatalog() {
return evobgpRequest('GET', '/router-lists/catalog');
}
function getCatalogItems(section) {
if (!section || typeof section !== 'object') return [];
return Array.isArray(section.items) ? section.items : [];
}
async function listCommunities() {
let communities = [];
try {
const catalog = await fetchRouterListsCatalog();
communities = getCatalogItems(catalog.communities);
} catch {
communities = await paginate('/communities');
}
return communities.map((c) => ({
id: c.id,
value: String(c.community || ''),
name: String(c.title || ''),
description: '',
tags: [],
color: '',
icon: '',
}));
}
async function getOrCreateModule(moduleType, defaultName) {
const modules = await paginate('/modules', { type: moduleType });
if (modules.length > 0) return modules[0];
const created = await evobgpRequest('POST', '/modules', {
body: { type: moduleType, name: defaultName, enabled: true },
});
return created?.data || created;
}
function normalizeList(items, keyName) {
const out = [];
const seen = new Set();
for (const item of items || []) {
const key = String(item?.[keyName] || '').trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ ...item, [keyName]: key });
}
return out;
}
async function buildCommunityMaps() {
const communities = await listCommunities();
const byId = new Map(communities.map((c) => [c.id, c.value]));
const byValue = new Map(communities.map((c) => [c.value, c.id]));
return { communities, byId, byValue };
}
function mapCommunityToValue(communityId, byId) {
if (!communityId) return '';
return byId.get(communityId) || String(communityId);
}
async function syncEntries({
moduleType,
moduleName,
listPath,
keyName,
createBody,
patchBody,
currentItems,
originalItems,
requireCommunity = false,
}) {
const module = await getOrCreateModule(moduleType, moduleName);
const moduleId = module.id;
const { byId, byValue } = await buildCommunityMaps();
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
const existingByKey = new Map();
for (const row of existing) {
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
if (!entryKey) continue;
existingByKey.set(entryKey, row);
}
const normalizedCurrent = normalizeList(currentItems, keyName);
const normalizedOriginal = normalizeList(originalItems, keyName);
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
for (const key of originalKeys) {
if (!currentMap.has(key) && existingByKey.has(key)) {
const row = existingByKey.get(key);
await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`);
existingByKey.delete(key);
}
}
for (const [key, item] of currentMap.entries()) {
const rawCommunity = String(item.community || '').trim();
const communityId = byValue.get(rawCommunity) || null;
if (requireCommunity && !communityId) {
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
}
const existingRow = existingByKey.get(key);
if (!existingRow) {
await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) });
continue;
}
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
if (currentCommunity !== rawCommunity) {
await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, {
body: patchBody(item, communityId),
});
}
}
}
async function fetchDomainsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.domains);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
domain: String(row?.entry?.fqdn || row?.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.domain);
} catch {
const module = await getOrCreateModule('DOMAINS', 'Domains');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/domain-entries`);
return rows.map((row) => ({
id: row.id,
domain: String(row.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchIpRangesData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.ip_ranges);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.ipRange);
} catch {
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
return rows.map((row) => ({
id: row.id,
ipRange: String(row.prefix || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchAsnsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.asns);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
asn: String(row?.entry?.asn || row?.asn || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.asn);
} catch {
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/as-entries`);
return rows.map((row) => ({
id: row.id,
asn: String(row.asn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */
async function fetchAutoUrlsData() {
try {
const catalog = await fetchRouterListsCatalog();
for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) {
const sec = catalog[name];
const items = getCatalogItems(sec);
if (items.length) {
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
return items.map((row) => {
const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim();
const community = mapCommunityToValue(
row?.entry?.community_id || row?.community_id || null,
byId,
);
return { url, community };
}).filter((r) => r.url);
}
}
} catch (_) {}
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
try {
const module = await getOrCreateModule(modType, 'Auto URLs');
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const rows = await paginate(`/modules/${module.id}/${listPath}`);
const { byId } = await buildCommunityMaps();
return rows.map((row) => ({
url: String(row.url || row.fqdn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
})).filter((r) => r.url);
} catch {
return [];
}
}
async function saveDomainsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'DOMAINS',
moduleName: 'Domains',
listPath: 'domain-entries',
keyName: 'domain',
currentItems,
originalItems,
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
});
}
async function saveIpRangesData(currentItems, originalItems) {
await syncEntries({
moduleType: 'IP_RANGES',
moduleName: 'IP ranges',
listPath: 'ip-range-entries',
keyName: 'ipRange',
currentItems,
originalItems,
requireCommunity: true,
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
});
}
async function saveAsnsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'AS_PREFIXES',
moduleName: 'ASNs',
listPath: 'as-entries',
keyName: 'asn',
currentItems,
originalItems,
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
});
}
async function saveCommunitiesData(currentItems) {
const existing = await paginate('/communities');
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
const nextByValue = new Map();
for (const item of currentItems || []) {
const value = String(item?.value || '').trim();
if (!value) continue;
nextByValue.set(value, item);
}
for (const [value, row] of existingByValue.entries()) {
if (!nextByValue.has(value)) {
await evobgpRequest('DELETE', `/communities/${row.id}`);
}
}
for (const [value, item] of nextByValue.entries()) {
const title = String(item?.name || '').trim();
const row = existingByValue.get(value);
if (!row) {
await evobgpRequest('POST', '/communities', { body: { community: value, title } });
continue;
}
if (String(row.title || '') !== title) {
await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } });
}
}
}
async function saveAutoUrlsData(urls) {
const originalItems = await fetchAutoUrlsData();
const list = (urls || []).map((u) => ({
url: String(u?.url || '').trim(),
community: String(u?.community || '').trim(),
})).filter((u) => u.url && u.community);
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url';
const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community }));
const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community }));
await syncEntries({
moduleType: modType,
moduleName: 'Auto URLs',
listPath,
keyName,
currentItems,
originalItems: origMapped,
requireCommunity: true,
createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
});
}
module.exports = {
isConfigured,
evobgpRequest,
paginate,
fetchRouterListsCatalog,
listCommunities,
fetchDomainsData,
fetchIpRangesData,
fetchAsnsData,
fetchAutoUrlsData,
saveCommunitiesData,
saveDomainsData,
saveIpRangesData,
saveAsnsData,
saveAutoUrlsData,
buildCommunityMaps,
mapCommunityToValue,
};
+124
View File
@@ -0,0 +1,124 @@
/**
* Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
/** @typedef {'global'|'simple'} FilterScope */
function assertScope(scope) {
if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope');
}
function mtimeMetaKey(scope) {
return `filter_rules_mtime_${scope}`;
}
function sha256EtagFromString(s) {
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
return `"${h}"`;
}
function rowToItem(row) {
let extra = {};
try {
extra = JSON.parse(row.extra_json || '{}');
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {};
} catch {
extra = {};
}
const out = {
community: row.community,
gateway: row.gateway,
description: row.description || '',
};
for (const k of Object.keys(extra).sort()) {
if (k === 'community' || k === 'gateway' || k === 'description') continue;
out[k] = extra[k];
}
return out;
}
/**
* @param {FilterScope} scope
*/
function listRules(scope) {
assertScope(scope);
const db = openDatabase();
const rows = db
.prepare(
`SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`,
)
.all(scope);
return rows.map(rowToItem);
}
/**
* @param {FilterScope} scope
* @returns {{ etag: string, lastModified: string|null, contentLength: number }}
*/
function headMetaForScope(scope) {
assertScope(scope);
const items = listRules(scope);
const body = JSON.stringify(items);
const etag = sha256EtagFromString(body);
const contentLength = Buffer.byteLength(body, 'utf8');
const db = openDatabase();
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope));
const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null;
return { etag, lastModified, contentLength };
}
/**
* @param {FilterScope} scope
* @param {any[]} items
* @param {{ validateItem?: (item: any, i: number) => string|null }} [opts]
*/
function replaceRules(scope, items, opts = {}) {
assertScope(scope);
const { validateItem } = opts;
if (!Array.isArray(items)) throw new Error('items must be an array');
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const err = validateItem(items[i], i);
if (err) {
const e = new Error(err);
e.code = 'E_SCHEMA';
throw e;
}
}
}
const db = openDatabase();
const now = Date.now();
const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
const ins = db.prepare(
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)');
const run = () => {
del.run(scope);
for (let i = 0; i < items.length; i++) {
const it = items[i] || {};
const community = String(it.community || '').trim();
const gateway = String(it.gateway || '').trim();
const description = String(it.description || '').trim();
const extra = { ...it };
delete extra.community;
delete extra.gateway;
delete extra.description;
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
}
metaUpsert.run(mtimeMetaKey(scope), String(now));
};
db.transaction(run)();
return headMetaForScope(scope);
}
module.exports = {
listRules,
headMetaForScope,
replaceRules,
};
+2 -2
View File
@@ -4,7 +4,7 @@
* Работает внутри Node-процесса backend: * Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json * - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact) * - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг в S3 через saveBackupForServer * - сохраняет конфиг на диск через saveBackupForServer
* *
* Управляется через переменные окружения: * Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true) * - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
log.info( log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key }, { component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to S3', 'Automatic backup saved to disk',
); );
} catch (err) { } catch (err) {
log.error( log.error(
+120 -243
View File
@@ -1,58 +1,35 @@
/** /**
* Сервис для работы с S3 (Yandex Object Storage) * Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
* Централизованные операции чтения/записи/кэширования * Справочники EvoBGP не хранятся здесь см. evobgpClient.js
*/ */
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3'); const { openDatabase } = require('../db/sqliteDb');
const { NodeHttpHandler } = require('@smithy/node-http-handler'); const blobStorage = require('./blobStorage');
const http = require('http');
const https = require('https');
// S3 Client setup openDatabase();
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,
}
});
const BUCKET_NAME = process.env.S3_BUCKET_NAME; const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
// In-memory cache
const cache = { const cache = {
text: new Map(), text: new Map(),
head: new Map(), head: new Map(),
max: 100, max: 100,
ttlMs: 30_000 ttlMs: 30_000,
}; };
/**
* Конвертирует stream в строку
*/
async function streamToString(stream) { async function streamToString(stream) {
if (!stream) return ''; if (!stream) return '';
if (typeof stream.transformToString === 'function') { if (typeof stream.transformToString === 'function') {
return await stream.transformToString(); return await stream.transformToString();
} }
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
let chunks = []; const chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c)))); stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject); stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
}); });
} }
/**
* Получить значение из кэша
*/
function getCache(map, key) { function getCache(map, key) {
const v = map.get(key); const v = map.get(key);
if (!v) return null; if (!v) return null;
@@ -63,9 +40,6 @@ function getCache(map, key) {
return v.value; return v.value;
} }
/**
* Установить значение в кэш
*/
function setCache(map, key, value) { function setCache(map, key, value) {
if (map.size >= cache.max) { if (map.size >= cache.max) {
const firstKey = map.keys().next().value; const firstKey = map.keys().next().value;
@@ -74,213 +48,115 @@ function setCache(map, key, value) {
map.set(key, { value, at: Date.now() }); map.set(key, { value, at: Date.now() });
} }
/**
* Инвалидировать кэш для ключа
*/
function invalidateCacheForKey(key) { function invalidateCacheForKey(key) {
try { cache.text.delete(key); } catch {}
try { cache.head.delete(key); } catch {}
}
/**
* Прочитать текстовый объект из S3
*/
async function readS3TextObject(key, s3Duration = null) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const s3Start = Date.now();
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (s3Duration) {
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(cache.text, key, out);
return out;
}
/**
* Получить ETag объекта из S3
*/
async function headS3ObjectEtag(key, s3Duration = null) {
const cached = getCache(cache.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 }));
if (s3Duration) {
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
}
setCache(cache.head, key, { etag: head.ETag || undefined });
return head.ETag || undefined;
}
/**
* Получить метаданные объекта (etag, lastModified, contentLength)
*/
async function headMeta(key) {
try { try {
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key })); cache.text.delete(key);
return { } catch (_) {}
etag: h.ETag || null, try {
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null, cache.head.delete(key);
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null, } catch (_) {}
}; }
} catch (e) {
return { etag: null, lastModified: null, contentLength: null }; async function readS3TextObject(key) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const row = blobStorage.readBlob(key);
if (!row) {
const err = new Error('NoSuchKey');
err.code = 'NoSuchKey';
throw err;
} }
} const out = {
body: row.body,
/** etag: row.etag,
* Записать текстовый объект в S3 lastModified: row.lastModified,
*/ contentLength: row.contentLength,
async function writeS3TextObject(key, content, contentType = 'text/plain') { };
await s3.send(new PutObjectCommand({ setCache(cache.text, key, out);
Bucket: BUCKET_NAME,
Key: key,
Body: content,
ContentType: contentType,
}));
invalidateCacheForKey(key);
return await headMeta(key);
}
/**
* Записать JSON объект в S3
*/
async function writeS3JsonObject(key, data) {
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
/**
* Удалить объект из S3
*/
async function deleteS3Object(key) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
invalidateCacheForKey(key);
}
/**
* Список объектов в S3 по префиксу
* Используется для истории/бэкапов (количество ограничено для безопасности)
*/
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
const out = [];
let continuationToken = undefined;
while (out.length < maxKeys) {
const resp = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
ContinuationToken: continuationToken,
MaxKeys: Math.min(1000, maxKeys - out.length),
}));
const contents = resp.Contents || [];
for (const obj of contents) {
out.push({
key: obj.Key,
size: typeof obj.Size === 'number' ? obj.Size : null,
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
etag: obj.ETag || null,
});
if (out.length >= maxKeys) break;
}
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
break;
}
continuationToken = resp.NextContinuationToken;
}
return out; return out;
} }
/** async function headS3ObjectEtag(key) {
* Потоковое чтение с пагинацией больших текстовых файлов const cached = getCache(cache.head, key);
*/ if (cached && cached.etag) return cached.etag;
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) { const etag = blobStorage.headEtag(key);
return new Promise(async (resolve, reject) => { if (etag) setCache(cache.head, key, { etag });
let total = 0; return etag;
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));
}
}
});
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);
}
});
} }
async function headMeta(key) {
return blobStorage.headMetaRow(key);
}
async function writeS3TextObject(key, content, contentType = 'text/plain') {
const db = openDatabase();
blobStorage.writeBlobTx(db, key, content, contentType);
invalidateCacheForKey(key);
return headMeta(key);
}
async function writeS3JsonObject(key, data) {
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
async function deleteS3Object(key) {
blobStorage.deleteBlob(key);
invalidateCacheForKey(key);
}
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
return blobStorage.listByPrefix(prefix, maxKeys);
}
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
let text = '';
try {
const row = await readS3TextObject(key);
text = row.body || '';
} catch (e) {
if (e?.code === 'NoSuchKey') {
return { items: [], total: 0 };
}
throw e;
}
const lines = text.split('\n');
let total = 0;
const items = [];
const qstr = q ? String(q).toLowerCase() : '';
const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr);
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 && items.length < limit) items.push(mapLine(line));
} else {
items.push(mapLine(line));
}
}
return { items, total };
}
function listBlobVersionsForKey(key, maxKeys = 50) {
return blobStorage.listVersions(key, maxKeys);
}
function rollbackBlobVersion(key, versionId) {
openDatabase();
return blobStorage.rollbackToVersionId(key, versionId);
}
/** Заглушка: прямой вызов S3 SDK больше не используется. */
const s3 = {
send() {
throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient');
},
};
module.exports = { module.exports = {
s3, s3,
BUCKET_NAME, BUCKET_NAME,
@@ -294,5 +170,6 @@ module.exports = {
streamPaginatedText, streamPaginatedText,
invalidateCacheForKey, invalidateCacheForKey,
listS3Objects, listS3Objects,
listBlobVersionsForKey,
rollbackBlobVersion,
}; };
+6 -5
View File
@@ -39,14 +39,15 @@ function mapAjvErrors(errors) {
} }
/** /**
* Маппинг UI ресурсов на S3 ключи (для истории версий) * Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты).
* Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют.
*/ */
function resourceToKey(resource) { function resourceToKey(resource) {
switch (resource) { switch (resource) {
case 'domains-new': return 'bgp_data/domains_community.txt'; case 'servers':
case 'ip-ranges': return 'bgp_data/ips.txt'; return 'servers.json';
case 'asns': return 'bgp_data/asns.txt'; default:
default: return null; return null;
} }
} }
+36 -50
View File
@@ -1,41 +1,36 @@
/** /**
* Универсальные хелперы для работы с S3 * Универсальные хелперы для JSON/текста из локального storage (SQLite)
* Устраняют дублирование кода в routes
*/ */
const { s3, BUCKET_NAME } = require('../services/s3Service'); const { headMeta, readS3TextObject } = require('../services/s3Service');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler'); const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
/** /**
* Универсальный GET для JSON объектов из S3 с заголовками * Универсальный GET для JSON объектов с заголовками
* @param {string} s3Key - Ключ S3
* @param {object} req - Express request
* @param {object} res - Express response
* @param {object} options - Опции
* @param {function} options.transform - Функция трансформации данных
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
*/ */
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) { async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
const { transform = (data) => data, defaultValue = [] } = options; const { transform = (data) => data, defaultValue = [] } = options;
try { try {
// Получаем HEAD для установки заголовков const head = await headMeta(s3Key);
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const etag = head?.etag || null;
const etag = head?.ETag || null;
// Устанавливаем заголовки
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
// Проверяем If-None-Match
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
// Получаем объект let fileContent;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); try {
const fileContent = await streamToString(data.Body); const data = await readS3TextObject(s3Key);
fileContent = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(transform(defaultValue));
}
throw e;
}
let parsed; let parsed;
try { try {
@@ -48,54 +43,46 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
parsed = defaultValue; parsed = defaultValue;
} }
// Применяем трансформацию
const result = transform(parsed); const result = transform(parsed);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(defaultValue); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
} }
/** /**
* Универсальный GET для текстовых объектов из S3 с заголовками * Универсальный GET для текстовых объектов с заголовками
* @param {string} s3Key - Ключ S3
* @param {object} req - Express request
* @param {object} res - Express response
* @param {object} options - Опции
* @param {function} options.transform - Функция трансформации текста
* @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
*/ */
async function getS3TextWithHeaders(s3Key, req, res, options = {}) { async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
const { transform = (text) => ({ config: text }), defaultValue = '' } = options; const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
try { try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null); const head = await headMeta(s3Key);
const etag = head?.ETag || null; const etag = head?.etag || null;
if (etag) res.set('ETag', String(etag)); if (etag) res.set('ETag', String(etag));
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); 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 (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
if (checkIfNoneMatch(req, res, etag)) return; if (checkIfNoneMatch(req, res, etag)) return;
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })); let text;
const text = await streamToString(data.Body); try {
const data = await readS3TextObject(s3Key);
text = data.body;
} catch (e) {
if (e?.code === 'NoSuchKey') {
return res.json(transform(defaultValue));
}
throw e;
}
const result = transform(text); const result = transform(text);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { console.error(error);
res.json(transform(defaultValue)); return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3');
}
} }
} }
@@ -103,4 +90,3 @@ module.exports = {
getS3JsonWithHeaders, getS3JsonWithHeaders,
getS3TextWithHeaders, getS3TextWithHeaders,
}; };