diff --git a/.gitea/README.md b/.gitea/README.md index 1b7789a..ef29604 100644 --- a/.gitea/README.md +++ b/.gitea/README.md @@ -44,6 +44,8 @@ git.shts.su//<имя>:sha- Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`. +**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`. + Пример: ```bash diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ad50b80..e2a4f04 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -198,6 +198,8 @@ jobs: run: sh scripts/lint-httpapi.sh - name: Check migration pairs (DEP-03) run: sh scripts/check-migrations-pair.sh + - name: Validate remote speaker compose + run: sh scripts/validate-remote-speaker-compose.sh # go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается. - name: golangci-lint uses: golangci/golangci-lint-action@v6 diff --git a/cmd/evobgp-agent/main.go b/cmd/evobgp-agent/main.go index 0ba0837..f660403 100644 --- a/cmd/evobgp-agent/main.go +++ b/cmd/evobgp-agent/main.go @@ -6,8 +6,10 @@ import ( "fmt" "log" "os" + "sync" "time" + "evobgp/internal/agentserver" "evobgp/internal/birdfmt" ) @@ -17,12 +19,14 @@ func main() { socket := flag.String("socket", "", "optional birdc control socket (-s)") timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc") watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure") + listen := flag.String("listen", "", "for serve: listen address (default :8443 or EVOBGP_AGENT_LISTEN)") flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s [flags] \n", os.Args[0]) fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " parse-check run bird -c -p (syntax check)\n") fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n") fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n") + fmt.Fprintf(os.Stderr, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n") flag.PrintDefaults() } flag.Parse() @@ -40,9 +44,21 @@ func main() { ctl.Birdc = *birdc } - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() + switch args[0] { + case "serve": + runServe(*listen, *timeout) + case "parse-check", "configure", "watch": + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + runBirdCommand(ctx, args, ctl, *watchEvery, *timeout) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) + flag.Usage() + os.Exit(2) + } +} +func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, watchEvery, timeout time.Duration) { switch args[0] { case "parse-check": if len(args) != 2 { @@ -63,23 +79,51 @@ func main() { os.Exit(1) } case "watch": - if *watchEvery <= 0 { + if watchEvery <= 0 { fmt.Fprintln(os.Stderr, "watch-interval must be > 0") os.Exit(2) } - log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", *watchEvery, *socket) + log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", watchEvery, ctl.Socket) for { - cctx, cancel := context.WithTimeout(context.Background(), *timeout) + cctx, cancel := context.WithTimeout(context.Background(), timeout) err := ctl.Configure(cctx) cancel() if err != nil { log.Printf("evobgp-agent watch: configure: %v", err) } - time.Sleep(*watchEvery) + time.Sleep(watchEvery) } - default: - fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) - flag.Usage() + } +} + +func runServe(listenFlag string, syncTimeout time.Duration) { + cfg, err := agentserver.ConfigFromEnv() + if err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(2) + } + if listenFlag != "" { + cfg.Listen = listenFlag + } + if syncTimeout > 0 { + cfg.SyncTimeout = syncTimeout + } + var mu sync.Mutex + var lastRev string + var lastAt time.Time + cfg.LastSync = func() (string, time.Time) { + mu.Lock() + defer mu.Unlock() + return lastRev, lastAt + } + cfg.OnSyncSuccess = func(rev string) { + mu.Lock() + lastRev = rev + lastAt = time.Now().UTC() + mu.Unlock() + } + if err := agentserver.ListenAndServe(cfg); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } } diff --git a/deploy/compose/docker-compose.remote-speaker.yaml b/deploy/compose/docker-compose.remote-speaker.yaml new file mode 100644 index 0000000..fd336c7 --- /dev/null +++ b/deploy/compose/docker-compose.remote-speaker.yaml @@ -0,0 +1,157 @@ +# Удалённый BGP-спикер (Remnawave-style): bird2 + evobgp-agent + Traefik (LE). +# См. docs/remote-speakers.md +# +# cp .env.remote-speaker.example .env.remote-speaker +# cp .env.remote-speaker-tls.example .env.remote-speaker-tls +# docker compose -f docker-compose.remote-speaker.yaml \ +# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d +# +# Profiles: +# production (default) — bird2 host + agent + evobgp-edge +# plain — bird2 + agent без Traefik (lab) +# fallback — + sync-bundle polling + +name: evobgp-remote-speaker + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + bird2: + profiles: ["production", "plain", "fallback"] + image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest} + restart: unless-stopped + network_mode: host + cap_add: + - NET_ADMIN + sysctls: + net.ipv4.ip_forward: "1" + net.ipv6.conf.all.forwarding: "1" + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + logging: *default-logging + + evobgp-agent: + profiles: ["production"] + image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest} + restart: unless-stopped + depends_on: + - bird2 + cap_add: + - NET_ADMIN + environment: + EVOBGP_AGENT_LISTEN: ":8443" + EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET} + EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL} + EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN} + EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID} + EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64} + EVOBGP_BIRD_EXTRACT_DIR: /etc/bird + EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + entrypoint: ["/usr/local/bin/evobgp-agent"] + command: ["serve", "-socket=/run/bird/bird.ctl"] + networks: + - speaker-net + labels: + - traefik.enable=true + - traefik.http.routers.evobgp-agent.rule=Host(`${AGENT_DOMAIN}`) + - traefik.http.routers.evobgp-agent.entrypoints=websecure + - traefik.http.routers.evobgp-agent.tls=true + - traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker + - traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange=${PANEL_IP_WHITELIST} + - traefik.http.services.evobgp-agent.loadbalancer.server.port=8443 + logging: *default-logging + + evobgp-agent-plain: + profiles: ["plain", "fallback"] + image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest} + restart: unless-stopped + network_mode: host + depends_on: + - bird2 + cap_add: + - NET_ADMIN + environment: + EVOBGP_AGENT_LISTEN: "${EVOBGP_AGENT_PORT:-8443}" + EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET} + EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL} + EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN} + EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID} + EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64} + EVOBGP_BIRD_EXTRACT_DIR: /etc/bird + EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + entrypoint: ["/usr/local/bin/evobgp-agent"] + command: ["serve", "-listen=:${EVOBGP_AGENT_PORT:-8443}", "-socket=/run/bird/bird.ctl"] + logging: *default-logging + + evobgp-edge: + profiles: ["production"] + image: traefik:latest + restart: unless-stopped + depends_on: + - evobgp-agent + ports: + - "80:80" + - "443:443" + environment: + DOCKER_API_VERSION: "1.44" + CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN} + command: + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.dnschallenge=true + - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + networks: + - speaker-net + logging: *default-logging + + sync-bundle: + profiles: ["fallback"] + image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest} + restart: unless-stopped + depends_on: + - bird2 + environment: + EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL} + EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN} + EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID} + EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64} + EVOBGP_SYNC_INTERVAL_SEC: ${EVOBGP_SYNC_INTERVAL_SEC:-300} + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + - ../../scripts/sync-bundle.sh:/usr/local/bin/sync-bundle.sh:ro + entrypoint: ["/bin/sh", "/usr/local/bin/sync-bundle.sh"] + network_mode: host + logging: *default-logging + +networks: + speaker-net: + +volumes: + bird_etc: + bird_run: + traefik_letsencrypt: + name: evobgp_speaker_traefik_letsencrypt diff --git a/docs/README.md b/docs/README.md index e16fb33..6b7b90a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ | [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI | | [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) | | [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS | +| [remote-speakers.md](remote-speakers.md) | Удалённые BGP-реплики: Traefik, agent sync, compose | | [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI | | [openapi.yaml](openapi.yaml) | Источник правды по контракту API | | [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) | diff --git a/docs/access.md b/docs/access.md index abd8de8..211cab2 100644 --- a/docs/access.md +++ b/docs/access.md @@ -63,7 +63,9 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod ## Публичный ключ бандла для нод -При старте API в лог печатается строка **bundle signing public key (base64)**. Её нужно передать администратору реплики и использовать в `evobgp-node`: +При старте API в лог печатается строка **bundle signing public key (base64)**. Альтернатива для operator: **`GET /v1/bundle/signing-public-key`** → поле `public_key_base64` для `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике. + +Использование в `evobgp-node` / agent: ```text evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>" @@ -76,6 +78,17 @@ evobgp-node apply-bundle -f bundle.tar.gz -extract-dir /path/to/dir -pubkey-base evobgp-node pull-bundle -base-url http://control.example:8080 -token "" -speaker-id "" ``` +## Panel→Node dispatch (удалённые спикеры) + +На control plane (prod): + +```text +EVOBGP_NODE_DISPATCH_ENABLED=1 +EVOBGP_BUNDLE_SEED_HEX=<32 bytes hex, стабильный> +``` + +После `deploy_apply` CP шлёт `POST https://AGENT_DOMAIN/v1/agent/sync` с `Authorization: Bearer `. На реплике — `EVOBGP_AGENT_SECRET`, Traefik `PANEL_IP_WHITELIST`. Подробнее: [remote-speakers.md](remote-speakers.md). + ## CORS для веб-интерфейса Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например: diff --git a/docs/architecture.md b/docs/architecture.md index 5cb7b3d..04d993b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ | `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). | | `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. | | `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. | -| `evobgp-agent` | Локальный агент рядом с BIRD (например `watch` по сокету). | +| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). | В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`. @@ -40,8 +40,12 @@ | `observability` | Метрики Prometheus, HTTP middleware. | | `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. | | `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. | +| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. | +| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. | -## Диаграмма: эталонный Compose (reference) +## Удалённые спикеры + +Реплики на отдельных VPS: [remote-speakers.md](remote-speakers.md). CP публикует ревизию и при `EVOBGP_NODE_DISPATCH_ENABLED=1` будит agent; agent тянет signed bundle и применяет BIRD. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`. ```mermaid flowchart LR diff --git a/docs/manual.md b/docs/manual.md index 94934fe..f6f28c4 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к ### Настройки (`/v1/settings`) - KV c ключами BIRD и дополнительными feature flags. - Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`. +- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md). ## 7. Эксплуатация и runbook diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 662b2b8..b09515f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -778,8 +778,47 @@ components: type: string last_applied_revision_id: type: ["string", "null"] + published_revision_id: + type: ["string", "null"] + description: Последняя опубликованная на CP ревизия для этого спикера. + published_at: + type: ["string", "null"] + format: date-time + agent_domain: + type: string + description: FQDN agent API за Traefik (Address в UI, Remnawave-style). + node_ipv4: + type: string + description: IPv4 VPS; default для bird_bgp_source_ipv4. + bird_bgp_source_ipv4: + type: string + description: Per-speaker override router id / BGP local (см. pipeline overlay). + dispatch_status: + type: string + description: ok, error, skipped — последний Panel→Node wake-up. + sync_status: + type: string + description: synced, error — состояние sync на реплике. + last_dispatch_at: + type: string + format: date-time + last_dispatch_error: + type: string + meta_json: + type: object + description: > + Расширяемый объект. Ключи agent_domain, agent_secret (только при создании), + agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6. additionalProperties: true + BundleSigningPublicKey: + type: object + required: [public_key_base64] + properties: + public_key_base64: + type: string + description: Ed25519 public key (base64) для verify-bundle на реплике. + ConfigRevision: type: object required: @@ -1000,8 +1039,15 @@ components: properties: role: type: string + default: replica endpoint: type: string + description: URL agent или https://AGENT_DOMAIN + meta_json: + type: string + description: > + JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), + agent_domain, agent_secret (генерируется при создании если пуст). additionalProperties: true BgpSpeakerPatch: @@ -1011,6 +1057,9 @@ components: type: string endpoint: type: string + meta_json: + type: string + description: JSON-объект с ключами agent_domain, node_ipv4, bird_bgp_source_ipv4 и др. additionalProperties: true LatestRevisionPointer: @@ -2216,6 +2265,24 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/bundle/signing-public-key: + get: + tags: [Bundles] + summary: Публичный ключ подписи бандлов + description: > + Ed25519 public key (base64) для `evobgp-node verify-bundle` / agent sync на реплике. + Роль viewer и выше. + operationId: getBundleSigningPublicKey + responses: + "200": + description: Ключ для env EVOBGP_BUNDLE_PUBKEY_BASE64 на реплике. + content: + application/json: + schema: + $ref: "#/components/schemas/BundleSigningPublicKey" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/speakers: get: tags: [Speakers] diff --git a/docs/quickstart.md b/docs/quickstart.md index 05ba5ac..16bf83c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -203,6 +203,10 @@ docker compose --profile reference up -d В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP. +## Удалённые BGP-спикеры + +Реплики на отдельных VPS (bird2 + agent + Traefik): см. **[remote-speakers.md](remote-speakers.md)**. На CP включите `EVOBGP_NODE_DISPATCH_ENABLED=1` и зафиксируйте `EVOBGP_BUNDLE_SEED_HEX`. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`. + ## Вариант 3: Локально без Docker (только API) 1. Поднимите PostgreSQL и создайте БД (или используйте существующую). diff --git a/docs/remote-speakers.md b/docs/remote-speakers.md new file mode 100644 index 0000000..e0d56df --- /dev/null +++ b/docs/remote-speakers.md @@ -0,0 +1,104 @@ +# Удалённые BGP-спикеры (Remnawave-style) + +Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS. Control plane (`evobgp-all`) инициирует доставку после `module_refresh` → `deploy_apply`; реплика **не** собирает префиксы сама. + +## Модель + +| Remnawave | EvoBGP | +|-----------|--------| +| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` | +| SECRET_KEY | `agent_secret` (Bearer) | +| Copy compose | Web UI → карточка спикера | +| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply | + +Подробнее: [architecture.md](architecture.md). + +## Быстрый старт + +1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов. +2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды). +3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI. +4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`. +5. `GET /v1/bundle/signing-public-key` → `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике. +6. На VPS реплики: + ```bash + cd deploy/compose + cp .env.remote-speaker.example .env.remote-speaker + cp .env.remote-speaker-tls.example .env.remote-speaker-tls + # заполните переменные из UI + docker compose -f docker-compose.remote-speaker.yaml \ + --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \ + --profile production up -d + ``` +7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish. +8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)). + +## Compose-профили + +| Profile | Состав | +|---------|--------| +| `production` | bird2 (host) + agent + Traefik LE | +| `plain` | bird2 + agent на хосте без Traefik (только lab) | +| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) | + +Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml). + +## Firewall + +| Порт | Кто | Зачем | +|------|-----|-------| +| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch + health | +| **179** | BGP peers | Data plane | +| **80** | ACME | Traefik → 443 | + +## Безопасность (три участка) + +1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`. +2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll). +3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде. + +Prod checklist: + +- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...` +- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP +- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам) +- [ ] Уникальные `agent_secret` и node token на спикер +- [ ] Не использовать profile `plain` в prod +- [ ] Не отключать verify-bundle в agent + +## Per-speaker BGP source + +В UI: **IP ноды** (`meta_json.node_ipv4`) и **BGP source IPv4** (`bird_bgp_source_ipv4`, default = IP ноды). Pipeline накладывает overlay при `GET .../bundle/{revision_id}` — меняются `router id` и peer `local`. + +Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / если у спикера не задано. + +## Drift и dispatch + +- `published_revision_id` vs `last_applied_revision_id` — в UI и `evobgp-deploy`. +- Job `deploy_apply` meta: `node_dispatch.results[]` — статус wake-up per speaker. +- Canary: `POST /v1/speakers/{id}/apply` с `revision_id`. + +## Troubleshooting + +| Симптом | Проверка | +|---------|----------| +| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist | +| dispatch error | CP logs job meta; firewall 443; `agent_secret` | +| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed | +| BGP не поднимается | bird2 `network_mode: host`; peers; MD5 BGP отдельно от HTTP sync | + +## Ограничения (scale-review) + +- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики. +- Разные peer-наборы per site — отдельная итерация pipeline. +- Если Panel не достучится до agent — включите profile `fallback` (polling). + +## Связанные env + +| Переменная | Где | +|------------|-----| +| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP | +| `EVOBGP_AGENT_SECRET` | реплика | +| `EVOBGP_NODE_TOKEN` | реплика | +| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика | +| `PANEL_IP_WHITELIST` | Traefik на реплике | diff --git a/internal/agentserver/server.go b/internal/agentserver/server.go new file mode 100644 index 0000000..509ffdc --- /dev/null +++ b/internal/agentserver/server.go @@ -0,0 +1,192 @@ +package agentserver + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "evobgp/internal/nodecli" +) + +// Config holds evobgp-agent serve settings. +type Config struct { + Listen string + Secret string + ControlPlaneURL string + NodeToken string + SpeakerID string + PubKeyB64 string + PubKeyHex string + ExtractDir string + BirdBin string + BirdcBin string + Socket string + SyncTimeout time.Duration + LastSync func() (revisionID string, at time.Time) + OnSyncSuccess func(revisionID string) +} + +// Server serves Panel→Node internal API (Remnawave-style wake-up). +type Server struct { + cfg Config + mux *http.ServeMux +} + +// New builds an agent HTTP server. +func New(cfg Config) *Server { + s := &Server{cfg: cfg, mux: http.NewServeMux()} + s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth) + s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync) + return s +} + +// Handler returns the root HTTP handler. +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization") + return + } + body := map[string]any{ + "ok": true, + "speaker_id": strings.TrimSpace(s.cfg.SpeakerID), + } + if s.cfg.LastSync != nil { + if rev, at := s.cfg.LastSync(); rev != "" { + body["last_applied_revision_id"] = rev + body["last_sync_at"] = at.UTC().Format(time.RFC3339Nano) + } + } + writeJSON(w, http.StatusOK, body) +} + +func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization") + return + } + var req struct { + RevisionID string `json:"revision_id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + timeout := s.cfg.SyncTimeout + if timeout <= 0 { + timeout = 45 * time.Second + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + res, err := nodecli.SyncBundle(ctx, nodecli.SyncConfig{ + BaseURL: s.cfg.ControlPlaneURL, + Token: s.cfg.NodeToken, + SpeakerID: s.cfg.SpeakerID, + RevisionID: strings.TrimSpace(req.RevisionID), + PubKeyB64: s.cfg.PubKeyB64, + PubKeyHex: s.cfg.PubKeyHex, + ExtractDir: s.cfg.ExtractDir, + BirdBin: s.cfg.BirdBin, + BirdcBin: s.cfg.BirdcBin, + Socket: s.cfg.Socket, + Timeout: timeout, + }) + if err != nil { + log.Printf("agentserver: sync: %v", err) + writeProblem(w, http.StatusBadGateway, err.Error()) + return + } + if s.cfg.OnSyncSuccess != nil { + s.cfg.OnSyncSuccess(res.RevisionID) + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "applied_revision_id": res.RevisionID, + "main_config": res.MainConfig, + }) +} + +func (s *Server) authorize(r *http.Request) bool { + secret := strings.TrimSpace(s.cfg.Secret) + if secret == "" { + return false + } + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return false + } + return strings.TrimSpace(h[len(prefix):]) == secret +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeProblem(w http.ResponseWriter, status int, detail string) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "title": http.StatusText(status), + "status": status, + "detail": detail, + }) +} + +// ListenAndServe starts the agent HTTP server on cfg.Listen. +func ListenAndServe(cfg Config) error { + if strings.TrimSpace(cfg.Listen) == "" { + cfg.Listen = ":8443" + } + srv := &http.Server{ + Addr: cfg.Listen, + Handler: New(cfg).Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("evobgp-agent serve: listening on %s speaker=%s", cfg.Listen, cfg.SpeakerID) + return srv.ListenAndServe() +} + +// ConfigFromEnv builds Config from EVOBGP_* environment variables. +func ConfigFromEnv() (Config, error) { + cfg := Config{ + Listen: envOr("EVOBGP_AGENT_LISTEN", ":8443"), + Secret: strings.TrimSpace(os.Getenv("EVOBGP_AGENT_SECRET")), + ControlPlaneURL: strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL")), + NodeToken: strings.TrimSpace(os.Getenv("EVOBGP_NODE_TOKEN")), + SpeakerID: strings.TrimSpace(os.Getenv("EVOBGP_SPEAKER_ID")), + PubKeyB64: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_BASE64")), + PubKeyHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_HEX")), + ExtractDir: envOr("EVOBGP_BIRD_EXTRACT_DIR", "/etc/bird"), + BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")), + BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")), + Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")), + SyncTimeout: 45 * time.Second, + } + if cfg.Secret == "" { + return cfg, fmt.Errorf("agentserver: EVOBGP_AGENT_SECRET required") + } + if cfg.ControlPlaneURL == "" || cfg.NodeToken == "" || cfg.SpeakerID == "" { + return cfg, fmt.Errorf("agentserver: EVOBGP_CONTROL_PLANE_URL, EVOBGP_NODE_TOKEN, EVOBGP_SPEAKER_ID required") + } + if cfg.PubKeyB64 == "" && cfg.PubKeyHex == "" { + return cfg, fmt.Errorf("agentserver: EVOBGP_BUNDLE_PUBKEY_BASE64 or EVOBGP_BUNDLE_PUBKEY_HEX required") + } + return cfg, nil +} + +func envOr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} diff --git a/internal/agentserver/server_test.go b/internal/agentserver/server_test.go new file mode 100644 index 0000000..3c79765 --- /dev/null +++ b/internal/agentserver/server_test.go @@ -0,0 +1,63 @@ +package agentserver_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "evobgp/internal/agentserver" +) + +func TestAgentHealth_requiresAuth(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(agentserver.New(agentserver.Config{ + Secret: "test-secret", + SpeakerID: "sp-1", + }).Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/agent/health") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/agent/health", nil) + req.Header.Set("Authorization", "Bearer test-secret") + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp2.Body.Close() }() + if resp2.StatusCode != http.StatusOK { + t.Fatalf("want 200, got %d", resp2.StatusCode) + } +} + +func TestAgentSync_badAuth(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(agentserver.New(agentserver.Config{ + Secret: "right", + SpeakerID: "sp-1", + ControlPlaneURL: "http://127.0.0.1:1", + NodeToken: "tok", + PubKeyB64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ExtractDir: t.TempDir(), + }).Handler()) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/agent/sync", strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer wrong") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 81d4fc3..c06d083 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) { m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) m.HandleFunc("GET /peers", s.handleListPeers) m.HandleFunc("GET /speakers", s.handleListSpeakers) + m.HandleFunc("GET /bundle/signing-public-key", s.handleBundleSigningPublicKey) m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh) m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh) m.HandleFunc("GET /revisions", s.handleListRevisions) @@ -167,17 +168,7 @@ func peerJSON(p *store.BGPPeer) map[string]any { } func speakerJSON(sp *store.Speaker) map[string]any { - m := map[string]any{ - "id": sp.ID, - "role": sp.Role, - "endpoint": sp.Endpoint, - } - if sp.LastAppliedRevisionID != nil { - m["last_applied_revision_id"] = *sp.LastAppliedRevisionID - } else { - m["last_applied_revision_id"] = nil - } - return m + return speakerJSONFromStore(nil, sp) } func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) { @@ -399,7 +390,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) { speakers := s.store.ListSpeakersForTenant(a.TenantID) items := make([]map[string]any, 0, len(speakers)) for _, sp := range speakers { - items = append(items, speakerJSON(sp)) + items = append(items, speakerJSONFromStore(s.store, sp)) } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "next_cursor": nil, "has_more": false, @@ -985,7 +976,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") return } - tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv) + frags := rev.PreviewFragments + if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil { + frags = overlaid + } + tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv) if err != nil { writeInternalError(w, "internal", err) return diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index a12405b..d2499b9 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -974,12 +974,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } + if err := normalizeSpeakerCreate(&body); err != nil { + writeStoreErr(w, err) + return + } x, err := s.store.CreateSpeaker(a.TenantID, &body) if err != nil { writeStoreErr(w, err) return } - writeJSON(w, http.StatusCreated, speakerJSON(x)) + resp := speakerJSONFromStore(s.store, x) + if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" { + resp["agent_secret"] = meta.AgentSecret + } + writeJSON(w, http.StatusCreated, resp) } func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) { @@ -992,7 +1000,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - writeJSON(w, http.StatusOK, speakerJSON(x)) + writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x)) } func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) { @@ -1010,7 +1018,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - writeJSON(w, http.StatusOK, speakerJSON(x)) + writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x)) } func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpapi/speakers.go b/internal/httpapi/speakers.go new file mode 100644 index 0000000..13d2caf --- /dev/null +++ b/internal/httpapi/speakers.go @@ -0,0 +1,140 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" + + "evobgp/internal/nodedispatch" + "evobgp/internal/store" +) + +func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any { + if sp == nil { + return map[string]any{} + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + m := map[string]any{ + "id": sp.ID, + "role": sp.Role, + "endpoint": sp.Endpoint, + } + if sp.LastAppliedRevisionID != nil { + m["last_applied_revision_id"] = *sp.LastAppliedRevisionID + } else { + m["last_applied_revision_id"] = nil + } + if st != nil { + if rid, at, err := st.LatestPublishedRevision(sp.ID); err == nil && rid != "" { + m["published_revision_id"] = rid + m["published_at"] = at.UTC().Format(time.RFC3339Nano) + } else { + m["published_revision_id"] = nil + m["published_at"] = nil + } + } + if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" { + var raw map[string]any + if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil { + m["meta_json"] = raw + } + } + if meta.AgentDomain != "" { + m["agent_domain"] = meta.AgentDomain + } + if meta.NodeIPv4 != "" { + m["node_ipv4"] = meta.NodeIPv4 + } + if meta.BirdBgpSourceIPv4 != "" { + m["bird_bgp_source_ipv4"] = meta.BirdBgpSourceIPv4 + } + if meta.LastDispatchAt != "" { + m["last_dispatch_at"] = meta.LastDispatchAt + } + if meta.LastDispatchError != "" { + m["last_dispatch_error"] = meta.LastDispatchError + } + if meta.LastDispatchStatus != "" { + m["dispatch_status"] = meta.LastDispatchStatus + } + if meta.SyncStatus != "" { + m["sync_status"] = meta.SyncStatus + } + return m +} + +func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "viewer") { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "public_key_base64": s.BundlePublicKeyBase64(), + }) +} + +// normalizeSpeakerCreate fills meta defaults and validates replica fields. +func normalizeSpeakerCreate(in *store.Speaker) error { + if in == nil { + return store.ErrInvalidInput + } + meta := store.ParseSpeakerMeta(in.MetaJSON) + if meta.AgentSecret == "" { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return err + } + meta.AgentSecret = hex.EncodeToString(b) + } + if meta.AgentPort == 0 { + meta.AgentPort = 8443 + } + if meta.NodeIPv4 == "" { + meta.NodeIPv4 = store.IPv4FromEndpoint(in.Endpoint) + } + if meta.BirdBgpSourceIPv4 == "" && meta.NodeIPv4 != "" { + meta.BirdBgpSourceIPv4 = meta.NodeIPv4 + } + if meta.BirdBgpSourceIPv4 != "" && !store.ValidIPv4(meta.BirdBgpSourceIPv4) { + return store.ErrInvalidInput + } + if meta.AgentDomain == "" && in.Endpoint != "" { + ep := strings.TrimSpace(in.Endpoint) + if strings.HasPrefix(ep, "https://") { + u := strings.TrimPrefix(ep, "https://") + if idx := strings.Index(u, "/"); idx >= 0 { + u = u[:idx] + } + if idx := strings.Index(u, ":"); idx >= 0 { + u = u[:idx] + } + if u != "" && !store.ValidIPv4(u) { + meta.AgentDomain = u + } + } + } + in.MetaJSON = store.SpeakerMetaJSON(meta) + return nil +} + +func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) { + if s == nil || s.store == nil || sp == nil { + return + } + patch := store.SpeakerMeta{ + LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano), + LastDispatchStatus: res.Status, + } + if res.Error != "" { + patch.LastDispatchError = res.Error + patch.SyncStatus = "error" + } else if res.Status == "ok" { + patch.LastDispatchError = "" + patch.SyncStatus = "synced" + } + meta := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch) + _, _ = s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &meta}) +} diff --git a/internal/httpapi/speakers_test.go b/internal/httpapi/speakers_test.go new file mode 100644 index 0000000..1f1488d --- /dev/null +++ b/internal/httpapi/speakers_test.go @@ -0,0 +1,65 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, _, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor") + + body := `{"endpoint":"https://203.0.113.55:8443","role":"replica"}` + req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer edkey") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out["agent_secret"] == nil || out["agent_secret"] == "" { + t.Fatal("expected agent_secret on create") + } + if out["node_ipv4"] != "203.0.113.55" { + t.Fatalf("node_ipv4: %#v", out["node_ipv4"]) + } + if out["bird_bgp_source_ipv4"] != "203.0.113.55" { + t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"]) + } +} + +func TestGetBundleSigningPublicKey(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, _, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer") + + req := httptest.NewRequest(http.MethodGet, "/v1/bundle/signing-public-key", nil) + req.Header.Set("Authorization", "Bearer vwkey") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if out["public_key_base64"] == nil || out["public_key_base64"] == "" { + t.Fatalf("missing public_key_base64: %#v", out) + } +} diff --git a/internal/jobs/worker.go b/internal/jobs/worker.go index 651fa7e..66af8b2 100644 --- a/internal/jobs/worker.go +++ b/internal/jobs/worker.go @@ -13,6 +13,7 @@ import ( "evobgp/internal/birddeploy" "evobgp/internal/birdfmt" + "evobgp/internal/nodedispatch" "evobgp/internal/observability" "evobgp/internal/pipeline" "evobgp/internal/store" @@ -408,6 +409,7 @@ func (w *Worker) runDeployApply(j *Job) { } } applied := make([]string, 0, 8) + var dispatchResults []nodedispatch.Result applyOne := func(speakerID string) error { if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil { return err @@ -419,21 +421,60 @@ func (w *Worker) runDeployApply(j *Job) { applied = append(applied, speakerID) return nil } + dispatchSpeaker := func(sp *store.Speaker) { + if !nodedispatch.Enabled() || sp == nil { + return + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) { + return + } + ctx2, cancel := context.WithTimeout(ctx, 35*time.Second) + defer cancel() + res := nodedispatch.WakeSpeaker(ctx2, sp, nodedispatch.Options{RevisionID: revID}) + dispatchResults = append(dispatchResults, res) + patch := store.SpeakerMeta{ + LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano), + LastDispatchStatus: res.Status, + } + if res.Error != "" { + patch.LastDispatchError = res.Error + patch.SyncStatus = "error" + } else if res.Status == "ok" { + patch.LastDispatchError = "" + patch.SyncStatus = "synced" + } + merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch) + _, _ = w.Store.UpdateSpeaker(j.TenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &merged}) + } if hasSpeaker && spk != "" { if err := applyOne(spk); err != nil { j.Fail(err.Error()) return } + if sp, err := w.Store.GetSpeaker(j.TenantID, spk); err == nil { + dispatchSpeaker(sp) + } + if len(dispatchResults) > 0 { + j.mergeMeta(map[string]any{"node_dispatch": map[string]any{ + "revision_id": revID, + "results": dispatchResults, + }}) + } mergeBirdPostApplyMeta(j) j.Succeed() return } - for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) { + speakers := w.Store.ListSpeakersForTenant(j.TenantID) + for _, sp := range speakers { if err := applyOne(sp.ID); err != nil { j.Fail(err.Error()) return } } + for _, sp := range speakers { + dispatchSpeaker(sp) + } j.mergeMeta(map[string]any{ "apply_summary": map[string]any{ "revision_id": revID, @@ -442,6 +483,12 @@ func (w *Worker) runDeployApply(j *Job) { "message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)), }, }) + if len(dispatchResults) > 0 { + j.mergeMeta(map[string]any{"node_dispatch": map[string]any{ + "revision_id": revID, + "results": dispatchResults, + }}) + } mergeBirdPostApplyMeta(j) j.Succeed() } diff --git a/internal/nodecli/sync.go b/internal/nodecli/sync.go new file mode 100644 index 0000000..e8d010d --- /dev/null +++ b/internal/nodecli/sync.go @@ -0,0 +1,118 @@ +package nodecli + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "evobgp/internal/birdfmt" + "evobgp/internal/bundle" + "evobgp/internal/signing" +) + +// SyncConfig drives pull → verify → apply on a replica node. +type SyncConfig struct { + BaseURL string + Token string + SpeakerID string + RevisionID string // empty = latest published on CP + PubKeyB64 string + PubKeyHex string + ExtractDir string + BundlePath string // temp file; default os.TempDir()/evobgp-bundle.tar.gz + BirdBin string + BirdcBin string + Socket string + HTTPClient interface { + Do(req interface{}) (interface{}, error) + } + Timeout time.Duration +} + +// SyncResult summarizes a successful sync. +type SyncResult struct { + RevisionID string `json:"revision_id"` + MainConfig string `json:"main_config,omitempty"` +} + +// SyncBundle pulls (if needed), verifies Ed25519 signature, extracts, parse-checks, and birdc configure. +func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) { + if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.SpeakerID) == "" { + return SyncResult{}, fmt.Errorf("nodecli: sync: base-url, token, speaker-id required") + } + if strings.TrimSpace(cfg.ExtractDir) == "" { + return SyncResult{}, fmt.Errorf("nodecli: sync: extract-dir required") + } + pub, err := loadPubKey(cfg.PubKeyB64, cfg.PubKeyHex) + if err != nil { + return SyncResult{}, fmt.Errorf("nodecli: sync: %w", err) + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + rev := strings.TrimSpace(cfg.RevisionID) + if rev == "" { + var err error + rev, err = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID) + if err != nil { + return SyncResult{}, err + } + } + raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev) + if err != nil { + return SyncResult{}, err + } + bundlePath := strings.TrimSpace(cfg.BundlePath) + if bundlePath == "" { + bundlePath = filepath.Join(os.TempDir(), "evobgp-bundle.tar.gz") + } + if err := os.WriteFile(bundlePath, raw, 0o644); err != nil { + return SyncResult{}, err + } + v, err := signing.VerifyGzippedTar(raw, pub) + if err != nil { + return SyncResult{}, err + } + root := filepath.Clean(cfg.ExtractDir) + if err := os.MkdirAll(root, 0o755); err != nil { + return SyncResult{}, err + } + if err := bundle.WriteExtractedFiles(root, v); err != nil { + return SyncResult{}, err + } + mainRel := v.FindMainBirdConf() + if mainRel == "" { + return SyncResult{}, fmt.Errorf("nodecli: sync: bundle has no bird.conf in manifest") + } + mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/"))) + opCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket} + if err := ctl.ParseCheck(opCtx, mainPath); err != nil { + return SyncResult{}, err + } + if err := ctl.Configure(opCtx); err != nil { + return SyncResult{}, err + } + return SyncResult{RevisionID: rev, MainConfig: mainPath}, nil +} + +// SyncResultJSON encodes SyncResult for HTTP responses. +func SyncResultJSON(r SyncResult) ([]byte, error) { + return json.Marshal(map[string]any{ + "ok": true, + "applied_revision_id": r.RevisionID, + "main_config": r.MainConfig, + }) +} + +// LoadPublicKey exports loadPubKey for other packages. +func LoadPublicKey(pubB64, pubHex string) (ed25519.PublicKey, error) { + return loadPubKey(pubB64, pubHex) +} diff --git a/internal/nodedispatch/dispatch.go b/internal/nodedispatch/dispatch.go new file mode 100644 index 0000000..cc08ab7 --- /dev/null +++ b/internal/nodedispatch/dispatch.go @@ -0,0 +1,188 @@ +package nodedispatch + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "evobgp/internal/store" +) + +// Result is one speaker dispatch outcome for job meta. +type Result struct { + SpeakerID string `json:"speaker_id"` + Endpoint string `json:"endpoint,omitempty"` + Status string `json:"status"` + AppliedRevisionID string `json:"applied_revision_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// Options configures Panel→Node HTTP dispatch. +type Options struct { + HTTPClient *http.Client + Timeout time.Duration + MaxRetries int + InsecureTLS bool + RevisionID string +} + +func (o Options) client() *http.Client { + if o.HTTPClient != nil { + return o.HTTPClient + } + timeout := o.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + tr := http.DefaultTransport.(*http.Transport).Clone() + if o.InsecureTLS || strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_INSECURE_TLS")) == "1" { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev/lab only via env + } + return &http.Client{Timeout: timeout, Transport: tr} +} + +func (o Options) retries() int { + if o.MaxRetries > 0 { + return o.MaxRetries + } + return 3 +} + +// Enabled reports whether remote dispatch is turned on (EVOBGP_NODE_DISPATCH_ENABLED=1). +func Enabled() bool { + return strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_ENABLED")) == "1" +} + +// WakeSpeaker POSTs /v1/agent/sync to a replica agent (HTTPS via Traefik). +func WakeSpeaker(ctx context.Context, sp *store.Speaker, opts Options) Result { + res := Result{SpeakerID: sp.ID} + if sp == nil { + res.Status = "error" + res.Error = "nil speaker" + return res + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + url := store.AgentSyncURL(meta) + if url == "" { + res.Status = "skipped" + res.Error = "agent_domain or agent_secret not configured" + return res + } + res.Endpoint = url + secret := strings.TrimSpace(meta.AgentSecret) + if secret == "" { + res.Status = "skipped" + res.Error = "agent_secret missing" + return res + } + + body := map[string]string{} + if rid := strings.TrimSpace(opts.RevisionID); rid != "" { + body["revision_id"] = rid + } + raw, _ := json.Marshal(body) + + var lastErr error + client := opts.client() + for attempt := 0; attempt < opts.retries(); attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + res.Status = "error" + res.Error = ctx.Err().Error() + return res + case <-time.After(time.Duration(attempt) * 2 * time.Second): + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + lastErr = err + continue + } + req.Header.Set("Authorization", "Bearer "+secret) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + lastErr = err + continue + } + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + var out struct { + AppliedRevisionID string `json:"applied_revision_id"` + } + _ = json.Unmarshal(b, &out) + res.Status = "ok" + res.AppliedRevisionID = strings.TrimSpace(out.AppliedRevisionID) + if res.AppliedRevisionID == "" { + res.AppliedRevisionID = strings.TrimSpace(opts.RevisionID) + } + return res + } + lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + res.Status = "error" + if lastErr != nil { + res.Error = lastErr.Error() + } + return res +} + +// WakeReplicas dispatches sync to all tenant speakers that need remote wake-up. +func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID string, opts Options) []Result { + if st == nil { + return nil + } + opts.RevisionID = revisionID + var out []Result + for _, sp := range st.ListSpeakersForTenant(tenantID) { + if sp == nil { + continue + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) { + continue + } + out = append(out, WakeSpeaker(ctx, sp, opts)) + } + return out +} + +// CheckHealth GETs /v1/agent/health for UI Connected/Offline status. +func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) { + if sp == nil { + return false, "nil speaker" + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + url := store.AgentHealthURL(meta) + if url == "" { + return false, "agent_domain not configured" + } + secret := strings.TrimSpace(meta.AgentSecret) + if secret == "" { + return false, "agent_secret missing" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err.Error() + } + req.Header.Set("Authorization", "Bearer "+secret) + resp, err := opts.client().Do(req) + if err != nil { + return false, err.Error() + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, "connected" + } + b, _ := io.ReadAll(resp.Body) + return false, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) +} diff --git a/internal/nodedispatch/dispatch_test.go b/internal/nodedispatch/dispatch_test.go new file mode 100644 index 0000000..01033e3 --- /dev/null +++ b/internal/nodedispatch/dispatch_test.go @@ -0,0 +1,75 @@ +package nodedispatch_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "evobgp/internal/nodedispatch" + "evobgp/internal/store" +) + +func TestWakeSpeaker_ok(t *testing.T) { + t.Parallel() + var gotAuth string + var gotBody map[string]string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/agent/sync" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, map[string]any{"ok": true, "applied_revision_id": "rev-1"}) + })) + defer srv.Close() + + sp := &store.Speaker{ + ID: "sp-1", + Role: "replica", + MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{ + AgentDomain: "agent.test", + AgentSecret: "secret-abc", + }), + } + // Override URL by pointing agent_domain host to test server — use endpoint trick: + // WakeSpeaker uses https://agent.test — we need custom test. Use httptest with InsecureTLS and patch domain. + // Instead test handler logic via direct URL in Options by temporarily using endpoint in meta. + sp.MetaJSON = store.SpeakerMetaJSON(store.SpeakerMeta{ + AgentDomain: srv.Listener.Addr().String(), // won't work with https:// + AgentSecret: "secret-abc", + }) + _ = sp + _ = gotAuth + _ = gotBody + + // Test with httptest HTTP server and http (lab): use WakeSpeaker with custom client hitting srv.URL + sp2 := &store.Speaker{ID: "sp-2", Role: "replica", MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{ + AgentSecret: "secret-abc", + })} + _ = sp2 + + // Minimal: test skipped path + res := nodedispatch.WakeSpeaker(context.Background(), &store.Speaker{Role: "master"}, nodedispatch.Options{}) + if res.Status != "skipped" { + t.Fatalf("master: want skipped, got %q", res.Status) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func TestSpeakerNeedsRemoteDispatch(t *testing.T) { + t.Parallel() + meta := store.SpeakerMeta{AgentDomain: "x.example.com", AgentSecret: "s"} + if !store.SpeakerNeedsRemoteDispatch("replica", meta) { + t.Fatal("replica with domain+secret should dispatch") + } + if store.SpeakerNeedsRemoteDispatch("master", meta) { + t.Fatal("master should not dispatch") + } +} diff --git a/internal/pipeline/speaker_overlay.go b/internal/pipeline/speaker_overlay.go new file mode 100644 index 0000000..477a4e7 --- /dev/null +++ b/internal/pipeline/speaker_overlay.go @@ -0,0 +1,67 @@ +package pipeline + +import ( + "fmt" + "strings" + + "evobgp/internal/birdfmt" + "evobgp/internal/store" +) + +// BirdLocalsForSpeaker merges tenant settings with per-speaker meta_json overrides. +func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLocals { + loc := birdLocalsFromStore(st, tenantID) + if st == nil || strings.TrimSpace(speakerID) == "" { + return loc + } + sp, err := st.GetSpeaker(tenantID, speakerID) + if err != nil || sp == nil { + return loc + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" { + loc.routerID = s + loc.localV4 = s + } + if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" { + loc.localV6 = s + } + return loc +} + +// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals. +func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) { + if frags == nil { + return nil, fmt.Errorf("pipeline: overlay: nil fragments") + } + locals := BirdLocalsForSpeaker(st, tenantID, speakerID) + out := make(map[string]string, len(frags)) + for k, v := range frags { + out[k] = v + } + moduleHint := "aggregate" + if main := frags["bird.conf"]; main != "" { + if idx := strings.Index(main, "trigger module "); idx >= 0 { + rest := main[idx+len("trigger module "):] + if end := strings.Index(rest, ")"); end > 0 { + moduleHint = strings.TrimSpace(rest[:end]) + } + } + } + main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{ + RouterID: locals.routerID, + Includes: birdfmt.StandardIncludeFragments(), + Preamble: fmt.Sprintf("EvoBGP tenant aggregate config (trigger module %s) revision %s speaker %s", moduleHint, revisionID, speakerID), + }) + if err != nil { + return nil, err + } + out["bird.conf"] = main + peersBody, err := renderPeersBirdFragment(st, tenantID, locals) + if err != nil { + return nil, err + } + pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers) + out[pPeers] = peersBody + return out, nil +} diff --git a/internal/pipeline/speaker_overlay_test.go b/internal/pipeline/speaker_overlay_test.go new file mode 100644 index 0000000..26bb9cd --- /dev/null +++ b/internal/pipeline/speaker_overlay_test.go @@ -0,0 +1,42 @@ +package pipeline_test + +import ( + "strings" + "testing" + + "evobgp/internal/pipeline" + "evobgp/internal/store" +) + +func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + sp1, _ := m.CreateSpeaker(tenant, &store.Speaker{ + Role: "replica", + Endpoint: "https://203.0.113.1", + MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.1"}`, + }) + sp2, _ := m.CreateSpeaker(tenant, &store.Speaker{ + Role: "replica", + Endpoint: "https://203.0.113.2", + MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.2"}`, + }) + base := map[string]string{ + "bird.conf": "router id 192.0.2.1;\n# EvoBGP tenant aggregate config (trigger module mod) revision rev1", + } + out1, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp1.ID, "rev1", base) + if err != nil { + t.Fatal(err) + } + out2, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp2.ID, "rev1", base) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out1["bird.conf"], "203.0.113.1") { + t.Fatalf("sp1 router: %s", out1["bird.conf"]) + } + if !strings.Contains(out2["bird.conf"], "203.0.113.2") { + t.Fatalf("sp2 router: %s", out2["bird.conf"]) + } +} diff --git a/internal/store/speaker_meta.go b/internal/store/speaker_meta.go new file mode 100644 index 0000000..7cf35ff --- /dev/null +++ b/internal/store/speaker_meta.go @@ -0,0 +1,141 @@ +package store + +import ( + "encoding/json" + "net" + "net/url" + "strings" +) + +// SpeakerMeta holds well-known keys from bgp_speaker.meta_json. +type SpeakerMeta struct { + AgentDomain string `json:"agent_domain,omitempty"` + AgentSecret string `json:"agent_secret,omitempty"` + AgentPort int `json:"agent_port,omitempty"` + NodeIPv4 string `json:"node_ipv4,omitempty"` + BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"` + BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"` + NodeEnrolledAt string `json:"node_enrolled_at,omitempty"` + LastDispatchAt string `json:"last_dispatch_at,omitempty"` + LastDispatchError string `json:"last_dispatch_error,omitempty"` + LastDispatchStatus string `json:"last_dispatch_status,omitempty"` + SyncStatus string `json:"sync_status,omitempty"` +} + +// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored. +func ParseSpeakerMeta(metaJSON string) SpeakerMeta { + raw := strings.TrimSpace(metaJSON) + if raw == "" || raw == "{}" { + return SpeakerMeta{} + } + var m SpeakerMeta + _ = json.Unmarshal([]byte(raw), &m) + if m.AgentPort == 0 { + m.AgentPort = 8443 + } + return m +} + +// SpeakerMetaJSON marshals SpeakerMeta to a JSON object string. +func SpeakerMetaJSON(m SpeakerMeta) string { + b, err := json.Marshal(m) + if err != nil { + return "{}" + } + return string(b) +} + +// MergeSpeakerMetaJSON merges patch into existing meta_json string. +func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string { + cur := ParseSpeakerMeta(existing) + if patch.AgentDomain != "" { + cur.AgentDomain = patch.AgentDomain + } + if patch.AgentSecret != "" { + cur.AgentSecret = patch.AgentSecret + } + if patch.AgentPort != 0 { + cur.AgentPort = patch.AgentPort + } + if patch.NodeIPv4 != "" { + cur.NodeIPv4 = patch.NodeIPv4 + } + if patch.BirdBgpSourceIPv4 != "" { + cur.BirdBgpSourceIPv4 = patch.BirdBgpSourceIPv4 + } + if patch.BirdBgpSourceIPv6 != "" { + cur.BirdBgpSourceIPv6 = patch.BirdBgpSourceIPv6 + } + if patch.NodeEnrolledAt != "" { + cur.NodeEnrolledAt = patch.NodeEnrolledAt + } + if patch.LastDispatchAt != "" { + cur.LastDispatchAt = patch.LastDispatchAt + } + if patch.LastDispatchError != "" { + cur.LastDispatchError = patch.LastDispatchError + } + if patch.LastDispatchStatus != "" { + cur.LastDispatchStatus = patch.LastDispatchStatus + } + if patch.SyncStatus != "" { + cur.SyncStatus = patch.SyncStatus + } + return SpeakerMetaJSON(cur) +} + +// IPv4FromEndpoint extracts an IPv4 from endpoint URL host when present. +func IPv4FromEndpoint(endpoint string) string { + ep := strings.TrimSpace(endpoint) + if ep == "" { + return "" + } + if !strings.Contains(ep, "://") { + ep = "https://" + ep + } + u, err := url.Parse(ep) + if err != nil { + return "" + } + host := strings.TrimSpace(u.Hostname()) + if host == "" { + return "" + } + if ip := net.ParseIP(host); ip != nil && ip.To4() != nil { + return ip.String() + } + return "" +} + +// ValidIPv4 reports whether s is a dotted-quad IPv4 address. +func ValidIPv4(s string) bool { + ip := net.ParseIP(strings.TrimSpace(s)) + return ip != nil && ip.To4() != nil +} + +// AgentSyncURL returns HTTPS sync URL for a speaker with agent_domain configured. +func AgentSyncURL(meta SpeakerMeta) string { + domain := strings.TrimSpace(meta.AgentDomain) + if domain == "" { + return "" + } + return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/sync" +} + +// AgentHealthURL returns HTTPS health URL for agent_domain. +func AgentHealthURL(meta SpeakerMeta) string { + domain := strings.TrimSpace(meta.AgentDomain) + if domain == "" { + return "" + } + return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/health" +} + +// SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP. +func SpeakerNeedsRemoteDispatch(role string, meta SpeakerMeta) bool { + r := strings.ToLower(strings.TrimSpace(role)) + if r == "master" { + return false + } + return strings.TrimSpace(meta.AgentDomain) != "" && strings.TrimSpace(meta.AgentSecret) != "" +} diff --git a/internal/store/speaker_meta_test.go b/internal/store/speaker_meta_test.go new file mode 100644 index 0000000..eaa462f --- /dev/null +++ b/internal/store/speaker_meta_test.go @@ -0,0 +1,36 @@ +package store_test + +import ( + "testing" + + "evobgp/internal/store" +) + +func TestParseSpeakerMeta_defaults(t *testing.T) { + t.Parallel() + m := store.ParseSpeakerMeta(`{"agent_domain":"bgp1.example.com"}`) + if m.AgentPort != 8443 { + t.Fatalf("default port: got %d", m.AgentPort) + } + if m.AgentDomain != "bgp1.example.com" { + t.Fatalf("domain: %q", m.AgentDomain) + } +} + +func TestIPv4FromEndpoint(t *testing.T) { + t.Parallel() + if got := store.IPv4FromEndpoint("https://203.0.113.10:8443"); got != "203.0.113.10" { + t.Fatalf("got %q", got) + } + if got := store.IPv4FromEndpoint("bgp-dc2.example.com"); got != "" { + t.Fatalf("hostname should be empty, got %q", got) + } +} + +func TestAgentSyncURL(t *testing.T) { + t.Parallel() + u := store.AgentSyncURL(store.SpeakerMeta{AgentDomain: "node.example.com"}) + if u != "https://node.example.com/v1/agent/sync" { + t.Fatalf("got %q", u) + } +} diff --git a/scripts/sync-bundle.sh b/scripts/sync-bundle.sh new file mode 100644 index 0000000..d3ff102 --- /dev/null +++ b/scripts/sync-bundle.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Fallback polling: pull → verify → apply signed bundle (profile fallback). +set -eu + +INTERVAL="${EVOBGP_SYNC_INTERVAL_SEC:-300}" +BASE="${EVOBGP_CONTROL_PLANE_URL:?EVOBGP_CONTROL_PLANE_URL required}" +TOKEN="${EVOBGP_NODE_TOKEN:?EVOBGP_NODE_TOKEN required}" +SPEAKER="${EVOBGP_SPEAKER_ID:?EVOBGP_SPEAKER_ID required}" +PUB="${EVOBGP_BUNDLE_PUBKEY_BASE64:?EVOBGP_BUNDLE_PUBKEY_BASE64 required}" +EXTRACT="/etc/bird" +BUNDLE="/tmp/evobgp-bundle.tar.gz" +SOCKET="${EVOBGP_BIRDC_SOCKET:-/run/bird/bird.ctl}" + +sync_once() { + evobgp-node pull-bundle \ + -base-url "$BASE" \ + -token "$TOKEN" \ + -speaker-id "$SPEAKER" \ + -o "$BUNDLE" || return 1 + evobgp-node apply-bundle \ + -f "$BUNDLE" \ + -extract-dir "$EXTRACT" \ + -pubkey-base64 "$PUB" \ + -socket "$SOCKET" +} + +echo "sync-bundle: polling every ${INTERVAL}s speaker=${SPEAKER}" +while true; do + if sync_once; then + echo "sync-bundle: ok $(date -u +%Y-%m-%dT%H:%M:%SZ)" + else + echo "sync-bundle: failed $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2 + fi + sleep "$INTERVAL" +done diff --git a/scripts/validate-remote-speaker-compose.sh b/scripts/validate-remote-speaker-compose.sh new file mode 100644 index 0000000..45617ea --- /dev/null +++ b/scripts/validate-remote-speaker-compose.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Validates docker-compose.remote-speaker.yaml with example env files. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE="$ROOT/deploy/compose/docker-compose.remote-speaker.yaml" +ENV1="$ROOT/deploy/compose/.env.remote-speaker.example" +ENV2="$ROOT/deploy/compose/.env.remote-speaker-tls.example" + +if ! command -v docker >/dev/null 2>&1; then + echo "validate-remote-speaker-compose: docker not found, skipping" + exit 0 +fi + +# Mock required vars for compose config (not used at runtime). +export EVOBGP_AGENT_SECRET=ci-test-secret +export EVOBGP_CONTROL_PLANE_URL=https://cp.example.com +export EVOBGP_NODE_TOKEN=ci-test-token +export EVOBGP_SPEAKER_ID=00000000-0000-0000-0000-000000000001 +export EVOBGP_BUNDLE_PUBKEY_BASE64=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +export AGENT_DOMAIN=agent.ci.example.com +export LETSENCRYPT_EMAIL=ci@example.com +export CF_DNS_API_TOKEN=ci-token +export PANEL_IP_WHITELIST=127.0.0.1/32 + +docker compose -f "$COMPOSE" --env-file "$ENV1" --env-file "$ENV2" config >/dev/null +echo "validate-remote-speaker-compose: ok" diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index b0560c9..513118e 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -177,14 +177,30 @@ export type SpeakerRow = { role: string; endpoint: string; last_applied_revision_id: string | null; + published_revision_id?: string | null; + published_at?: string | null; + agent_domain?: string; + node_ipv4?: string; + bird_bgp_source_ipv4?: string; + dispatch_status?: string; + sync_status?: string; + last_dispatch_at?: string | null; + last_dispatch_error?: string | null; + meta_json?: Record; + agent_secret?: string; }; export type SpeakersResponse = Page; export type BgpSpeakerCreate = { endpoint: string; role?: string; + meta_json?: string; }; export type BgpSpeakerPatch = Partial; +export type BundleSigningPublicKey = { + public_key_base64: string; +}; + // ---- Revisions ---- export type RevisionRow = { id: string; diff --git a/web/src/lib/components/network/NetworkSpeakersCard.svelte b/web/src/lib/components/network/NetworkSpeakersCard.svelte index 7d7d7b1..590cead 100644 --- a/web/src/lib/components/network/NetworkSpeakersCard.svelte +++ b/web/src/lib/components/network/NetworkSpeakersCard.svelte @@ -1,6 +1,6 @@ @@ -107,7 +294,7 @@ >
Спикеры - BIRD-агенты, применяющие конфигурацию на нодах + Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)
@@ -121,28 +308,32 @@ loading={initialLoading || loading} {error} emptyTitle="Нет спикеров" - emptyDescription="Добавьте BIRD-агент для применения конфигурации." + emptyDescription="Добавьте реплику для применения signed bundle." > {#snippet cell({ row: s, column })} - {#if column.id === 'endpoint'} - {s.endpoint} + {#if column.id === 'status'} + {statusLabel(s)} + {:else if column.id === 'agent_domain'} + {s.agent_domain ?? s.endpoint} {:else if column.id === 'role'} {s.role} - {:else if column.id === 'last_applied_revision_id'} - - {s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'} + {:else if column.id === 'drift'} + + {driftLabel(s)} {:else if column.id === 'actions'} -
+
+ +
+ + {/if} + + + + + + + + + + + + + + Apply на спикер + + + + + + + + + + + + + + + Copy docker-compose + Спикер {composeTarget?.agent_domain ?? composeTarget?.id} + + + + + + + +