--- name: Технический аудит EvoBGP overview: "Полный технический аудит EvoBGP для production-сценария (10+ клиентов, нестабильная сеть). Архитектура — hybrid control plane; сильные стороны: stale fallback, CDN/RIPEstat resilience, Ed25519 bundles. Критичные риски: in-process jobs, DoH без retry, misconfiguration demo-seed, отсутствие HA API." todos: - id: quick-ops-checklist content: Применить production-checklist (SEED_DEMO=0, BUNDLE_SEED_HEX, DB/JOB/CONCURRENCY tuning, TLS) status: pending - id: fix-doh-retry content: Добавить DoWithRetry для DoH в internal/pipeline/refresh.go status: pending - id: fix-job-meta-race content: Исправить чтение j.Meta в worker.go через Snapshot() или locked accessor status: pending - id: cdn-preview-resilience content: Перевести CDN preview на upstreamHTTPDo в routes_crud.go status: pending - id: partial-cdn-failure content: "Partial CDN source failure: skip/degrade вместо fail всего модуля" status: pending - id: graceful-shutdown content: Cancel/drain jobs при SIGTERM в cmd/evobgp-api и evobgp-all status: pending - id: ha-job-queue content: "Roadmap: распределённая очередь jobs (PG claim или NATS) для HA API" status: pending isProject: false --- # Технический аудит EvoBGP ## Executive summary EvoBGP — **hybrid control plane**: один процесс [`evobgp-all`](cmd/evobgp-all/main.go) (monolith) или **reference Compose** с разделёнными воркерами ([`docs/architecture.md`](docs/architecture.md)). Data plane (BIRD + agent) отделён от control plane (API + PostgreSQL + jobs). **Сильные стороны для нестабильной сети:** - Stale snapshot fallback по умолчанию (`EVOBGP_STALE_ON_UPSTREAM_ERROR=1`) — [`internal/pipeline/collect_stale.go`](internal/pipeline/collect_stale.go) - CDN/RIPEstat: retry (3×) + per-host circuit breaker — [`internal/httpclient/httpclient.go`](internal/httpclient/httpclient.go), [`circuit.go`](internal/httpclient/circuit.go) - ETag conditional GET, ASN TTL-кэш, parallel collect с cap - Подписанные бандлы Ed25519, verify перед apply **Главные риски для 10+ клиентов:** 1. `jobs.Registry` — **in-memory, только в процессе API** (ARCH-04) 2. DoH — **без retry/breaker** (критично при блокировках провайдеров) 3. Один failed CDN source **без stale cache валит весь модуль** 4. Production misconfiguration: `Bearer dev`, HTTP API, ephemeral bundle key 5. Data race на `Job.Meta` и alias pointers в `store.Memory` --- ## 1. Архитектура ### Стиль ```mermaid flowchart TB subgraph hybrid [Hybrid deployment] All[evobgp_all monolith] Split[evobgp_api + workers] end subgraph cp [Control plane] API[HTTP API] Jobs[jobs.Registry in-process] PG[(PostgreSQL)] end subgraph dp [Data plane per speaker] Agent[evobgp_agent] BIRD[BIRD2] NodeCLI[evobgp_node] end All --> API Split --> API API --> Jobs API --> PG NodeCLI --> API Agent --> API Agent --> BIRD ``` | Профиль | Стиль | Когда | |---------|-------|-------| | `microvps` / `evobgp-all` | Monolith | 1 VPS, shared Registry | | reference Compose | Microservices-lite | API + scheduler/ingest/render/deploy | | Remote speakers | Edge agents | Panel→Node dispatch | ### Узкие места (bottlenecks) | # | Bottleneck | Где | Impact | |---|------------|-----|--------| | B1 | **In-process job queue** | [`internal/jobs/job.go:175-177`](internal/jobs/job.go) | HA API невозможен без потери/дублирования jobs; scheduler без `EVOBGP_CONTROL_PLANE_URL` создаёт **отдельный Registry** — [`cmd/evobgp-scheduler/main.go:58-60`](cmd/evobgp-scheduler/main.go) | | B2 | **Module refresh = sync upstream fan-out** | [`internal/pipeline/collect_parallel.go`](internal/pipeline/collect_parallel.go) | До `EVOBGP_COLLECT_CONCURRENCY` (8 default, max 32) параллельных HTTP; worst case ~45s × retries на источник | | B3 | **Default job concurrency = 8** | [`internal/jobs/job.go:264-268`](internal/jobs/job.go) | При burst refresh 10+ tenants — очередь растёт, goroutine блокируются на sem | | B4 | **PostgreSQL pool default** | [`internal/db/open.go:28-38`](internal/db/open.go) | pgx default ~4 conns; при `JOB_MAX=16` + HTTP — contention без `EVOBGP_DB_MAX_CONNS=25` | | B5 | **Live endpoints fan-out** | [`internal/httpapi/peers_live.go`](internal/httpapi/peers_live.go) | N goroutines × N speakers, 12s timeout каждый | | B6 | **Broker — заглушка** | [`internal/broker`](internal/broker) | NATS URL логируется, очередь не распределена | ### Масштабируемость - **Вертикальная:** хорошо до ~10–20 tenants при `evobgp-all` + tuning ([`docs/production-checklist.md`](docs/production-checklist.md)) - **Горизонтальная API:** **не поддерживается** — два `evobgp-api` = два независимых Registry; `job_audit` в PG — audit only, не очередь исполнения - **Workers (ingest/render/deploy):** координируются через **общую БД**, не через jobs — OK для prefetch/drift ### Отказоустойчивость | Сценарий | Поведение | Оценка | |----------|-----------|--------| | CDN/RIPEstat недоступен | Stale snapshot + circuit breaker | **Хорошо** (если был prior snapshot) | | DoH недоступен | Fail модуля или stale domain snapshot | **Средне** (нет HTTP retry) | | API restart mid-job | Job теряется из Registry; audit может быть inconsistent | **Плохо** | | PG недоступен | API `/v1/ready` → 503 | **OK** | | Agent unreachable | Deploy job succeed, drift в `evobgp-deploy` | **Частичный fail** (by design) | **Рекомендация:** для 10+ клиентов — **`evobgp-all` на каждом CP** или один CP + tuning; HA API требует **распределённой очереди** (NATS/Redis + worker pool) — задокументировано как future work. --- ## 2. Анализ кода ### Антипаттерны | ID | Проблема | Файл | Критичность | |----|----------|------|-------------| | A1 | **Concurrent map read/write** — worker читает `j.Meta` без lock, handler пишет через `mergeMeta`/`Snapshot` | [`worker.go:108,263,379`](internal/jobs/worker.go), [`job.go:103-111`](internal/jobs/job.go) | **high** | | A2 | **Escape internal pointers** из Memory store | [`store/memory.go:416-475`](internal/store/memory.go) | **high** (tests/dev); **low** (prod PG) | | A3 | **Fire-and-forget goroutine** на каждый auth | [`auth.go:79-81`](internal/httpapi/auth.go) | **medium** | | A4 | **Silent error swallow** в prefetch | [`internal/ingest/run.go`](internal/ingest/run.go), `prefetch.go` | **medium** | | A5 | **Bypass resilience layer** — CDN preview прямой `Do` | [`routes_crud.go:267`](internal/httpapi/routes_crud.go) | **medium** | | A6 | **`EVOBGP_DEV_INSECURE` — dead code** | compose + [`server.go`](internal/httpapi/server.go) | **low** (misleading ops) | | A7 | **Unused Registry** в ingest/render/deploy binaries | [`cmd/evobgp-ingest/main.go`](cmd/evobgp-ingest/main.go) | **low** (resource waste) | ### Maintainability **Плюсы:** чёткое разделение слоёв (ARCH-01..10), `store.Backend`, OpenAPI как контракт, engineering rules, table-driven tests в birdfmt/pipeline. **Минусы:** - Дублирование retry-логики (httpclient vs nodedispatch inline loop) - Env-tuning разбросан (`EVOBGP_*` в 15+ местах без central config struct для pipeline) - `Job` comment «персистенция в БД пока не подключена» устарел — hooks есть в [`bootstrap.go:67-92`](internal/httpapi/bootstrap.go) ### Потенциальные баги и race conditions 1. **`j.Meta` data race** — `-race` на `TestParallelModuleRefresh_*` + concurrent `GET /v1/jobs/{id}` polling 2. **Memory store alias** — `deploy.Run` читает `LastAppliedRevisionID` пока worker пишет 3. **peerLiveCache** возвращает slice без копии — [`peers_live.go:82-84`](internal/httpapi/peers_live.go) 4. **TOCTOU idempotency** — terminal job удаляется из `byIdempo`, повторный POST создаст новый job (by design, но клиент должен знать) ### Error handling **Хорошо:** - Префиксы ошибок (`httpclient:`, `birdfmt:`) - HTTP 5xx через `writeProblem`, без raw `err.Error()` (ERR-01) - `context.Context` в pipeline workers **Пробелы:** - `runRollback` без `workContext` — не отменяется — [`worker.go:500+`](internal/jobs/worker.go) - Prefetch/ingest: ошибки не логируются - `mergeBirdPostApplyMeta` — `context.Background()` 8s, игнорирует job cancel --- ## 3. Производительность ### Блокирующие операции | Участок | Блокировка | Риск | |---------|------------|------| | `POST .../cdn-sources/preview` | Sync CDN fetch до 45s в HTTP handler | UI timeout, worker starvation | | `GET /v1/peers/live` | N × agent HTTP, wg.Wait | Slow при многих speakers | | Module refresh job | Sequential: ingest → render revision → optional deploy | Long job chain | | `bird -p` / `birdc configure` | Subprocess в deploy | Disk I/O на ноде | ### Неэффективные алгоритмы / лишние запросы - **Tenant refresh:** `aggregateTenantPrefixRowsAll` — parallel по модулям, но каждый модуль может refetch все CDN/ASN/DoH — [`aggregate.go:28+`](internal/pipeline/aggregate.go). Snapshot skip есть через `module_hash` — проверять hit rate в meta. - **ASN resolve:** `PolitePause()` 150ms между AS — [`asnresolve/ripestat.go`](internal/asnresolve/ripestat.go) — при 50 AS = +7.5s minimum. - **GetModulePrefixSnapshot** вызывается многократно в одном refresh (cdn_snapshot, collect_parallel) — potential duplicate DB reads. - **Auth TouchAPIKeyLastUsed:** UPDATE на каждый request (async) — load на PG при high RPS. ### Кэширование | Кэш | TTL | Gap | |-----|-----|-----| | ASN prefix cache | 1800s (`EVOBGP_ASN_CACHE_TTL_SEC`) | OK | | CDN ETag in DB | Until 304/change | OK | | Module prefix snapshot | Content-hash based skip | OK | | peerLiveCache | In-memory, per-process | Не shared между API replicas; нет defensive copy | | Circuit breaker state | Per-process | Не shared | ### Конкретные улучшения ```go // 1. CDN preview — использовать upstreamHTTPDo вместо прямого Do resp, err := pipeline.UpstreamHTTPDo(r.Context(), s.cdnHTTP, req) // extract upstreamHTTPDo // 2. Job.Meta — читать под lock или через Snapshot() st := j.Snapshot() mid, _ := st["meta"].(map[string]any)["module_id"].(string) // 3. Memory store — возвращать копии (как Postgres) modCopy := *mod return &modCopy, nil ``` --- ## 4. Сетевое взаимодействие (критично) ### Текущее состояние ```mermaid flowchart LR subgraph resilient [Resilient path] CDN[CDN fetch] RIPE[RIPEstat] CDN --> Breaker[Circuit breaker] RIPE --> Breaker Breaker --> Retry[DoWithRetry 3x linear 2s] end subgraph fragile [Fragile path] DoH[DoH resolve] Preview[CDN preview API] AgentHealth[Agent health/bird] DoH --> SingleDo[Single hc.Do] Preview --> SingleDo AgentHealth --> SingleDo end subgraph fallback [App-level fallback] Stale[Stale snapshot] SysDNS[System DNS] DoH --> SysDNS CDN --> Stale RIPE --> Stale end ``` | Upstream | Timeout | Retry | Breaker | Stale fallback | |----------|---------|-------|---------|----------------| | CDN ingest | 45s | 3× linear | per-host | yes | | RIPEstat | 45s | 3× | per-host | yes + cache | | DoH | 10s/profile | **no** | **no** | domain snapshot | | CDN preview | 45s | **no** | **no** | N/A | | Scheduler→API | 45s | 3× | no | N/A | | Node dispatch | 30s | inline 3× | no | N/A | ### Пробелы для блокировок провайдеров 1. **DoH без retry** — transient timeout = fail; failover между profiles есть, но каждый profile — single shot 2. **429/408 не ретраятся** — только `>= 500` 3. **Нет jitter** — thundering herd при mass tenant refresh 4. **DNS rebinding TOCTOU** — SSRF check до fetch, HTTP dial без pinned IP — [`cdn_url.go:75-115`](internal/pipeline/cdn_url.go) 5. **Circuit breaker без half-open** — после 30s cooldown сразу full traffic — [`circuit.go:29-33`](internal/httpclient/circuit.go) 6. **Breaker per-process** — ingest container ≠ API container ### Рекомендации для нестабильной сети | # | Изменение | Effort | Effect | |---|-----------|--------|--------| | N1 | DoH через `DoWithRetry` + optional breaker | Low | **High** для DOMAINS modules | | N2 | Retry 429/503 с `Retry-After` + exponential backoff + jitter | Medium | **High** при rate limits | | N3 | **Partial CDN failure** — continue с stale per-source, не fail whole module | Medium | **High** | | N4 | Multiple DoH profiles + `failover` policy (already exists) — **документировать ops playbook** | Low | **High** (config, not code) | | N5 | Pinned dialer / custom `Transport.DialContext` после SSRF resolve | Medium | **Medium** (SSRF hardening) | | N6 | Proxy support (`HTTP_PROXY` / `EVOBGP_HTTP_PROXY`) для CDN/DoH | Medium | **High** в censored networks | | N7 | Unify CDN preview на `upstreamHTTPDo` | Low | **Medium** | --- ## 5. Устойчивость и надёжность ### Graceful degradation **Работает:** - `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` — ASN/CDN/domain stale — [`collect_stale.go`](internal/pipeline/collect_stale.go) - CDN 304 без local cache → forced full GET — [`cdn_snapshot.go:141-159`](internal/pipeline/cdn_snapshot.go) - DoH → system DNS fallback — [`doh_resolve.go:75-93`](internal/pipeline/doh_resolve.go) - Deploy: job succeed even if agent wake fails (drift detection) **Не работает / частично:** - Один CDN source fail без cache → **весь module_refresh failed** — [`collect_parallel.go:221-223`](internal/pipeline/collect_parallel.go) - Circuit open → immediate error, stale only if prior data exists - API shutdown: HTTP drain 15s, **jobs не cancel/drain** — [`cmd/evobgp-api/main.go:67-72`](cmd/evobgp-api/main.go) ### Сценарии отказов | Событие | Что произойдёт | |---------|----------------| | **Потеря CP↔PG** | Ready=false; running jobs fail; no new jobs persist audit reliably | | **Потеря CP↔CDN** | Stale prefixes если были; иначе job fail; breaker opens 30s | | **Потеря CP↔agent** | Deploy meta `dispatch_failed`; BIRD на старой ревизии; drift logs | | **RIPEstat rate limit** | 429 → no retry → stale or fail | | **Рост нагрузки** | Job queue; goroutine pile-up; PG pool exhaustion; `/metrics` shows queue depth | | **API restart** | In-flight jobs lost; clients poll 404 or stale terminal state | --- ## 6. Безопасность | ID | Finding | Severity | Fix | |----|---------|----------|-----| | S1 | `Bearer dev` → operator при demo-seed | **high** (misconfig) | `EVOBGP_SEED_DEMO=0` — [`auth.go:66-92`](internal/httpapi/auth.go) | | S2 | API plain HTTP | **high** (ops) | TLS на edge (Traefik/nginx) | | S3 | Ephemeral bundle key без `EVOBGP_BUNDLE_SEED_HEX` | **high** (ops) | Stable seed + pubkey на нодах | | S4 | Compose defaults: weak PG password, `sslmode=disable` | **high** (ops) | Secrets manager, `sslmode=require` | | S5 | `/metrics` без auth | **medium** | Network policy / mTLS | | S6 | No rate limiting on auth | **medium** | Middleware limiter (e.g. per-IP) | | S7 | CDN SSRF DNS rebinding | **medium** | Pinned dialer after resolve | | S8 | `EVOBGP_CDN_ALLOW_PRIVATE=1` | **medium** | Never in prod | | S9 | `EVOBGP_NODE_DISPATCH_INSECURE_TLS=1` | **medium** | Valid TLS to agent | | S10 | Plaintext `EVOBGP_API_KEYS` in env | **medium** | DB keys via API | | S11 | `editor` can cancel jobs | **low** | Restrict to operator | | S12 | agent_secret `==` compare | **low** | `subtle.ConstantTimeCompare` | **SQL injection:** не обнаружено — параметризованные запросы в [`repository/`](internal/repository/). **Bundle crypto:** Ed25519 корректно; path traversal blocked в tar extract. --- ## 7. Конкретные рекомендации (prioritized backlog) ### High | # | Описание | Как исправить | |---|----------|---------------| | H1 | DoH без retry | Обернуть `hc.Do` в `DoWithRetry(ctx, hc, req, 3)` в [`refresh.go:288,360`](internal/pipeline/refresh.go) | | H2 | Data race `Job.Meta` | Читать через `Snapshot()` или добавить `MetaLocked()` accessor | | H3 | CDN source partial failure | В `collectCDNPrefixRows`: при err без stale — log warning + skip source вместо `return nil, r.err` (config flag `EVOBGP_CDN_PARTIAL_OK=1`) | | H4 | Production checklist enforcement | CI/deploy validation: reject `SEED_DEMO=1`, require `BUNDLE_SEED_HEX` | | H5 | Job queue HA roadmap | Persist queued jobs in PG + worker claim (`SELECT FOR UPDATE SKIP LOCKED`) или NATS — ARCH-04 | ### Medium | # | Описание | Как исправить | |---|----------|---------------| | M1 | CDN preview bypass | [`routes_crud.go:267`](internal/httpapi/routes_crud.go) → `upstreamHTTPDo` | | M2 | Retry 429/503 | Extend `DoWithRetry` status check + parse `Retry-After` | | M3 | Graceful shutdown | On SIGTERM: `Registry.RequestCancelAll()` + wait workers with timeout | | M4 | Auth goroutine storm | Worker pool или sync touch with debounce | | M5 | HTTP proxy support | Custom Transport reading `EVOBGP_HTTP_PROXY` | | M6 | Memory store copies | Defensive copy in Get/List (dev/test safety) | | M7 | Rate limiting | `golang.org/x/time/rate` on auth middleware | ### Low | # | Описание | Как исправить | |---|----------|---------------| | L1 | Jitter in backoff | `wait + rand.Intn(wait/2)` in DoWithRetry | | L2 | Half-open breaker | Single probe request after cooldown | | L3 | Remove dead `EVOBGP_DEV_INSECURE` from compose | Docs + compose cleanup | | L4 | Prefetch error logging | `log.Printf` or structured log in prefetch | | L5 | peerLiveCache defensive copy | `append([]T(nil), views...)` on store | --- ## 8. Quick wins (максимальный эффект / минимум усилий) 1. **Ops (0 code):** [`docs/production-checklist.md`](docs/production-checklist.md) — `SEED_DEMO=0`, `BUNDLE_SEED_HEX`, `DB_MAX_CONNS=25`, `JOB_MAX=16`, `COLLECT_CONCURRENCY=16`, TLS edge, restrict metrics 2. **DoH retry** — 5–10 строк в `refresh.go`, reuse existing `DoWithRetry` 3. **CDN preview → upstreamHTTPDo** — 1 line change in handler 4. **Job.Meta read fix** — replace 4 reads in `worker.go` with `Snapshot()` parsing 5. **Log prefetch failures** — visibility без изменения behavior 6. **Document DoH failover playbook** — multiple profiles (Cloudflare, Google, Quad9) + `failover` policy for censored regions 7. **Run `go test -race ./internal/jobs/...`** in CI — catch Meta race 8. **Prefer `evobgp-all`** over split reference for <20 tenants — eliminates Registry split bug --- ## Диаграмма: refresh под сетевым stress ```mermaid sequenceDiagram participant Op as Operator participant API as evobgp_api participant Job as module_refresh participant CDN as CDN_upstream participant PG as PostgreSQL Op->>API: POST /modules/id/refresh API->>Job: Enqueue Job->>CDN: GET with ETag alt CDN timeout or 5xx CDN-->>Job: error after 3 retries Job->>PG: load prior snapshot alt stale exists Job->>PG: CreateRenderRevision stale Job-->>API: succeeded degraded else no stale Job-->>API: failed end else CDN 200 CDN-->>Job: new prefixes Job->>PG: CreateRenderRevision end ``` --- ## Итоговая оценка зрелости | Область | Оценка | Комментарий | |---------|--------|-------------| | Архитектура | 7/10 | Чистые слои; HA/API scaling — слабое место | | Сеть/resilience | 6/10 | CDN/ASN хорошо; DoH/preview — пробелы | | Concurrency | 6/10 | Registry продуман; Meta race, shutdown | | Performance | 7/10 | Parallel collect, caching; tuning needed at scale | | Security | 6/10 | Crypto OK; ops/config risks dominate | | Maintainability | 8/10 | Docs, rules, OpenAPI, tests | **Вердикт:** проект **готов для 10+ клиентов в single-CP deployment** (`evobgp-all` + PostgreSQL + production checklist) при условии ops discipline. Для **multi-CP HA** и **агрессивных сетевых блокировок** — приоритет: DoH retry, partial CDN failure, distributed job queue, HTTP proxy.