Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa265a246 | ||
|
|
b8170c4204 | ||
|
|
c6e13bb86b | ||
|
|
2aecbf96fd | ||
|
|
ec65249bf1 | ||
|
|
6329a4df27 | ||
|
|
880d77810a | ||
|
|
8ebce28e34 | ||
|
|
dd7d43c2c2 | ||
|
|
a8c5e9701f | ||
|
|
be3d73f374 | ||
|
|
5fca165c69 | ||
|
|
293115e0e1 | ||
|
|
6d2051f813 | ||
|
|
b51a9ae3b3 | ||
|
|
4d4cd2301f | ||
|
|
d687881eaa | ||
|
|
87e756f34f |
@@ -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.
|
||||
|
||||
@@ -50,10 +50,10 @@ 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) | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
||||
|
||||
### Выбор type: semver, а не «красивые слова»
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@ Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
-39
@@ -8,10 +8,9 @@ on:
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Гранулярная детекция изменений по модулям.
|
||||
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
|
||||
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
|
||||
# поднимает все флаги, чтобы гарантировать полный прогон.
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -40,6 +39,23 @@ jobs:
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
@@ -52,62 +68,84 @@ jobs:
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
openapi=true; go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
echo "$v=true" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "No parent commit — full pipeline"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
go=true; web=true
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
echo "Empty diff — safe fallback: go=true web=true"
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ci_changed=false
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*) ci_changed=true ;;
|
||||
docs/openapi.yaml|redocly.yaml) openapi=true ;;
|
||||
web/README.md) ;; # doc-only
|
||||
web/*) web=true ;;
|
||||
deploy/bird/*) bird_conf=true ;;
|
||||
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
|
||||
deploy/docker/gobinary/*) docker_go=true ;;
|
||||
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
|
||||
deploy/docker/evobgp-agent/*) docker_go=true ;;
|
||||
deploy/docker/evobgp-web/*) docker_web=true ;;
|
||||
deploy/docker/bird2/*) docker_bird=true ;;
|
||||
go.mod|go.sum|go.work) go=true ;;
|
||||
migrations/*) go=true ;;
|
||||
*.go) go=true ;;
|
||||
cmd/*|internal/*) go=true ;;
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
web/README.md|web/components.json)
|
||||
;;
|
||||
web/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $ci_changed; then
|
||||
go=true; web=true; bird_conf=true
|
||||
docker_go=true; docker_web=true; docker_bird=true
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
@@ -160,10 +198,14 @@ 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
|
||||
with:
|
||||
version: v1.62
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
|
||||
@@ -16,3 +16,4 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
@@ -2,6 +2,7 @@ run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gofmt
|
||||
- govet
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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` не пропускает без этого.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,157 @@
|
||||
# Удалённый BGP-спикер (Remnawave-style): bird2 + evobgp-agent + Traefik (LE).
|
||||
# См. docs/remote-speakers.md
|
||||
#
|
||||
# cp .env.remote-speaker.example .env.remote-speaker
|
||||
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# docker compose -f docker-compose.remote-speaker.yaml \
|
||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
|
||||
#
|
||||
# Profiles:
|
||||
# production (default) — bird2 host + agent + evobgp-edge
|
||||
# plain — bird2 + agent без Traefik (lab)
|
||||
# fallback — + sync-bundle polling
|
||||
|
||||
name: evobgp-remote-speaker
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
bird2:
|
||||
profiles: ["production", "plain", "fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "1"
|
||||
net.ipv6.conf.all.forwarding: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent:
|
||||
profiles: ["production"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: ":8443"
|
||||
EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET}
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-socket=/run/bird/bird.ctl"]
|
||||
networks:
|
||||
- speaker-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.evobgp-agent.rule=Host(`${AGENT_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-agent.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-agent.tls=true
|
||||
- traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker
|
||||
- traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange=${PANEL_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-agent.loadbalancer.server.port=8443
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent-plain:
|
||||
profiles: ["plain", "fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: "${EVOBGP_AGENT_PORT:-8443}"
|
||||
EVOBGP_AGENT_SECRET: ${EVOBGP_AGENT_SECRET:?set EVOBGP_AGENT_SECRET}
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-listen=:${EVOBGP_AGENT_PORT:-8443}", "-socket=/run/bird/bird.ctl"]
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-edge:
|
||||
profiles: ["production"]
|
||||
image: traefik:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- evobgp-agent
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN}
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
sync-bundle:
|
||||
profiles: ["fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-node:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
environment:
|
||||
EVOBGP_CONTROL_PLANE_URL: ${EVOBGP_CONTROL_PLANE_URL:?set EVOBGP_CONTROL_PLANE_URL}
|
||||
EVOBGP_NODE_TOKEN: ${EVOBGP_NODE_TOKEN:?set EVOBGP_NODE_TOKEN}
|
||||
EVOBGP_SPEAKER_ID: ${EVOBGP_SPEAKER_ID:?set EVOBGP_SPEAKER_ID}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: ${EVOBGP_BUNDLE_PUBKEY_BASE64:?set EVOBGP_BUNDLE_PUBKEY_BASE64}
|
||||
EVOBGP_SYNC_INTERVAL_SEC: ${EVOBGP_SYNC_INTERVAL_SEC:-300}
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
- ../../scripts/sync-bundle.sh:/usr/local/bin/sync-bundle.sh:ro
|
||||
entrypoint: ["/bin/sh", "/usr/local/bin/sync-bundle.sh"]
|
||||
network_mode: host
|
||||
logging: *default-logging
|
||||
|
||||
networks:
|
||||
speaker-net:
|
||||
|
||||
volumes:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
name: evobgp_speaker_traefik_letsencrypt
|
||||
@@ -5,8 +5,14 @@ server {
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $evobgp_upstream evobgp-api;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://evobgp-api: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;
|
||||
@@ -15,7 +21,7 @@ server {
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
proxy_pass http://evobgp-api:8080/metrics;
|
||||
proxy_pass http://$evobgp_upstream:8080/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| `evobgp-render` | По умолчанию только heartbeat; при `EVOBGP_RENDER_AUTOPUBLISH=1` выставляет всем спикерам tenant последнюю ревизию (упрощение для демо). |
|
||||
| `evobgp-deploy` | Периодически логирует **drift**: `last_applied_revision_id` vs опубликованная ревизия для ноды. |
|
||||
| `evobgp-node` | CLI реплики: `pull-bundle`, `verify-bundle`, `apply-bundle`. |
|
||||
| `evobgp-agent` | Локальный агент рядом с BIRD (например `watch` по сокету). |
|
||||
| `evobgp-agent` | Локальный агент рядом с BIRD: `watch`, **`serve`** (Panel→Node sync API на реплике). |
|
||||
|
||||
В Docker Compose профиль **reference** запускает отдельные контейнеры под `evobgp-api` и четыре воркера; профиль **microvps** использует один контейнер `evobgp-all`.
|
||||
|
||||
@@ -40,8 +40,12 @@
|
||||
| `observability` | Метрики Prometheus, HTTP middleware. |
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
|
||||
|
||||
## Диаграмма: эталонный Compose (reference)
|
||||
## Удалённые спикеры
|
||||
|
||||
Реплики на отдельных VPS: [remote-speakers.md](remote-speakers.md). CP публикует ревизию и при `EVOBGP_NODE_DISPATCH_ENABLED=1` будит agent; agent тянет signed bundle и применяет BIRD. Compose: `deploy/compose/docker-compose.remote-speaker.yaml`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
@@ -104,6 +104,7 @@ EvoBGP управляет генерацией и применением BGP-к
|
||||
### Настройки (`/v1/settings`)
|
||||
- KV c ключами BIRD и дополнительными feature flags.
|
||||
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
||||
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
||||
|
||||
## 7. Эксплуатация и runbook
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -692,8 +778,47 @@ components:
|
||||
type: string
|
||||
last_applied_revision_id:
|
||||
type: ["string", "null"]
|
||||
published_revision_id:
|
||||
type: ["string", "null"]
|
||||
description: Последняя опубликованная на CP ревизия для этого спикера.
|
||||
published_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
agent_domain:
|
||||
type: string
|
||||
description: FQDN agent API за Traefik (Address в UI, Remnawave-style).
|
||||
node_ipv4:
|
||||
type: string
|
||||
description: IPv4 VPS; default для bird_bgp_source_ipv4.
|
||||
bird_bgp_source_ipv4:
|
||||
type: string
|
||||
description: Per-speaker override router id / BGP local (см. pipeline overlay).
|
||||
dispatch_status:
|
||||
type: string
|
||||
description: ok, error, skipped — последний Panel→Node wake-up.
|
||||
sync_status:
|
||||
type: string
|
||||
description: synced, error — состояние sync на реплике.
|
||||
last_dispatch_at:
|
||||
type: string
|
||||
format: date-time
|
||||
last_dispatch_error:
|
||||
type: string
|
||||
meta_json:
|
||||
type: object
|
||||
description: >
|
||||
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
|
||||
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
|
||||
additionalProperties: true
|
||||
|
||||
BundleSigningPublicKey:
|
||||
type: object
|
||||
required: [public_key_base64]
|
||||
properties:
|
||||
public_key_base64:
|
||||
type: string
|
||||
description: Ed25519 public key (base64) для verify-bundle на реплике.
|
||||
|
||||
ConfigRevision:
|
||||
type: object
|
||||
required:
|
||||
@@ -914,8 +1039,15 @@ components:
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
default: replica
|
||||
endpoint:
|
||||
type: string
|
||||
description: URL agent или https://AGENT_DOMAIN
|
||||
meta_json:
|
||||
type: string
|
||||
description: >
|
||||
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
|
||||
agent_domain, agent_secret (генерируется при создании если пуст).
|
||||
additionalProperties: true
|
||||
|
||||
BgpSpeakerPatch:
|
||||
@@ -925,6 +1057,9 @@ components:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
meta_json:
|
||||
type: string
|
||||
description: JSON-объект с ключами agent_domain, node_ipv4, bird_bgp_source_ipv4 и др.
|
||||
additionalProperties: true
|
||||
|
||||
LatestRevisionPointer:
|
||||
@@ -2130,6 +2265,24 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/bundle/signing-public-key:
|
||||
get:
|
||||
tags: [Bundles]
|
||||
summary: Публичный ключ подписи бандлов
|
||||
description: >
|
||||
Ed25519 public key (base64) для `evobgp-node verify-bundle` / agent sync на реплике.
|
||||
Роль viewer и выше.
|
||||
operationId: getBundleSigningPublicKey
|
||||
responses:
|
||||
"200":
|
||||
description: Ключ для env EVOBGP_BUNDLE_PUBKEY_BASE64 на реплике.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BundleSigningPublicKey"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/speakers:
|
||||
get:
|
||||
tags: [Speakers]
|
||||
@@ -2643,6 +2796,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]
|
||||
|
||||
@@ -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 и создайте БД (или используйте существующую).
|
||||
|
||||
+6
-2
@@ -7,9 +7,13 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
| Тип коммита | Bump |
|
||||
|-------------|------|
|
||||
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
|
||||
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `ci`, `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`).
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Удалённые BGP-спикеры (Remnawave-style)
|
||||
|
||||
Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS. Control plane (`evobgp-all`) инициирует доставку после `module_refresh` → `deploy_apply`; реплика **не** собирает префиксы сама.
|
||||
|
||||
## Модель
|
||||
|
||||
| Remnawave | EvoBGP |
|
||||
|-----------|--------|
|
||||
| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` |
|
||||
| SECRET_KEY | `agent_secret` (Bearer) |
|
||||
| Copy compose | Web UI → карточка спикера |
|
||||
| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply |
|
||||
|
||||
Подробнее: [architecture.md](architecture.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов.
|
||||
2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды).
|
||||
3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI.
|
||||
4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`.
|
||||
5. `GET /v1/bundle/signing-public-key` → `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
|
||||
6. На VPS реплики:
|
||||
```bash
|
||||
cd deploy/compose
|
||||
cp .env.remote-speaker.example .env.remote-speaker
|
||||
cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# заполните переменные из UI
|
||||
docker compose -f docker-compose.remote-speaker.yaml \
|
||||
--env-file .env.remote-speaker --env-file .env.remote-speaker-tls \
|
||||
--profile production up -d
|
||||
```
|
||||
7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish.
|
||||
8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)).
|
||||
|
||||
## Compose-профили
|
||||
|
||||
| Profile | Состав |
|
||||
|---------|--------|
|
||||
| `production` | bird2 (host) + agent + Traefik LE |
|
||||
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
|
||||
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
|
||||
|
||||
## Firewall
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch + health |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | ACME | Traefik → 443 |
|
||||
|
||||
## Безопасность (три участка)
|
||||
|
||||
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll).
|
||||
3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде.
|
||||
|
||||
Prod checklist:
|
||||
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...`
|
||||
- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP
|
||||
- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам)
|
||||
- [ ] Уникальные `agent_secret` и node token на спикер
|
||||
- [ ] Не использовать profile `plain` в prod
|
||||
- [ ] Не отключать verify-bundle в agent
|
||||
|
||||
## Per-speaker BGP source
|
||||
|
||||
В UI: **IP ноды** (`meta_json.node_ipv4`) и **BGP source IPv4** (`bird_bgp_source_ipv4`, default = IP ноды). Pipeline накладывает overlay при `GET .../bundle/{revision_id}` — меняются `router id` и peer `local`.
|
||||
|
||||
Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / если у спикера не задано.
|
||||
|
||||
## Drift и dispatch
|
||||
|
||||
- `published_revision_id` vs `last_applied_revision_id` — в UI и `evobgp-deploy`.
|
||||
- Job `deploy_apply` meta: `node_dispatch.results[]` — статус wake-up per speaker.
|
||||
- Canary: `POST /v1/speakers/{id}/apply` с `revision_id`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Проверка |
|
||||
|---------|----------|
|
||||
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
|
||||
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
|
||||
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
|
||||
| BGP не поднимается | bird2 `network_mode: host`; peers; MD5 BGP отдельно от HTTP sync |
|
||||
|
||||
## Ограничения (scale-review)
|
||||
|
||||
- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики.
|
||||
- Разные peer-наборы per site — отдельная итерация pipeline.
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling).
|
||||
|
||||
## Связанные env
|
||||
|
||||
| Переменная | Где |
|
||||
|------------|-----|
|
||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
||||
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
|
||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
||||
@@ -0,0 +1,192 @@
|
||||
package agentserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
// Config holds evobgp-agent serve settings.
|
||||
type Config struct {
|
||||
Listen string
|
||||
Secret string
|
||||
ControlPlaneURL string
|
||||
NodeToken string
|
||||
SpeakerID string
|
||||
PubKeyB64 string
|
||||
PubKeyHex string
|
||||
ExtractDir string
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string
|
||||
SyncTimeout time.Duration
|
||||
LastSync func() (revisionID string, at time.Time)
|
||||
OnSyncSuccess func(revisionID string)
|
||||
}
|
||||
|
||||
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New builds an agent HTTP server.
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"speaker_id": strings.TrimSpace(s.cfg.SpeakerID),
|
||||
}
|
||||
if s.cfg.LastSync != nil {
|
||||
if rev, at := s.cfg.LastSync(); rev != "" {
|
||||
body["last_applied_revision_id"] = rev
|
||||
body["last_sync_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
timeout := s.cfg.SyncTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := nodecli.SyncBundle(ctx, nodecli.SyncConfig{
|
||||
BaseURL: s.cfg.ControlPlaneURL,
|
||||
Token: s.cfg.NodeToken,
|
||||
SpeakerID: s.cfg.SpeakerID,
|
||||
RevisionID: strings.TrimSpace(req.RevisionID),
|
||||
PubKeyB64: s.cfg.PubKeyB64,
|
||||
PubKeyHex: s.cfg.PubKeyHex,
|
||||
ExtractDir: s.cfg.ExtractDir,
|
||||
BirdBin: s.cfg.BirdBin,
|
||||
BirdcBin: s.cfg.BirdcBin,
|
||||
Socket: s.cfg.Socket,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("agentserver: sync: %v", err)
|
||||
writeProblem(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if s.cfg.OnSyncSuccess != nil {
|
||||
s.cfg.OnSyncSuccess(res.RevisionID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": res.RevisionID,
|
||||
"main_config": res.MainConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authorize(r *http.Request) bool {
|
||||
secret := strings.TrimSpace(s.cfg.Secret)
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):]) == secret
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"title": http.StatusText(status),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe starts the agent HTTP server on cfg.Listen.
|
||||
func ListenAndServe(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Listen) == "" {
|
||||
cfg.Listen = ":8443"
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: New(cfg).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("evobgp-agent serve: listening on %s speaker=%s", cfg.Listen, cfg.SpeakerID)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from EVOBGP_* environment variables.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
Listen: envOr("EVOBGP_AGENT_LISTEN", ":8443"),
|
||||
Secret: strings.TrimSpace(os.Getenv("EVOBGP_AGENT_SECRET")),
|
||||
ControlPlaneURL: strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL")),
|
||||
NodeToken: strings.TrimSpace(os.Getenv("EVOBGP_NODE_TOKEN")),
|
||||
SpeakerID: strings.TrimSpace(os.Getenv("EVOBGP_SPEAKER_ID")),
|
||||
PubKeyB64: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_BASE64")),
|
||||
PubKeyHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_HEX")),
|
||||
ExtractDir: envOr("EVOBGP_BIRD_EXTRACT_DIR", "/etc/bird"),
|
||||
BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")),
|
||||
BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
SyncTimeout: 45 * time.Second,
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_AGENT_SECRET required")
|
||||
}
|
||||
if cfg.ControlPlaneURL == "" || cfg.NodeToken == "" || cfg.SpeakerID == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_CONTROL_PLANE_URL, EVOBGP_NODE_TOKEN, EVOBGP_SPEAKER_ID required")
|
||||
}
|
||||
if cfg.PubKeyB64 == "" && cfg.PubKeyHex == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_BUNDLE_PUBKEY_BASE64 or EVOBGP_BUNDLE_PUBKEY_HEX required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agentserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/agentserver"
|
||||
)
|
||||
|
||||
func TestAgentHealth_requiresAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "test-secret",
|
||||
SpeakerID: "sp-1",
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/v1/agent/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/agent/health", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-secret")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSync_badAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "right",
|
||||
SpeakerID: "sp-1",
|
||||
ControlPlaneURL: "http://127.0.0.1:1",
|
||||
NodeToken: "tok",
|
||||
PubKeyB64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
ExtractDir: t.TempDir(),
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/agent/sync", strings.NewReader("{}"))
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
|
||||
|
||||
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
|
||||
type BGPPeerFromTemplateOptions struct {
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
ProtocolName string
|
||||
TemplateName string
|
||||
NeighborIP string
|
||||
NeighborASN uint32
|
||||
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
|
||||
OverrideLocalIP string
|
||||
OverrideLocalASN uint32
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
|
||||
// Manifest describes bundle contents for evobgp-node verification.
|
||||
type Manifest struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
// FileEntry is one file inside the bundle archive.
|
||||
|
||||
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+11
-11
@@ -7,21 +7,21 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
notFoundDetail = "resource not found"
|
||||
invalidInputDetail = "invalid request data"
|
||||
cdnExtractDetail = "could not extract prefixes from source"
|
||||
csvInvalidRowDetail = "invalid row in csv file"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
|
||||
|
||||
+83
-17
@@ -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,7 +283,7 @@ 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())
|
||||
liveStates := s.liveBGPProtocolStates(r)
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
@@ -289,7 +297,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 +316,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.
|
||||
@@ -370,7 +390,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||
items := make([]map[string]any, 0, len(speakers))
|
||||
for _, sp := range speakers {
|
||||
items = append(items, speakerJSON(sp))
|
||||
items = append(items, speakerJSONFromStore(s.store, sp))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": nil, "has_more": false,
|
||||
@@ -522,7 +542,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 +610,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 +867,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 +976,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||
return
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||
frags := rev.PreviewFragments
|
||||
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
|
||||
frags = overlaid
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
|
||||
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) {
|
||||
@@ -258,7 +260,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeBadGateway(w, "cdn preview fetch", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||
@@ -972,12 +974,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, speakerJSON(x))
|
||||
resp := speakerJSONFromStore(s.store, x)
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -990,7 +1000,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1008,7 +1018,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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()
|
||||
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respList.Body.Close()
|
||||
defer func() { _ = respList.Body.Close() }()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respImport.Body.Close()
|
||||
defer func() { _ = respImport.Body.Close() }()
|
||||
if respImport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respImport.Body)
|
||||
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
|
||||
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respExport.Body.Close()
|
||||
defer func() { _ = respExport.Body.Close() }()
|
||||
if respExport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respExport.Body)
|
||||
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
|
||||
|
||||
@@ -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()
|
||||
@@ -35,7 +35,7 @@ func TestNestedModuleListPagination(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func TestNestedModuleListPagination(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("list status %d: %s", resp.StatusCode, b)
|
||||
@@ -73,7 +73,7 @@ func TestNestedModuleListPagination(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
var page2 struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
|
||||
@@ -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
|
||||
@@ -32,10 +31,10 @@ type Server struct {
|
||||
type Options struct {
|
||||
APIKeys string
|
||||
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
BundleSeedHex string
|
||||
CORSAllowedOrigins string
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
@@ -341,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
if sp == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
m := map[string]any{
|
||||
"id": sp.ID,
|
||||
"role": sp.Role,
|
||||
"endpoint": sp.Endpoint,
|
||||
}
|
||||
if sp.LastAppliedRevisionID != nil {
|
||||
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
|
||||
} else {
|
||||
m["last_applied_revision_id"] = nil
|
||||
}
|
||||
if st != nil {
|
||||
if rid, at, err := st.LatestPublishedRevision(sp.ID); err == nil && rid != "" {
|
||||
m["published_revision_id"] = rid
|
||||
m["published_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
} else {
|
||||
m["published_revision_id"] = nil
|
||||
m["published_at"] = nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" {
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil {
|
||||
m["meta_json"] = raw
|
||||
}
|
||||
}
|
||||
if meta.AgentDomain != "" {
|
||||
m["agent_domain"] = meta.AgentDomain
|
||||
}
|
||||
if meta.NodeIPv4 != "" {
|
||||
m["node_ipv4"] = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" {
|
||||
m["bird_bgp_source_ipv4"] = meta.BirdBgpSourceIPv4
|
||||
}
|
||||
if meta.LastDispatchAt != "" {
|
||||
m["last_dispatch_at"] = meta.LastDispatchAt
|
||||
}
|
||||
if meta.LastDispatchError != "" {
|
||||
m["last_dispatch_error"] = meta.LastDispatchError
|
||||
}
|
||||
if meta.LastDispatchStatus != "" {
|
||||
m["dispatch_status"] = meta.LastDispatchStatus
|
||||
}
|
||||
if meta.SyncStatus != "" {
|
||||
m["sync_status"] = meta.SyncStatus
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"public_key_base64": s.BundlePublicKeyBase64(),
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeSpeakerCreate fills meta defaults and validates replica fields.
|
||||
func normalizeSpeakerCreate(in *store.Speaker) error {
|
||||
if in == nil {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(in.MetaJSON)
|
||||
if meta.AgentSecret == "" {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
meta.AgentSecret = hex.EncodeToString(b)
|
||||
}
|
||||
if meta.AgentPort == 0 {
|
||||
meta.AgentPort = 8443
|
||||
}
|
||||
if meta.NodeIPv4 == "" {
|
||||
meta.NodeIPv4 = store.IPv4FromEndpoint(in.Endpoint)
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 == "" && meta.NodeIPv4 != "" {
|
||||
meta.BirdBgpSourceIPv4 = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" && !store.ValidIPv4(meta.BirdBgpSourceIPv4) {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
if meta.AgentDomain == "" && in.Endpoint != "" {
|
||||
ep := strings.TrimSpace(in.Endpoint)
|
||||
if strings.HasPrefix(ep, "https://") {
|
||||
u := strings.TrimPrefix(ep, "https://")
|
||||
if idx := strings.Index(u, "/"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if idx := strings.Index(u, ":"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if u != "" && !store.ValidIPv4(u) {
|
||||
meta.AgentDomain = u
|
||||
}
|
||||
}
|
||||
}
|
||||
in.MetaJSON = store.SpeakerMetaJSON(meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
|
||||
if s == nil || s.store == nil || sp == nil {
|
||||
return
|
||||
}
|
||||
patch := store.SpeakerMeta{
|
||||
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastDispatchStatus: res.Status,
|
||||
}
|
||||
if res.Error != "" {
|
||||
patch.LastDispatchError = res.Error
|
||||
patch.SyncStatus = "error"
|
||||
} else if res.Status == "ok" {
|
||||
patch.LastDispatchError = ""
|
||||
patch.SyncStatus = "synced"
|
||||
}
|
||||
meta := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
_, _ = s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &meta})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{"endpoint":"https://203.0.113.55:8443","role":"replica"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["agent_secret"] == nil || out["agent_secret"] == "" {
|
||||
t.Fatal("expected agent_secret on create")
|
||||
}
|
||||
if out["node_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
|
||||
}
|
||||
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bundle/signing-public-key", nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["public_key_base64"] == nil || out["public_key_base64"] == "" {
|
||||
t.Fatalf("missing public_key_base64: %#v", out)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+104
-9
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
|
||||
@@ -27,7 +27,7 @@ func Run(args []string) int {
|
||||
|
||||
// Usage prints CLI help to w.
|
||||
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||
fmt.Fprintf(w, `Usage:
|
||||
_, _ = fmt.Fprintf(w, `Usage:
|
||||
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package nodecli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/bundle"
|
||||
"evobgp/internal/signing"
|
||||
)
|
||||
|
||||
// SyncConfig drives pull → verify → apply on a replica node.
|
||||
type SyncConfig struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
SpeakerID string
|
||||
RevisionID string // empty = latest published on CP
|
||||
PubKeyB64 string
|
||||
PubKeyHex string
|
||||
ExtractDir string
|
||||
BundlePath string // temp file; default os.TempDir()/evobgp-bundle.tar.gz
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string
|
||||
HTTPClient interface {
|
||||
Do(req interface{}) (interface{}, error)
|
||||
}
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// SyncResult summarizes a successful sync.
|
||||
type SyncResult struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
MainConfig string `json:"main_config,omitempty"`
|
||||
}
|
||||
|
||||
// SyncBundle pulls (if needed), verifies Ed25519 signature, extracts, parse-checks, and birdc configure.
|
||||
func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
|
||||
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.SpeakerID) == "" {
|
||||
return SyncResult{}, fmt.Errorf("nodecli: sync: base-url, token, speaker-id required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ExtractDir) == "" {
|
||||
return SyncResult{}, fmt.Errorf("nodecli: sync: extract-dir required")
|
||||
}
|
||||
pub, err := loadPubKey(cfg.PubKeyB64, cfg.PubKeyHex)
|
||||
if err != nil {
|
||||
return SyncResult{}, fmt.Errorf("nodecli: sync: %w", err)
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
rev := strings.TrimSpace(cfg.RevisionID)
|
||||
if rev == "" {
|
||||
var err error
|
||||
rev, err = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
}
|
||||
raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
bundlePath := strings.TrimSpace(cfg.BundlePath)
|
||||
if bundlePath == "" {
|
||||
bundlePath = filepath.Join(os.TempDir(), "evobgp-bundle.tar.gz")
|
||||
}
|
||||
if err := os.WriteFile(bundlePath, raw, 0o644); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
root := filepath.Clean(cfg.ExtractDir)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
mainRel := v.FindMainBirdConf()
|
||||
if mainRel == "" {
|
||||
return SyncResult{}, fmt.Errorf("nodecli: sync: bundle has no bird.conf in manifest")
|
||||
}
|
||||
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
|
||||
opCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
if err := ctl.ParseCheck(opCtx, mainPath); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
if err := ctl.Configure(opCtx); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
return SyncResult{RevisionID: rev, MainConfig: mainPath}, nil
|
||||
}
|
||||
|
||||
// SyncResultJSON encodes SyncResult for HTTP responses.
|
||||
func SyncResultJSON(r SyncResult) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": r.RevisionID,
|
||||
"main_config": r.MainConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadPublicKey exports loadPubKey for other packages.
|
||||
func LoadPublicKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
||||
return loadPubKey(pubB64, pubHex)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package nodedispatch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Result is one speaker dispatch outcome for job meta.
|
||||
type Result struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Status string `json:"status"`
|
||||
AppliedRevisionID string `json:"applied_revision_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Options configures Panel→Node HTTP dispatch.
|
||||
type Options struct {
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
InsecureTLS bool
|
||||
RevisionID string
|
||||
}
|
||||
|
||||
func (o Options) client() *http.Client {
|
||||
if o.HTTPClient != nil {
|
||||
return o.HTTPClient
|
||||
}
|
||||
timeout := o.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if o.InsecureTLS || strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_INSECURE_TLS")) == "1" {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev/lab only via env
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: tr}
|
||||
}
|
||||
|
||||
func (o Options) retries() int {
|
||||
if o.MaxRetries > 0 {
|
||||
return o.MaxRetries
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// Enabled reports whether remote dispatch is turned on (EVOBGP_NODE_DISPATCH_ENABLED=1).
|
||||
func Enabled() bool {
|
||||
return strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_ENABLED")) == "1"
|
||||
}
|
||||
|
||||
// WakeSpeaker POSTs /v1/agent/sync to a replica agent (HTTPS via Traefik).
|
||||
func WakeSpeaker(ctx context.Context, sp *store.Speaker, opts Options) Result {
|
||||
res := Result{SpeakerID: sp.ID}
|
||||
if sp == nil {
|
||||
res.Status = "error"
|
||||
res.Error = "nil speaker"
|
||||
return res
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
url := store.AgentSyncURL(meta)
|
||||
if url == "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = "agent_domain or agent_secret not configured"
|
||||
return res
|
||||
}
|
||||
res.Endpoint = url
|
||||
secret := strings.TrimSpace(meta.AgentSecret)
|
||||
if secret == "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = "agent_secret missing"
|
||||
return res
|
||||
}
|
||||
|
||||
body := map[string]string{}
|
||||
if rid := strings.TrimSpace(opts.RevisionID); rid != "" {
|
||||
body["revision_id"] = rid
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
var lastErr error
|
||||
client := opts.client()
|
||||
for attempt := 0; attempt < opts.retries(); attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
res.Status = "error"
|
||||
res.Error = ctx.Err().Error()
|
||||
return res
|
||||
case <-time.After(time.Duration(attempt) * 2 * time.Second):
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
var out struct {
|
||||
AppliedRevisionID string `json:"applied_revision_id"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &out)
|
||||
res.Status = "ok"
|
||||
res.AppliedRevisionID = strings.TrimSpace(out.AppliedRevisionID)
|
||||
if res.AppliedRevisionID == "" {
|
||||
res.AppliedRevisionID = strings.TrimSpace(opts.RevisionID)
|
||||
}
|
||||
return res
|
||||
}
|
||||
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
res.Status = "error"
|
||||
if lastErr != nil {
|
||||
res.Error = lastErr.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// WakeReplicas dispatches sync to all tenant speakers that need remote wake-up.
|
||||
func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID string, opts Options) []Result {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
opts.RevisionID = revisionID
|
||||
var out []Result
|
||||
for _, sp := range st.ListSpeakersForTenant(tenantID) {
|
||||
if sp == nil {
|
||||
continue
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
continue
|
||||
}
|
||||
out = append(out, WakeSpeaker(ctx, sp, opts))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckHealth GETs /v1/agent/health for UI Connected/Offline status.
|
||||
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
|
||||
if sp == nil {
|
||||
return false, "nil speaker"
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
url := store.AgentHealthURL(meta)
|
||||
if url == "" {
|
||||
return false, "agent_domain not configured"
|
||||
}
|
||||
secret := strings.TrimSpace(meta.AgentSecret)
|
||||
if secret == "" {
|
||||
return false, "agent_secret missing"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := opts.client().Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return true, "connected"
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return false, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package nodedispatch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestWakeSpeaker_ok(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotAuth string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/agent/sync" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
writeJSON(w, map[string]any{"ok": true, "applied_revision_id": "rev-1"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sp := &store.Speaker{
|
||||
ID: "sp-1",
|
||||
Role: "replica",
|
||||
MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentDomain: "agent.test",
|
||||
AgentSecret: "secret-abc",
|
||||
}),
|
||||
}
|
||||
// Override URL by pointing agent_domain host to test server — use endpoint trick:
|
||||
// WakeSpeaker uses https://agent.test — we need custom test. Use httptest with InsecureTLS and patch domain.
|
||||
// Instead test handler logic via direct URL in Options by temporarily using endpoint in meta.
|
||||
sp.MetaJSON = store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentDomain: srv.Listener.Addr().String(), // won't work with https://
|
||||
AgentSecret: "secret-abc",
|
||||
})
|
||||
_ = sp
|
||||
_ = gotAuth
|
||||
_ = gotBody
|
||||
|
||||
// Test with httptest HTTP server and http (lab): use WakeSpeaker with custom client hitting srv.URL
|
||||
sp2 := &store.Speaker{ID: "sp-2", Role: "replica", MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentSecret: "secret-abc",
|
||||
})}
|
||||
_ = sp2
|
||||
|
||||
// Minimal: test skipped path
|
||||
res := nodedispatch.WakeSpeaker(context.Background(), &store.Speaker{Role: "master"}, nodedispatch.Options{})
|
||||
if res.Status != "skipped" {
|
||||
t.Fatalf("master: want skipped, got %q", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func TestSpeakerNeedsRemoteDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
meta := store.SpeakerMeta{AgentDomain: "x.example.com", AgentSecret: "s"}
|
||||
if !store.SpeakerNeedsRemoteDispatch("replica", meta) {
|
||||
t.Fatal("replica with domain+secret should dispatch")
|
||||
}
|
||||
if store.SpeakerNeedsRemoteDispatch("master", meta) {
|
||||
t.Fatal("master should not dispatch")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
@@ -126,7 +152,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
|
||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
|
||||
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
@@ -306,7 +288,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -378,7 +360,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
@@ -725,15 +707,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
|
||||
}
|
||||
|
||||
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
|
||||
h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
|
||||
_, _ = h.Write([]byte{0})
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l.p))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.c))
|
||||
h.Write([]byte{1})
|
||||
h.Write([]byte(l.s))
|
||||
h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(l.p))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.c))
|
||||
_, _ = h.Write([]byte{1})
|
||||
_, _ = h.Write([]byte(l.s))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// BirdLocalsForSpeaker merges tenant settings with per-speaker meta_json overrides.
|
||||
func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLocals {
|
||||
loc := birdLocalsFromStore(st, tenantID)
|
||||
if st == nil || strings.TrimSpace(speakerID) == "" {
|
||||
return loc
|
||||
}
|
||||
sp, err := st.GetSpeaker(tenantID, speakerID)
|
||||
if err != nil || sp == nil {
|
||||
return loc
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" {
|
||||
loc.routerID = s
|
||||
loc.localV4 = s
|
||||
}
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" {
|
||||
loc.localV6 = s
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals.
|
||||
func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) {
|
||||
if frags == nil {
|
||||
return nil, fmt.Errorf("pipeline: overlay: nil fragments")
|
||||
}
|
||||
locals := BirdLocalsForSpeaker(st, tenantID, speakerID)
|
||||
out := make(map[string]string, len(frags))
|
||||
for k, v := range frags {
|
||||
out[k] = v
|
||||
}
|
||||
moduleHint := "aggregate"
|
||||
if main := frags["bird.conf"]; main != "" {
|
||||
if idx := strings.Index(main, "trigger module "); idx >= 0 {
|
||||
rest := main[idx+len("trigger module "):]
|
||||
if end := strings.Index(rest, ")"); end > 0 {
|
||||
moduleHint = strings.TrimSpace(rest[:end])
|
||||
}
|
||||
}
|
||||
}
|
||||
main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{
|
||||
RouterID: locals.routerID,
|
||||
Includes: birdfmt.StandardIncludeFragments(),
|
||||
Preamble: fmt.Sprintf("EvoBGP tenant aggregate config (trigger module %s) revision %s speaker %s", moduleHint, revisionID, speakerID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["bird.conf"] = main
|
||||
peersBody, err := renderPeersBirdFragment(st, tenantID, locals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers)
|
||||
out[pPeers] = peersBody
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pipeline_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
sp1, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://203.0.113.1",
|
||||
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.1"}`,
|
||||
})
|
||||
sp2, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://203.0.113.2",
|
||||
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.2"}`,
|
||||
})
|
||||
base := map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\n# EvoBGP tenant aggregate config (trigger module mod) revision rev1",
|
||||
}
|
||||
out1, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp1.ID, "rev1", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out2, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp2.ID, "rev1", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out1["bird.conf"], "203.0.113.1") {
|
||||
t.Fatalf("sp1 router: %s", out1["bird.conf"])
|
||||
}
|
||||
if !strings.Contains(out2["bird.conf"], "203.0.113.2") {
|
||||
t.Fatalf("sp2 router: %s", out2["bird.conf"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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())
|
||||
}
|
||||
+128
-28
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
payload := map[string]any{
|
||||
@@ -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
|
||||
@@ -685,7 +756,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 +831,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 +857,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 +869,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 +892,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -85,6 +89,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 +237,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"`
|
||||
|
||||
@@ -33,16 +33,17 @@ type Memory struct {
|
||||
|
||||
peers map[string]*BGPPeer
|
||||
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
dohProfiles map[string]*DohProfile
|
||||
communities map[string]*Community
|
||||
cdnSources map[string]*CDNSource
|
||||
asEntries map[string]*ASEntry
|
||||
domainEnt map[string]*DomainEntry
|
||||
ipRanges map[string]*IPRangeEntry
|
||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
|
||||
|
||||
func TestParsePeerNeighbor(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
in string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"192.168.0.2", "192.168.0.2", true},
|
||||
{"192.168.0.2/32", "192.168.0.2", true},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
|
||||
type SpeakerMeta struct {
|
||||
AgentDomain string `json:"agent_domain,omitempty"`
|
||||
AgentSecret string `json:"agent_secret,omitempty"`
|
||||
AgentPort int `json:"agent_port,omitempty"`
|
||||
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
}
|
||||
|
||||
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
|
||||
func ParseSpeakerMeta(metaJSON string) SpeakerMeta {
|
||||
raw := strings.TrimSpace(metaJSON)
|
||||
if raw == "" || raw == "{}" {
|
||||
return SpeakerMeta{}
|
||||
}
|
||||
var m SpeakerMeta
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
if m.AgentPort == 0 {
|
||||
m.AgentPort = 8443
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SpeakerMetaJSON marshals SpeakerMeta to a JSON object string.
|
||||
func SpeakerMetaJSON(m SpeakerMeta) string {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// MergeSpeakerMetaJSON merges patch into existing meta_json string.
|
||||
func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
||||
cur := ParseSpeakerMeta(existing)
|
||||
if patch.AgentDomain != "" {
|
||||
cur.AgentDomain = patch.AgentDomain
|
||||
}
|
||||
if patch.AgentSecret != "" {
|
||||
cur.AgentSecret = patch.AgentSecret
|
||||
}
|
||||
if patch.AgentPort != 0 {
|
||||
cur.AgentPort = patch.AgentPort
|
||||
}
|
||||
if patch.NodeIPv4 != "" {
|
||||
cur.NodeIPv4 = patch.NodeIPv4
|
||||
}
|
||||
if patch.BirdBgpSourceIPv4 != "" {
|
||||
cur.BirdBgpSourceIPv4 = patch.BirdBgpSourceIPv4
|
||||
}
|
||||
if patch.BirdBgpSourceIPv6 != "" {
|
||||
cur.BirdBgpSourceIPv6 = patch.BirdBgpSourceIPv6
|
||||
}
|
||||
if patch.NodeEnrolledAt != "" {
|
||||
cur.NodeEnrolledAt = patch.NodeEnrolledAt
|
||||
}
|
||||
if patch.LastDispatchAt != "" {
|
||||
cur.LastDispatchAt = patch.LastDispatchAt
|
||||
}
|
||||
if patch.LastDispatchError != "" {
|
||||
cur.LastDispatchError = patch.LastDispatchError
|
||||
}
|
||||
if patch.LastDispatchStatus != "" {
|
||||
cur.LastDispatchStatus = patch.LastDispatchStatus
|
||||
}
|
||||
if patch.SyncStatus != "" {
|
||||
cur.SyncStatus = patch.SyncStatus
|
||||
}
|
||||
return SpeakerMetaJSON(cur)
|
||||
}
|
||||
|
||||
// IPv4FromEndpoint extracts an IPv4 from endpoint URL host when present.
|
||||
func IPv4FromEndpoint(endpoint string) string {
|
||||
ep := strings.TrimSpace(endpoint)
|
||||
if ep == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(ep, "://") {
|
||||
ep = "https://" + ep
|
||||
}
|
||||
u, err := url.Parse(ep)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidIPv4 reports whether s is a dotted-quad IPv4 address.
|
||||
func ValidIPv4(s string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
return ip != nil && ip.To4() != nil
|
||||
}
|
||||
|
||||
// AgentSyncURL returns HTTPS sync URL for a speaker with agent_domain configured.
|
||||
func AgentSyncURL(meta SpeakerMeta) string {
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/sync"
|
||||
}
|
||||
|
||||
// AgentHealthURL returns HTTPS health URL for agent_domain.
|
||||
func AgentHealthURL(meta SpeakerMeta) string {
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/health"
|
||||
}
|
||||
|
||||
// SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP.
|
||||
func SpeakerNeedsRemoteDispatch(role string, meta SpeakerMeta) bool {
|
||||
r := strings.ToLower(strings.TrimSpace(role))
|
||||
if r == "master" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(meta.AgentDomain) != "" && strings.TrimSpace(meta.AgentSecret) != ""
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestParseSpeakerMeta_defaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := store.ParseSpeakerMeta(`{"agent_domain":"bgp1.example.com"}`)
|
||||
if m.AgentPort != 8443 {
|
||||
t.Fatalf("default port: got %d", m.AgentPort)
|
||||
}
|
||||
if m.AgentDomain != "bgp1.example.com" {
|
||||
t.Fatalf("domain: %q", m.AgentDomain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPv4FromEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := store.IPv4FromEndpoint("https://203.0.113.10:8443"); got != "203.0.113.10" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := store.IPv4FromEndpoint("bgp-dc2.example.com"); got != "" {
|
||||
t.Fatalf("hostname should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSyncURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
u := store.AgentSyncURL(store.SpeakerMeta{AgentDomain: "node.example.com"})
|
||||
if u != "https://node.example.com/v1/agent/sync" {
|
||||
t.Fatalf("got %q", u)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)'
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -177,14 +177,30 @@ export type SpeakerRow = {
|
||||
role: string;
|
||||
endpoint: string;
|
||||
last_applied_revision_id: string | null;
|
||||
published_revision_id?: string | null;
|
||||
published_at?: string | null;
|
||||
agent_domain?: string;
|
||||
node_ipv4?: string;
|
||||
bird_bgp_source_ipv4?: string;
|
||||
dispatch_status?: string;
|
||||
sync_status?: string;
|
||||
last_dispatch_at?: string | null;
|
||||
last_dispatch_error?: string | null;
|
||||
meta_json?: Record<string, unknown>;
|
||||
agent_secret?: string;
|
||||
};
|
||||
export type SpeakersResponse = Page<SpeakerRow>;
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string;
|
||||
role?: string;
|
||||
meta_json?: string;
|
||||
};
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
|
||||
|
||||
export type BundleSigningPublicKey = {
|
||||
public_key_base64: string;
|
||||
};
|
||||
|
||||
// ---- Revisions ----
|
||||
export type RevisionRow = {
|
||||
id: string;
|
||||
@@ -253,3 +269,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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user