Compare commits

...
17 Commits
Author SHA1 Message Date
Denozordec 8a19c2a3f4 refactor(NetworkPeersCard): improve sessionBadge function formatting
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 36s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m21s
- Reformatted the sessionBadge function for better readability by adjusting the parameter layout.
- No functional changes were made; this is purely a code style improvement.
2026-05-21 15:22:10 +07:00
Denozordec 1c0d78b552 feat(api): enhance peer session management and documentation
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Failing after 35s
CI / go (push) Successful in 55s
CI / bird2 (push) Successful in 15s
CI / release (push) Has been skipped
- Added new fields to the API for tracking connected speakers and session states across multiple nodes, including `connected_speaker_id`, `connected_speaker_label`, `session_on_speakers`, `established_on_speakers`, and `session_mismatch`.
- Implemented a new endpoint for retrieving bird protocol sessions, enhancing the agent server functionality.
- Updated the OpenAPI documentation to reflect the new fields and query parameters, improving clarity for API consumers.
- Modified the frontend to display connected speaker information and session states, providing better visibility into peer connections.
- Updated deployment documentation to clarify the configuration requirements for enabling IP forwarding on VPS.
2026-05-21 15:18:21 +07:00
Denozordec e0a912a693 fix(worker): ensure job success is called before enqueueing deploy speakers
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m47s
- Moved the call to j.Succeed() to occur before enqueueing deploy speakers in the runPeerReconcile and finishModuleRefreshSuccess methods, ensuring proper job success handling.
2026-05-21 13:21:33 +07:00
Denozordec 9740a34fdc feat(api): add delete speaker functionality and corresponding tests
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 34s
CI / go (push) Failing after 36s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Implemented a DELETE endpoint for removing speakers, including necessary authorization checks and response handling.
- Added a handler for the delete operation in the HTTP API.
- Created unit tests to verify the delete functionality, ensuring proper deletion and response codes.
- Updated OpenAPI documentation to reflect the new delete speaker endpoint.
2026-05-21 13:12:50 +07:00
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
DenozordecandCursor ec65249bf1 fix(ci): satisfy golangci-lint in api keys code
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 41s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m18s
Правки errcheck в тестах httpapi и gofmt полей APIKeyPatch в store.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:33:19 +07:00
Denozordec 6329a4df27 feat(api): implement API key management and authentication enhancements
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation.
- Introduced a new Auth session endpoint to retrieve current tenant and role information.
- Updated the authentication middleware to support API key-based authentication and track last used timestamps.
- Enhanced documentation to reflect new API key functionalities and usage guidelines.
- Improved logging for demo authentication scenarios.
2026-05-21 11:26:17 +07:00
Denozordec 880d77810a refactor(httpapi): standardize output formatting in job report response
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 37s
CI / go (push) Successful in 50s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m51s
- Adjusted the order of fields in the job report response for consistency.
- Ensured that the output structure remains clear and maintainable.
2026-05-21 10:52:08 +07:00
DenozordecandCursor 8ebce28e34 perf: wire job_audit terminal persistence hook
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Failing after 35s
CI / go (push) Failing after 30s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- TerminalHook в Registry для записи статуса job в PostgreSQL job_audit
- Подключение через BootstrapWorkers при наличии pool

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:46:13 +07:00
DenozordecandCursor dd7d43c2c2 perf: incremental render scale, SQL pagination and observability
- BIRD filter chunking (500 CIDR); bounded job worker pool
- ListModulesPage SQL push-down; revision diff limit; batch revision prune
- DB pool tuning; Prometheus pipeline/job metrics
- Coalesce module_refresh via idempotency; GET /jobs/{id}/report
- JobAuditWriter foundation for job_audit persistence

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:45:46 +07:00
DenozordecandCursor a8c5e9701f perf: optimize data path, indexes and frontend virtualization
- Убран latestCDNRowsBySource; expanded BIRD preview генерируется on-demand
- Batch AS meta updates; миграция perf-индексов 000012
- VirtualPrefixList для preview префиксов; метрики pipeline refresh
- PolitePause для RIPEstat после cache miss

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:45:39 +07:00
DenozordecandCursor be3d73f374 perf: quick wins for pipeline, jobs, httpapi and web ops
- CDN snapshot: batch merge после parallel fetch, без race на persist
- ListRevisionPrefixes: лёгкая проверка revision вместо GetRevision
- Jobs: timeout/cancel context для pipeline и deploy
- HTTP server timeouts; кэш birdc для GET /peers
- ListModules: batch DoH profiles одним запросом
- Web: tab-scoped load на /operations, debounce job search, меньше over-fetch на dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:42:13 +07:00
Denozordec 5fca165c69 refactor(settings): deprecate settingsKnownSchema and integrate bird and revision settings
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 36s
CI / go (push) Successful in 49s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m59s
- Merged birdSettingsSchema and revisionSettingsSchema into settingsKnownSchema, marking the previous schema as deprecated.
- Updated emptySettingsKnownForm to utilize emptyBirdSettingsForm and emptyRevisionSettingsForm.
- Refactored layout and page components to streamline theme management and improve tab synchronization in network and operations pages.
- Introduced new system settings tab in operations and updated settings page to manage theme preferences.
2026-05-21 10:11:04 +07:00
Denozordec 293115e0e1 fix(nginx): update proxy_pass configuration to use full request URI
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m33s
- Modified the NGINX configuration to pass the full request URI in the proxy_pass directive, ensuring proper handling of requests.
- Added a comment to clarify the limitation of using a variable in proxy_pass for URI replacement.
2026-05-20 16:05:54 +07:00
113 changed files with 6375 additions and 726 deletions
+1 -1
View File
@@ -32,6 +32,6 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
- Новая пользовательская возможность → `feat` (minor)
- Починка ожидаемого поведения / баг → `fix` (patch)
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
- Только перестройка без нового поведения → `refactor` (none)
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+3 -3
View File
@@ -50,7 +50,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
| `perf` | ускорение без смены API | patch |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
| `docs` | только документация | — |
| `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
@@ -62,7 +62,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
4. Ускорение без изменения контракта → `perf`
**Не путать с формулировкой diff:**
@@ -105,7 +105,7 @@ feat(web): migrate modules list to AppDataTable
# Хорошо — если не было нового user-facing
refactor(web): migrate modules list to AppDataTable
Единый паттерн таблиц; поведение списка модулей без изменений.
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
```
```
+7 -6
View File
@@ -57,9 +57,9 @@ alwaysApply: true
## Code Style
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`.
*Rationale:* единый стиль.
*Проверка:* CI job `go`.
**STYLE-01** | MUST | Go-код после `gofmt`; перед PR — `go vet ./...`. Агент после правок Go: `gofmt -w` на изменённых файлах + `golangci-lint run` (или `scripts/lint-go.*`) до exit 0.
*Rationale:* CI job `go` включает golangci-lint (gofmt).
*Проверка:* CI job `go`; `.cursor/rules/engineering.mdc` STYLE-01.
**STYLE-02** | MUST | Экспортируемые типы/функции публичных пакетов — godoc-комментарий.
*Rationale:* навигация по API пакетов.
@@ -123,8 +123,8 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* 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`.
*Проверка:* CI job `openapi`.
@@ -230,7 +230,8 @@ alwaysApply: true
go vet ./...
go test ./... -race -count=1
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
```
+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-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`.
*Проверка:* review.
@@ -95,15 +104,23 @@ Tailwind v4: https://shadcn-svelte.com/docs/migration/tailwind-v4
## Enforcement
**Обязательный финальный шаг агента при правках `web/**`:** `npm run check` **и** `npm run lint` (см. **WEB-19**). Только `check` недостаточно.
```powershell
cd web
npm run check
npm run lint
# при warn/fail lint:
npx prettier --write .
npm run check
npm run lint
```
**PR checklist `web/**`:**
- [ ] `npm run check` — exit 0
- [ ] `npm run lint` (prettier --check) — exit 0
- [ ] `ui/core` / `ui/patterns`, не дубли примитивов
- [ ] Новые примитивы через shadcn CLI
- [ ] Ссылка на docs компонента (если новый паттерн)
**CI:** job `web` рекомендован; пока обязательно локально.
**CI:** job `web` — `npm run check` + `npm run lint`.
+1 -1
View File
@@ -101,7 +101,7 @@ git commit -m "$( @'
| Пользователь получает **новую** возможность? | `feat` (minor) |
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
| Только скорость, контракт тот же? | `perf` (patch) |
| Только структура кода/UI, поведение то же? | `refactor` (none) |
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
+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`.
**Удалённый спикер** (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
+2
View File
@@ -198,6 +198,8 @@ jobs:
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
- name: Validate remote speaker compose
run: sh scripts/validate-remote-speaker-compose.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
+1
View File
@@ -16,3 +16,4 @@ Thumbs.db
.env
.env.*
!.env.example
!.env.*.example
+1
View File
@@ -11,6 +11,7 @@
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
+10 -1
View File
@@ -51,6 +51,7 @@
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
- Быстрый старт и переменные: [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 / фронтенд
При правках `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"
"log"
"os"
"sync"
"time"
"evobgp/internal/agentserver"
"evobgp/internal/birdfmt"
)
@@ -17,12 +19,14 @@ func main() {
socket := flag.String("socket", "", "optional birdc control socket (-s)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
listen := flag.String("listen", "", "for serve: listen address (default :8443 or EVOBGP_AGENT_LISTEN)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -p (syntax check)\n")
fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n")
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
fmt.Fprintf(os.Stderr, " serve Panel→Node HTTP API (POST /v1/agent/sync)\n")
flag.PrintDefaults()
}
flag.Parse()
@@ -40,9 +44,21 @@ func main() {
ctl.Birdc = *birdc
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
switch args[0] {
case "serve":
runServe(*listen, *timeout)
case "parse-check", "configure", "watch":
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
runBirdCommand(ctx, args, ctl, *watchEvery, *timeout)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
flag.Usage()
os.Exit(2)
}
}
func runBirdCommand(ctx context.Context, args []string, ctl *birdfmt.BirdCtl, watchEvery, timeout time.Duration) {
switch args[0] {
case "parse-check":
if len(args) != 2 {
@@ -63,23 +79,51 @@ func main() {
os.Exit(1)
}
case "watch":
if *watchEvery <= 0 {
if watchEvery <= 0 {
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
os.Exit(2)
}
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", *watchEvery, *socket)
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", watchEvery, ctl.Socket)
for {
cctx, cancel := context.WithTimeout(context.Background(), *timeout)
cctx, cancel := context.WithTimeout(context.Background(), timeout)
err := ctl.Configure(cctx)
cancel()
if err != nil {
log.Printf("evobgp-agent watch: configure: %v", err)
}
time.Sleep(*watchEvery)
time.Sleep(watchEvery)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
flag.Usage()
}
}
func runServe(listenFlag string, syncTimeout time.Duration) {
cfg, err := agentserver.ConfigFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if listenFlag != "" {
cfg.Listen = listenFlag
}
if syncTimeout > 0 {
cfg.SyncTimeout = syncTimeout
}
var mu sync.Mutex
var lastRev string
var lastAt time.Time
cfg.LastSync = func() (string, time.Time) {
mu.Lock()
defer mu.Unlock()
return lastRev, lastAt
}
cfg.OnSyncSuccess = func(rev string) {
mu.Lock()
lastRev = rev
lastAt = time.Now().UTC()
mu.Unlock()
}
if err := agentserver.ListenAndServe(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+7 -2
View File
@@ -55,8 +55,12 @@ func main() {
startBirdMetricsPoller(ctx)
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
svc := platform.ServiceName("evobgp-all")
@@ -91,6 +95,7 @@ func startBirdMetricsPoller(ctx context.Context) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
birdfmt.ParseBGPProtocolStates,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
+8 -3
View File
@@ -42,8 +42,12 @@ func main() {
startBirdMetricsPoller(ctx)
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
svc := platform.ServiceName("evobgp-api")
@@ -53,7 +57,7 @@ func main() {
tid, mCDN, mIP, rev, sp := srv.Store().DemoIDs()
log.Printf("demo tenant=%s module_cdn=%s module_ip_ranges=%s revision=%s speaker=%s", tid, mCDN, mIP, rev, sp)
log.Printf("example: EVOBGP_API_KEYS=op|%s|operator,node|%s|node", tid, tid)
log.Printf("with EVOBGP_DEV_INSECURE=1 use Authorization: Bearer dev (operator, demo tenant only)")
log.Printf("demo auth: Authorization: Bearer dev (operator, demo tenant only)")
}
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
@@ -84,6 +88,7 @@ func startBirdMetricsPoller(ctx context.Context) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
birdfmt.ParseBGPProtocolStates,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
@@ -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=ops@example.com
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,155 @@
# Удалённый 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 нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
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
+2 -1
View File
@@ -11,7 +11,8 @@ server {
set $evobgp_upstream evobgp-api;
location /v1/ {
proxy_pass http://$evobgp_upstream:8080/v1/;
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
proxy_pass http://$evobgp_upstream:8080$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+1
View File
@@ -20,6 +20,7 @@
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
| [remote-speakers.md](remote-speakers.md) | Удалённые BGP-реплики: Traefik, agent sync, compose |
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
+31 -4
View File
@@ -22,6 +22,19 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
При включённом демо-сиде сервер при старте может вывести в лог готовую подсказку с реальным `tenant_id` из БД — см. лог `evobgp-api` / `evobgp-all`.
Ключи из `EVOBGP_API_KEYS` загружаются при старте и **дополняют** ключи из таблицы `api_key` в БД (break-glass / bootstrap). После первого operator-ключа можно создавать остальные через API или веб-настройки.
### Управление через API и UI
При подключённой БД operator может:
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
`GET /v1/auth/session` — текущие `tenant_id` и `role` (для UI).
### Роли
| Роль | Уровень | Назначение |
@@ -33,11 +46,11 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
### Режим разработки `EVOBGP_DEV_INSECURE`
### Токен `dev` (локальная разработка)
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### Синхронные «тяжёлые» GET (control plane)
@@ -50,7 +63,9 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
## Публичный ключ бандла для нод
При старте API в лог печатается строка **bundle signing public key (base64)**. Её нужно передать администратору реплики и использовать в `evobgp-node`:
При старте API в лог печатается строка **bundle signing public key (base64)**. Альтернатива для operator: **`GET /v1/bundle/signing-public-key`** → поле `public_key_base64` для `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
Использование в `evobgp-node` / agent:
```text
evobgp-node verify-bundle -f bundle.tar.gz -pubkey-base64 "<из_лога_API>"
@@ -63,6 +78,17 @@ evobgp-node apply-bundle -f bundle.tar.gz -extract-dir /path/to/dir -pubkey-base
evobgp-node pull-bundle -base-url http://control.example:8080 -token "<node_token>" -speaker-id "<uuid>"
```
## Panel→Node dispatch (удалённые спикеры)
На control plane (prod):
```text
EVOBGP_NODE_DISPATCH_ENABLED=1
EVOBGP_BUNDLE_SEED_HEX=<32 bytes hex, стабильный>
```
После `deploy_apply` CP шлёт `POST https://AGENT_DOMAIN/v1/agent/sync` с `Authorization: Bearer <agent_secret>`. На реплике — `EVOBGP_AGENT_SECRET`, Traefik `PANEL_IP_WHITELIST`. Подробнее: [remote-speakers.md](remote-speakers.md).
## CORS для веб-интерфейса
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
@@ -93,6 +119,7 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
| GET модули, ревизии, peers, speakers | да | да | да | нет |
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
| apply, rollback, PATCH settings | нет | нет | да | нет |
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
| bundle, latest revision, enroll | нет | нет | нет | да |
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
+6
View File
@@ -44,6 +44,12 @@
- `GET|POST /v1/communities`
- `GET|PATCH|DELETE /v1/communities/{id}`
### API keys
- `GET /v1/auth/session` — tenant и роль текущего ключа
- `GET|POST /v1/api-keys` — список и создание (operator)
- `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate`
### Peers
- `GET /v1/peers`, `POST /v1/peers`
+6 -2
View File
@@ -19,7 +19,7 @@
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. |
| `evobgp-agent` | Локальный агент рядом с BIRD (например `watch` по сокету). |
| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). |
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
@@ -40,8 +40,12 @@
| `observability` | Метрики Prometheus, HTTP middleware. |
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
## Диаграмма: эталонный Compose (reference)
## Удалённые спикеры
Реплики на отдельных VPS: [remote-speakers.md](remote-speakers.md). CP публикует ревизию и при `EVOBGP_NODE_DISPATCH_ENABLED=1` будит agent; agent тянет signed bundle и применяет BIRD. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`.
```mermaid
flowchart LR
+1
View File
@@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к
### Настройки (`/v1/settings`)
- KV c ключами BIRD и дополнительными feature flags.
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
## 7. Эксплуатация и runbook
+375
View File
@@ -45,6 +45,10 @@ tags:
description: "API для evobgp-node (бандлы ревизий и enrollment). Отдельный ключ или mTLS, роль node."
- name: Settings
description: Глобальные настройки и feature flags; изменение - только operator.
- name: API keys
description: Управление API-ключами tenant (operator). Секрет возвращается только при создании и ротации.
- name: Auth
description: Сессия текущего API-ключа (tenant и роль).
security:
- bearerAuth: []
@@ -135,6 +139,12 @@ components:
required: true
schema:
$ref: "#/components/schemas/ResourceId"
ApiKeyId:
name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
SourceId:
name: source_id
in: path
@@ -639,6 +649,82 @@ components:
vault_secret_ref:
type: ["string", "null"]
AuthSession:
type: object
required: [tenant_id, role]
properties:
tenant_id:
$ref: "#/components/schemas/ResourceId"
role:
type: string
enum: [viewer, editor, operator, node]
ApiKey:
type: object
required: [id, name, role, prefix, created_at, updated_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
prefix:
type: string
description: Первые 8 символов токена для идентификации в UI.
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
expires_at:
type: ["string", "null"]
format: date-time
revoked_at:
type: ["string", "null"]
format: date-time
last_used_at:
type: ["string", "null"]
format: date-time
additionalProperties: true
ApiKeyCreate:
type: object
required: [name, role]
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyPatch:
type: object
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyCreated:
allOf:
- $ref: "#/components/schemas/ApiKey"
- type: object
required: [token]
properties:
token:
type: string
description: Полный Bearer-токен; показывается один раз.
BgpCommunity:
type: object
required:
@@ -669,6 +755,42 @@ components:
bgp_speaker_id:
type: ["string", "null"]
description: "`null` - политика для всех спикеров."
connected_speaker_id:
type: ["string", "null"]
description: >
Live (GET /v1/peers?live=1): спикер, на котором сессия Established; опрос CP birdc + agent /v1/agent/bird/protocols.
connected_speaker_label:
type: string
description: Человекочитаемая метка ноды из live-опроса.
session_on_speakers:
type: array
description: Состояние протокола пира на каждой опрошенной ноде.
items:
type: object
properties:
speaker_id:
type: string
label:
type: string
state:
type: string
established_on_speakers:
type: array
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
items:
type: object
properties:
speaker_id:
type: string
label:
type: string
state:
type: string
session_mismatch:
type: boolean
description: >
true если bgp_speaker_id задан, но на этой ноде нет Established
(сессия может быть на других нодах — это не ошибка для tenant-wide пиров).
policies_json:
type: string
description: >
@@ -692,8 +814,47 @@ components:
type: string
last_applied_revision_id:
type: ["string", "null"]
published_revision_id:
type: ["string", "null"]
description: Последняя опубликованная на CP ревизия для этого спикера.
published_at:
type: ["string", "null"]
format: date-time
agent_domain:
type: string
description: FQDN agent API за Traefik (Address в UI, Remnawave-style).
node_ipv4:
type: string
description: IPv4 VPS; default для bird_bgp_source_ipv4.
bird_bgp_source_ipv4:
type: string
description: Per-speaker override router id / BGP local (см. pipeline overlay).
dispatch_status:
type: string
description: ok, error, skipped — последний Panel→Node wake-up.
sync_status:
type: string
description: synced, error — состояние sync на реплике.
last_dispatch_at:
type: string
format: date-time
last_dispatch_error:
type: string
meta_json:
type: object
description: >
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
additionalProperties: true
BundleSigningPublicKey:
type: object
required: [public_key_base64]
properties:
public_key_base64:
type: string
description: Ed25519 public key (base64) для verify-bundle на реплике.
ConfigRevision:
type: object
required:
@@ -914,8 +1075,15 @@ components:
properties:
role:
type: string
default: replica
endpoint:
type: string
description: URL agent или https://AGENT_DOMAIN
meta_json:
type: string
description: >
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
agent_domain, agent_secret (генерируется при создании если пуст).
additionalProperties: true
BgpSpeakerPatch:
@@ -925,6 +1093,9 @@ components:
type: string
endpoint:
type: string
meta_json:
type: string
description: JSON-объект с ключами agent_domain, node_ipv4, bird_bgp_source_ipv4 и др.
additionalProperties: true
LatestRevisionPointer:
@@ -2021,6 +2192,13 @@ paths:
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/SpeakerFilter"
- name: live
in: query
schema:
type: string
enum: ["1"]
description: >
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
responses:
"200":
description: Успешно.
@@ -2130,6 +2308,24 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/bundle/signing-public-key:
get:
tags: [Bundles]
summary: Публичный ключ подписи бандлов
description: >
Ed25519 public key (base64) для `evobgp-node verify-bundle` / agent sync на реплике.
Роль viewer и выше.
operationId: getBundleSigningPublicKey
responses:
"200":
description: Ключ для env EVOBGP_BUNDLE_PUBKEY_BASE64 на реплике.
content:
application/json:
schema:
$ref: "#/components/schemas/BundleSigningPublicKey"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/speakers:
get:
tags: [Speakers]
@@ -2226,6 +2422,21 @@ paths:
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [Speakers]
summary: Удалить спикер
description: >
Удаляет BGP-спикер. Пиры с `bgp_speaker_id` этого спикера остаются, привязка сбрасывается (ON DELETE SET NULL).
operationId: deleteSpeaker
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Удалено.
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/revisions:
get:
@@ -2643,6 +2854,170 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/auth/session:
get:
tags: [Auth]
summary: Текущая сессия API-ключа
operationId: getAuthSession
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/AuthSession"
"401":
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys:
get:
tags: [API keys]
summary: Список API-ключей tenant
description: Только роль **operator**. Секреты не возвращаются.
operationId: listApiKeys
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [items, has_more]
properties:
items:
type: array
items:
$ref: "#/components/schemas/ApiKey"
next_cursor:
type: ["string", "null"]
has_more:
type: boolean
"403":
$ref: "#/components/responses/Forbidden"
default:
$ref: "#/components/responses/DefaultProblem"
post:
tags: [API keys]
summary: Создать API-ключ
operationId: createApiKey
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreate"
responses:
"201":
description: Ключ создан; token в ответе один раз.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
get:
tags: [API keys]
summary: Получить метаданные API-ключа
operationId: getApiKey
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
patch:
tags: [API keys]
summary: Обновить API-ключ
operationId: patchApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyPatch"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [API keys]
summary: Отозвать API-ключ
operationId: revokeApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Отозван.
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}/rotate:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
post:
tags: [API keys]
summary: Ротировать секрет API-ключа
description: Выдаёт новый token; старый перестаёт работать сразу.
operationId: rotateApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+4
View File
@@ -203,6 +203,10 @@ docker compose --profile reference up -d
В **evobgp-all** (microvps) те же пакеты крутятся в одном процессе и используют общий `jobs.Registry` без HTTP.
## Удалённые BGP-спикеры
Реплики на отдельных VPS (bird2 + agent + Traefik): см. **[remote-speakers.md](remote-speakers.md)**. На CP включите `EVOBGP_NODE_DISPATCH_ENABLED=1` и зафиксируйте `EVOBGP_BUNDLE_SEED_HEX`. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`.
## Вариант 3: Локально без Docker (только API)
1. Поднимите PostgreSQL и создайте БД (или используйте существующую).
+4 -2
View File
@@ -7,9 +7,11 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
| Тип коммита | Bump |
|-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf`, `ci` | patch (1.5.1 → 1.5.2) |
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `test`, `refactor` | без релиза |
| `docs`, `chore`, `test` | без релиза |
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
+118
View File
@@ -0,0 +1,118 @@
# Удалённые 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, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
| **179** | BGP peers | Data plane |
| **80** | ACME | Traefik → 443 |
## Подготовка VPS (перед `docker compose up`)
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
```bash
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
echo 'net.ipv4.ip_forward=1' | tee /etc/sysctl.d/99-evobgp-bird.conf
echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf
sysctl --system
```
## Безопасность (три участка)
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
| Симптом | Проверка |
|---------|----------|
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
| 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 на реплике |
+218
View File
@@ -0,0 +1,218 @@
package agentserver
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"evobgp/internal/birdfmt"
"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("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
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) handleBirdProtocols(w http.ResponseWriter, r *http.Request) {
if !s.authorize(r) {
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
return
}
sock := strings.TrimSpace(s.cfg.Socket)
if sock == "" {
writeProblem(w, http.StatusServiceUnavailable, "EVOBGP_BIRDC_SOCKET not configured")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(s.cfg.BirdcBin))
if err != nil {
log.Printf("agentserver: bird protocols: %v", err)
writeProblem(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"sessions": birdfmt.ParseBGPSessions(out),
})
}
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)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package authkey generates API tokens and derives lookup hashes (no persistence).
package authkey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
const tokenPrefix = "evobgp_"
// GenerateToken returns a new bearer token (evobgp_ + 32 random bytes, base64url).
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("authkey: generate token: %w", err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil
}
// HashToken returns SHA-256 of the full token (32 bytes).
func HashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// Prefix returns the first 8 characters of the token for display.
func Prefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8]
}
+48
View File
@@ -0,0 +1,48 @@
package birdfmt
import (
"strings"
)
// BGPSession is one BGP protocol block from `birdc show protocols all`.
type BGPSession struct {
Name string `json:"name"`
Neighbor string `json:"neighbor,omitempty"`
State string `json:"state"`
}
// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
func ParseBGPSessions(output string) []BGPSession {
var out []BGPSession
var cur *BGPSession
for _, raw := range strings.Split(output, "\n") {
line := strings.TrimRight(raw, "\r")
trim := strings.TrimSpace(line)
if trim == "" {
continue
}
low := strings.ToLower(trim)
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
continue
}
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && isBGPProtocolSummaryRow(trim) {
fields := strings.Fields(trim)
state := extractBGPSessionStateLine(trim)
if state == "" && len(fields) >= 4 {
state = fields[3]
}
cur = &BGPSession{Name: fields[0], State: state}
out = append(out, *cur)
cur = &out[len(out)-1]
continue
}
if cur == nil {
continue
}
const neighborPrefix = "Neighbor address:"
if idx := strings.Index(trim, neighborPrefix); idx >= 0 {
cur.Neighbor = strings.TrimSpace(trim[idx+len(neighborPrefix):])
}
}
return out
}
+26
View File
@@ -0,0 +1,26 @@
package birdfmt
import "testing"
func TestParseBGPSessions_neighborAndState(t *testing.T) {
sample := `
BIRD 2.14 ready.
Name Proto Table State Since Info
device1 Device --- up 10:00:00
evobgp_p_abc123 BGP master4 up 10:00:05 Established
Neighbor address: 198.51.100.2
Neighbor AS: 65001
evobgp_p_def456 BGP master4 up 10:00:06 Active
Neighbor address: 2001:db8::2
`
sessions := ParseBGPSessions(sample)
if len(sessions) != 2 {
t.Fatalf("got %d sessions want 2", len(sessions))
}
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
t.Fatalf("session0: %+v", sessions[0])
}
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
t.Fatalf("session1: %+v", sessions[1])
}
}
+17 -6
View File
@@ -9,6 +9,21 @@ import (
const maxBGPASN = 4294967295
const filterPrefixChunkSize = 500
func writePrefixSetAcceptBlocks(b *strings.Builder, keys []string) {
for i := 0; i < len(keys); i += filterPrefixChunkSize {
end := i + filterPrefixChunkSize
if end > len(keys) {
end = len(keys)
}
chunk := keys[i:end]
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(chunk, ", "))
b.WriteString(" ] then accept;\n")
}
}
func filterUniqueASNs(pathASNs []int64) []int64 {
seen := make(map[int64]struct{})
for _, a := range pathASNs {
@@ -52,9 +67,7 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) > 0 {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
writePrefixSetAcceptBlocks(&b, keys)
}
for _, asn := range asns {
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
@@ -95,9 +108,7 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) > 0 {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
writePrefixSetAcceptBlocks(&b, keys)
}
for _, asn := range asns {
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
+16
View File
@@ -0,0 +1,16 @@
package birdfmt
import "strings"
// PeerProtocolName returns the BIRD protocol name for a control-plane peer UUID.
// Must stay in sync with pipeline peer rendering.
func PeerProtocolName(peerID string) string {
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
if len(s) > 16 {
s = s[:16]
}
if s == "" {
s = "x"
}
return "evobgp_p_" + s
}
+45
View File
@@ -50,3 +50,48 @@ func CountEstablishedBGPSessions(showProtocolsOutput string) int {
}
return n
}
// ParseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
func ParseBGPProtocolStates(output string) map[string]string {
out := make(map[string]string)
for _, raw := range strings.Split(output, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
low := strings.ToLower(line)
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
if !strings.EqualFold(fields[1], "BGP") {
continue
}
state := extractBGPSessionStateLine(line)
if state == "" {
state = fields[3]
}
out[fields[0]] = state
}
return out
}
func extractBGPSessionStateLine(line string) string {
known := []string{
"Established",
"Idle",
"Connect",
"Active",
"OpenSent",
"OpenConfirm",
}
for _, st := range known {
if strings.Contains(line, st) {
return st
}
}
return ""
}
+14
View File
@@ -6,9 +6,12 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"evobgp/migrations"
@@ -22,6 +25,17 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
if err != nil {
return nil, err
}
if max := os.Getenv("EVOBGP_DB_MAX_CONNS"); max != "" {
if n, err := strconv.Atoi(strings.TrimSpace(max)); err == nil && n > 0 {
cfg.MaxConns = int32(n)
}
}
if min := os.Getenv("EVOBGP_DB_MIN_CONNS"); min != "" {
if n, err := strconv.Atoi(strings.TrimSpace(min)); err == nil && n >= 0 {
cfg.MinConns = int32(n)
}
}
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"sync"
"evobgp/internal/store"
)
type apiKeyResolver struct {
mu sync.RWMutex
envByToken map[string]apiKeyRecord
byHash map[string]apiKeyRecord
}
func newAPIKeyResolver(envSpec string, st store.Backend) (*apiKeyResolver, error) {
r := &apiKeyResolver{
envByToken: make(map[string]apiKeyRecord),
byHash: make(map[string]apiKeyRecord),
}
for _, rec := range parseAPIKeysSpec(envSpec) {
r.envByToken[rec.token] = rec
}
return r, r.reloadFromStore(st)
}
func (r *apiKeyResolver) reloadFromStore(st store.Backend) error {
rows, err := st.ListActiveAPIKeyHashes()
if err != nil {
return err
}
byHash := make(map[string]apiKeyRecord, len(rows))
for _, row := range rows {
if len(row.TokenHash) != 32 {
continue
}
byHash[hex.EncodeToString(row.TokenHash)] = apiKeyRecord{
token: "",
tenantID: row.TenantID,
role: row.Role,
keyID: row.ID,
}
}
r.mu.Lock()
r.byHash = byHash
r.mu.Unlock()
return nil
}
func (r *apiKeyResolver) Reload(st store.Backend) error {
return r.reloadFromStore(st)
}
func (r *apiKeyResolver) Lookup(raw string) (apiKeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if rec, ok := r.envByToken[raw]; ok {
return rec, true
}
sum := sha256.Sum256([]byte(raw))
key := hex.EncodeToString(sum[:])
rec, ok := r.byHash[key]
return rec, ok
}
+13 -21
View File
@@ -15,6 +15,7 @@ type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
APIKeyID string // non-empty for DB-managed keys
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -26,6 +27,7 @@ type apiKeyRecord struct {
token string
tenantID string
role string
keyID string // set for DB-managed keys (last_used_at)
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
@@ -54,20 +56,6 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
@@ -75,18 +63,22 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
if matched == nil {
matched, ok := s.keyResolver.Lookup(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
+15
View File
@@ -53,6 +53,21 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
reg := jobs.NewRegistry(wk.Process)
wk.Registry = reg
if pool != nil {
audit := repository.NewJobAuditWriter(pool)
reg.SetTerminalHook(func(j *jobs.Job) {
if j == nil {
return
}
st := j.Snapshot()
status, _ := st["status"].(string)
var errMsg *string
if e, ok := st["error"].(string); ok && e != "" {
errMsg = &e
}
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
})
}
observability.RegisterStoreBackend(backend)
return backend, reg, pool, nil
}
+263
View File
@@ -0,0 +1,263 @@
package httpapi
import (
"context"
"net/netip"
"os"
"strings"
"sync"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/nodedispatch"
"evobgp/internal/store"
)
const peerLiveCacheTTL = 15 * time.Second
type speakerBGPLive struct {
SpeakerID string
Label string
Sessions []birdfmt.BGPSession
Error string
}
type peerLiveCacheEntry struct {
at time.Time
views []speakerBGPLive
}
var peerLiveCache sync.Map // tenantID -> peerLiveCacheEntry
type peerSessionOnSpeaker struct {
SpeakerID string `json:"speaker_id"`
Label string `json:"label"`
State string `json:"state"`
}
func speakerDisplayLabel(sp *store.Speaker) string {
if sp == nil {
return ""
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
host := strings.TrimSpace(meta.AgentDomain)
if host == "" {
host = strings.TrimSpace(sp.Endpoint)
}
if strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
if host != "" {
return "CP · " + host
}
return "CP (master)"
}
if host != "" {
return host
}
return sp.ID
}
func masterSpeakerID(speakers []*store.Speaker) string {
for _, sp := range speakers {
if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
return sp.ID
}
}
return ""
}
func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fresh bool) []speakerBGPLive {
if !fresh {
if v, ok := peerLiveCache.Load(tenantID); ok {
ent := v.(peerLiveCacheEntry)
if time.Since(ent.at) < peerLiveCacheTTL {
return ent.views
}
}
}
speakers := s.store.ListSpeakersForTenant(tenantID)
views := make([]speakerBGPLive, 0, len(speakers)+1)
if sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")); sock != "" {
v := speakerBGPLive{Label: "CP (local BIRD)"}
if mid := masterSpeakerID(speakers); mid != "" {
v.SpeakerID = mid
for _, sp := range speakers {
if sp != nil && sp.ID == mid {
v.Label = speakerDisplayLabel(sp)
break
}
}
}
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")))
if err != nil {
v.Error = err.Error()
} else {
v.Sessions = birdfmt.ParseBGPSessions(out)
}
views = append(views, v)
}
opts := nodedispatch.Options{Timeout: 8 * time.Second}
type resWrap struct {
sp *store.Speaker
res nodedispatch.BirdProtocolsResult
}
ch := make(chan resWrap, len(speakers))
var wg sync.WaitGroup
for _, sp := range speakers {
if sp == nil {
continue
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
continue
}
wg.Add(1)
go func(speaker *store.Speaker) {
defer wg.Done()
ch <- resWrap{
sp: speaker,
res: nodedispatch.FetchBirdProtocols(ctx, speaker, opts),
}
}(sp)
}
wg.Wait()
close(ch)
for rw := range ch {
views = append(views, speakerBGPLive{
SpeakerID: rw.sp.ID,
Label: speakerDisplayLabel(rw.sp),
Sessions: rw.res.Sessions,
Error: rw.res.Error,
})
}
peerLiveCache.Store(tenantID, peerLiveCacheEntry{at: time.Now(), views: views})
return views
}
func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) (
bestState string,
connectedID string,
connectedLabel string,
establishedOn []peerSessionOnSpeaker,
on []peerSessionOnSpeaker,
mismatch bool,
) {
if peer == nil {
return "", "", "", nil, nil, false
}
neighbor, hasNeighbor := store.ParsePeerNeighbor(peer.Neighbor)
protoName := birdfmt.PeerProtocolName(peer.ID)
for _, v := range views {
if v.Error != "" && len(v.Sessions) == 0 {
continue
}
for _, sess := range v.Sessions {
if !peerSessionMatches(sess, protoName, neighbor, hasNeighbor) {
continue
}
hit := peerSessionOnSpeaker{
SpeakerID: v.SpeakerID,
Label: v.Label,
State: sess.State,
}
on = append(on, hit)
if strings.EqualFold(strings.TrimSpace(sess.State), "Established") {
establishedOn = append(establishedOn, hit)
}
if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) {
bestState = sess.State
}
}
}
if len(establishedOn) > 0 {
bestState = "Established"
labels := make([]string, 0, len(establishedOn))
for _, e := range establishedOn {
labels = append(labels, e.Label)
}
connectedLabel = strings.Join(labels, ", ")
if len(establishedOn) == 1 {
connectedID = establishedOn[0].SpeakerID
}
} else if len(on) == 1 {
connectedID = on[0].SpeakerID
connectedLabel = on[0].Label
}
if peer.SpeakerID != nil && strings.TrimSpace(*peer.SpeakerID) != "" && len(establishedOn) > 0 {
want := strings.TrimSpace(*peer.SpeakerID)
found := false
for _, e := range establishedOn {
if strings.EqualFold(strings.TrimSpace(e.SpeakerID), want) {
found = true
break
}
}
mismatch = !found
}
return bestState, connectedID, connectedLabel, establishedOn, on, mismatch
}
func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) bool {
if strings.EqualFold(strings.TrimSpace(sess.Name), protoName) {
return true
}
if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" {
return false
}
addr, err := netip.ParseAddr(strings.TrimSpace(sess.Neighbor))
if err != nil {
return false
}
return addr == neighbor
}
func sessionStateRank(state string) int {
switch strings.ToLower(strings.TrimSpace(state)) {
case "established":
return 100
case "openconfirm", "opensent":
return 80
case "active", "connect":
return 60
case "idle":
return 20
default:
return 10
}
}
func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) {
state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views)
if len(on) > 0 {
if state != "" {
row["session_state"] = state
}
row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID)
row["connected_speaker_label"] = connLabel
row["session_on_speakers"] = on
if len(establishedOn) > 0 {
row["established_on_speakers"] = establishedOn
} else {
row["established_on_speakers"] = []peerSessionOnSpeaker{}
}
row["session_conflict"] = false
row["session_mismatch"] = mismatch
return
}
row["session_on_speakers"] = []peerSessionOnSpeaker{}
row["established_on_speakers"] = []peerSessionOnSpeaker{}
row["session_conflict"] = false
row["session_mismatch"] = false
}
func peerLiveSpeakerIDOrNull(id string) any {
if strings.TrimSpace(id) == "" {
return nil
}
return id
}
+67
View File
@@ -0,0 +1,67 @@
package httpapi
import (
"testing"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
)
func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) {
peer := &store.BGPPeer{
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Neighbor: "198.51.100.2",
}
views := []speakerBGPLive{
{
SpeakerID: "master-id",
Label: "CP · bgp.shz.su",
Sessions: []birdfmt.BGPSession{
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Idle"},
},
},
{
SpeakerID: "replica-id",
Label: "bgp2.shz.su",
Sessions: []birdfmt.BGPSession{
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
},
},
}
state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views)
if state != "Established" || connID != "replica-id" || connLabel != "bgp2.shz.su" {
t.Fatalf("got state=%q conn=%q label=%q", state, connID, connLabel)
}
if mismatch || len(on) != 2 || len(established) != 1 {
t.Fatalf("on=%+v established=%+v mismatch=%v", on, established, mismatch)
}
}
func TestMatchPeerOnSpeakers_multipleEstablished(t *testing.T) {
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"}
views := []speakerBGPLive{
{SpeakerID: "a", Label: "n1", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
{SpeakerID: "b", Label: "n2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
}
_, connID, label, established, _, mismatch := matchPeerOnSpeakers(peer, views)
if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 {
t.Fatalf("connID=%q label=%q established=%+v mismatch=%v", connID, label, established, mismatch)
}
}
func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) {
replica := "replica-id"
peer := &store.BGPPeer{
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Neighbor: "198.51.100.2",
SpeakerID: &replica,
}
views := []speakerBGPLive{
{SpeakerID: "master-id", Label: "CP", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
{SpeakerID: replica, Label: "bgp2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Idle"}}},
}
_, _, _, _, _, mismatch := matchPeerOnSpeakers(peer, views)
if !mismatch {
t.Fatal("expected mismatch when configured replica has no Established")
}
}
+89 -29
View File
@@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers)
m.HandleFunc("GET /bundle/signing-public-key", s.handleBundleSigningPublicKey)
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
@@ -69,6 +70,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /bird/status", s.handleBirdStatus)
m.HandleFunc("GET /jobs", s.handleListJobs)
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
m.HandleFunc("GET /jobs/{job_id}/report", s.handleGetJobReport)
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
@@ -166,17 +168,7 @@ func peerJSON(p *store.BGPPeer) map[string]any {
}
func speakerJSON(sp *store.Speaker) map[string]any {
m := map[string]any{
"id": sp.ID,
"role": sp.Role,
"endpoint": sp.Endpoint,
}
if sp.LastAppliedRevisionID != nil {
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
} else {
m["last_applied_revision_id"] = nil
}
return m
return speakerJSONFromStore(nil, sp)
}
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
@@ -201,6 +193,22 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}
filtered := make([]*store.Module, 0)
limit := parseListLimit(r)
cursor := r.URL.Query().Get("cursor")
if typeFilter == "" && enabledFilter == nil {
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
for _, mod := range page {
filtered = append(filtered, mod)
}
items := make([]map[string]any, 0, len(filtered))
for _, mod := range filtered {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
return
}
for _, mod := range s.store.ListModules(a.TenantID) {
if typeFilter != "" && mod.Type != typeFilter {
continue
@@ -275,13 +283,14 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
}
allPeers := s.store.ListPeers(a.TenantID)
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
liveStates := s.liveBGPProtocolStates(r.Context())
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
items := make([]map[string]any, 0, len(page))
for _, p := range page {
row := peerJSON(p)
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
row["session_state"] = strings.TrimSpace(st)
}
applyPeerLiveFields(row, p, liveViews)
items = append(items, row)
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -289,7 +298,17 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
func (s *Server) liveBGPProtocolStates(r *http.Request) map[string]string {
if r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1") {
return s.liveBGPProtocolStatesFresh(r.Context())
}
if cached, ok := observability.CachedBirdProtocolStates(90 * time.Second); ok {
return cached
}
return s.liveBGPProtocolStatesFresh(r.Context())
}
func (s *Server) liveBGPProtocolStatesFresh(ctx context.Context) map[string]string {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
return map[string]string{}
@@ -298,7 +317,9 @@ func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
if err != nil {
return map[string]string{}
}
return parseBGPProtocolStates(out)
states := birdfmt.ParseBGPProtocolStates(out)
observability.SetBirdProtocolStates(states)
return states
}
// parseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
@@ -346,16 +367,9 @@ func extractBGPSessionState(line string) string {
return ""
}
// peerProtocolNameForID must stay in sync with pipeline peer protocol naming.
// peerProtocolNameForID forwards to birdfmt for tests and legacy callers.
func peerProtocolNameForID(peerID string) string {
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
if len(s) > 16 {
s = s[:16]
}
if s == "" {
s = "x"
}
return "evobgp_p_" + s
return birdfmt.PeerProtocolName(peerID)
}
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
@@ -370,7 +384,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
speakers := s.store.ListSpeakersForTenant(a.TenantID)
items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers {
items = append(items, speakerJSON(sp))
items = append(items, speakerJSONFromStore(s.store, sp))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
@@ -522,7 +536,8 @@ func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger strin
return
}
mid := moduleID
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{
key := "module_refresh:" + moduleID
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, &key, &mid, map[string]any{
"module_id": moduleID,
"trigger": trigger,
})
@@ -589,6 +604,9 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
for k, v := range rev.PreviewFragments {
obj[k] = v
}
if expanded := pipeline.BuildExpandedBirdPreview(rev.PreviewFragments); expanded != "" {
obj[pipeline.AuxBirdFullExpandedKey()] = expanded
}
writeJSON(w, http.StatusOK, obj)
}
@@ -843,6 +861,44 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, j.Snapshot())
}
func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
snap := j.Snapshot()
meta, _ := snap["meta"].(map[string]any)
out := map[string]any{
"job_id": snap["job_id"],
"kind": snap["kind"],
"status": snap["status"],
"meta": meta,
"error": snap["error"],
"created_at": snap["created_at"],
}
if meta != nil {
if v, ok := meta["log_entries"]; ok {
out["log_entries"] = v
}
if v, ok := meta["log_total"]; ok {
out["log_total"] = v
}
if v, ok := meta["revision_id"]; ok {
out["revision_id"] = v
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
@@ -914,7 +970,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
frags := rev.PreviewFragments
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
frags = overlaid
}
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
if err != nil {
writeInternalError(w, "internal", err)
return
+217
View File
@@ -0,0 +1,217 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"evobgp/internal/store"
)
func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
m.HandleFunc("GET /auth/session", s.handleAuthSession)
m.HandleFunc("GET /api-keys", s.handleListAPIKeys)
m.HandleFunc("POST /api-keys", s.handlePostAPIKey)
m.HandleFunc("GET /api-keys/{id}", s.handleGetAPIKey)
m.HandleFunc("PATCH /api-keys/{id}", s.handlePatchAPIKey)
m.HandleFunc("DELETE /api-keys/{id}", s.handleDeleteAPIKey)
m.HandleFunc("POST /api-keys/{id}/rotate", s.handleRotateAPIKey)
}
func (s *Server) handleAuthSession(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{
"tenant_id": a.TenantID,
"role": a.Role,
})
}
func apiKeyJSON(k *store.APIKey) map[string]any {
m := map[string]any{
"id": k.ID,
"name": k.Name,
"role": k.Role,
"prefix": k.Prefix,
"created_at": k.CreatedAt.UTC().Format(time.RFC3339),
"updated_at": k.UpdatedAt.UTC().Format(time.RFC3339),
}
if k.ExpiresAt != nil {
m["expires_at"] = k.ExpiresAt.UTC().Format(time.RFC3339)
} else {
m["expires_at"] = nil
}
if k.RevokedAt != nil {
m["revoked_at"] = k.RevokedAt.UTC().Format(time.RFC3339)
} else {
m["revoked_at"] = nil
}
if k.LastUsedAt != nil {
m["last_used_at"] = k.LastUsedAt.UTC().Format(time.RFC3339)
} else {
m["last_used_at"] = nil
}
return m
}
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
writePaginatedListJSON(w, r, list, func(k *store.APIKey) map[string]any {
return apiKeyJSON(k)
})
}
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *string `json:"expires_at"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
in := &store.APIKeyCreate{
Name: strings.TrimSpace(body.Name),
Role: strings.TrimSpace(body.Role),
}
if body.ExpiresAt != nil && strings.TrimSpace(*body.ExpiresAt) != "" {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*body.ExpiresAt))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
in.ExpiresAt = &t
}
created, err := s.store.CreateAPIKey(a.TenantID, in)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&created.APIKey)
out["token"] = created.Token
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
patch := &store.APIKeyPatch{}
if v, ok := raw["name"]; ok {
var name string
if err := json.Unmarshal(v, &name); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid name")
return
}
patch.Name = &name
}
if v, ok := raw["role"]; ok {
var role string
if err := json.Unmarshal(v, &role); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid role")
return
}
patch.Role = &role
}
if v, ok := raw["expires_at"]; ok {
if string(v) == "null" {
patch.ClearExpiresAt = true
} else {
var s string
if err := json.Unmarshal(v, &s); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid expires_at")
return
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
patch.ExpiresAt = &t
}
}
k, err := s.store.UpdateAPIKey(a.TenantID, r.PathValue("id"), patch)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&rotated.APIKey)
out["token"] = rotated.Token
writeJSON(w, http.StatusOK, out)
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestBearerDevWithoutInsecureDev(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules?limit=1", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestAPIKeysCRUDAndAuth(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
reqCreate, _ := http.NewRequest(http.MethodPost, base+"/v1/api-keys", strings.NewReader(`{"name":"ci","role":"editor"}`))
reqCreate.Header.Set("Authorization", "Bearer opkey")
reqCreate.Header.Set("Content-Type", "application/json")
respCreate, err := client.Do(reqCreate)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respCreate.Body.Close() }()
if respCreate.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respCreate.Body)
t.Fatalf("create status=%d body=%s", respCreate.StatusCode, b)
}
var created map[string]any
if err := json.NewDecoder(respCreate.Body).Decode(&created); err != nil {
t.Fatal(err)
}
token, _ := created["token"].(string)
if token == "" {
t.Fatal("missing token in create response")
}
id, _ := created["id"].(string)
if id == "" {
t.Fatal("missing id")
}
reqMod, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqMod.Header.Set("Authorization", "Bearer "+token)
respMod, err := client.Do(reqMod)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respMod.Body.Close() }()
if respMod.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respMod.Body)
t.Fatalf("modules status=%d body=%s", respMod.StatusCode, b)
}
reqDel, _ := http.NewRequest(http.MethodDelete, base+"/v1/api-keys/"+id, nil)
reqDel.Header.Set("Authorization", "Bearer opkey")
respDel, err := client.Do(reqDel)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDel.Body.Close() }()
if respDel.StatusCode != http.StatusNoContent {
t.Fatalf("delete status=%d", respDel.StatusCode)
}
reqAfter, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqAfter.Header.Set("Authorization", "Bearer "+token)
respAfter, err := client.Do(reqAfter)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respAfter.Body.Close() }()
if respAfter.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 after revoke, got %d", respAfter.StatusCode)
}
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator")
reqNode2, _ := http.NewRequest(http.MethodGet, base+"/v1/api-keys", nil)
reqNode2.Header.Set("Authorization", "Bearer nodekey")
respNode, err := client.Do(reqNode2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respNode.Body.Close() }()
if respNode.StatusCode != http.StatusForbidden {
t.Fatalf("node list api-keys status=%d want 403", respNode.StatusCode)
}
}
+26 -3
View File
@@ -65,11 +65,14 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
m.HandleFunc("DELETE /speakers/{speaker_id}", s.handleDeleteSpeaker)
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
m.HandleFunc("GET /settings", s.handleGetSettings)
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
s.registerAPIKeyRoutes(m)
}
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
@@ -972,12 +975,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
if err := normalizeSpeakerCreate(&body); err != nil {
writeStoreErr(w, err)
return
}
x, err := s.store.CreateSpeaker(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, speakerJSON(x))
resp := speakerJSONFromStore(s.store, x)
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
resp["agent_secret"] = meta.AgentSecret
}
writeJSON(w, http.StatusCreated, resp)
}
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
@@ -990,7 +1001,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, speakerJSON(x))
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
}
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
@@ -1008,7 +1019,19 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, speakerJSON(x))
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
}
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -21,7 +21,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("opkey|" + tenant + "|operator")
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+1 -1
View File
@@ -17,7 +17,7 @@ func TestNestedModuleListPagination(t *testing.T) {
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+6 -4
View File
@@ -21,8 +21,7 @@ type Server struct {
pgPool *pgxpool.Pool
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
keyResolver *apiKeyResolver
corsOrigins []string
cdnHTTP *http.Client
mux *http.ServeMux
@@ -60,13 +59,16 @@ func New(opts Options) (*Server, error) {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
resolver, err := newAPIKeyResolver(opts.APIKeys, backend)
if err != nil {
return nil, err
}
s := &Server{
store: backend,
pgPool: pool,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
keyResolver: resolver,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
}
+1 -1
View File
@@ -29,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator,edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+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})
}
+86
View File
@@ -0,0 +1,86 @@
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 TestDeleteSpeaker(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, demoSpk := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
req := httptest.NewRequest(http.MethodDelete, "/v1/speakers/"+demoSpk, nil)
req.Header.Set("Authorization", "Bearer edkey")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if _, err := srv.Store().GetSpeaker(tenant, demoSpk); err == nil {
t.Fatal("speaker should be deleted")
}
}
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)
}
}
+12
View File
@@ -0,0 +1,12 @@
package httpapi
import "testing"
func mustSetTestAPIKeys(t *testing.T, srv *Server, spec string) {
t.Helper()
resolver, err := newAPIKeyResolver(spec, srv.store)
if err != nil {
t.Fatal(err)
}
srv.keyResolver = resolver
}
+77
View File
@@ -0,0 +1,77 @@
package jobs
import (
"context"
"os"
"strconv"
"time"
)
const (
defaultJobTimeoutModuleRefresh = 10 * time.Minute
defaultJobTimeoutTenantRefresh = 15 * time.Minute
defaultJobTimeoutDeployApply = 5 * time.Minute
defaultJobTimeoutPeerReconcile = 10 * time.Minute
defaultJobTimeoutRollback = 5 * time.Minute
defaultJobTimeoutBirdReload = 2 * time.Minute
)
func jobTimeout(kind string) time.Duration {
envKey := map[string]string{
KindModuleRefresh: "EVOBGP_JOB_TIMEOUT_MODULE_REFRESH",
KindTenantRefresh: "EVOBGP_JOB_TIMEOUT_TENANT_REFRESH",
KindDeployApply: "EVOBGP_JOB_TIMEOUT_DEPLOY_APPLY",
KindPeerReconcile: "EVOBGP_JOB_TIMEOUT_PEER_RECONCILE",
KindRevisionRollback: "EVOBGP_JOB_TIMEOUT_ROLLBACK",
KindBirdReload: "EVOBGP_JOB_TIMEOUT_BIRD_RELOAD",
}[kind]
if envKey != "" {
if d, err := time.ParseDuration(os.Getenv(envKey)); err == nil && d > 0 {
return d
}
}
switch kind {
case KindModuleRefresh:
return defaultJobTimeoutModuleRefresh
case KindTenantRefresh:
return defaultJobTimeoutTenantRefresh
case KindDeployApply:
return defaultJobTimeoutDeployApply
case KindPeerReconcile:
return defaultJobTimeoutPeerReconcile
case KindRevisionRollback:
return defaultJobTimeoutRollback
case KindBirdReload:
return defaultJobTimeoutBirdReload
default:
if n, err := strconv.Atoi(os.Getenv("EVOBGP_JOB_TIMEOUT_SEC")); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
return defaultJobTimeoutModuleRefresh
}
}
// workContext returns a timeout context that also cancels when the job is cancelled.
func (j *Job) workContext() (context.Context, context.CancelFunc) {
if j == nil {
return context.Background(), func() {}
}
timeout := jobTimeout(j.Kind)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if j.IsCancelRequested() {
cancel()
return
}
}
}
}()
return ctx, cancel
}
+51 -2
View File
@@ -9,6 +9,8 @@ import (
"sync"
"time"
"evobgp/internal/observability"
"github.com/google/uuid"
)
@@ -178,6 +180,8 @@ type Registry struct {
byID map[string]*Job
byIdempo map[idempoKey]*Job
workerStart func(j *Job)
workerSem chan struct{}
onTerminal func(j *Job)
}
type idempoKey struct {
@@ -186,13 +190,44 @@ type idempoKey struct {
}
func NewRegistry(workerStart func(j *Job)) *Registry {
maxWorkers := registryMaxConcurrentJobs()
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
}
}
// SetTerminalHook registers a best-effort callback when jobs reach a terminal state.
func (r *Registry) SetTerminalHook(fn func(j *Job)) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.onTerminal = fn
}
func (r *Registry) fireTerminal(j *Job) {
if r == nil || j == nil {
return
}
r.mu.RLock()
fn := r.onTerminal
r.mu.RUnlock()
if fn != nil {
fn(j)
}
}
func registryMaxConcurrentJobs() int {
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_JOB_MAX_CONCURRENT"))); err == nil && n > 0 {
return n
}
return 8
}
// pruneTerminalIfOver удаляет самые старые завершённые джобы (succeeded/failed/cancelled), пока len(byID) > maxJobs.
func (r *Registry) pruneTerminalIfOver(maxJobs int) {
if r == nil || maxJobs <= 0 || len(r.byID) <= maxJobs {
@@ -244,7 +279,11 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
if idempotencyKey != nil && *idempotencyKey != "" {
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
if existing, ok := r.byIdempo[k]; ok {
return existing, false, nil
st := existing.statusLocked()
if st == StatusQueued || st == StatusRunning {
return existing, false, nil
}
delete(r.byIdempo, k)
}
}
@@ -265,7 +304,17 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
r.pruneTerminalIfOver(maxJobs)
if r.workerStart != nil {
go r.workerStart(j)
go func() {
r.workerSem <- struct{}{}
active := len(r.workerSem)
capacity := cap(r.workerSem)
observability.RecordJobQueueDepth(active, capacity)
defer func() {
<-r.workerSem
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
}()
r.workerStart(j)
}()
}
return j, true, nil
}
+106 -11
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/birddeploy"
"evobgp/internal/birdfmt"
"evobgp/internal/nodedispatch"
"evobgp/internal/observability"
"evobgp/internal/pipeline"
"evobgp/internal/store"
@@ -82,6 +83,9 @@ func (w *Worker) httpClient() *http.Client {
func (w *Worker) Process(j *Job) {
defer func() {
observability.RecordJobTerminal(j.Kind, j.statusLocked())
if w != nil && w.Registry != nil {
w.Registry.fireTerminal(j)
}
}()
if w == nil || w.Store == nil {
@@ -102,7 +106,17 @@ func (w *Worker) Process(j *Job) {
j.Fail("missing module_id in job meta")
return
}
if err := pipeline.RefreshModuleIngest(context.Background(), w.Store, w.httpClient(), j.TenantID, mid); err != nil {
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -121,11 +135,17 @@ func (w *Worker) Process(j *Job) {
j.Succeed()
return
}
ctx, cancel := j.workContext()
defer cancel()
ctl := &birdfmt.BirdCtl{
Socket: sock,
Birdc: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
}
if err := ctl.Configure(context.Background()); err != nil {
if err := ctl.Configure(ctx); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -152,9 +172,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
return
}
if len(latest) == 0 {
// First run fallback: render full tenant state once if no baseline revision exists yet.
rid, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
ctx, cancel := j.workContext()
defer cancel()
rid, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -171,8 +196,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
}
cursor = next
}
rid, err := pipeline.RenderTenantRevisionFromPrefixes(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
ctx, cancel := j.workContext()
defer cancel()
rid, err := pipeline.RenderTenantRevisionFromPrefixes(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -188,8 +219,8 @@ func (w *Worker) runPeerReconcile(j *Job) {
} else {
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
}
j.Succeed()
w.enqueueDeployAllSpeakers(j, j.TenantID, revID)
j.Succeed()
}
func (w *Worker) peerTriggerModuleID(tenantID string, latest []*store.Revision) (string, error) {
@@ -230,7 +261,13 @@ func (w *Worker) runTenantRefresh(j *Job) {
j.Fail("missing module_ids in job meta")
return
}
if err := pipeline.RefreshTenantModules(context.Background(), w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
ctx, cancel := j.workContext()
defer cancel()
if err := pipeline.RefreshTenantModules(ctx, w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -275,6 +312,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := j.workContext()
defer cancel()
deferDeploy := false
if w.Registry != nil {
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
@@ -288,8 +327,12 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
return
}
rev, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
rev, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -303,8 +346,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
} else {
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
}
j.Succeed()
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
j.Succeed()
}
// enqueueDeployAllSpeakers queues the same work as POST /v1/apply (all speakers, no speaker_id).
@@ -335,6 +378,8 @@ func (w *Worker) runDeployApply(j *Job) {
j.Fail("missing revision_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
activeDir := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR"))
if activeDir != "" {
revObj, err := w.Store.GetRevision(j.TenantID, revID)
@@ -354,12 +399,17 @@ func (w *Worker) runDeployApply(j *Job) {
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
}
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
if err := birddeploy.ApplyRevision(context.Background(), ctl, revObj, cfg); err != nil {
if err := birddeploy.ApplyRevision(ctx, ctl, revObj, cfg); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
}
applied := make([]string, 0, 8)
var dispatchResults []nodedispatch.Result
applyOne := func(speakerID string) error {
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
return err
@@ -371,21 +421,60 @@ func (w *Worker) runDeployApply(j *Job) {
applied = append(applied, speakerID)
return nil
}
dispatchSpeaker := func(sp *store.Speaker) {
if !nodedispatch.Enabled() || sp == nil {
return
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
return
}
ctx2, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
res := nodedispatch.WakeSpeaker(ctx2, sp, nodedispatch.Options{RevisionID: revID})
dispatchResults = append(dispatchResults, res)
patch := store.SpeakerMeta{
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
LastDispatchStatus: res.Status,
}
if res.Error != "" {
patch.LastDispatchError = res.Error
patch.SyncStatus = "error"
} else if res.Status == "ok" {
patch.LastDispatchError = ""
patch.SyncStatus = "synced"
}
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
_, _ = w.Store.UpdateSpeaker(j.TenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &merged})
}
if hasSpeaker && spk != "" {
if err := applyOne(spk); err != nil {
j.Fail(err.Error())
return
}
if sp, err := w.Store.GetSpeaker(j.TenantID, spk); err == nil {
dispatchSpeaker(sp)
}
if len(dispatchResults) > 0 {
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
"revision_id": revID,
"results": dispatchResults,
}})
}
mergeBirdPostApplyMeta(j)
j.Succeed()
return
}
for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) {
speakers := w.Store.ListSpeakersForTenant(j.TenantID)
for _, sp := range speakers {
if err := applyOne(sp.ID); err != nil {
j.Fail(err.Error())
return
}
}
for _, sp := range speakers {
dispatchSpeaker(sp)
}
j.mergeMeta(map[string]any{
"apply_summary": map[string]any{
"revision_id": revID,
@@ -394,6 +483,12 @@ func (w *Worker) runDeployApply(j *Job) {
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)),
},
})
if len(dispatchResults) > 0 {
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
"revision_id": revID,
"results": dispatchResults,
}})
}
mergeBirdPostApplyMeta(j)
j.Succeed()
}
+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)
}
+69
View File
@@ -0,0 +1,69 @@
package nodedispatch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
)
// BirdProtocolsResult is agent birdc scrape outcome.
type BirdProtocolsResult struct {
SpeakerID string `json:"speaker_id,omitempty"`
Sessions []birdfmt.BGPSession `json:"sessions"`
Error string `json:"error,omitempty"`
}
// FetchBirdProtocols GETs /v1/agent/bird/protocols on a replica agent.
func FetchBirdProtocols(ctx context.Context, sp *store.Speaker, opts Options) BirdProtocolsResult {
res := BirdProtocolsResult{}
if sp != nil {
res.SpeakerID = sp.ID
}
if sp == nil {
res.Error = "nil speaker"
return res
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
url := store.AgentBirdProtocolsURL(meta)
if url == "" {
res.Error = "agent_domain not configured"
return res
}
secret := strings.TrimSpace(meta.AgentSecret)
if secret == "" {
res.Error = "agent_secret missing"
return res
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
res.Error = err.Error()
return res
}
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := opts.client().Do(req)
if err != nil {
res.Error = err.Error()
return res
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
res.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
return res
}
var out struct {
Sessions []birdfmt.BGPSession `json:"sessions"`
}
if err := json.Unmarshal(b, &out); err != nil {
res.Error = err.Error()
return res
}
res.Sessions = out.Sessions
return res
}
+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")
}
}
+86 -1
View File
@@ -80,6 +80,38 @@ var (
Help: "Prefix row count after CIDR aggregation on tenant render.",
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
})
pipelineRefreshDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: namespace,
Name: "pipeline_refresh_duration_seconds",
Help: "Module refresh ingest duration by module type.",
Buckets: prometheus.ExponentialBuckets(0.05, 2, 14),
}, []string{"module_type"})
renderPrefixCount = promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace,
Name: "render_prefix_count",
Help: "Materialized prefix count per tenant render.",
Buckets: prometheus.ExponentialBuckets(10, 2, 16),
})
jobQueueActive = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "job_queue_active",
Help: "Currently running in-process async jobs.",
})
jobQueueCapacity = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "job_queue_capacity",
Help: "Maximum concurrent in-process async jobs.",
})
)
var (
birdProtocolStatesMu sync.RWMutex
birdProtocolStates map[string]string
birdProtocolStatesAt time.Time
)
// RecordPrefixAggregation records tenant render CIDR aggregation stats.
@@ -93,6 +125,27 @@ func RecordPrefixAggregation(rawCount, aggregatedCount int, duration time.Durati
prefixAggregationDuration.Observe(duration.Seconds())
prefixAggregationRawCount.Observe(float64(rawCount))
prefixAggregationAggregatedCount.Observe(float64(aggregatedCount))
renderPrefixCount.Observe(float64(aggregatedCount))
}
// RecordPipelineRefresh records module ingest duration.
func RecordPipelineRefresh(moduleType string, duration time.Duration) {
if moduleType == "" {
moduleType = "unknown"
}
pipelineRefreshDuration.WithLabelValues(moduleType).Observe(duration.Seconds())
}
// RecordJobQueueDepth updates in-process job worker utilization gauges.
func RecordJobQueueDepth(active, capacity int) {
if active < 0 {
active = 0
}
if capacity < 0 {
capacity = 0
}
jobQueueActive.Set(float64(active))
jobQueueCapacity.Set(float64(capacity))
}
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
@@ -205,6 +258,35 @@ func SetBirdSessionMetrics(established int, scrapeOK bool) {
}
}
// SetBirdProtocolStates caches parsed BGP protocol states from the last birdc scrape.
func SetBirdProtocolStates(states map[string]string) {
birdProtocolStatesMu.Lock()
defer birdProtocolStatesMu.Unlock()
if states == nil {
birdProtocolStates = map[string]string{}
} else {
birdProtocolStates = states
}
birdProtocolStatesAt = time.Now()
}
// CachedBirdProtocolStates returns cached protocol states if younger than maxAge.
func CachedBirdProtocolStates(maxAge time.Duration) (map[string]string, bool) {
if maxAge <= 0 {
maxAge = 60 * time.Second
}
birdProtocolStatesMu.RLock()
defer birdProtocolStatesMu.RUnlock()
if birdProtocolStates == nil || time.Since(birdProtocolStatesAt) > maxAge {
return nil, false
}
out := make(map[string]string, len(birdProtocolStates))
for k, v := range birdProtocolStates {
out[k] = v
}
return out, true
}
// MetricsHandler returns the Prometheus scrape handler.
func MetricsHandler() http.Handler {
return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{})
@@ -231,7 +313,7 @@ func (s *statusRecorder) WriteHeader(code int) {
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
// Горутина завершается при отмене ctx (корректное завершение вместе с процессом API).
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int, parseFn func(output string) map[string]string) {
socket = trimSpace(socket)
if ctx == nil || socket == "" || interval <= 0 || showFn == nil || countFn == nil {
return
@@ -245,6 +327,9 @@ func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath stri
return
}
SetBirdSessionMetrics(countFn(out), true)
if parseFn != nil {
SetBirdProtocolStates(parseFn(out))
}
}
go func() {
scrape()
+1
View File
@@ -44,6 +44,7 @@ func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client,
if err != nil {
return nil, "", err
}
asnresolve.PolitePause()
holder, _ := asnresolve.ASHolderName(ctx, hc, asn)
if st != nil {
strs := make([]string, len(pfxs))
+94 -3
View File
@@ -25,9 +25,6 @@ func cachedCDNPrefixRows(st store.Backend, tenantID, moduleID string, priorSnaps
return cached
}
}
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
return cached
}
}
return nil
}
@@ -45,6 +42,35 @@ func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.P
return out
}
// mergeSnapshotDropCDNSources removes all cdn:* rows (used before batch CDN merge).
func mergeSnapshotDropCDNSources(rows []store.PrefixRow) []store.PrefixRow {
if len(rows) == 0 {
return nil
}
out := make([]store.PrefixRow, 0, len(rows))
for _, row := range rows {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
out = append(out, row)
}
}
return out
}
// mergeAllCDNSourcesIntoModuleSnapshot replaces all CDN rows in one write (avoids parallel read-modify-write races).
func mergeAllCDNSourcesIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, priorSnapshot []store.PrefixRow, cdnRows []store.PrefixRow) error {
if st == nil || mod == nil {
return nil
}
var base []store.PrefixRow
if len(priorSnapshot) > 0 {
base = mergeSnapshotDropCDNSources(priorSnapshot)
} else if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
base = mergeSnapshotDropCDNSources(snap.Prefixes)
}
merged := append(base, cdnRows...)
return persistModuleSnapshot(st, tenantID, mod, merged)
}
func cdnRowsFromParsed(mod *store.Module, src *store.CDNSource, pfxStrings []string) []store.PrefixRow {
var rows []store.PrefixRow
for _, p := range pfxStrings {
@@ -156,3 +182,68 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
}
return rows, nil
}
// fetchCDNSourceRows loads CDN prefixes without persisting the module snapshot (caller merges once).
func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
u := strings.TrimSpace(src.URL)
if u == "" {
return nil, nil
}
sourceKey := cdnSourceKey(src.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
if cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey); len(cached) > 0 {
_ = resp.Body.Close()
return cached, nil
}
_ = resp.Body.Close()
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err = hc.Do(req2)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
prefixStrs, err := parseCDNBody(string(body), src)
if err != nil {
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{}
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
patch.Etag = &e
}
refreshedAt := now
patch.LastRefreshedAt = &refreshedAt
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
return cdnRowsFromParsed(mod, src, prefixStrs), nil
}
+22 -7
View File
@@ -97,15 +97,18 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
seenPfx := make(map[string]struct{})
var out []store.PrefixRow
var metaUpdates []store.ASEntryResolveMetaUpdate
now := time.Now().UTC()
for _, r := range results {
if r.err != nil {
return nil, r.err
}
if r.metaID != "" {
if err := st.UpdateASEntryResolveMeta(tenantID, moduleID, r.metaID, r.holder, r.count, now); err != nil {
return nil, fmt.Errorf("as entry meta AS%d: %w", r.asn, err)
}
metaUpdates = append(metaUpdates, store.ASEntryResolveMetaUpdate{
EntryID: r.metaID,
ASNName: r.holder,
PrefixCount: r.count,
})
}
for _, row := range r.rows {
k := row.Prefix
@@ -116,6 +119,11 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
out = append(out, row)
}
}
if len(metaUpdates) > 0 {
if err := st.UpdateASEntryResolveMetaBatch(tenantID, moduleID, metaUpdates, now); err != nil {
return nil, fmt.Errorf("as entry meta batch: %w", err)
}
}
return out, nil
}
@@ -150,12 +158,14 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
results[idx] = srcResult{rows: cached}
return
}
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
results[idx] = srcResult{rows: cached}
return
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
if cached := prefixRowsForSource(snap.Prefixes, sourceKey); len(cached) > 0 {
results[idx] = srcResult{rows: cached}
return
}
}
}
rows, err := applyCDNSourceHTTPResult(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
if err != nil {
results[idx] = srcResult{err: err}
return
@@ -172,6 +182,11 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
}
out = append(out, r.rows...)
}
if len(valid) > 0 {
if err := mergeAllCDNSourcesIntoModuleSnapshot(st, tenantID, mod, priorSnapshot, out); err != nil {
return nil, err
}
}
return out, nil
}
+19 -40
View File
@@ -36,6 +36,11 @@ const (
revisionDefaultTTL = 30 * 24 * time.Hour
)
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
func AuxBirdFullExpandedKey() string {
return auxBirdFullExpanded
}
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
func MaterializedASPrefixKey(asn int64) string {
return fmt.Sprintf("as:%d", asn)
@@ -47,10 +52,14 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
if hc == nil {
hc = http.DefaultClient
}
start := time.Now()
mod, err := st.GetModule(tenantID, moduleID)
if err != nil {
return err
}
defer func() {
observability.RecordPipelineRefresh(mod.Type, time.Since(start))
}()
if !mod.Enabled {
return fmt.Errorf("pipeline: module disabled")
}
@@ -197,33 +206,6 @@ func shouldSkipCDNSourceFetch(src *store.CDNSource, now time.Time) bool {
return now.UTC().Before(nextRefreshAt)
}
func latestCDNRowsBySource(st store.Backend, tenantID string) map[string][]store.PrefixRow {
out := make(map[string][]store.PrefixRow)
if st == nil {
return out
}
revs, _, _ := st.ListRevisions(tenantID, "", "", 1)
if len(revs) == 0 || strings.TrimSpace(revs[0].ID) == "" {
return out
}
revID := strings.TrimSpace(revs[0].ID)
cursor := ""
for {
page, next, more := st.ListRevisionPrefixes(tenantID, revID, cursor, 2000)
for _, row := range page {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
continue
}
out[row.Source] = append(out[row.Source], row)
}
if !more || strings.TrimSpace(next) == "" {
break
}
cursor = next
}
return out
}
type dohJSONAnswer struct {
Type int `json:"type"`
Data string `json:"data"`
@@ -836,10 +818,18 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6),
pPeers: peersBody,
}
out[auxBirdFullExpanded] = buildExpandedBirdText(main, out)
return out, nil
}
// BuildExpandedBirdPreview concatenates bird.conf and deployable includes for UI preview (not persisted in revision).
func BuildExpandedBirdPreview(frags map[string]string) string {
if frags == nil {
return ""
}
main := frags["bird.conf"]
return buildExpandedBirdText(main, frags)
}
func renderStaticProtocolsByCommunity(groups []staticCommunityRoutes) (string, string) {
var b4 strings.Builder
var b6 strings.Builder
@@ -1039,7 +1029,7 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
}
pol := parsePeerPolicies(p.PoliciesJSON)
lv4, lv6, asn := effectivePeerLocals(loc, pol)
proto := peerProtocolName(p.ID)
proto := birdfmt.PeerProtocolName(p.ID)
ra := uint32(p.RemoteASN)
if addr.Is4() {
opts := birdfmt.BGPPeerFromTemplateOptions{
@@ -1093,17 +1083,6 @@ func parsePeerPolicies(raw string) peerPolicyJSON {
return pol
}
func peerProtocolName(peerID string) string {
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
if len(s) > 16 {
s = s[:16]
}
if s == "" {
s = "x"
}
return "evobgp_p_" + s
}
// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview).
func buildExpandedBirdText(main string, frags map[string]string) string {
var b strings.Builder
+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"])
}
}
+50
View File
@@ -0,0 +1,50 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// JobAuditWriter persists async job lifecycle rows to job_audit (optional cross-process queue foundation).
type JobAuditWriter struct {
pool *pgxpool.Pool
}
func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
if pool == nil {
return nil
}
return &JobAuditWriter{pool: pool}
}
// UpsertRunning inserts or updates a running job row (best-effort).
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
if w == nil || w.pool == nil {
return
}
metaJSON, _ := json.Marshal(meta)
var idem any
if idempotencyKey != nil && *idempotencyKey != "" {
idem = *idempotencyKey
}
_, _ = w.pool.Exec(ctx, `
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
jobID, tenantID, kind, idem, metaJSON)
}
// MarkTerminal updates job_audit terminal state (best-effort).
func (w *JobAuditWriter) MarkTerminal(ctx context.Context, tenantID, jobID, status string, errMsg *string, finishedAt time.Time) {
if w == nil || w.pool == nil {
return
}
_, _ = w.pool.Exec(ctx, `
UPDATE job_audit SET status=$3, error_message=$4, finished_at=$5
WHERE id=$1::uuid AND tenant_id=$2::uuid`,
jobID, tenantID, status, errMsg, finishedAt.UTC())
}
+139 -27
View File
@@ -126,6 +126,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
}
defer rows.Close()
var out []*store.Module
moduleByID := make(map[string]*store.Module)
for rows.Next() {
var m store.Module
m.TenantID = tenantID
@@ -152,14 +153,84 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
if err := p.fillModuleDohFields(ctx, &m); err != nil {
continue
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
return nil
}
return out
}
func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store.Module, string, bool) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY priority, name
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
if err != nil {
return nil, "", false
}
defer rows.Close()
var out []*store.Module
moduleByID := make(map[string]*store.Module)
for rows.Next() {
var m store.Module
m.TenantID = tenantID
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if refresh != nil {
m.RefreshIntervalSec = int(*refresh)
}
if cron != nil {
m.CronExpr = *cron
}
if doh != nil && *doh != "" {
m.DohProfileID = doh
}
if dc != nil && *dc != "" {
m.DefaultCommunityID = dc
}
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
return nil, "", false
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = fmt.Sprintf("%d", off+limit)
}
if len(out) == 0 {
return nil, "", false
}
return out, next, more
}
func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
ctx := context.Background()
var m store.Module
@@ -575,6 +646,18 @@ func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch)
return p.GetSpeaker(tenantID, id)
}
func (p *Postgres) DeleteSpeaker(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_speaker WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
ctx := context.Background()
var r store.Revision
@@ -685,7 +768,13 @@ func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor stri
}
}
ctx := context.Background()
if _, err := p.GetRevision(tenantID, revisionID); err != nil {
var one int
if err := p.pool.QueryRow(ctx, `
SELECT 1 FROM config_revision WHERE id = $1::uuid AND tenant_id = $2::uuid`,
revisionID, tenantID).Scan(&one); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", false
}
return nil, "", false
}
rows, err := p.pool.Query(ctx, `
@@ -754,6 +843,8 @@ func (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (st
return newID, nil
}
const maxRevisionDiffRows = 5000
func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
if _, err := p.GetRevision(tenantID, aID); err != nil {
return nil, err
@@ -778,7 +869,7 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
EXCEPT
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
) s ORDER BY 1`, bID, aID)
) s ORDER BY 1 LIMIT $3`, bID, aID, maxRevisionDiffRows+1)
if err != nil {
return nil, err
}
@@ -790,13 +881,18 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
continue
}
added = append(added, s)
if len(added) > maxRevisionDiffRows {
added = added[:maxRevisionDiffRows]
break
}
}
addedTruncated := len(added) >= maxRevisionDiffRows
rowsRem, err := p.pool.Query(ctx, `
SELECT prefix::text FROM (
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
EXCEPT
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
) s ORDER BY 1`, aID, bID)
) s ORDER BY 1 LIMIT $3`, aID, bID, maxRevisionDiffRows+1)
if err != nil {
return nil, err
}
@@ -808,40 +904,56 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
continue
}
removed = append(removed, s)
if len(removed) > maxRevisionDiffRows {
removed = removed[:maxRevisionDiffRows]
break
}
}
return map[string]any{
"revision_a": aID,
"revision_b": bID,
"prefixes": map[string]any{
"added": added, "removed": removed, "unchanged_count": unchanged,
"truncated": addedTruncated || len(removed) >= maxRevisionDiffRows,
},
}, nil
}
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
ctx := context.Background()
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.tenant_id = $1
AND cr.created_at < $2
AND cr.id <> (
SELECT id
FROM config_revision
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1
FROM bgp_speaker AS sp
WHERE sp.tenant_id = $1
AND (sp.last_applied_revision_id = cr.id OR sp.published_revision_id = cr.id)
)`,
tenantID, cutoff.UTC())
if err != nil {
return 0, err
total := 0
const batchSize = 50
for {
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.id IN (
SELECT id FROM config_revision
WHERE tenant_id = $1
AND created_at < $2
AND id <> (
SELECT id FROM config_revision
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM bgp_speaker AS sp
WHERE sp.tenant_id = $1
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
)
ORDER BY created_at ASC
LIMIT $3
)`, tenantID, cutoff.UTC(), batchSize)
if err != nil {
return total, err
}
n := int(cmd.RowsAffected())
total += n
if n < batchSize {
break
}
}
return int(cmd.RowsAffected()), nil
return total, nil
}
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
+203
View File
@@ -0,0 +1,203 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"evobgp/internal/authkey"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (p *Postgres) ListAPIKeys(tenantID string) ([]*store.APIKey, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.APIKey
for rows.Next() {
k, err := scanAPIKeyRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
func (p *Postgres) GetAPIKey(tenantID, id string) (*store.APIKey, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE id=$1 AND tenant_id=$2`, id, tenantID)
k, err := scanAPIKeyRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return k, nil
}
func (p *Postgres) CreateAPIKey(tenantID string, in *store.APIKeyCreate) (*store.APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !store.ValidAPIKeyRole(in.Role) {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
id := uuid.NewString()
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
role := strings.ToLower(strings.TrimSpace(in.Role))
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
INSERT INTO api_key (id, tenant_id, name, role, token_prefix, token_hash, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, strings.TrimSpace(in.Name), role, prefix, hash, in.ExpiresAt)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) UpdateAPIKey(tenantID, id string, patch *store.APIKeyPatch) (*store.APIKey, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
if patch == nil {
return nil, store.ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, store.ErrInvalidInput
}
cur.Name = n
}
if patch.Role != nil {
if !store.ValidAPIKeyRole(*patch.Role) {
return nil, store.ErrInvalidInput
}
cur.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
cur.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
cur.ExpiresAt = patch.ExpiresAt
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET name=$3, role=$4, expires_at=$5, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, cur.Name, cur.Role, cur.ExpiresAt)
if err != nil {
return nil, err
}
return p.GetAPIKey(tenantID, id)
}
func (p *Postgres) RevokeAPIKey(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE api_key SET revoked_at=now(), updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) RotateAPIKey(tenantID, id string) (*store.APIKeyWithSecret, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET token_hash=$3, token_prefix=$4, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, hash, prefix)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) ListActiveAPIKeyHashes() ([]store.APIKeyAuthRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, role, token_hash
FROM api_key
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.APIKeyAuthRow
for rows.Next() {
var row store.APIKeyAuthRow
var hash []byte
if err := rows.Scan(&row.ID, &row.TenantID, &row.Role, &hash); err != nil {
return nil, err
}
row.TokenHash = append([]byte(nil), hash...)
out = append(out, row)
}
return out, rows.Err()
}
func (p *Postgres) TouchAPIKeyLastUsed(id string) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `UPDATE api_key SET last_used_at=now() WHERE id=$1`, id)
return err
}
type scanFn func(dest ...any) error
func scanAPIKeyRow(scan scanFn, tenantID string) (*store.APIKey, error) {
var k store.APIKey
k.TenantID = tenantID
var expires, revoked, lastUsed *time.Time
if err := scan(&k.ID, &k.Name, &k.Role, &k.Prefix, &k.CreatedAt, &k.UpdatedAt, &expires, &revoked, &lastUsed); err != nil {
return nil, err
}
k.ExpiresAt = expires
k.RevokedAt = revoked
k.LastUsedAt = lastUsed
return &k, nil
}
+32
View File
@@ -325,6 +325,38 @@ func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string,
return nil
}
func (p *Postgres) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []store.ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
if len(updates) == 0 {
return nil
}
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
}
ctx := context.Background()
batch := &pgx.Batch{}
for _, u := range updates {
var nameArg any
sn := strings.TrimSpace(u.ASNName)
if sn == "" {
nameArg = nil
} else {
nameArg = sn
}
batch.Queue(`
UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now()
WHERE id=$1 AND module_id=$2`,
u.EntryID, moduleID, nameArg, u.PrefixCount, resolvedAt.UTC())
}
br := p.pool.SendBatch(ctx, batch)
defer func() { _ = br.Close() }()
for range updates {
if _, err := br.Exec(); err != nil {
return err
}
}
return nil
}
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
+22 -9
View File
@@ -10,28 +10,41 @@ func (p *Postgres) fillModuleDohFields(ctx context.Context, m *store.Module) err
if m == nil {
return nil
}
return p.batchFillModuleDohFields(ctx, map[string]*store.Module{m.ID: m})
}
func (p *Postgres) batchFillModuleDohFields(ctx context.Context, modules map[string]*store.Module) error {
if len(modules) == 0 {
return nil
}
ids := make([]string, 0, len(modules))
for id := range modules {
ids = append(ids, id)
}
rows, err := p.pool.Query(ctx, `
SELECT doh_profile_id::text
SELECT module_id::text, doh_profile_id::text
FROM module_doh_profile
WHERE module_id = $1
ORDER BY sort_order, doh_profile_id`, m.ID)
WHERE module_id = ANY($1::uuid[])
ORDER BY module_id, sort_order, doh_profile_id`, ids)
if err != nil {
return err
}
defer rows.Close()
var ids []string
byModule := make(map[string][]string, len(modules))
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
var moduleID, profileID string
if err := rows.Scan(&moduleID, &profileID); err != nil {
return err
}
ids = append(ids, id)
byModule[moduleID] = append(byModule[moduleID], profileID)
}
if err := rows.Err(); err != nil {
return err
}
m.DohProfileIDs = store.NormalizeDohProfileIDList(ids)
m.SyncLegacyDohProfileID()
for id, m := range modules {
m.DohProfileIDs = store.NormalizeDohProfileIDList(byModule[id])
m.SyncLegacyDohProfileID()
}
return nil
}
+8
View File
@@ -0,0 +1,8 @@
package store
// ASEntryResolveMetaUpdate is one row for batch AS resolve metadata writes.
type ASEntryResolveMetaUpdate struct {
EntryID string
ASNName string
PrefixCount int64
}
+67
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"strings"
"time"
)
@@ -18,6 +19,8 @@ type Backend interface {
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
ListModules(tenantID string) []*Module
// ListModulesPage returns one page of modules (limit capped by caller).
ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool)
GetModule(tenantID, moduleID string) (*Module, error)
CreateModule(tenantID string, in *Module) (*Module, error)
UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error)
@@ -34,6 +37,7 @@ type Backend interface {
DeleteASEntry(tenantID, moduleID, entryID string) error
// UpdateASEntryResolveMeta записывает имя AS, число объявленных префиксов и время успешного резолва (pipeline).
UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error
UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
@@ -69,6 +73,7 @@ type Backend interface {
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error)
UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error)
DeleteSpeaker(tenantID, id string) error
GetRevision(tenantID, revisionID string) (*Revision, error)
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
@@ -85,6 +90,15 @@ type Backend interface {
ListGlobalSettings(tenantID string) (map[string]any, error)
PatchGlobalSettings(tenantID string, patch map[string]any) error
ListAPIKeys(tenantID string) ([]*APIKey, error)
GetAPIKey(tenantID, id string) (*APIKey, error)
CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error)
UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error)
RevokeAPIKey(tenantID, id string) error
RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error)
ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error)
TouchAPIKeyLastUsed(id string) error
// Module prefix snapshots cache last successful collect per module (pipeline ingest/render).
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
@@ -224,6 +238,59 @@ type CommunityPatch struct {
ValueJSON *string `json:"value_json,omitempty"`
}
// APIKey is tenant-scoped API key metadata (secret never stored in plaintext).
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Role string `json:"role"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// APIKeyCreate is input for issuing a new key.
type APIKeyCreate struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APIKeyPatch is a partial update (role change affects auth after resolver reload).
type APIKeyPatch struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
ClearExpiresAt bool `json:"-"`
}
// APIKeyWithSecret is returned only on create/rotate.
type APIKeyWithSecret struct {
APIKey
Token string `json:"token"`
}
// APIKeyAuthRow is used to build the in-process auth index.
type APIKeyAuthRow struct {
ID string
TenantID string
Role string
TokenHash []byte
}
// ValidAPIKeyRole reports whether role is allowed for API keys.
func ValidAPIKeyRole(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "viewer", "editor", "operator", "node":
return true
default:
return false
}
}
type PeerPatch struct {
Neighbor *string `json:"neighbor,omitempty"`
RemoteASN *int64 `json:"remote_asn,omitempty"`
+12
View File
@@ -43,6 +43,7 @@ type Memory struct {
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -57,6 +58,11 @@ type publishedInfo struct {
PublishedAt time.Time
}
type apiKeyRec struct {
APIKey
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -133,6 +139,7 @@ func NewMemory() *Memory {
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
}
}
@@ -382,6 +389,11 @@ func (m *Memory) ListModules(tenantID string) []*Module {
return out
}
func (m *Memory) ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool) {
all := m.ListModules(tenantID)
return PaginateOffset(all, cursor, limit)
}
// ListPeers returns BGP peers for a tenant (sorted by name).
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
m.mu.RLock()
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"strings"
"time"
"evobgp/internal/authkey"
"github.com/google/uuid"
)
func (m *Memory) ListAPIKeys(tenantID string) ([]*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*APIKey
for _, rec := range m.apiKeys {
if rec.TenantID == tenantID {
out = append(out, apiKeyCopy(&rec.APIKey))
}
}
return out, nil
}
func (m *Memory) GetAPIKey(tenantID, id string) (*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !ValidAPIKeyRole(in.Role) {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
id := uuid.NewString()
k := &apiKeyRec{
APIKey: APIKey{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Role: strings.ToLower(strings.TrimSpace(in.Role)),
Prefix: authkey.Prefix(tok),
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: in.ExpiresAt,
},
TokenHash: authkey.HashToken(tok),
}
m.apiKeys[id] = k
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&k.APIKey), Token: tok}, nil
}
func (m *Memory) UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Role != nil {
if !ValidAPIKeyRole(*patch.Role) {
return nil, ErrInvalidInput
}
rec.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
rec.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
rec.ExpiresAt = patch.ExpiresAt
}
rec.UpdatedAt = time.Now().UTC()
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) RevokeAPIKey(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.RevokedAt = &now
rec.UpdatedAt = now
return nil
}
func (m *Memory) RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec.TokenHash = authkey.HashToken(tok)
rec.Prefix = authkey.Prefix(tok)
rec.UpdatedAt = now
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&rec.APIKey), Token: tok}, nil
}
func (m *Memory) ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now().UTC()
var out []APIKeyAuthRow
for _, rec := range m.apiKeys {
if rec.RevokedAt != nil {
continue
}
if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) {
continue
}
out = append(out, APIKeyAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
Role: rec.Role,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) TouchAPIKeyLastUsed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastUsedAt = &now
return nil
}
func apiKeyCopy(k *APIKey) *APIKey {
cp := *k
if k.ExpiresAt != nil {
t := *k.ExpiresAt
cp.ExpiresAt = &t
}
if k.RevokedAt != nil {
t := *k.RevokedAt
cp.RevokedAt = &t
}
if k.LastUsedAt != nil {
t := *k.LastUsedAt
cp.LastUsedAt = &t
}
return &cp
}
+21
View File
@@ -301,6 +301,15 @@ func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, as
return nil
}
func (m *Memory) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
for _, u := range updates {
if err := m.UpdateASEntryResolveMeta(tenantID, moduleID, u.EntryID, u.ASNName, u.PrefixCount, resolvedAt); err != nil {
return err
}
}
return nil
}
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -770,6 +779,18 @@ func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speak
return sp, nil
}
func (m *Memory) DeleteSpeaker(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
sp, ok := m.speakers[id]
if !ok || sp.TenantID != tenantID {
return ErrNotFound
}
delete(m.speakers, id)
delete(m.publishedRevision, id)
return nil
}
func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) {
if limit <= 0 {
limit = 50
+150
View File
@@ -0,0 +1,150 @@
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 {
return agentHTTPSURL(meta, "/v1/agent/health")
}
// AgentBirdProtocolsURL returns HTTPS bird protocols URL for agent_domain.
func AgentBirdProtocolsURL(meta SpeakerMeta) string {
return agentHTTPSURL(meta, "/v1/agent/bird/protocols")
}
func agentHTTPSURL(meta SpeakerMeta, path string) string {
domain := strings.TrimSpace(meta.AgentDomain)
if domain == "" {
return ""
}
return "https://" + strings.TrimSuffix(domain, "/") + path
}
// 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)
}
}
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
DROP INDEX IF EXISTS idx_bgp_speaker_published;
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
DROP INDEX IF EXISTS idx_module_default_community;
DROP INDEX IF EXISTS idx_module_doh_profile_id;
@@ -0,0 +1,17 @@
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_module_default_community
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
ON config_revision (tenant_id, module_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
ON revision_materialized_prefix (revision_id, id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
CONSTRAINT api_key_role_chk CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CONSTRAINT api_key_name_chk CHECK (length(trim(name)) > 0),
CONSTRAINT api_key_token_hash_len_chk CHECK (octet_length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
DROP INDEX IF EXISTS idx_bgp_speaker_published;
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
DROP INDEX IF EXISTS idx_module_default_community;
DROP INDEX IF EXISTS idx_module_doh_profile_id;
@@ -0,0 +1,17 @@
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_module_default_community
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
ON config_revision (tenant_id, module_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
ON revision_materialized_prefix (revision_id, id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT,
revoked_at TEXT,
last_used_at TEXT,
CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CHECK (length(trim(name)) > 0),
CHECK (length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
+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 run dev
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/`):
```sh
+57
View File
@@ -152,6 +152,12 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
export type CommunitiesResponse = Page<BgpCommunity>;
// ---- Peers ----
export type PeerSessionOnSpeaker = {
speaker_id: string;
label: string;
state: string;
};
export type PeerRow = {
id: string;
name?: string;
@@ -160,6 +166,11 @@ export type PeerRow = {
enabled?: boolean;
session_state: string;
bgp_speaker_id: string | null;
connected_speaker_id?: string | null;
connected_speaker_label?: string;
session_on_speakers?: PeerSessionOnSpeaker[];
established_on_speakers?: PeerSessionOnSpeaker[];
session_mismatch?: boolean;
};
export type PeersResponse = Page<PeerRow>;
export type BgpPeerCreate = {
@@ -177,14 +188,30 @@ export type SpeakerRow = {
role: string;
endpoint: string;
last_applied_revision_id: string | null;
published_revision_id?: string | null;
published_at?: string | null;
agent_domain?: string;
node_ipv4?: string;
bird_bgp_source_ipv4?: string;
dispatch_status?: string;
sync_status?: string;
last_dispatch_at?: string | null;
last_dispatch_error?: string | null;
meta_json?: Record<string, unknown>;
agent_secret?: string;
};
export type SpeakersResponse = Page<SpeakerRow>;
export type BgpSpeakerCreate = {
endpoint: string;
role?: string;
meta_json?: string;
};
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
export type BundleSigningPublicKey = {
public_key_base64: string;
};
// ---- Revisions ----
export type RevisionRow = {
id: string;
@@ -253,3 +280,33 @@ export type JobsResponse = Page<JobRow>;
// ---- Settings ----
export type AppSettings = Record<string, unknown>;
// ---- Auth / API keys ----
export type AuthSession = {
tenant_id: string;
role: 'viewer' | 'editor' | 'operator' | 'node';
};
export type ApiKeyRole = AuthSession['role'];
export type ApiKey = {
id: string;
name: string;
role: ApiKeyRole;
prefix: string;
created_at: string;
updated_at: string;
expires_at: string | null;
revoked_at: string | null;
last_used_at: string | null;
};
export type ApiKeysResponse = Page<ApiKey>;
export type ApiKeyCreate = {
name: string;
role: ApiKeyRole;
expires_at?: string | null;
};
export type ApiKeyCreated = ApiKey & { token: string };
@@ -0,0 +1,279 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: ApiKey[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' }
];
let dialogOpen = $state(false);
let tokenDialogOpen = $state(false);
let revealedToken = $state('');
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
let expiresLocal = $state('');
let saving = $state(false);
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
{
id: 'revoked',
label: 'Статус',
sortable: true,
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
},
{ id: 'actions', label: '', class: 'w-24' }
] as const;
function openCreate() {
form = { name: '', role: 'editor' };
expiresLocal = '';
dialogOpen = true;
}
function showToken(created: ApiKeyCreated) {
revealedToken = created.token;
tokenDialogOpen = true;
}
async function copyToken() {
try {
await navigator.clipboard.writeText(revealedToken);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
function requestRevoke(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Отозвать API-ключ?',
description: `${k.name} (${k.prefix}…)`,
confirmLabel: 'Отозвать',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Ключ отозван');
await onRefresh();
}
});
}
function requestRotate(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Ротировать ключ?',
description: 'Старый токен перестанет работать сразу.',
confirmLabel: 'Ротировать',
onConfirm: async () => {
try {
const out = await apiMutate<ApiKeyCreated>(
`/v1/api-keys/${k.id}/rotate`,
'POST',
undefined,
{ idempotent: false }
);
notify.success('Ключ обновлён');
showToken(out);
await onRefresh();
} catch (e) {
notifyApiError(e);
}
}
});
}
async function save() {
if (!form.name.trim()) {
notify.error('Укажите имя');
return;
}
saving = true;
try {
const body: ApiKeyCreate = {
name: form.name.trim(),
role: form.role
};
if (expiresLocal.trim()) {
const d = new Date(expiresLocal);
if (Number.isNaN(d.getTime())) {
notify.error('Некорректная дата истечения');
return;
}
body.expires_at = d.toISOString();
}
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
notify.success('Ключ создан');
dialogOpen = false;
showToken(created);
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(k) => k.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
>
{#snippet cell({ row: k, column })}
{#if column.id === 'name'}
<span class="font-medium">{k.name}</span>
{:else if column.id === 'role'}
<span class="font-mono text-sm">{k.role}</span>
{:else if column.id === 'prefix'}
<span class="font-mono text-xs text-muted-foreground">{k.prefix}</span>
{:else if column.id === 'revoked'}
{#if k.revoked_at}
<span class="text-sm text-destructive">отозван</span>
{:else}
<span class="text-sm text-muted-foreground">активен</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
onclick={() => requestRotate(k)}
>
<RefreshCw class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
disabled={!!k.revoked_at}
onclick={() => requestRevoke(k)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Имя" id="key-name" required>
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
</FormField>
<FormField label="Роль" id="key-role" required>
<Select
type="single"
value={form.role}
onValueChange={(v) => (form.role = v as ApiKeyRole)}
>
<SelectTrigger id="key-role" class="w-full">
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
</SelectTrigger>
<SelectContent>
{#each roleOptions as opt (opt.value)}
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<FormField label="Истекает (опционально)" id="key-expires">
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={tokenDialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
>
</DialogHeader>
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
<DialogFooter>
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import {
birdSettingsSchema,
emptyBirdSettingsForm,
type BirdSettingsForm
} from '$lib/settings/bird-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Info from '@lucide/svelte/icons/info';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
{
validators: zod4(birdSettingsSchema),
SPA: true,
dataType: 'json'
}
);
let hasValidationErrors = $derived(
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
);
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return BIRD_SETTING_KEYS.some((key) => {
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
});
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.bird });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
BIRD_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры BIRD сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
адреса). Пиры и спикеры настраиваются на соседних вкладках.
</AlertDescription>
</Alert>
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
{:else}
<div class="space-y-3">
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
</FormField>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10"
/>
</FormField>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10"
/>
</FormField>
<FormField
id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input
id="bird-bgp-source-ipv4"
bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input
id="bird-bgp-source-ipv6"
bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
</FormField>
</div>
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры'}
</Button>
{/if}
</CardContent>
</Card>
@@ -77,12 +77,50 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
function peerConnectedLabel(p: PeerRow): string {
const established = p.established_on_speakers ?? [];
if (established.length > 0) {
const labels = established.map((s) => s.label).join(', ');
return established.length === 1
? `Подключён: ${labels}`
: `Подключён (${established.length} нод): ${labels}`;
}
if (p.connected_speaker_label?.trim()) {
return `На ноде: ${p.connected_speaker_label.trim()}`;
}
if (p.session_on_speakers && p.session_on_speakers.length > 0) {
const parts = p.session_on_speakers.map((s) => `${s.label} (${s.state})`);
return parts.join(', ');
}
return 'Не найден на опрошенных нодах';
}
function sessionBadge(state: string) {
function peerSessionHint(p: PeerRow): string | null {
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
const expected = speakerLabelById(p.bgp_speaker_id);
const actual =
p.established_on_speakers?.map((s) => s.label).join(', ') ||
p.connected_speaker_label?.trim() ||
'другие ноды';
return `В конфиге: ${expected}; Established на: ${actual}`;
}
function speakerLabelById(id: string | null | undefined) {
if (!id) return 'Все спикеры';
const s = speakerById.get(id);
if (!s) return id.slice(0, 8) + '…';
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint;
return host ? `CP · ${host}` : 'CP (master)';
}
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
}
function sessionBadge(
state: string,
p: PeerRow
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (p.session_mismatch) return 'destructive';
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
@@ -203,9 +241,22 @@
/>
</div>
{:else if column.id === 'session_state'}
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
<div class="flex min-w-0 flex-col gap-0.5">
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
<span
class="truncate text-xs text-muted-foreground"
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
>
{peerConnectedLabel(p)}
</span>
{#if peerSessionHint(p)}
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
{/if}
</div>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
@@ -251,7 +302,7 @@
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
@@ -15,15 +15,20 @@
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: SpeakerRow[];
@@ -35,41 +40,225 @@
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
type SpeakerForm = {
endpoint: string;
role: string;
agent_domain: string;
node_ipv4: string;
bird_bgp_source_ipv4: string;
bgpSourceManual: boolean;
};
let dialogOpen = $state(false);
let wizardOpen = $state(false);
let applyDialogOpen = $state(false);
let composeDialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null);
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let applyTarget = $state<SpeakerRow | null>(null);
let composeTarget = $state<SpeakerRow | null>(null);
let applyRevisionId = $state('');
let composeText = $state('');
let createdSpeaker = $state<SpeakerRow | null>(null);
let form = $state<SpeakerForm>({
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
});
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{ id: 'status', label: 'Статус' },
{
id: 'endpoint',
label: 'Endpoint',
id: 'agent_domain',
label: 'Agent domain',
sortable: true,
sortValue: (s: SpeakerRow) => s.endpoint
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
{ id: 'actions', label: '', class: 'w-32' }
{ id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-40' }
] as const;
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
const host = u.hostname;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
} catch {
/* ignore */
}
return '';
}
function onNodeIPv4Change(ip: string) {
form.node_ipv4 = ip;
if (!form.bgpSourceManual) {
form.bird_bgp_source_ipv4 = ip;
}
}
function onEndpointChange(ep: string) {
form.endpoint = ep;
const ip = parseIpv4FromEndpoint(ep);
if (ip && !form.node_ipv4) {
onNodeIPv4Change(ip);
}
}
function emptyForm(): SpeakerForm {
return {
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
};
}
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
return {
endpoint: s.endpoint,
role: s.role,
agent_domain: s.agent_domain ?? '',
node_ipv4: s.node_ipv4 ?? '',
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
bgpSourceManual: Boolean(
s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4
)
};
}
function buildMetaJson(f: SpeakerForm): string {
const meta: Record<string, string> = {};
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
return JSON.stringify(meta);
}
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
const ep =
f.endpoint.trim() || (f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
return {
endpoint: ep,
role: f.role.trim() || 'replica',
meta_json: buildMetaJson(f)
};
}
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
return 'outline';
}
function statusLabel(s: SpeakerRow): string {
if (s.sync_status === 'synced') return 'Connected';
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
if (s.dispatch_status === 'ok') return 'Synced';
return 'Unknown';
}
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function openCreate() {
editTarget = null;
form = { endpoint: '', role: 'operator' };
form = emptyForm();
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = { endpoint: s.endpoint, role: s.role };
form = formFromSpeaker(s);
dialogOpen = true;
}
async function applySpeaker(id: string) {
applyingId = id;
function requestDelete(s: SpeakerRow) {
const label = s.agent_domain ?? s.endpoint ?? s.id;
void confirm({
title: 'Удалить спикера?',
description: label,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Спикер удалён');
await onRefresh();
}
});
}
function openApply(s: SpeakerRow) {
applyTarget = s;
applyRevisionId = s.published_revision_id ?? '';
applyDialogOpen = true;
}
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
let pubkey = '';
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
pubkey = pk.public_key_base64;
} catch {
pubkey = '<GET /v1/bundle/signing-public-key>';
}
const domain = s.agent_domain ?? 'bgp-dc.example.com';
return `# deploy/compose/docker-compose.remote-speaker.yaml
# cp .env.remote-speaker.example .env.remote-speaker
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
EVOBGP_SPEAKER_ID=${s.id}
EVOBGP_AGENT_SECRET=<from UI wizard>
EVOBGP_NODE_TOKEN=<node API key from /access>
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
AGENT_DOMAIN=${domain}
PANEL_IP_WHITELIST=<CP public IP>/32
LETSENCRYPT_EMAIL=ops@example.com
CF_DNS_API_TOKEN=<cloudflare token>
# docker compose -f docker-compose.remote-speaker.yaml \\
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
# --profile production up -d`;
}
async function openCompose(s: SpeakerRow) {
composeTarget = s;
composeText = await buildComposeSnippet(s);
composeDialogOpen = true;
}
async function copyCompose() {
try {
await navigator.clipboard.writeText(composeText);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
async function applySpeaker() {
if (!applyTarget || !applyRevisionId.trim()) {
notify.error('Укажите revision_id');
return;
}
applyingId = applyTarget.id;
try {
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
revision_id: applyRevisionId.trim()
});
notify.success('Apply запущен');
applyDialogOpen = false;
} catch (e) {
notifyApiError(e);
} finally {
@@ -78,20 +267,25 @@
}
async function save() {
if (!form.endpoint.trim()) {
notify.error('Укажите endpoint');
const body = buildApiBody(form);
if (!body.endpoint.trim()) {
notify.error('Укажите endpoint или agent domain');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
notify.success('Спикер обновлён');
dialogOpen = false;
} else {
await apiMutate('/v1/speakers', 'POST', form);
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
notify.success('Спикер создан');
dialogOpen = false;
createdSpeaker = created;
composeText = await buildComposeSnippet(created);
wizardOpen = true;
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
@@ -99,6 +293,17 @@
saving = false;
}
}
async function copyAgentSecret() {
const secret = createdSpeaker?.agent_secret;
if (!secret) return;
try {
await navigator.clipboard.writeText(secret);
notify.success('agent_secret скопирован');
} catch {
notify.error('Не удалось скопировать');
}
}
</script>
<Card>
@@ -107,7 +312,9 @@
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
<CardDescription
>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription
>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
@@ -121,32 +328,45 @@
loading={initialLoading || loading}
{error}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
emptyDescription="Добавьте реплику для применения signed bundle."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'endpoint'}
<span class="font-mono text-sm">{s.endpoint}</span>
{#if column.id === 'status'}
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
{:else if column.id === 'agent_domain'}
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'last_applied_revision_id'}
<span class="font-mono text-xs text-muted-foreground">
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
{:else if column.id === 'drift'}
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
{driftLabel(s)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<div class="flex flex-wrap gap-1">
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
<Copy class="size-3" />
</Button>
<Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
title="Apply revision (canary)"
onclick={() => openApply(s)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
title="Удалить спикера"
onclick={() => requestDelete(s)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
@@ -155,16 +375,44 @@
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Endpoint" id="s-endpoint" required>
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
<FormField label="Agent domain (FQDN)" id="s-domain">
<AppInput id="s-domain" placeholder="bgp-dc2.example.com" bind:value={form.agent_domain} />
</FormField>
<FormField label="Endpoint" id="s-endpoint">
<AppInput
id="s-endpoint"
placeholder="https://bgp-dc2.example.com"
value={form.endpoint}
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="IP ноды (IPv4)" id="s-node-ip">
<AppInput
id="s-node-ip"
placeholder="203.0.113.10"
value={form.node_ipv4}
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="BGP source IPv4" id="s-bgp-src">
<AppInput
id="s-bgp-src"
placeholder="= IP ноды"
bind:value={form.bird_bgp_source_ipv4}
disabled={!form.bgpSourceManual}
/>
</FormField>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.bgpSourceManual} />
Задать BGP source вручную
</label>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
</FormField>
</div>
<DialogFooter>
@@ -175,3 +423,74 @@
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={wizardOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Спикер создан</DialogTitle>
<DialogDescription>
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
</DialogDescription>
</DialogHeader>
{#if createdSpeaker?.agent_secret}
<FormField label="agent_secret (один раз)" id="w-secret">
<div class="flex gap-2">
<AppInput
id="w-secret"
readonly
value={createdSpeaker.agent_secret}
class="font-mono text-xs"
/>
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
</div>
</FormField>
{/if}
<FormField label="docker-compose env" id="w-compose">
<textarea
id="w-compose"
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
</FormField>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={applyDialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Apply на спикер</DialogTitle>
</DialogHeader>
<FormField label="revision_id" id="a-rev" required>
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
</FormField>
<DialogFooter>
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={composeDialogOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Copy docker-compose</DialogTitle>
<DialogDescription
>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription
>
</DialogHeader>
<textarea
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,223 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import {
emptyRevisionSettingsForm,
revisionSettingsSchema
} from '$lib/settings/revision-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings,
type AdditionalSettingEntry
} from '$lib/settings/settings-api.js';
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Info from '@lucide/svelte/icons/info';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
let additionalIdCounter = $state(1);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
{
validators: zod4(revisionSettingsSchema),
SPA: true,
dataType: 'json'
}
);
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
}
function removeAdditionalSetting(id: number) {
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
}
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
const hasRetention = String($form.revision_retention_minutes ?? '').trim() !== '';
const hasAdditional = additionalSettings.some((entry) => entry.key.trim() !== '');
return hasRetention || hasAdditional;
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
reset({ data: partitioned.revision });
additionalSettings = partitioned.additional;
additionalIdCounter = nextId;
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
REVISION_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
for (const entry of additionalSettings) {
const key = entry.key.trim();
if (!key) continue;
payload[key] = entry.value;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Системные настройки сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<div class="flex flex-col gap-6">
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение параметров через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Хранение ревизий</CardTitle>
<CardDescription>
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
</CardDescription>
</CardHeader>
<CardContent>
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
{:else}
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Допустимый диапазон: 15–43200 минут."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
</FormField>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>Произвольные KV-пары в global_settings.</CardDescription>
</div>
{#if loaded}
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !loaded}
<p class="text-sm text-muted-foreground">Загрузите настройки выше.</p>
{:else if additionalSettings.length === 0}
<EmptyState
title="Нет дополнительных параметров"
description="Добавьте KV-пару при необходимости."
/>
{:else}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => removeAdditionalSetting(entry.id)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
{#if loaded}
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить настройки'}
</Button>
{/if}
</div>
@@ -0,0 +1,24 @@
import { z } from 'zod';
import { optionalIPv4, optionalIPv6 } from './ip-validation.js';
export const birdSettingsSchema = z.object({
bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6')
});
export type BirdSettingsForm = z.infer<typeof birdSettingsSchema>;
export const emptyBirdSettingsForm = (): BirdSettingsForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: ''
});
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod';
function isValidIPv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
if (part.length > 1 && part.startsWith('0')) return false;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
}
return true;
}
function isValidIPv6(value: string): boolean {
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
if ((value.match(/::/g) ?? []).length > 1) return false;
const hasCompression = value.includes('::');
const [leftRaw, rightRaw = ''] = value.split('::');
const left = leftRaw === '' ? [] : leftRaw.split(':');
const right = rightRaw === '' ? [] : rightRaw.split(':');
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
let segments = [...left, ...right];
let ipv4TailSegments = 0;
const lastSegment = segments.at(-1);
if (lastSegment && lastSegment.includes('.')) {
if (!isValidIPv4(lastSegment)) return false;
segments = segments.slice(0, -1);
ipv4TailSegments = 2;
}
for (const segment of segments) {
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
}
const totalSegments = segments.length + ipv4TailSegments;
if (hasCompression) return totalSegments < 8;
return totalSegments === 8;
}
export const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
export const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});

Some files were not shown because too many files have changed in this diff Show More