Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095f891b02 |
@@ -5,7 +5,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- v4
|
||||
- v5
|
||||
- tabler
|
||||
- v3
|
||||
workflow_dispatch:
|
||||
@@ -30,31 +29,7 @@ jobs:
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
# Ветка 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' }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
# 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-валидация критических сценариев.
|
||||
|
||||
@@ -1,142 +1,118 @@
|
||||
# Docker Images
|
||||
|
||||
Образ собирается из [`Dockerfile.fast`](Dockerfile.fast) (многостадийная сборка: Vite frontend + `npm ci` backend, рантайм **distroless** Node 20). В Gitea Actions используется тот же файл (см. [`.gitea/workflows/docker-publish.yml`](.gitea/workflows/docker-publish.yml)).
|
||||
Этот проект предоставляет Docker образы для RouterOS Manager с двумя различными версиями интерфейса.
|
||||
|
||||
## Теги в реестре `git.shts.su`
|
||||
## 🏷️ Доступные теги
|
||||
|
||||
Замените `[repository]` на путь репозитория в Gitea (например `denozord/router-lists-ui`).
|
||||
### Основная версия (main branch)
|
||||
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
|
||||
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
|
||||
|
||||
| Ветка (триггер push) | Образы в реестре |
|
||||
|----------------------|-------------------|
|
||||
| **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`, если включён |
|
||||
### Tabler версия (tabler branch)
|
||||
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI
|
||||
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом
|
||||
|
||||
Пин на коммит: используйте тег с суффиксом `-<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
|
||||
docker run -d \
|
||||
--name router-lists-ui \
|
||||
--name s3-lists-manager \
|
||||
-p 3001: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-data:/data \
|
||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
Каталог `/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)
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name router-lists-ui-tabler \
|
||||
-p 3003:3001 \
|
||||
--env-file ./backend/.env \
|
||||
-v router-lists-tabler-data:/data \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3001:3001 \
|
||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## Обновление образа
|
||||
|
||||
### latest
|
||||
## 🔄 Обновление
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:latest
|
||||
docker stop router-lists-ui && docker rm router-lists-ui
|
||||
# затем снова docker run ... как выше
|
||||
docker stop s3-lists-manager
|
||||
docker rm s3-lists-manager
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### v5
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:v5
|
||||
docker stop router-lists-ui-v5 && docker rm router-lists-ui-v5
|
||||
docker run -d ... git.shts.su/[repository]:v5
|
||||
docker pull git.shts.su/[repository]:tabler
|
||||
docker stop s3-lists-manager-tabler
|
||||
docker rm s3-lists-manager-tabler
|
||||
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
|
||||
|
||||
Пример **одной** службы на `latest` с томом данных (подставьте свой `image:` и при необходимости `env_file`):
|
||||
Создайте файл `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
router-lists-ui:
|
||||
s3-lists-manager:
|
||||
image: git.shts.su/[repository]:latest
|
||||
container_name: router-lists-ui
|
||||
container_name: s3-lists-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
EVOBGP_API_URL: ${EVOBGP_API_URL}
|
||||
EVOBGP_API_TOKEN: ${EVOBGP_API_TOKEN}
|
||||
SQLITE_PATH: /data/router-lists.db
|
||||
MIKROTIK_BACKUP_DIR: /data/backups/mikrotik
|
||||
volumes:
|
||||
- router-lists-data:/data
|
||||
- NODE_ENV=production
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
||||
# Прокси EvoBGP для UI (Communities / Domains / …) — без CORS в браузере:
|
||||
# - EVOBGP_API_URL=http://evobgp:8080
|
||||
# - EVOBGP_API_TOKEN=...
|
||||
|
||||
volumes:
|
||||
router-lists-data:
|
||||
s3-lists-manager-tabler:
|
||||
image: git.shts.su/[repository]:tabler
|
||||
container_name: s3-lists-manager-tabler
|
||||
ports:
|
||||
- "3002:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # Обязательно! Используйте .env файл или secrets
|
||||
```
|
||||
|
||||
Отдельный сервис для **v5** — тот же фрагмент, но `image: git.shts.su/[repository]:v5`, другой `container_name` и при желании отдельный volume.
|
||||
**Создайте файл `.env` в той же директории:**
|
||||
```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)
|
||||
|
||||
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI.
|
||||
Запросы UI идут на тот же origin: `/api/evobgp/…` обрабатывает **Node** внутри образа. Если перед приложением стоит **nginx**, нужно проксировать префикс `/api` на Node **без потери** сегмента `/api` в URI (иначе в upstream попадёт не тот путь и будет 404).
|
||||
|
||||
**Рекомендуемый вариант** (путь `/api/…` сохраняется):
|
||||
|
||||
@@ -150,64 +126,140 @@ location /api/ {
|
||||
}
|
||||
```
|
||||
|
||||
**Типичная ошибка**: `proxy_pass http://127.0.0.1:3001/;` — nginx срезает `/api`. В коде есть запасной маршрут на `/evobgp`, но лучше исправить nginx.
|
||||
**Типичная ошибка**: внутри `location /api` указать `proxy_pass http://127.0.0.1:3001/;` — nginx **срезает** `/api`, в Node приходит `/evobgp/router-lists/catalog` вместо `/api/evobgp/router-lists/catalog`. В коде добавлен запасной маршрут на `/evobgp`, но лучше поправить nginx.
|
||||
|
||||
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` → JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}`.
|
||||
**Проверка**: `GET http://<хост>:<порт>/api/evobgp` (без хвоста) должен вернуть JSON вида `{"ok":true,"proxy":true,"evobgpConfigured":true}` — если вместо этого HTML «404» или страница SPA, запрос **не доходит** до Node (только статика, другой порт или неверный `proxy_pass`).
|
||||
|
||||
## Генерация `ENCRYPTION_KEY`
|
||||
### ENCRYPTION_KEY
|
||||
|
||||
**Важно**: `ENCRYPTION_KEY` является обязательным параметром для работы с IPSec паролями. Этот ключ используется для шифрования/дешифрования паролей, хранящихся в S3.
|
||||
|
||||
**Генерация ключа:**
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
## Мониторинг
|
||||
|
||||
**Пример запуска с ENCRYPTION_KEY:**
|
||||
```bash
|
||||
docker logs -f router-lists-ui
|
||||
docker stats router-lists-ui
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
-e ENCRYPTION_KEY=your-64-hex-character-key-here \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
## Локальная сборка (как в CI)
|
||||
**⚠️ Внимание**: Используйте один и тот же `ENCRYPTION_KEY` для всех запусков контейнера. При изменении ключа старые зашифрованные пароли не смогут быть расшифрованы.
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
docker build -f Dockerfile.fast -t router-lists-ui:local .
|
||||
docker run --rm -p 3001:3001 --env-file ./backend/.env -v $(pwd)/data:/data \
|
||||
-e SQLITE_PATH=/data/router-lists.db \
|
||||
-e MIKROTIK_BACKUP_DIR=/data/backups/mikrotik \
|
||||
router-lists-ui:local
|
||||
# Основная версия
|
||||
docker logs s3-lists-manager
|
||||
|
||||
# Tabler версия
|
||||
docker logs s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
(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
|
||||
docker network create router-lists-net
|
||||
docker run -d --network router-lists-net ... git.shts.su/[repository]:latest
|
||||
docker stats s3-lists-manager s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
Ограничение ресурсов:
|
||||
## 🔍 Отладка
|
||||
|
||||
### Вход в контейнер
|
||||
```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
|
||||
docker ps -a --filter name=router-lists-ui
|
||||
docker restart router-lists-ui
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Замените `[repository]`** на актуальный путь репозитория в Gitea (например `denozord/router-lists-ui`).
|
||||
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea.
|
||||
@@ -48,7 +48,6 @@ COPY backend/middleware ./middleware
|
||||
COPY backend/routes ./routes
|
||||
COPY backend/services ./services
|
||||
COPY backend/utils ./utils
|
||||
COPY backend/db ./db
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
# EvoBGP (справочник community / router-lists через прокси /api/evobgp, без CORS в браузере):
|
||||
|
||||
@@ -1,129 +1,347 @@
|
||||
# Router Lists UI
|
||||
|
||||
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.
|
||||
Полноценный UI/Backend для управления списками BGP (домены, IP-диапазоны, ASNs), фильтрами и конфигурациями MikroTik на базе S3 (Yandex Object Storage). Интерфейс построен на Tabler, frontend — Vite + React, backend — Express.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- S3-совместимое хранилище (Yandex Object Storage)
|
||||
- Доступы AWS: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `AWS_REGION`
|
||||
|
||||
### Запуск backend
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
npm install
|
||||
npm i
|
||||
# создайте .env и заполните (пример ниже)
|
||||
npm start
|
||||
```
|
||||
|
||||
Backend поднимется на `http://localhost:3001`.
|
||||
Сервис поднимется на `http://localhost:3001`.
|
||||
|
||||
### Запуск frontend
|
||||
|
||||
```powershell
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm i
|
||||
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`.
|
||||
|
||||
## Важные переменные окружения
|
||||
|
||||
- `SQLITE_PATH` — путь к файлу SQLite (по умолчанию `backend/data/router-lists.db`).
|
||||
- `MIKROTIK_BACKUP_DIR` — каталог для локальных `.rsc` backup-файлов MikroTik.
|
||||
- `ENCRYPTION_KEY` — 64 hex-символа для шифрования секретов (IPSec/MikroTik).
|
||||
- `EVOBGP_API_URL`, `EVOBGP_API_TOKEN` — если используете интеграцию EvoBGP.
|
||||
- `CORS_ORIGINS`, `LOG_LEVEL`, `PORT` — эксплуатационные настройки сервиса.
|
||||
- `BGP_BACKGROUND_URL` — URL фонового обновления BGP для прокси-эндпоинта `POST /api/update-bgp/background`.
|
||||
### Важные переменные окружения
|
||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage
|
||||
- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей
|
||||
- `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все)
|
||||
- `LOG_LEVEL` — уровень логов pino (`info` по умолчанию)
|
||||
- `PORT` — порт backend (по умолчанию 3001)
|
||||
- `BGP_BACKGROUND_URL` — адрес фонового обновления BGP, например:
|
||||
- `http://77.232.38.173:8080/api/update_bgp/background?api_key=...`
|
||||
- Используется эндпоинтом прокси `POST /api/update-bgp/background` для обхода CORS
|
||||
|
||||
Пример `.env`:
|
||||
|
||||
```dotenv
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
SQLITE_PATH=./data/router-lists.db
|
||||
MIKROTIK_BACKUP_DIR=./data/backups/mikrotik
|
||||
ENCRYPTION_KEY=<64-hex>
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
AWS_SECRET_ACCESS_KEY=...
|
||||
AWS_REGION=ru-central1
|
||||
S3_BUCKET_NAME=...
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=...
|
||||
BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502
|
||||
```
|
||||
|
||||
## API (backend)
|
||||
|
||||
Формат ошибок:
|
||||
|
||||
Все ответы об ошибке имеют единый формат:
|
||||
```json
|
||||
{ "code": "E_*", "message": "...", "details": {}, "requestId": "..." }
|
||||
```
|
||||
|
||||
Формат успешных `POST/PUT`:
|
||||
|
||||
Успешные POST/PUT возвращают:
|
||||
```json
|
||||
{ "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/s3/last-modified` и функции вида `readS3*`/`writeS3*` — это **legacy-названия**.
|
||||
- Фактически эти операции работают с SQLite-backed хранилищем.
|
||||
- GET `/api/ip-ranges` — список `[{ ipRange, community }]`
|
||||
- POST `/api/ip-ranges` — `{ ipRanges: [{ipRange, community}], etag }`
|
||||
|
||||
### Основные эндпоинты
|
||||
- GET `/api/asns` — список `[{ domain, type }]` (domain = AS, type = community)
|
||||
- POST `/api/asns` — `{ domains: [{domain, type}], etag }`
|
||||
|
||||
- Данные: `/api/domains-new`, `/api/ip-ranges`, `/api/asns`, `/api/communities`
|
||||
- Фильтры/конфиги: `/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`
|
||||
- GET `/api/communities` — справочник community (JSON)
|
||||
- POST `/api/communities` — `{ communities: [...] }` (валидация уникальности value)
|
||||
|
||||
## 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`).
|
||||
|
||||
Полная инструкция: [DOCKER.md](DOCKER.md).
|
||||
- GET `/api/server-configs` / POST `/api/server-configs` — список серверов (id, name, ...).
|
||||
- GET `/api/server-configs/:serverId` / POST `/api/server-configs/:serverId` — конфиг конкретного сервера.
|
||||
- DELETE `/api/server-configs/:serverId` — удалить конфиг.
|
||||
- DELETE `/api/server-configs/:serverId/complete` — удалить конфиг и фильтры.
|
||||
|
||||
Критично для production:
|
||||
- GET `/api/server-filters/:serverId` — фильтры сервера.
|
||||
- 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`.
|
||||
- Обязательно смонтировать volume для `MIKROTIK_BACKUP_DIR`, иначе `.rsc` backup-файлы потеряются при пересоздании контейнера.
|
||||
- Использовать стабильный `ENCRYPTION_KEY` между перезапусками.
|
||||
### Прочее
|
||||
- GET `/api/servers` / POST `/api/servers` — список серверов.
|
||||
- GET `/api/billing` / POST `/api/billing` — биллинг (ноды, статусы и т.п.).
|
||||
- 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
|
||||
docker run -d `
|
||||
--name router-lists-ui `
|
||||
-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 `
|
||||
### Скрипты
|
||||
```bash
|
||||
npm run dev # dev-сервер
|
||||
npm run build # продакшн сборка
|
||||
```
|
||||
|
||||
## Структура репозитория
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Чек-лист проверки соответствия (S3 -> SQLite)
|
||||
|
||||
1. В `backend/package.json` нет зависимостей AWS SDK.
|
||||
2. Приложение стартует с `SQLITE_PATH` и создает/использует файл БД.
|
||||
3. CRUD по основным данным (`/api/domains-new`, `/api/ip-ranges`, `/api/asns`) работает после перезапуска контейнера с тем же volume.
|
||||
4. Созданный MikroTik backup появляется как локальный `.rsc` файл в каталоге `MIKROTIK_BACKUP_DIR`.
|
||||
5. После перезапуска контейнера с тем же volume backup-файл остается доступным.
|
||||
6. `GET /api/s3/last-modified` возвращает метаданные, но интерпретируется как legacy endpoint поверх SQLite.
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```text
|
||||
backend/ Express API
|
||||
frontend/ Vite + React UI
|
||||
#### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3002:3001 \
|
||||
--env-file ./backend/.env \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
### Локальная сборка
|
||||
|
||||
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.
@@ -1,68 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Нормализованные глобальные и 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';
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Generated
+1666
-409
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.687.0",
|
||||
"ajv": "^8.17.1",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
|
||||
@@ -1,52 +1,38 @@
|
||||
/**
|
||||
* Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API
|
||||
* Роуты для работы с communities (справочник BGP Community)
|
||||
*/
|
||||
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
|
||||
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 { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||
|
||||
function requireEvobgp(res) {
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN',
|
||||
'E_EVOBGP_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const S3_KEY = 'bgp_data/communities.json';
|
||||
|
||||
// GET /api/communities
|
||||
async function getCommunities(req, res) {
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
try {
|
||||
const communities = await evobgpClient.listCommunities();
|
||||
const normalized = communities
|
||||
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||
.map((c) => ({
|
||||
value: String(c.value).trim(),
|
||||
name: c.name ? String(c.name) : '',
|
||||
description: c.description ? String(c.description) : '',
|
||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||
color: c.color ? String(c.color) : '',
|
||||
icon: c.icon ? String(c.icon) : '',
|
||||
}));
|
||||
res.json(normalized);
|
||||
} catch (e) {
|
||||
console.error('getCommunities', e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
|
||||
}
|
||||
await getS3JsonWithHeaders(S3_KEY, req, res, {
|
||||
transform: (communities) => {
|
||||
// Нормализация
|
||||
return communities
|
||||
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||
.map((c) => ({
|
||||
value: String(c.value).trim(),
|
||||
name: c.name ? String(c.name) : '',
|
||||
description: c.description ? String(c.description) : '',
|
||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||
color: c.color ? String(c.color) : '',
|
||||
icon: c.icon ? String(c.icon) : ''
|
||||
}));
|
||||
},
|
||||
defaultValue: []
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/communities
|
||||
async function postCommunities(req, res) {
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
|
||||
const { communities } = req.body;
|
||||
|
||||
if (!Array.isArray(communities)) {
|
||||
@@ -56,26 +42,26 @@ async function postCommunities(req, res) {
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
const validationErrors = [];
|
||||
|
||||
|
||||
for (let i = 0; i < communities.length; i++) {
|
||||
const entry = communities[i] || {};
|
||||
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
|
||||
|
||||
|
||||
if (!value) {
|
||||
validationErrors.push(`Community at index ${i} is missing required field: value`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!validators.isValidCommunity(value)) {
|
||||
validationErrors.push(`Community at index ${i} has invalid value: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (seen.has(value)) {
|
||||
validationErrors.push(`Duplicate community value at index ${i}: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
seen.add(value);
|
||||
normalized.push({
|
||||
value,
|
||||
@@ -89,78 +75,97 @@ async function postCommunities(req, res) {
|
||||
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors });
|
||||
}
|
||||
|
||||
try {
|
||||
await evobgpClient.saveCommunitiesData(normalized);
|
||||
return sendOk(res, {
|
||||
etag: null,
|
||||
lastModified: new Date().toISOString(),
|
||||
contentLength: null,
|
||||
});
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: S3_KEY,
|
||||
Body: JSON.stringify(normalized, null, 2),
|
||||
ContentType: 'application/json',
|
||||
}));
|
||||
invalidateCacheForKey(S3_KEY);
|
||||
const { headMeta } = require('../services/s3Service');
|
||||
const meta = await headMeta(S3_KEY);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error('Error writing communities to EvoBGP:', error);
|
||||
return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||
console.error('Error writing communities to S3:', error);
|
||||
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
async function getCommunityStats(req, res) {
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
try {
|
||||
const [domains, ipRanges, asns] = await Promise.allSettled([
|
||||
evobgpClient.fetchDomainsData(),
|
||||
evobgpClient.fetchIpRangesData(),
|
||||
evobgpClient.fetchAsnsData(),
|
||||
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
|
||||
]);
|
||||
|
||||
|
||||
const communityUsage = new Map();
|
||||
|
||||
if (domains.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(domains.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
if (ipRanges.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(ipRanges.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
if (asns.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(asns.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
try {
|
||||
const simpleFilters = filterRulesStorage.listRules('simple');
|
||||
for (const f of simpleFilters) {
|
||||
if (f.community) {
|
||||
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
||||
|
||||
// Подсчет использования в доменах
|
||||
if (domainsRes.status === 'fulfilled') {
|
||||
const text = await streamToString(domainsRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const stats = Array.from(communityUsage.entries())
|
||||
.map(([community, count]) => ({ community, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Подсчет в IP ranges
|
||||
if (ipRangesRes.status === 'fulfilled') {
|
||||
const text = await streamToString(ipRangesRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Подсчет в ASNs
|
||||
if (asnsRes.status === 'fulfilled') {
|
||||
const text = await streamToString(asnsRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Подсчет в фильтрах
|
||||
if (filtersRes.status === 'fulfilled') {
|
||||
try {
|
||||
const text = await streamToString(filtersRes.value.Body);
|
||||
const filters = JSON.parse(text);
|
||||
if (Array.isArray(filters)) {
|
||||
filters.forEach(f => {
|
||||
if (f.community) {
|
||||
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({
|
||||
community,
|
||||
count,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
|
||||
} catch (error) {
|
||||
console.error('Error getting community stats:', error);
|
||||
return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP');
|
||||
return sendError(res, 500, 'Error getting community stats', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,3 +174,4 @@ module.exports = {
|
||||
postCommunities,
|
||||
getCommunityStats,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -2,10 +2,12 @@
|
||||
* Роуты для фильтров и генерации конфигураций MikroTik
|
||||
*/
|
||||
|
||||
const { writeS3TextObject } = require('../services/s3Service');
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
|
||||
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||
|
||||
// GET /api/filters/generate-config?format=text|json
|
||||
async function generateConfig(req, res) {
|
||||
@@ -14,16 +16,13 @@ async function generateConfig(req, res) {
|
||||
if (!['text', 'json'].includes(format)) {
|
||||
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
|
||||
}
|
||||
const head = filterRulesStorage.headMetaForScope('global');
|
||||
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;
|
||||
const filters = filterRulesStorage.listRules('global');
|
||||
const result = generateMikrotikConfig(filters, { format });
|
||||
res.json(format === 'json' ? result : { config: result });
|
||||
await getS3JsonWithHeaders('filters.json', req, res, {
|
||||
transform: (filters) => {
|
||||
const result = generateMikrotikConfig(filters, { format });
|
||||
return format === 'json' ? result : { config: result };
|
||||
},
|
||||
defaultValue: [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
||||
@@ -33,7 +32,17 @@ async function generateConfig(req, res) {
|
||||
// POST /api/filters/export-config
|
||||
async function exportConfig(req, res) {
|
||||
try {
|
||||
const filters = filterRulesStorage.listRules('global');
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let filters = [];
|
||||
|
||||
try {
|
||||
filters = JSON.parse(fileContent);
|
||||
if (!Array.isArray(filters)) filters = [];
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing filters.json:', parseError);
|
||||
filters = [];
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
||||
@@ -41,10 +50,10 @@ async function exportConfig(req, res) {
|
||||
|
||||
const config = generateMikrotikConfig(filters);
|
||||
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
|
||||
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
|
||||
*/
|
||||
|
||||
const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
|
||||
/**
|
||||
* Создать GET эндпоинт для JSON данных
|
||||
* @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
|
||||
* @param {string} s3Key - Ключ в S3
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
|
||||
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
|
||||
@@ -15,63 +17,66 @@ const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHand
|
||||
function createJsonDataGET(s3Key, options = {}) {
|
||||
const { singleObject = false, defaultValue } = options;
|
||||
const fallback = defaultValue !== undefined ? defaultValue : (singleObject ? {} : []);
|
||||
|
||||
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const head = await headMeta(s3Key);
|
||||
const etag = head?.etag || null;
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).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 (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;
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(fallback);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let items = fallback;
|
||||
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fileContent);
|
||||
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 {
|
||||
// Для массива
|
||||
items = Array.isArray(parsed) ? parsed : fallback;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error(`Error parsing ${s3Key}:`, parseError);
|
||||
items = fallback;
|
||||
}
|
||||
|
||||
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(fallback);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать POST эндпоинт для JSON данных
|
||||
* @param {string} s3Key - Ключ в S3
|
||||
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
|
||||
*/
|
||||
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
const { singleObject = false } = options;
|
||||
|
||||
|
||||
return async (req, res) => {
|
||||
const { domains: items } = req.body;
|
||||
|
||||
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
|
||||
|
||||
if (singleObject) {
|
||||
// Для одиночного объекта
|
||||
if (typeof items !== 'object' || items === null || Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
|
||||
// Валидация объекта целиком
|
||||
if (validateItem) {
|
||||
const error = validateItem(items);
|
||||
if (error) {
|
||||
@@ -79,10 +84,12 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для массива
|
||||
if (!Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
|
||||
// Валидация каждого элемента массива
|
||||
if (validateItem) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const error = validateItem(items[i], i);
|
||||
@@ -98,15 +105,23 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать роуты для 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 = {}) {
|
||||
return {
|
||||
get: createJsonDataGET(s3Key, options),
|
||||
post: createJsonDataPOST(s3Key, validateItem, options),
|
||||
post: createJsonDataPOST(s3Key, validateItem, options)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,3 +130,4 @@ module.exports = {
|
||||
createJsonDataGET,
|
||||
createJsonDataPOST,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Роуты для бэкапов конфигурации MikroTik
|
||||
* Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
|
||||
* Хранят сырый текст конфигурации (например, вывод /export) в S3.
|
||||
*
|
||||
* Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
||||
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
||||
*
|
||||
* Задачи:
|
||||
* - создать бэкап (ручной или из планировщика)
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
|
||||
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
|
||||
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/, '')}`;
|
||||
|
||||
// Шифруем содержимое файла, если задан ENCRYPTION_KEY
|
||||
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY
|
||||
if (process.env.ENCRYPTION_KEY) {
|
||||
try {
|
||||
content = ENCRYPTED_PREFIX + encrypt(content);
|
||||
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await writeBackupFile(key, content, 'text/plain');
|
||||
return { key, createdAt: now.toISOString(), storage: meta };
|
||||
const meta = await writeS3TextObject(key, content, 'text/plain');
|
||||
return { key, createdAt: now.toISOString(), s3: meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mikrotik/backups
|
||||
* Body: { serverId: string, config: string, source?: string, comment?: string }
|
||||
*
|
||||
* Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
|
||||
* Сохраняет текст конфигурации (обычно вывод /export) в S3.
|
||||
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
|
||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
key,
|
||||
serverId,
|
||||
createdAt,
|
||||
storage,
|
||||
s3,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createBackup error:', error);
|
||||
@@ -120,11 +120,12 @@ async function listBackups(req, res) {
|
||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const objects = await listBackupObjects(safeId, 200);
|
||||
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
|
||||
const objects = await listS3Objects(prefix, { maxKeys: 200 });
|
||||
|
||||
const items = objects
|
||||
.map((o) => ({
|
||||
.filter(o => o.key && o.key.startsWith(prefix))
|
||||
.map(o => ({
|
||||
key: o.key,
|
||||
serverId,
|
||||
createdAt: o.lastModified,
|
||||
@@ -155,7 +156,7 @@ async function getBackup(req, res) {
|
||||
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const data = await readBackupFile(key);
|
||||
const data = await readS3TextObject(key);
|
||||
const config = decryptBackupContent(data.body || '');
|
||||
|
||||
return res.json({
|
||||
@@ -190,8 +191,8 @@ async function diffBackups(req, res) {
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
readBackupFile(keyA),
|
||||
readBackupFile(keyB),
|
||||
readS3TextObject(keyA),
|
||||
readS3TextObject(keyB),
|
||||
]);
|
||||
|
||||
const bodyA = decryptBackupContent(a.body || '');
|
||||
@@ -264,7 +265,7 @@ async function getMikrotikClientForServerId(serverId) {
|
||||
* POST /api/mikrotik/backups/run
|
||||
* Body: { serverId: string }
|
||||
*
|
||||
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
|
||||
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3.
|
||||
*/
|
||||
async function runBackupNow(req, res) {
|
||||
try {
|
||||
@@ -292,7 +293,7 @@ async function runBackupNow(req, res) {
|
||||
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
|
||||
}
|
||||
|
||||
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
|
||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, {
|
||||
source: 'manual',
|
||||
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
||||
});
|
||||
@@ -302,7 +303,7 @@ async function runBackupNow(req, res) {
|
||||
key,
|
||||
serverId,
|
||||
createdAt,
|
||||
storage,
|
||||
s3,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { decrypt } = require('../utils/encryption');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
@@ -53,11 +54,12 @@ function speedTestCacheKey(serverId, interfaceName) {
|
||||
/** Загрузить network-config из S3 */
|
||||
async function loadNetworkConfig() {
|
||||
try {
|
||||
const data = await readS3TextObject(NETWORK_CONFIG_KEY);
|
||||
const parsed = JSON.parse(data.body || '{}');
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY }));
|
||||
const body = await streamToString(data.Body);
|
||||
const parsed = JSON.parse(body || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') return {};
|
||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+163
-136
@@ -2,18 +2,10 @@
|
||||
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
||||
*/
|
||||
|
||||
const {
|
||||
writeS3TextObject,
|
||||
writeS3JsonObject,
|
||||
readS3TextObject,
|
||||
headMeta,
|
||||
listBlobVersionsForKey,
|
||||
rollbackBlobVersion,
|
||||
} = require('../services/s3Service');
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { resourceToKey, toIso } = require('../utils/helpers');
|
||||
const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const validators = require('../lib/validators');
|
||||
const net = require('net');
|
||||
const http = require('http');
|
||||
@@ -27,97 +19,66 @@ async function getS3LastModified(req, res) {
|
||||
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
|
||||
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
||||
{ name: 'servers', key: 'servers.json' },
|
||||
{ name: 'filters', key: null },
|
||||
{ name: 'filters', key: 'filters.json' },
|
||||
{ name: 'ipRanges', key: 'bgp_data/ips.txt' },
|
||||
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
keys.map(k => headMeta(k.key))
|
||||
);
|
||||
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
if (k.name === 'filters') {
|
||||
try {
|
||||
const head = filterRulesStorage.headMetaForScope('global');
|
||||
out.filters = {
|
||||
etag: head.etag,
|
||||
lastModified: head.lastModified,
|
||||
contentLength: head.contentLength,
|
||||
};
|
||||
} catch {
|
||||
out.filters = null;
|
||||
}
|
||||
continue;
|
||||
results.forEach((r, idx) => {
|
||||
const name = keys[idx].name;
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
out[name] = r.value;
|
||||
} else {
|
||||
out[name] = null;
|
||||
}
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error fetching last modified dates:', error);
|
||||
return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE');
|
||||
console.error('Error fetching last modified dates from S3:', error);
|
||||
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']);
|
||||
|
||||
// GET /api/history/:resource
|
||||
async function getHistory(req, res) {
|
||||
const { resource } = req.params;
|
||||
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);
|
||||
|
||||
|
||||
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
|
||||
|
||||
try {
|
||||
const rows = listBlobVersionsForKey(key, 50);
|
||||
const versionsAll = rows.map((r) => ({
|
||||
id: r.id,
|
||||
object_key: r.object_key,
|
||||
byte_size: r.byte_size,
|
||||
etag: r.etag,
|
||||
created_at: r.created_at,
|
||||
const out = await s3.send(new ListObjectVersionsCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Prefix: key,
|
||||
MaxKeys: 50
|
||||
}));
|
||||
|
||||
|
||||
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
|
||||
|
||||
if (countOnly === 'true') {
|
||||
const total = versionsAll.length;
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
}
|
||||
|
||||
const versions = versionsAll.slice(0, 10).map((v, idx) => ({
|
||||
versionId: String(v.id),
|
||||
isLatest: idx === 0,
|
||||
lastModified: toIso(v.created_at),
|
||||
size: v.byte_size,
|
||||
etag: v.etag,
|
||||
|
||||
const versions = versionsAll.slice(0, 10).map(v => ({
|
||||
versionId: v.VersionId,
|
||||
isLatest: v.IsLatest,
|
||||
lastModified: toIso(v.LastModified),
|
||||
size: v.Size,
|
||||
etag: v.ETag
|
||||
}));
|
||||
|
||||
|
||||
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
|
||||
} catch (e) {
|
||||
console.error('history error', e);
|
||||
return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) });
|
||||
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,76 +86,125 @@ async function getHistory(req, res) {
|
||||
async function postRollback(req, res) {
|
||||
const { resource } = req.params;
|
||||
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);
|
||||
|
||||
|
||||
if (!key || !versionId) {
|
||||
return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
try {
|
||||
rollbackBlobVersion(key, versionId);
|
||||
await s3.send(new CopyObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
|
||||
Key: key
|
||||
}));
|
||||
|
||||
const meta = await headMeta(key);
|
||||
return sendOk(res, meta);
|
||||
} catch (e) {
|
||||
console.error('rollback error', e);
|
||||
return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) });
|
||||
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auto-urls
|
||||
async function getAutoUrls(req, res) {
|
||||
const { checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
try {
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
const rows = await evobgpClient.fetchAutoUrlsData();
|
||||
res.json(rows);
|
||||
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const head = await s3.send(new HeadObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'bgp_data/auto_url/urls.txt'
|
||||
})).catch(() => null);
|
||||
|
||||
const etag = head?.ETag || null;
|
||||
if (etag) res.set('ETag', String(etag));
|
||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
||||
if (checkIfNoneMatch(req, res, etag)) return;
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({
|
||||
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) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auto-urls
|
||||
async function postAutoUrls(req, res) {
|
||||
const { urls } = req.body;
|
||||
if (!Array.isArray(urls)) {
|
||||
return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
|
||||
|
||||
try {
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
await evobgpClient.saveAutoUrlsData(urls);
|
||||
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP');
|
||||
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auto-urls/process
|
||||
async function processAutoUrls(req, res) {
|
||||
try {
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
// Загрузка URL списков
|
||||
let urls = [];
|
||||
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) {
|
||||
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const currentIps = await evobgpClient.fetchIpRangesData();
|
||||
const currentDomains = await evobgpClient.fetchDomainsData();
|
||||
// Загрузка текущих IP и доменов
|
||||
let currentIps = [];
|
||||
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
|
||||
const newIps = [];
|
||||
@@ -246,8 +256,22 @@ async function processAutoUrls(req, res) {
|
||||
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
|
||||
const allDomains = [...currentDomains, ...uniqueNewDomains];
|
||||
|
||||
await evobgpClient.saveIpRangesData(allIps, currentIps);
|
||||
await evobgpClient.saveDomainsData(allDomains, currentDomains);
|
||||
// Сохранение
|
||||
const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n');
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'bgp_data/ips.txt',
|
||||
Body: updatedIpsContent,
|
||||
ContentType: 'text/plain'
|
||||
}));
|
||||
|
||||
const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n');
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'bgp_data/domains_community.txt',
|
||||
Body: updatedDomainsContent,
|
||||
ContentType: 'text/plain'
|
||||
}));
|
||||
|
||||
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
|
||||
return res.json({
|
||||
@@ -261,7 +285,7 @@ async function processAutoUrls(req, res) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing auto URLs:', error);
|
||||
return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP');
|
||||
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,8 +357,9 @@ function getPingServicesListFromSettings(uiSettings) {
|
||||
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
||||
async function loadUiSettingsSync() {
|
||||
try {
|
||||
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||
const parsed = JSON.parse(data.body || '{}');
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' }));
|
||||
const jsonText = await streamToString(data.Body);
|
||||
const parsed = JSON.parse(jsonText || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -573,10 +598,10 @@ async function getServersAvailability(req, res) {
|
||||
return res.json({ ...availabilityCache.data, cached: true });
|
||||
}
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
|
||||
let servers = [];
|
||||
try {
|
||||
const data = await readS3TextObject('servers.json');
|
||||
servers = JSON.parse(data.body);
|
||||
servers = JSON.parse(await streamToString(data.Body));
|
||||
if (!Array.isArray(servers)) servers = [];
|
||||
} catch {
|
||||
servers = [];
|
||||
@@ -658,13 +683,12 @@ async function updateBgpBackground(req, res) {
|
||||
// GET /api/ws/url
|
||||
async function getWsUrl(req, res) {
|
||||
try {
|
||||
let settings = {};
|
||||
try {
|
||||
const d = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||
settings = JSON.parse(d.body || '{}');
|
||||
} catch (_) {
|
||||
settings = {};
|
||||
}
|
||||
const settings = await s3.send(new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'bgp_data/rt_ui_settings.json'
|
||||
})).then(async (d) => {
|
||||
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
|
||||
}).catch(() => ({}));
|
||||
|
||||
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
|
||||
return res.json({ url });
|
||||
@@ -676,22 +700,25 @@ async function getWsUrl(req, res) {
|
||||
// GET/POST /api/ui-settings
|
||||
async function getUiSettings(req, res) {
|
||||
const { checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
|
||||
try {
|
||||
const head = await headMeta('bgp_data/rt_ui_settings.json');
|
||||
const etag = head?.etag || null;
|
||||
const { HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const head = await s3.send(new HeadObjectCommand({
|
||||
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 (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 (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;
|
||||
|
||||
let jsonText = '{}';
|
||||
try {
|
||||
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||
jsonText = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code !== 'NoSuchKey') throw e;
|
||||
}
|
||||
const data = await s3.send(new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: 'bgp_data/rt_ui_settings.json'
|
||||
}));
|
||||
const jsonText = await streamToString(data.Body);
|
||||
let settings = {};
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
@@ -701,11 +728,11 @@ async function getUiSettings(req, res) {
|
||||
}
|
||||
return res.json(settings);
|
||||
} catch (error) {
|
||||
if (error?.code === 'NoSuchKey') {
|
||||
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
return res.json({});
|
||||
}
|
||||
console.error('Error reading ui settings:', error);
|
||||
return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE');
|
||||
console.error('Error reading ui settings from S3:', error);
|
||||
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,8 +756,8 @@ async function postUiSettings(req, res) {
|
||||
const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error('Error writing UI settings:', error);
|
||||
return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
|
||||
console.error('Error writing UI settings to S3:', error);
|
||||
return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
const { sendError } = require('../middleware/errorHandler');
|
||||
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
|
||||
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||
@@ -174,8 +173,9 @@ function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community
|
||||
|
||||
async function loadCommunitiesIndex() {
|
||||
try {
|
||||
if (!evobgpClient.isConfigured()) return new Map();
|
||||
const arr = await evobgpClient.listCommunities();
|
||||
const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null);
|
||||
if (!raw?.body) return new Map();
|
||||
const arr = JSON.parse(raw.body || '[]');
|
||||
const m = new Map();
|
||||
if (!Array.isArray(arr)) return m;
|
||||
for (const c of arr) {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* Это нужно для EasySwitch, который работает только с jumphost серверами
|
||||
*/
|
||||
|
||||
const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service');
|
||||
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 { isValidServerId } = require('../lib/validators');
|
||||
|
||||
@@ -39,20 +41,15 @@ function validateJumphostServer(server, i) {
|
||||
// GET /api/server-configs (список только jumphost серверов)
|
||||
async function getServerConfigsList(req, res) {
|
||||
try {
|
||||
const head = await headMeta(S3_KEY);
|
||||
const etag = head?.etag || null;
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
if (etag) res.set('ETag', String(etag));
|
||||
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||
if (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;
|
||||
|
||||
let fileContent = '[]';
|
||||
try {
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code !== 'NoSuchKey') throw e;
|
||||
}
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let allServers = [];
|
||||
|
||||
try {
|
||||
@@ -116,10 +113,12 @@ async function postServerConfigsList(req, res) {
|
||||
// Читаем текущие данные
|
||||
let allServers = [];
|
||||
try {
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
allServers = JSON.parse(data.body);
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
allServers = JSON.parse(fileContent);
|
||||
if (!Array.isArray(allServers)) allServers = [];
|
||||
} catch (e) {
|
||||
// Файл не существует или пустой - это нормально
|
||||
allServers = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
||||
*/
|
||||
|
||||
const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
|
||||
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
|
||||
@@ -27,7 +28,7 @@ function validateServer(server, i) {
|
||||
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
|
||||
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) {
|
||||
return `Server at index ${i} must have exactly one primary gateway`;
|
||||
}
|
||||
@@ -44,17 +45,24 @@ function validateServer(server, i) {
|
||||
|
||||
async function readServersFromS3() {
|
||||
try {
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
const parsed = JSON.parse(data.body || '[]');
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
||||
const body = await streamToString(data.Body);
|
||||
const parsed = JSON.parse(body || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') return [];
|
||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServersToS3(servers) {
|
||||
await writeS3JsonObject(S3_KEY, servers);
|
||||
const body = JSON.stringify(servers, null, 2);
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: S3_KEY,
|
||||
Body: body,
|
||||
ContentType: 'application/json',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,15 +70,15 @@ async function writeServersToS3(servers) {
|
||||
*/
|
||||
async function getServers(req, res) {
|
||||
try {
|
||||
const head = await headMeta(S3_KEY);
|
||||
const etag = head?.etag || null;
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
if (etag) res.set('ETag', String(etag));
|
||||
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||
if (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 servers = await readServersFromS3();
|
||||
const sanitized = servers.map((s) => {
|
||||
const sanitized = servers.map(s => {
|
||||
const out = { ...s };
|
||||
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
|
||||
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
|
||||
@@ -83,11 +91,11 @@ async function getServers(req, res) {
|
||||
});
|
||||
res.json(sanitized);
|
||||
} catch (error) {
|
||||
if (error?.code === 'NoSuchKey') {
|
||||
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
return res.json([]);
|
||||
}
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
|
||||
return sendError(res, 500, 'Error reading servers', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +115,9 @@ async function postServers(req, res) {
|
||||
|
||||
try {
|
||||
const currentServers = await readServersFromS3();
|
||||
const findCurrent = (srv) =>
|
||||
currentServers.find(
|
||||
(c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip),
|
||||
);
|
||||
const findCurrent = (srv) => 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 current = findCurrent(srv);
|
||||
@@ -133,15 +140,15 @@ async function postServers(req, res) {
|
||||
});
|
||||
|
||||
await writeServersToS3(toSave);
|
||||
const meta = await headMeta(S3_KEY);
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
return sendOk(res, {
|
||||
etag: meta?.etag || null,
|
||||
lastModified: meta?.lastModified || null,
|
||||
contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
|
||||
etag: head?.ETag || null,
|
||||
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
|
||||
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
|
||||
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
|
||||
* Уменьшают дублирование кода для похожих эндпоинтов
|
||||
*/
|
||||
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
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 запросов
|
||||
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||
@@ -23,10 +27,17 @@ function setCountOnlyCache(cacheKey, value) {
|
||||
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) {
|
||||
return async (req, res) => {
|
||||
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const cacheKey = `${cachePrefix}:count:${q}`;
|
||||
@@ -43,32 +54,24 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
q,
|
||||
offset: Number(offset) || 0,
|
||||
limit: Number(limit) || 0,
|
||||
mapLine,
|
||||
mapLine
|
||||
});
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||
} else {
|
||||
const head = await headMeta(s3Key);
|
||||
const etag = head?.etag || null;
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).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 (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;
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
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()));
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
|
||||
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
@@ -82,16 +85,24 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
res.json([]);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3', { 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) {
|
||||
return async (req, res) => {
|
||||
const { domains, ipRanges, items, etag } = req.body;
|
||||
|
||||
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные
|
||||
|
||||
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
|
||||
const payload = Array.isArray(domains)
|
||||
? domains
|
||||
: Array.isArray(ipRanges)
|
||||
@@ -103,11 +114,13 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
if (!Array.isArray(payload)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
|
||||
// Валидация с помощью AJV
|
||||
if (!validate(payload || [])) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
|
||||
|
||||
// Дополнительная валидация элементов, если предоставлена
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
@@ -116,41 +129,62 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
validationErrors.push(...errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10),
|
||||
errors: validationErrors.slice(0, 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
// Проверка ETag для optimistic concurrency
|
||||
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 {
|
||||
current = await headS3ObjectEtag(s3Key);
|
||||
} catch (_) {}
|
||||
|
||||
} catch {}
|
||||
|
||||
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
const { headMeta } = require('../services/s3Service');
|
||||
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);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
|
||||
error: String(error?.message || error)
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать роуты для текстовых данных (GET + POST)
|
||||
*/
|
||||
function createTextDataRoutes(config) {
|
||||
const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
|
||||
const {
|
||||
s3Key,
|
||||
mapLine,
|
||||
formatLine,
|
||||
validate,
|
||||
validateItem,
|
||||
cachePrefix
|
||||
} = config;
|
||||
|
||||
return {
|
||||
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,3 +193,4 @@ module.exports = {
|
||||
createTextDataGET,
|
||||
createTextDataPOST,
|
||||
};
|
||||
|
||||
|
||||
+95
-100
@@ -15,16 +15,9 @@ const crypto = require('crypto');
|
||||
// Импорт модулей
|
||||
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
||||
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
||||
const { createTextDataRoutes } = require('./routes/textDataRoutes');
|
||||
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
||||
const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes');
|
||||
const evobgpClient = require('./services/evobgpClient');
|
||||
const {
|
||||
createEvobgpListGET,
|
||||
createEvobgpDomainsNewPOST,
|
||||
createEvobgpAsnsPOST,
|
||||
createEvobgpIpRangesPOST,
|
||||
createEvobgpLegacyDomainsGET,
|
||||
} = require('./routes/evobgpReferenceRoutes');
|
||||
const { splitWhitespace } = require('./utils/helpers');
|
||||
const validators = require('./lib/validators');
|
||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
||||
|
||||
@@ -238,100 +231,99 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
|
||||
const validateAsns = ajv.compile(schemaAsns);
|
||||
const validateIpRanges = ajv.compile(schemaIpRanges);
|
||||
|
||||
// === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
|
||||
const asnValidateItem = (asn, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const domainsOldRoutes = {
|
||||
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
|
||||
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||
};
|
||||
// === Domains (старая версия) ===
|
||||
const domainsOldRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
cachePrefix: 'domains'
|
||||
});
|
||||
|
||||
app.get('/api/domains', domainsOldRoutes.get);
|
||||
app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
|
||||
app.post('/api/domains', domainsOldRoutes.post);
|
||||
|
||||
// === ASNs (EvoBGP) ===
|
||||
const asnsRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'asns',
|
||||
validate: validateAsns,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchAsnsData();
|
||||
return rows.map((r) => ({ domain: r.asn, type: r.community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||
};
|
||||
// === ASNs ===
|
||||
const asnsRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/asns.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
validateItem: (asn, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'asns'
|
||||
});
|
||||
|
||||
app.get('/api/asns', asnsRoutes.get);
|
||||
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
||||
|
||||
// === Domains New (EvoBGP) ===
|
||||
const domainsNewRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'domains-new',
|
||||
validate: validateDomainsNew,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchDomainsData();
|
||||
return rows.map(({ domain, community }) => ({ domain, community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpDomainsNewPOST({
|
||||
validate: validateDomainsNew,
|
||||
validateItem: (d, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
}),
|
||||
};
|
||||
// === Domains New ===
|
||||
const domainsNewRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains_community.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
|
||||
validate: validateDomainsNew,
|
||||
validateItem: (d, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'domains-new'
|
||||
});
|
||||
|
||||
app.get('/api/domains-new', domainsNewRoutes.get);
|
||||
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
||||
|
||||
// === IP Ranges (EvoBGP) ===
|
||||
const ipRangesRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'ip-ranges',
|
||||
validate: validateIpRanges,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchIpRangesData();
|
||||
return rows.map(({ ipRange, community }) => ({ ipRange, community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpIpRangesPOST({
|
||||
validate: validateIpRanges,
|
||||
validateItem: (ip, i) => {
|
||||
const errors = [];
|
||||
const ipStr = String(ip.ipRange || '').trim();
|
||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||
if (!isValidCIDR && !isValidIP) {
|
||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
}),
|
||||
};
|
||||
// === IP Ranges ===
|
||||
const ipRangesRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/ips.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
|
||||
validate: validateIpRanges,
|
||||
validateItem: (ip, i) => {
|
||||
const errors = [];
|
||||
const ipStr = String(ip.ipRange || '').trim();
|
||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||
|
||||
if (!isValidCIDR && !isValidIP) {
|
||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'ip-ranges'
|
||||
});
|
||||
|
||||
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
||||
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
||||
@@ -368,21 +360,24 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) =>
|
||||
app.get('/api/billing', billingRoutes.get);
|
||||
app.post('/api/billing', billingRoutes.post);
|
||||
|
||||
// Filters (SQL app_filter_rules, scope global)
|
||||
const filterItemValidator = (filter, i) => {
|
||||
// Filters
|
||||
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => {
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return `Filter at index ${i} is missing required fields`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
|
||||
});
|
||||
|
||||
app.get('/api/filters', filtersRoutes2.get);
|
||||
app.post('/api/filters', filtersRoutes2.post);
|
||||
|
||||
// Simple Filters (app_filter_rules, scope simple)
|
||||
const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
|
||||
// Simple Filters
|
||||
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => {
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return `Filter at index ${i} is missing required fields`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
app.get('/api/simple-filters', simpleFiltersRoutes.get);
|
||||
app.post('/api/simple-filters', simpleFiltersRoutes.post);
|
||||
@@ -513,7 +508,7 @@ app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
||||
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
|
||||
app.get('/api/alerts', alertsRoutes.getAlerts);
|
||||
|
||||
// === MIKROTIK BACKUPS (локальная ФС) ===
|
||||
// === MIKROTIK BACKUPS (S3) ===
|
||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
||||
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Бэкапы 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,
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -1,426 +0,0 @@
|
||||
/**
|
||||
* Серверный клиент 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,
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Глобальные и 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,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
* Работает внутри Node-процесса backend:
|
||||
* - периодически обходит jumphost-сервера из servers.json
|
||||
* - вызывает RouterOS REST API /rest/export (compact)
|
||||
* - сохраняет конфиг на диск через saveBackupForServer
|
||||
* - сохраняет конфиг в S3 через saveBackupForServer
|
||||
*
|
||||
* Управляется через переменные окружения:
|
||||
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
|
||||
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
|
||||
|
||||
log.info(
|
||||
{ component: 'mikrotik-backup', serverId: id, key: result.key },
|
||||
'Automatic backup saved to disk',
|
||||
'Automatic backup saved to S3',
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
|
||||
+240
-117
@@ -1,35 +1,58 @@
|
||||
/**
|
||||
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
|
||||
* Справочники EvoBGP не хранятся здесь — см. evobgpClient.js
|
||||
* Сервис для работы с S3 (Yandex Object Storage)
|
||||
* Централизованные операции чтения/записи/кэширования
|
||||
*/
|
||||
|
||||
const { openDatabase } = require('../db/sqliteDb');
|
||||
const blobStorage = require('./blobStorage');
|
||||
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
openDatabase();
|
||||
// S3 Client setup
|
||||
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.SQLITE_BUCKET_LABEL || 'sqlite';
|
||||
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
|
||||
|
||||
// In-memory cache
|
||||
const cache = {
|
||||
text: new Map(),
|
||||
head: new Map(),
|
||||
max: 100,
|
||||
ttlMs: 30_000,
|
||||
ttlMs: 30_000
|
||||
};
|
||||
|
||||
/**
|
||||
* Конвертирует stream в строку
|
||||
*/
|
||||
async function streamToString(stream) {
|
||||
if (!stream) return '';
|
||||
if (typeof stream.transformToString === 'function') {
|
||||
return await stream.transformToString();
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
});
|
||||
if (!stream) return '';
|
||||
if (typeof stream.transformToString === 'function') {
|
||||
return await stream.transformToString();
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
let chunks = [];
|
||||
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить значение из кэша
|
||||
*/
|
||||
function getCache(map, key) {
|
||||
const v = map.get(key);
|
||||
if (!v) return null;
|
||||
@@ -40,6 +63,9 @@ function getCache(map, key) {
|
||||
return v.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить значение в кэш
|
||||
*/
|
||||
function setCache(map, key, value) {
|
||||
if (map.size >= cache.max) {
|
||||
const firstKey = map.keys().next().value;
|
||||
@@ -48,115 +74,213 @@ function setCache(map, key, value) {
|
||||
map.set(key, { value, at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кэш для ключа
|
||||
*/
|
||||
function invalidateCacheForKey(key) {
|
||||
try {
|
||||
cache.text.delete(key);
|
||||
} catch (_) {}
|
||||
try {
|
||||
cache.head.delete(key);
|
||||
} catch (_) {}
|
||||
try { cache.text.delete(key); } catch {}
|
||||
try { cache.head.delete(key); } catch {}
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Прочитать текстовый объект из 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 {
|
||||
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
return {
|
||||
etag: h.ETag || null,
|
||||
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null,
|
||||
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { etag: null, lastModified: null, contentLength: null };
|
||||
}
|
||||
const out = {
|
||||
body: row.body,
|
||||
etag: row.etag,
|
||||
lastModified: row.lastModified,
|
||||
contentLength: row.contentLength,
|
||||
};
|
||||
setCache(cache.text, key, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать текстовый объект в S3
|
||||
*/
|
||||
async function writeS3TextObject(key, content, contentType = 'text/plain') {
|
||||
await s3.send(new PutObjectCommand({
|
||||
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;
|
||||
}
|
||||
|
||||
async function headS3ObjectEtag(key) {
|
||||
const cached = getCache(cache.head, key);
|
||||
if (cached && cached.etag) return cached.etag;
|
||||
const etag = blobStorage.headEtag(key);
|
||||
if (etag) setCache(cache.head, key, { etag });
|
||||
return etag;
|
||||
}
|
||||
|
||||
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 };
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let total = 0;
|
||||
const items = [];
|
||||
let sent = 0;
|
||||
let buffered = '';
|
||||
|
||||
const matchesQuery = (line) => {
|
||||
if (!q) return true;
|
||||
return line.toLowerCase().includes(String(q).toLowerCase());
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
const stream = resp.Body;
|
||||
|
||||
if (!stream || typeof stream.on !== 'function') {
|
||||
const text = await streamToString(resp.Body);
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = String(lines[i] || '').trim();
|
||||
if (!line) continue;
|
||||
if (!matchesQuery(line)) continue;
|
||||
total++;
|
||||
const pos = total - 1;
|
||||
if (limit > 0) {
|
||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
}
|
||||
return resolve({ items, total });
|
||||
}
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
buffered += chunk.toString('utf-8');
|
||||
let lines = buffered.split('\n');
|
||||
buffered = lines.pop();
|
||||
for (const lnRaw of lines) {
|
||||
const line = lnRaw.trim();
|
||||
if (!line) continue;
|
||||
if (!matchesQuery(line)) continue;
|
||||
total++;
|
||||
const pos = total - 1;
|
||||
if (limit > 0) {
|
||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
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 = {
|
||||
s3,
|
||||
BUCKET_NAME,
|
||||
@@ -170,6 +294,5 @@ module.exports = {
|
||||
streamPaginatedText,
|
||||
invalidateCacheForKey,
|
||||
listS3Objects,
|
||||
listBlobVersionsForKey,
|
||||
rollbackBlobVersion,
|
||||
};
|
||||
|
||||
|
||||
@@ -39,15 +39,14 @@ function mapAjvErrors(errors) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты).
|
||||
* Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют.
|
||||
* Маппинг UI ресурсов на S3 ключи (для истории версий)
|
||||
*/
|
||||
function resourceToKey(resource) {
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
return 'servers.json';
|
||||
default:
|
||||
return null;
|
||||
case 'domains-new': return 'bgp_data/domains_community.txt';
|
||||
case 'ip-ranges': return 'bgp_data/ips.txt';
|
||||
case 'asns': return 'bgp_data/asns.txt';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
-45
@@ -1,37 +1,42 @@
|
||||
/**
|
||||
* Универсальные хелперы для JSON/текста из локального storage (SQLite)
|
||||
* Универсальные хелперы для работы с S3
|
||||
* Устраняют дублирование кода в routes
|
||||
*/
|
||||
|
||||
const { headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
/**
|
||||
* Универсальный GET для JSON объектов с заголовками
|
||||
* Универсальный GET для JSON объектов из S3 с заголовками
|
||||
* @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 = {}) {
|
||||
const { transform = (data) => data, defaultValue = [] } = options;
|
||||
|
||||
|
||||
try {
|
||||
const head = await headMeta(s3Key);
|
||||
const etag = head?.etag || null;
|
||||
|
||||
// Получаем HEAD для установки заголовков
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).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 (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-None-Match
|
||||
if (checkIfNoneMatch(req, res, etag)) return;
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(transform(defaultValue));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Получаем объект
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fileContent);
|
||||
@@ -42,47 +47,55 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
||||
console.error(`Error parsing ${s3Key}:`, parseError);
|
||||
parsed = defaultValue;
|
||||
}
|
||||
|
||||
|
||||
// Применяем трансформацию
|
||||
const result = transform(parsed);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(defaultValue);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальный GET для текстовых объектов с заголовками
|
||||
* Универсальный GET для текстовых объектов из S3 с заголовками
|
||||
* @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 = {}) {
|
||||
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
|
||||
|
||||
|
||||
try {
|
||||
const head = await headMeta(s3Key);
|
||||
const etag = head?.etag || null;
|
||||
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).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 (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;
|
||||
|
||||
let text;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
text = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(transform(defaultValue));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||
const text = await streamToString(data.Body);
|
||||
|
||||
const result = transform(text);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(transform(defaultValue));
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,3 +103,4 @@ module.exports = {
|
||||
getS3JsonWithHeaders,
|
||||
getS3TextWithHeaders,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user