Compare commits

...
4 Commits
Author SHA1 Message Date
Denozordec 6fa265a246 docs: update Go code style guidelines and linting requirements
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 36s
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m46s
- Enhanced AGENTS.md to include post-editing commands for Go code, specifying the use of `gofmt -w`, `go vet ./...`, and `scripts/lint-go.ps1`.
- Updated engineering rules in .cursor/rules/engineering.mdc to clarify the linting process and ensure consistency in Go code formatting.
- Improved documentation for CI checks related to Go code style and linting.
2026-05-21 12:50:14 +07:00
Denozordec b8170c4204 chore: update .gitignore and enhance remote speaker compose validation script
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 43s
CI / go (push) Failing after 32s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added support for additional example environment files in .gitignore.
- Improved the validation script for remote speaker compose by checking for the existence of the compose file and allowing for optional merging of example environment files, enhancing local and documentation parity.
2026-05-21 12:46:32 +07:00
Denozordec c6e13bb86b docs: update guidelines for Svelte module changes and linting requirements
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been cancelled
CI / web (push) Has been cancelled
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
- Enhanced documentation in AGENTS.md and web/README.md to clarify the mandatory steps for running `npm run check` and `npm run lint` before finalizing changes in the `web/**` directory.
- Updated engineering rules in .cursor/rules/engineering.mdc to specify the necessity of both commands and the use of Prettier for formatting issues.
- Added detailed instructions for handling linting failures and emphasized the importance of these checks in CI workflows.
2026-05-21 12:45:37 +07:00
Denozordec 2aecbf96fd 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
- 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.
2026-05-21 12:42:06 +07:00
40 changed files with 2157 additions and 71 deletions
+7 -6
View File
@@ -57,9 +57,9 @@ alwaysApply: true
## Code Style ## Code Style
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`. **STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`. Агент после правок Go: `gofmt -w` на изменённых файлах + `golangci-lint run` (или `scripts/lint-go.*`) до exit 0.
*Rationale:* единый стиль. *Rationale:* CI job `go` включает golangci-lint (gofmt).
*Проверка:* CI job `go`. *Проверка:* CI job `go`; `.cursor/rules/engineering.mdc` STYLE-01.
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий. **STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
*Rationale:* навигация по API пакетов. *Rationale:* навигация по API пакетов.
@@ -123,8 +123,8 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`. **TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* CI job `bird2`. *Проверка:* CI job `bird2`.
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`. **TEST-04** | MUST | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`.
*Проверка:* локальные команды. *Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`. **TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
*Проверка:* CI job `openapi`. *Проверка:* CI job `openapi`.
@@ -230,7 +230,8 @@ alwaysApply: true
go vet ./... go vet ./...
go test ./... -race -count=1 go test ./... -race -count=1
npx @redocly/cli lint docs/openapi.yaml npx @redocly/cli lint docs/openapi.yaml
# web: cd web; npm run check; npm run lint # web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
# birdfmt: go test ./internal/birdfmt/... -count=1 # birdfmt: go test ./internal/birdfmt/... -count=1
``` ```
+18 -1
View File
@@ -73,6 +73,15 @@ alwaysApply: false
**WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`. **WEB-15** | MUST | Сомнения — https://shadcn-svelte.com/llms.txt , Svelte MCP, `npm run check`.
*Проверка:* локально. *Проверка:* локально.
**WEB-19** | MUST | **После любого изменения `web/**`** — перед завершением задачи агент **обязан** выполнить в `web/`:
```powershell
npm run check
npm run lint
```
Если `npm run lint` падает (Prettier) — **сначала** `npx prettier --write <изменённые файлы>` или `npx prettier --write .`, затем снова `npm run check` и `npm run lint`. Не сдавать PR/ответ, пока обе команды не exit 0.
*Rationale:* CI job `web` = `check` + `prettier --check`; `svelte-check` не ловит форматирование.
*Проверка:* CI job `web`; pre-commit hook `prettier-web`.
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`. **WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из patterns, не `window.confirm`.
*Проверка:* review. *Проверка:* review.
@@ -95,15 +104,23 @@ Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
## Enforcement ## Enforcement
**Обязательный финальный шаг агента при правках `web/**`:** `npm run check` **и** `npm run lint` (см. **WEB-19**). Только `check` недостаточно.
```powershell ```powershell
cd web cd web
npm run check npm run check
npm run lint npm run lint
# при warn/fail lint:
npx prettier --write .
npm run check
npm run lint
``` ```
**PR checklist `web/**`:** **PR checklist `web/**`:**
- [ ] `npm run check` — exit 0
- [ ] `npm run lint` (prettier --check) — exit 0
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов - [ ] `ui/core` / `ui/patterns`, не дубли примитивов
- [ ] Новые примитивы через shadcn CLI - [ ] Новые примитивы через shadcn CLI
- [ ] Ссылка на docs компонента (если новый паттерн) - [ ] Ссылка на docs компонента (если новый паттерн)
**CI:** job `web` рекомендован; пока обязательно локально. **CI:** job `web` — `npm run check` + `npm run lint`.
+2
View File
@@ -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`. Имена образов: `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 ```bash
+2
View File
@@ -198,6 +198,8 @@ jobs:
run: sh scripts/lint-httpapi.sh run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03) - name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh 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) не запускается. # go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
+1
View File
@@ -16,3 +16,4 @@ Thumbs.db
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.*.example
+10 -1
View File
@@ -51,6 +51,7 @@
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`. - Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md). - Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
- **Go:** после правок — `gofmt -w`, `go vet ./...`, `scripts/lint-go.ps1` (как CI golangci-lint).
## Язык документации проекта ## Язык документации проекта
@@ -58,4 +59,12 @@
## Svelte / фронтенд ## Svelte / фронтенд
При правках `web/**/*.svelte` или Svelte-модулей следуйте навыкам/инструментам проекта (официальный Svelte MCP и скиллы Cursor, если подключены). При правках `web/**/*.svelte` или Svelte-модулей следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
```powershell
cd web
npm run check
npm run lint
```
Если `lint` падает — `npx prettier --write .` и повторить обе команды. CI job `web` не пропускает без этого.
+53 -9
View File
@@ -6,8 +6,10 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"sync"
"time" "time"
"evobgp/internal/agentserver"
"evobgp/internal/birdfmt" "evobgp/internal/birdfmt"
) )
@@ -17,12 +19,14 @@ func main() {
socket := flag.String("socket", "", "optional birdc control socket (-s)") socket := flag.String("socket", "", "optional birdc control socket (-s)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc") 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") 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() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n") 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, " 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, " configure run birdc configure (reload running BIRD)\n")
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\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.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -40,9 +44,21 @@ func main() {
ctl.Birdc = *birdc ctl.Birdc = *birdc
} }
ctx, cancel := context.WithTimeout(context.Background(), *timeout) switch args[0] {
defer cancel() 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] { switch args[0] {
case "parse-check": case "parse-check":
if len(args) != 2 { if len(args) != 2 {
@@ -63,23 +79,51 @@ func main() {
os.Exit(1) os.Exit(1)
} }
case "watch": case "watch":
if *watchEvery <= 0 { if watchEvery <= 0 {
fmt.Fprintln(os.Stderr, "watch-interval must be > 0") fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
os.Exit(2) 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 { for {
cctx, cancel := context.WithTimeout(context.Background(), *timeout) cctx, cancel := context.WithTimeout(context.Background(), timeout)
err := ctl.Configure(cctx) err := ctl.Configure(cctx)
cancel() cancel()
if err != nil { if err != nil {
log.Printf("evobgp-agent watch: configure: %v", err) 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) 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,11 @@
# TLS для Traefik (profile production). Скопируйте в .env.remote-speaker-tls
# FQDN agent API (DNS only в Cloudflare → IP этой VPS)
AGENT_DOMAIN=bgp-dc2.example.com
# Let's Encrypt + Cloudflare DNS challenge (как evobgp-edge на CP)
LETSENCRYPT_EMAIL=[email protected]
CF_DNS_API_TOKEN=
# IP основного сервера (Panel) — единственный источник wake-up / health
PANEL_IP_WHITELIST=203.0.113.1/32
@@ -0,0 +1,22 @@
# Скопируйте в .env.remote-speaker рядом с docker-compose.remote-speaker.yaml
# Значения agent_secret и node token — из Web UI после создания спикера.
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_IMAGE_TAG=latest
# Control plane (HTTPS в prod)
EVOBGP_CONTROL_PLANE_URL=https://cp.example.com:8080
# Из карточки спикера в панели
EVOBGP_SPEAKER_ID=00000000-0000-0000-0000-000000000001
EVOBGP_AGENT_SECRET=change-me-from-ui-once
EVOBGP_NODE_TOKEN=evobgp_node_token_from_access
# GET /v1/bundle/signing-public-key (operator) или env CP EVOBGP_BUNDLE_SEED_HEX
EVOBGP_BUNDLE_PUBKEY_BASE64=
# Fallback polling (profile fallback)
EVOBGP_SYNC_INTERVAL_SEC=300
# Lab profile plain — порт agent на хосте
EVOBGP_AGENT_PORT=8443
@@ -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
+1
View File
@@ -20,6 +20,7 @@
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI | | [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`) | | [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 | | [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
| [remote-speakers.md](remote-speakers.md) | Удалённые BGP-реплики: Traefik, agent sync, compose |
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI | | [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API | | [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) | | [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
+14 -1
View File
@@ -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 ```text
evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>" 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>" 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 для веб-интерфейса ## CORS для веб-интерфейса
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например: Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
+6 -2
View File
@@ -19,7 +19,7 @@
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). | | `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. | | `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. | | `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`. В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
@@ -40,8 +40,12 @@
| `observability` | Метрики Prometheus, HTTP middleware. | | `observability` | Метрики Prometheus, HTTP middleware. |
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. | | `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. | | `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 ```mermaid
flowchart LR flowchart LR
+1
View File
@@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к
### Настройки (`/v1/settings`) ### Настройки (`/v1/settings`)
- KV c ключами BIRD и дополнительными feature flags. - 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`. - Ключевые параметры 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 ## 7. Эксплуатация и runbook
+67
View File
@@ -778,8 +778,47 @@ components:
type: string type: string
last_applied_revision_id: last_applied_revision_id:
type: ["string", "null"] 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 additionalProperties: true
BundleSigningPublicKey:
type: object
required: [public_key_base64]
properties:
public_key_base64:
type: string
description: Ed25519 public key (base64) для verify-bundle на реплике.
ConfigRevision: ConfigRevision:
type: object type: object
required: required:
@@ -1000,8 +1039,15 @@ components:
properties: properties:
role: role:
type: string type: string
default: replica
endpoint: endpoint:
type: string 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 additionalProperties: true
BgpSpeakerPatch: BgpSpeakerPatch:
@@ -1011,6 +1057,9 @@ components:
type: string type: string
endpoint: endpoint:
type: string type: string
meta_json:
type: string
description: JSON-объект с ключами agent_domain, node_ipv4, bird_bgp_source_ipv4 и др.
additionalProperties: true additionalProperties: true
LatestRevisionPointer: LatestRevisionPointer:
@@ -2216,6 +2265,24 @@ paths:
default: default:
$ref: "#/components/responses/DefaultProblem" $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: /v1/speakers:
get: get:
tags: [Speakers] tags: [Speakers]
+4
View File
@@ -203,6 +203,10 @@ docker compose --profile reference up -d
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP. В **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) ## Вариант 3: Локально без Docker (только API)
1. Поднимите PostgreSQL и создайте БД (или используйте существующую). 1. Поднимите PostgreSQL и создайте БД (или используйте существующую).
+104
View File
@@ -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 на реплике |
+192
View File
@@ -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
}
+63
View File
@@ -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)
}
}
+8 -13
View File
@@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers) m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers) 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 /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh) m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions) 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 { func speakerJSON(sp *store.Speaker) map[string]any {
m := map[string]any{ return speakerJSONFromStore(nil, sp)
"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
} }
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) { 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) speakers := s.store.ListSpeakersForTenant(a.TenantID)
items := make([]map[string]any, 0, len(speakers)) items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers { for _, sp := range speakers {
items = append(items, speakerJSON(sp)) items = append(items, speakerJSONFromStore(s.store, sp))
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false, "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") writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return 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 { if err != nil {
writeInternalError(w, "internal", err) writeInternalError(w, "internal", err)
return return
+11 -3
View File
@@ -974,12 +974,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return return
} }
if err := normalizeSpeakerCreate(&body); err != nil {
writeStoreErr(w, err)
return
}
x, err := s.store.CreateSpeaker(a.TenantID, &body) x, err := s.store.CreateSpeaker(a.TenantID, &body)
if err != nil { if err != nil {
writeStoreErr(w, err) writeStoreErr(w, err)
return 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) { 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) writeStoreErr(w, err)
return 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) { 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) writeStoreErr(w, err)
return 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) { func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
+140
View File
@@ -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})
}
+65
View File
@@ -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
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/birddeploy" "evobgp/internal/birddeploy"
"evobgp/internal/birdfmt" "evobgp/internal/birdfmt"
"evobgp/internal/nodedispatch"
"evobgp/internal/observability" "evobgp/internal/observability"
"evobgp/internal/pipeline" "evobgp/internal/pipeline"
"evobgp/internal/store" "evobgp/internal/store"
@@ -408,6 +409,7 @@ func (w *Worker) runDeployApply(j *Job) {
} }
} }
applied := make([]string, 0, 8) applied := make([]string, 0, 8)
var dispatchResults []nodedispatch.Result
applyOne := func(speakerID string) error { applyOne := func(speakerID string) error {
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil { if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
return err return err
@@ -419,21 +421,60 @@ func (w *Worker) runDeployApply(j *Job) {
applied = append(applied, speakerID) applied = append(applied, speakerID)
return nil 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 hasSpeaker && spk != "" {
if err := applyOne(spk); err != nil { if err := applyOne(spk); err != nil {
j.Fail(err.Error()) j.Fail(err.Error())
return 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) mergeBirdPostApplyMeta(j)
j.Succeed() j.Succeed()
return 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 { if err := applyOne(sp.ID); err != nil {
j.Fail(err.Error()) j.Fail(err.Error())
return return
} }
} }
for _, sp := range speakers {
dispatchSpeaker(sp)
}
j.mergeMeta(map[string]any{ j.mergeMeta(map[string]any{
"apply_summary": map[string]any{ "apply_summary": map[string]any{
"revision_id": revID, "revision_id": revID,
@@ -442,6 +483,12 @@ func (w *Worker) runDeployApply(j *Job) {
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)), "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) mergeBirdPostApplyMeta(j)
j.Succeed() j.Succeed()
} }
+118
View File
@@ -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)
}
+188
View File
@@ -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)))
}
+75
View File
@@ -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")
}
}
+67
View File
@@ -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
}
+42
View File
@@ -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"])
}
}
+141
View File
@@ -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) != ""
}
+36
View File
@@ -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)
}
}
+19
View File
@@ -0,0 +1,19 @@
# Same gates as CI job go (subset): gofmt, vet, golangci-lint.
$ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..')
$unfmt = gofmt -l . 2>$null
if ($unfmt) {
Write-Error "gofmt: unformatted files:`n$unfmt"
}
go vet ./...
$golangci = Get-Command golangci-lint -ErrorAction SilentlyContinue
if (-not $golangci) {
$golangciPath = Join-Path $env:USERPROFILE 'go\bin\golangci-lint.exe'
if (Test-Path $golangciPath) { $golangci = @{ Source = $golangciPath } }
}
if ($golangci) {
& $golangci.Source run
} else {
Write-Warning 'lint-go: golangci-lint not found, skipping (CI will run it)'
}
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Same gates as CI job go (subset before full test): gofmt, vet, golangci-lint.
set -euxo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
UNFMT="$(gofmt -l .)"
if [ -n "$UNFMT" ]; then
echo "gofmt: unformatted files:" >&2
echo "$UNFMT" >&2
exit 1
fi
go vet ./...
if command -v golangci-lint >/dev/null 2>&1; then
golangci-lint run
else
echo "lint-go: golangci-lint not in PATH, skipping (CI will run it)" >&2
fi
+5
View File
@@ -0,0 +1,5 @@
# Same gates as CI job web (.gitea/workflows/ci.yaml).
$ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..' 'web')
npm run check
npm run lint
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Same gates as CI job web (.gitea/workflows/ci.yaml).
set -euxo pipefail
cd "$(dirname "$0")/../web"
npm run check
npm run lint
+35
View File
@@ -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,42 @@
#!/bin/sh
# Validates docker-compose.remote-speaker.yaml (same gates as CI job go).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE="$ROOT/deploy/compose/docker-compose.remote-speaker.yaml"
if ! command -v docker >/dev/null 2>&1; then
echo "validate-remote-speaker-compose: docker not found, skipping"
exit 0
fi
if [ ! -f "$COMPOSE" ]; then
echo "validate-remote-speaker-compose: missing $COMPOSE" >&2
exit 1
fi
# Required compose interpolation vars (CI mock values).
export EVOBGP_REGISTRY="${EVOBGP_REGISTRY:-git.shts.su/denozord}"
export EVOBGP_IMAGE_TAG="${EVOBGP_IMAGE_TAG:-latest}"
export EVOBGP_AGENT_SECRET="${EVOBGP_AGENT_SECRET:-ci-test-secret}"
export EVOBGP_CONTROL_PLANE_URL="${EVOBGP_CONTROL_PLANE_URL:-https://cp.example.com}"
export EVOBGP_NODE_TOKEN="${EVOBGP_NODE_TOKEN:-ci-test-token}"
export EVOBGP_SPEAKER_ID="${EVOBGP_SPEAKER_ID:-00000000-0000-0000-0000-000000000001}"
export EVOBGP_BUNDLE_PUBKEY_BASE64="${EVOBGP_BUNDLE_PUBKEY_BASE64:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
export AGENT_DOMAIN="${AGENT_DOMAIN:-agent.ci.example.com}"
export LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-ci@example.com}"
export CF_DNS_API_TOKEN="${CF_DNS_API_TOKEN:-ci-token}"
export PANEL_IP_WHITELIST="${PANEL_IP_WHITELIST:-127.0.0.1/32}"
# Optional: merge example env files when present (local/docs parity).
ENV_ARGS=""
for f in \
"$ROOT/deploy/compose/.env.remote-speaker.example" \
"$ROOT/deploy/compose/.env.remote-speaker-tls.example"; do
if [ -f "$f" ]; then
ENV_ARGS="$ENV_ARGS --env-file $f"
fi
done
# shellcheck disable=SC2086
docker compose -f "$COMPOSE" $ENV_ARGS config >/dev/null
echo "validate-remote-speaker-compose: ok"
+13
View File
@@ -21,8 +21,21 @@ SvelteKit-приложение панели управления EvoBGP. Зап
npm install npm install
npm run dev npm run dev
npm run check npm run check
npm run lint # prettier --check; обязательно перед PR (CI job web)
``` ```
Из корня репозитория (обе проверки как в CI):
```powershell
powershell -NoProfile -File scripts/lint-web.ps1
```
```sh
sh scripts/lint-web.sh
```
При падении `lint`: `npx prettier --write .` в каталоге `web/`, затем снова `check` + `lint`.
Добавление компонентов shadcn (из каталога `web/`): Добавление компонентов shadcn (из каталога `web/`):
```sh ```sh
+16
View File
@@ -177,14 +177,30 @@ export type SpeakerRow = {
role: string; role: string;
endpoint: string; endpoint: string;
last_applied_revision_id: string | null; 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 SpeakersResponse = Page<SpeakerRow>;
export type BgpSpeakerCreate = { export type BgpSpeakerCreate = {
endpoint: string; endpoint: string;
role?: string; role?: string;
meta_json?: string;
}; };
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>; export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
export type BundleSigningPublicKey = {
public_key_base64: string;
};
// ---- Revisions ---- // ---- Revisions ----
export type RevisionRow = { export type RevisionRow = {
id: string; id: string;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { apiMutate } from '$lib/api/client.js'; import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js'; import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js'; import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js'; import { Button } from '$lib/ui/core/button/index.js';
import { import {
@@ -15,8 +15,10 @@
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js'; } 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 FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte'; import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.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 Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil'; import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
type Props = { type Props = {
items: SpeakerRow[]; items: SpeakerRow[];
@@ -35,41 +38,210 @@
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props(); 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 dialogOpen = $state(false);
let wizardOpen = $state(false);
let applyDialogOpen = $state(false);
let composeDialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null); 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 saving = $state(false);
let applyingId = $state<string | null>(null); let applyingId = $state<string | null>(null);
const columns = [ const columns = [
{ id: 'status', label: 'Статус' },
{ {
id: 'endpoint', id: 'agent_domain',
label: 'Endpoint', label: 'Agent domain',
sortable: true, 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: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' }, { id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-32' } { id: 'actions', label: '', class: 'w-40' }
] as const; ] 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() { function openCreate() {
editTarget = null; editTarget = null;
form = { endpoint: '', role: 'operator' }; form = emptyForm();
dialogOpen = true; dialogOpen = true;
} }
function openEdit(s: SpeakerRow) { function openEdit(s: SpeakerRow) {
editTarget = s; editTarget = s;
form = { endpoint: s.endpoint, role: s.role }; form = formFromSpeaker(s);
dialogOpen = true; dialogOpen = true;
} }
async function applySpeaker(id: string) { function openApply(s: SpeakerRow) {
applyingId = id; applyTarget = s;
applyRevisionId = s.published_revision_id ?? '';
applyDialogOpen = true;
}
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
let pubkey = '';
try { 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
[email protected]
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 запущен'); notify.success('Apply запущен');
applyDialogOpen = false;
} catch (e) { } catch (e) {
notifyApiError(e); notifyApiError(e);
} finally { } finally {
@@ -78,20 +250,25 @@
} }
async function save() { async function save() {
if (!form.endpoint.trim()) { const body = buildApiBody(form);
notify.error('Укажите endpoint'); if (!body.endpoint.trim()) {
notify.error('Укажите endpoint или agent domain');
return; return;
} }
saving = true; saving = true;
try { try {
if (editTarget) { if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form); await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
notify.success('Спикер обновлён'); notify.success('Спикер обновлён');
dialogOpen = false;
} else { } else {
await apiMutate('/v1/speakers', 'POST', form); const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
notify.success('Спикер создан'); notify.success('Спикер создан');
dialogOpen = false;
createdSpeaker = created;
composeText = await buildComposeSnippet(created);
wizardOpen = true;
} }
dialogOpen = false;
await onRefresh(); await onRefresh();
} catch (e) { } catch (e) {
notifyApiError(e); notifyApiError(e);
@@ -99,6 +276,17 @@
saving = false; 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> </script>
<Card> <Card>
@@ -107,7 +295,9 @@
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle> <CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription> <CardDescription
>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription
>
</div> </div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2"> <div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button> <Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
@@ -121,28 +311,32 @@
loading={initialLoading || loading} loading={initialLoading || loading}
{error} {error}
emptyTitle="Нет спикеров" emptyTitle="Нет спикеров"
emptyDescription="Добавьте BIRD-агент для применения конфигурации." emptyDescription="Добавьте реплику для применения signed bundle."
> >
{#snippet cell({ row: s, column })} {#snippet cell({ row: s, column })}
{#if column.id === 'endpoint'} {#if column.id === 'status'}
<span class="font-mono text-sm">{s.endpoint}</span> <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'} {:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge> <Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'last_applied_revision_id'} {:else if column.id === 'drift'}
<span class="font-mono text-xs text-muted-foreground"> <span class="font-mono text-xs text-muted-foreground" title="applied / published">
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'} {driftLabel(s)}
</span> </span>
{:else if column.id === 'actions'} {: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 <Button
variant="outline" variant="outline"
size="xs" size="xs"
title="Запустить применение ревизии на спикере" title="Apply revision (canary)"
onclick={() => applySpeaker(s.id)} onclick={() => openApply(s)}
disabled={applyingId === s.id} disabled={applyingId === s.id}
> >
<Play class="size-3" /> <Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button> </Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}> <Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" /> <Pencil class="size-3.5" />
@@ -155,16 +349,44 @@
</Card> </Card>
<Dialog bind:open={dialogOpen}> <Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm"> <DialogContent class="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle> <DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div class="space-y-4 py-2"> <div class="space-y-4 py-2">
<FormField label="Endpoint" id="s-endpoint" required> <FormField label="Agent domain (FQDN)" id="s-domain">
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} /> <AppInput id="s-domain" placeholder="bgp-dc2.example.com" bind:value={form.agent_domain} />
</FormField> </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"> <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> </FormField>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -175,3 +397,74 @@
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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>