feat(remote-speakers): enhance remote speaker management and API integration
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added support for remote speaker configuration in the README and documentation. - Implemented a new endpoint for retrieving the bundle signing public key. - Updated the `evobgp-agent` to include a `serve` command for Panel→Node sync API. - Enhanced CI workflow to validate remote speaker compose files. - Introduced new fields in the API and UI for managing speaker metadata, including dispatch status and sync status. - Improved error handling and response formatting in speaker-related API endpoints. - Updated documentation to reflect changes in remote speaker functionality and usage guidelines.
This commit is contained in:
@@ -44,6 +44,8 @@ git.shts.su/<owner>/<имя>:sha-<full-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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] <command>\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "Commands:\n")
|
||||
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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) |
|
||||
|
||||
+14
-1
@@ -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 "<node_token>" -speaker-id "<uuid>"
|
||||
```
|
||||
|
||||
## 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 <agent_secret>`. На реплике — `EVOBGP_AGENT_SECRET`, Traefik `PANEL_IP_WHITELIST`. Подробнее: [remote-speakers.md](remote-speakers.md).
|
||||
|
||||
## CORS для веб-интерфейса
|
||||
|
||||
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 и создайте БД (или используйте существующую).
|
||||
|
||||
@@ -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 на реплике |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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<string, unknown>;
|
||||
agent_secret?: string;
|
||||
};
|
||||
export type SpeakersResponse = Page<SpeakerRow>;
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string;
|
||||
role?: string;
|
||||
meta_json?: string;
|
||||
};
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
|
||||
|
||||
export type BundleSigningPublicKey = {
|
||||
public_key_base64: string;
|
||||
};
|
||||
|
||||
// ---- Revisions ----
|
||||
export type RevisionRow = {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
@@ -15,8 +15,10 @@
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
@@ -24,6 +26,7 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
@@ -35,41 +38,209 @@
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
type SpeakerForm = {
|
||||
endpoint: string;
|
||||
role: string;
|
||||
agent_domain: string;
|
||||
node_ipv4: string;
|
||||
bird_bgp_source_ipv4: string;
|
||||
bgpSourceManual: boolean;
|
||||
};
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let wizardOpen = $state(false);
|
||||
let applyDialogOpen = $state(false);
|
||||
let composeDialogOpen = $state(false);
|
||||
let editTarget = $state<SpeakerRow | null>(null);
|
||||
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
||||
let applyTarget = $state<SpeakerRow | null>(null);
|
||||
let composeTarget = $state<SpeakerRow | null>(null);
|
||||
let applyRevisionId = $state('');
|
||||
let composeText = $state('');
|
||||
let createdSpeaker = $state<SpeakerRow | null>(null);
|
||||
let form = $state<SpeakerForm>({
|
||||
endpoint: '',
|
||||
role: 'replica',
|
||||
agent_domain: '',
|
||||
node_ipv4: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bgpSourceManual: false
|
||||
});
|
||||
let saving = $state(false);
|
||||
let applyingId = $state<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{ id: 'status', label: 'Статус' },
|
||||
{
|
||||
id: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
id: 'agent_domain',
|
||||
label: 'Agent domain',
|
||||
sortable: true,
|
||||
sortValue: (s: SpeakerRow) => s.endpoint
|
||||
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
|
||||
},
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
|
||||
{ id: 'actions', label: '', class: 'w-32' }
|
||||
{ id: 'drift', label: 'Drift' },
|
||||
{ id: 'actions', label: '', class: 'w-40' }
|
||||
] as const;
|
||||
|
||||
function parseIpv4FromEndpoint(ep: string): string {
|
||||
try {
|
||||
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
|
||||
const host = u.hostname;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function onNodeIPv4Change(ip: string) {
|
||||
form.node_ipv4 = ip;
|
||||
if (!form.bgpSourceManual) {
|
||||
form.bird_bgp_source_ipv4 = ip;
|
||||
}
|
||||
}
|
||||
|
||||
function onEndpointChange(ep: string) {
|
||||
form.endpoint = ep;
|
||||
const ip = parseIpv4FromEndpoint(ep);
|
||||
if (ip && !form.node_ipv4) {
|
||||
onNodeIPv4Change(ip);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyForm(): SpeakerForm {
|
||||
return {
|
||||
endpoint: '',
|
||||
role: 'replica',
|
||||
agent_domain: '',
|
||||
node_ipv4: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bgpSourceManual: false
|
||||
};
|
||||
}
|
||||
|
||||
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
|
||||
return {
|
||||
endpoint: s.endpoint,
|
||||
role: s.role,
|
||||
agent_domain: s.agent_domain ?? '',
|
||||
node_ipv4: s.node_ipv4 ?? '',
|
||||
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
|
||||
bgpSourceManual: Boolean(s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4)
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetaJson(f: SpeakerForm): string {
|
||||
const meta: Record<string, string> = {};
|
||||
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
|
||||
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
|
||||
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
|
||||
return JSON.stringify(meta);
|
||||
}
|
||||
|
||||
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
|
||||
const ep =
|
||||
f.endpoint.trim() ||
|
||||
(f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
|
||||
return {
|
||||
endpoint: ep,
|
||||
role: f.role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(f)
|
||||
};
|
||||
}
|
||||
|
||||
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
|
||||
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function statusLabel(s: SpeakerRow): string {
|
||||
if (s.sync_status === 'synced') return 'Connected';
|
||||
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
|
||||
if (s.dispatch_status === 'ok') return 'Synced';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function driftLabel(s: SpeakerRow): string {
|
||||
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||
return `${app} / ${pub}`;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { endpoint: '', role: 'operator' };
|
||||
form = emptyForm();
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(s: SpeakerRow) {
|
||||
editTarget = s;
|
||||
form = { endpoint: s.endpoint, role: s.role };
|
||||
form = formFromSpeaker(s);
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
async function applySpeaker(id: string) {
|
||||
applyingId = id;
|
||||
function openApply(s: SpeakerRow) {
|
||||
applyTarget = s;
|
||||
applyRevisionId = s.published_revision_id ?? '';
|
||||
applyDialogOpen = true;
|
||||
}
|
||||
|
||||
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
|
||||
let pubkey = '';
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
|
||||
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
|
||||
pubkey = pk.public_key_base64;
|
||||
} catch {
|
||||
pubkey = '<GET /v1/bundle/signing-public-key>';
|
||||
}
|
||||
const domain = s.agent_domain ?? 'bgp-dc.example.com';
|
||||
return `# deploy/compose/docker-compose.remote-speaker.yaml
|
||||
# cp .env.remote-speaker.example .env.remote-speaker
|
||||
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
|
||||
EVOBGP_SPEAKER_ID=${s.id}
|
||||
EVOBGP_AGENT_SECRET=<from UI wizard>
|
||||
EVOBGP_NODE_TOKEN=<node API key from /access>
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
|
||||
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
|
||||
|
||||
AGENT_DOMAIN=${domain}
|
||||
PANEL_IP_WHITELIST=<CP public IP>/32
|
||||
LETSENCRYPT_EMAIL=ops@example.com
|
||||
CF_DNS_API_TOKEN=<cloudflare token>
|
||||
|
||||
# docker compose -f docker-compose.remote-speaker.yaml \\
|
||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
|
||||
# --profile production up -d`;
|
||||
}
|
||||
|
||||
async function openCompose(s: SpeakerRow) {
|
||||
composeTarget = s;
|
||||
composeText = await buildComposeSnippet(s);
|
||||
composeDialogOpen = true;
|
||||
}
|
||||
|
||||
async function copyCompose() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(composeText);
|
||||
notify.success('Скопировано');
|
||||
} catch {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
|
||||
async function applySpeaker() {
|
||||
if (!applyTarget || !applyRevisionId.trim()) {
|
||||
notify.error('Укажите revision_id');
|
||||
return;
|
||||
}
|
||||
applyingId = applyTarget.id;
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
|
||||
revision_id: applyRevisionId.trim()
|
||||
});
|
||||
notify.success('Apply запущен');
|
||||
applyDialogOpen = false;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
@@ -78,20 +249,25 @@
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint');
|
||||
const body = buildApiBody(form);
|
||||
if (!body.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint или agent domain');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
|
||||
notify.success('Спикер обновлён');
|
||||
dialogOpen = false;
|
||||
} else {
|
||||
await apiMutate('/v1/speakers', 'POST', form);
|
||||
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
|
||||
notify.success('Спикер создан');
|
||||
dialogOpen = false;
|
||||
createdSpeaker = created;
|
||||
composeText = await buildComposeSnippet(created);
|
||||
wizardOpen = true;
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
@@ -99,6 +275,17 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAgentSecret() {
|
||||
const secret = createdSpeaker?.agent_secret;
|
||||
if (!secret) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
notify.success('agent_secret скопирован');
|
||||
} catch {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
@@ -107,7 +294,7 @@
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
||||
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
|
||||
<CardDescription>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
@@ -121,28 +308,32 @@
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
|
||||
emptyDescription="Добавьте реплику для применения signed bundle."
|
||||
>
|
||||
{#snippet cell({ row: s, column })}
|
||||
{#if column.id === 'endpoint'}
|
||||
<span class="font-mono text-sm">{s.endpoint}</span>
|
||||
{#if column.id === 'status'}
|
||||
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
||||
{:else if column.id === 'agent_domain'}
|
||||
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
{:else if column.id === 'last_applied_revision_id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
|
||||
{:else if column.id === 'drift'}
|
||||
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
|
||||
{driftLabel(s)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
||||
<Copy class="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
title="Запустить применение ревизии на спикере"
|
||||
onclick={() => applySpeaker(s.id)}
|
||||
title="Apply revision (canary)"
|
||||
onclick={() => openApply(s)}
|
||||
disabled={applyingId === s.id}
|
||||
>
|
||||
<Play class="size-3" />
|
||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||
<Pencil class="size-3.5" />
|
||||
@@ -155,16 +346,48 @@
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Endpoint" id="s-endpoint" required>
|
||||
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
|
||||
<FormField label="Agent domain (FQDN)" id="s-domain">
|
||||
<AppInput
|
||||
id="s-domain"
|
||||
placeholder="bgp-dc2.example.com"
|
||||
bind:value={form.agent_domain}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" id="s-endpoint">
|
||||
<AppInput
|
||||
id="s-endpoint"
|
||||
placeholder="https://bgp-dc2.example.com"
|
||||
value={form.endpoint}
|
||||
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="IP ноды (IPv4)" id="s-node-ip">
|
||||
<AppInput
|
||||
id="s-node-ip"
|
||||
placeholder="203.0.113.10"
|
||||
value={form.node_ipv4}
|
||||
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="BGP source IPv4" id="s-bgp-src">
|
||||
<AppInput
|
||||
id="s-bgp-src"
|
||||
placeholder="= IP ноды"
|
||||
bind:value={form.bird_bgp_source_ipv4}
|
||||
disabled={!form.bgpSourceManual}
|
||||
/>
|
||||
</FormField>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox bind:checked={form.bgpSourceManual} />
|
||||
Задать BGP source вручную
|
||||
</label>
|
||||
<FormField label="Роль" id="s-role">
|
||||
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
|
||||
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -175,3 +398,67 @@
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={wizardOpen}>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Спикер создан</DialogTitle>
|
||||
<DialogDescription>
|
||||
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{#if createdSpeaker?.agent_secret}
|
||||
<FormField label="agent_secret (один раз)" id="w-secret">
|
||||
<div class="flex gap-2">
|
||||
<AppInput id="w-secret" readonly value={createdSpeaker.agent_secret} class="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
|
||||
</div>
|
||||
</FormField>
|
||||
{/if}
|
||||
<FormField label="docker-compose env" id="w-compose">
|
||||
<textarea
|
||||
id="w-compose"
|
||||
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||
readonly
|
||||
value={composeText}
|
||||
></textarea>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
|
||||
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={applyDialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Apply на спикер</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormField label="revision_id" id="a-rev" required>
|
||||
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={composeDialogOpen}>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Copy docker-compose</DialogTitle>
|
||||
<DialogDescription>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||
readonly
|
||||
value={composeText}
|
||||
></textarea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
|
||||
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user