From bf52b21150a4d7187477bb675aeb120916d92894 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 5 Apr 2026 14:07:45 +0700 Subject: [PATCH] feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices. --- .../plans/evobgp_архитектура_0e73ef02.plan.md | 18 +- cmd/evobgp-agent/main.go | 66 +- cmd/evobgp-all/main.go | 88 +- cmd/evobgp-api/main.go | 85 +- cmd/evobgp-deploy/main.go | 23 +- cmd/evobgp-ingest/main.go | 23 +- cmd/evobgp-node/main.go | 239 +- cmd/evobgp-render/main.go | 23 +- cmd/evobgp-scheduler/main.go | 23 +- deploy/bird/bird.conf | 10 + deploy/compose/bgp-stack.compose.yaml | 41 + deploy/compose/docker-compose.yaml | 193 ++ deploy/docker/bird2/Dockerfile | 9 + deploy/docker/evobgp-agent/Dockerfile | 13 + deploy/docker/evobgp-web/Dockerfile | 11 + deploy/docker/evobgp-web/nginx.conf | 26 + deploy/docker/gobinary/Dockerfile | 18 + deploy/prometheus/alerts.yml | 24 + deploy/prometheus/prometheus.yml | 12 + go.mod | 5 + go.sum | 68 + internal/birdfmt/bgp.go | 114 + internal/birdfmt/bgp_test.go | 29 + internal/birdfmt/birdctl.go | 71 + internal/birdfmt/doc.go | 19 + internal/birdfmt/filter.go | 79 + internal/birdfmt/filter_test.go | 47 + internal/birdfmt/fragment.go | 47 + internal/birdfmt/fragment_test.go | 22 + internal/birdfmt/layout.go | 95 + internal/birdfmt/layout_test.go | 55 + internal/birdfmt/protocols.go | 52 + internal/birdfmt/protocols_test.go | 16 + internal/birdfmt/scenarios_test.go | 1 + internal/birdfmt/standard_layout_test.go | 72 + internal/birdfmt/static.go | 31 + internal/birdfmt/static_test.go | 11 + .../testdata/golden/main_bird_skeleton.golden | 15 + .../scenarios/standard_layout/bird.conf | 18 + .../bird.d/evobgp_filters_v4.conf | 4 + .../bird.d/evobgp_filters_v6.conf | 3 + .../standard_layout/bird.d/evobgp_peers.conf | 8 + .../bird.d/evobgp_prefixes_v4.conf | 4 + .../bird.d/evobgp_prefixes_v6.conf | 3 + internal/bundle/bundle_test.go | 51 + internal/bundle/extract.go | 46 + internal/bundle/pack.go | 131 + internal/bundle/verify.go | 110 + internal/config/config.go | 35 + internal/db/migrations.go | 9 + internal/deploy/run.go | 23 + internal/domain/module.go | 10 + internal/httpapi/auth.go | 135 + internal/httpapi/cors.go | 48 + internal/httpapi/problem.go | 36 + internal/httpapi/routes.go | 673 +++++ internal/httpapi/server.go | 78 + internal/httpapi/server_test.go | 228 ++ internal/ingest/run.go | 23 + internal/jobs/errors.go | 5 + internal/jobs/job.go | 285 ++ internal/jobs/worker.go | 98 + internal/observability/metrics.go | 224 ++ internal/platform/platform.go | 16 + internal/render/run.go | 23 + internal/repository/doc.go | 6 + internal/scheduler/run.go | 25 + internal/signing/verify.go | 13 + internal/signing/verify_test.go | 28 + internal/store/memory.go | 494 ++++ migrations/embed.go | 13 + migrations/postgres/000001_init.down.sql | 15 + migrations/postgres/000001_init.up.sql | 220 ++ migrations/sqlite/000001_init.down.sql | 17 + migrations/sqlite/000001_init.up.sql | 214 ++ web/.gitignore | 23 + web/.npmrc | 1 + web/.prettierignore | 9 + web/.prettierrc | 19 + web/.vscode/extensions.json | 7 + web/.vscode/settings.json | 5 + web/README.md | 42 + web/package-lock.json | 2402 +++++++++++++++++ web/package.json | 38 + web/src/app.d.ts | 13 + web/src/app.html | 12 + web/src/lib/AppShell.svelte | 60 + web/src/lib/api/client.ts | 47 + web/src/lib/api/types.ts | 77 + web/src/lib/assets/favicon.svg | 1 + web/src/lib/components/ui/badge/badge.svelte | 38 + web/src/lib/components/ui/badge/index.ts | 2 + .../lib/components/ui/button/button.svelte | 54 + web/src/lib/components/ui/button/index.ts | 8 + .../components/ui/card/card-content.svelte | 12 + .../ui/card/card-description.svelte | 12 + .../lib/components/ui/card/card-header.svelte | 12 + .../lib/components/ui/card/card-title.svelte | 14 + web/src/lib/components/ui/card/card.svelte | 17 + web/src/lib/components/ui/card/index.ts | 14 + web/src/lib/components/ui/input/index.ts | 2 + web/src/lib/components/ui/input/input.svelte | 19 + web/src/lib/components/ui/label/index.ts | 2 + web/src/lib/components/ui/label/label.svelte | 15 + .../lib/components/ui/scroll-area/index.ts | 2 + .../ui/scroll-area/scroll-area.svelte | 12 + web/src/lib/components/ui/separator/index.ts | 2 + .../components/ui/separator/separator.svelte | 20 + web/src/lib/components/ui/table/index.ts | 16 + .../lib/components/ui/table/table-body.svelte | 12 + .../lib/components/ui/table/table-cell.svelte | 23 + .../lib/components/ui/table/table-head.svelte | 18 + .../components/ui/table/table-header.svelte | 12 + .../lib/components/ui/table/table-row.svelte | 15 + web/src/lib/components/ui/table/table.svelte | 14 + web/src/lib/index.ts | 1 + web/src/lib/utils.ts | 13 + web/src/routes/+layout.svelte | 15 + web/src/routes/+layout.ts | 2 + web/src/routes/+page.svelte | 97 + web/src/routes/layout.css | 120 + web/src/routes/modules/+page.svelte | 84 + web/src/routes/monitoring/+page.svelte | 148 + web/src/routes/peers/+page.svelte | 118 + web/src/routes/revisions/+page.svelte | 80 + web/src/routes/schedule/+page.svelte | 161 ++ web/src/routes/settings/+page.svelte | 51 + web/static/robots.txt | 3 + web/svelte.config.js | 24 + web/tsconfig.json | 20 + web/vite.config.ts | 13 + 131 files changed, 9222 insertions(+), 17 deletions(-) create mode 100644 deploy/bird/bird.conf create mode 100644 deploy/compose/bgp-stack.compose.yaml create mode 100644 deploy/compose/docker-compose.yaml create mode 100644 deploy/docker/bird2/Dockerfile create mode 100644 deploy/docker/evobgp-agent/Dockerfile create mode 100644 deploy/docker/evobgp-web/Dockerfile create mode 100644 deploy/docker/evobgp-web/nginx.conf create mode 100644 deploy/docker/gobinary/Dockerfile create mode 100644 deploy/prometheus/alerts.yml create mode 100644 deploy/prometheus/prometheus.yml create mode 100644 go.sum create mode 100644 internal/birdfmt/bgp.go create mode 100644 internal/birdfmt/bgp_test.go create mode 100644 internal/birdfmt/birdctl.go create mode 100644 internal/birdfmt/doc.go create mode 100644 internal/birdfmt/filter.go create mode 100644 internal/birdfmt/filter_test.go create mode 100644 internal/birdfmt/fragment.go create mode 100644 internal/birdfmt/fragment_test.go create mode 100644 internal/birdfmt/layout.go create mode 100644 internal/birdfmt/layout_test.go create mode 100644 internal/birdfmt/protocols.go create mode 100644 internal/birdfmt/protocols_test.go create mode 100644 internal/birdfmt/standard_layout_test.go create mode 100644 internal/birdfmt/testdata/golden/main_bird_skeleton.golden create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.conf create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf create mode 100644 internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf create mode 100644 internal/bundle/bundle_test.go create mode 100644 internal/bundle/extract.go create mode 100644 internal/bundle/pack.go create mode 100644 internal/bundle/verify.go create mode 100644 internal/config/config.go create mode 100644 internal/db/migrations.go create mode 100644 internal/deploy/run.go create mode 100644 internal/domain/module.go create mode 100644 internal/httpapi/auth.go create mode 100644 internal/httpapi/cors.go create mode 100644 internal/httpapi/problem.go create mode 100644 internal/httpapi/routes.go create mode 100644 internal/httpapi/server.go create mode 100644 internal/httpapi/server_test.go create mode 100644 internal/ingest/run.go create mode 100644 internal/jobs/errors.go create mode 100644 internal/jobs/job.go create mode 100644 internal/jobs/worker.go create mode 100644 internal/observability/metrics.go create mode 100644 internal/platform/platform.go create mode 100644 internal/render/run.go create mode 100644 internal/repository/doc.go create mode 100644 internal/scheduler/run.go create mode 100644 internal/signing/verify.go create mode 100644 internal/signing/verify_test.go create mode 100644 internal/store/memory.go create mode 100644 migrations/embed.go create mode 100644 migrations/postgres/000001_init.down.sql create mode 100644 migrations/postgres/000001_init.up.sql create mode 100644 migrations/sqlite/000001_init.down.sql create mode 100644 migrations/sqlite/000001_init.up.sql create mode 100644 web/.gitignore create mode 100644 web/.npmrc create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc create mode 100644 web/.vscode/extensions.json create mode 100644 web/.vscode/settings.json create mode 100644 web/README.md create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/lib/AppShell.svelte create mode 100644 web/src/lib/api/client.ts create mode 100644 web/src/lib/api/types.ts create mode 100644 web/src/lib/assets/favicon.svg create mode 100644 web/src/lib/components/ui/badge/badge.svelte create mode 100644 web/src/lib/components/ui/badge/index.ts create mode 100644 web/src/lib/components/ui/button/button.svelte create mode 100644 web/src/lib/components/ui/button/index.ts create mode 100644 web/src/lib/components/ui/card/card-content.svelte create mode 100644 web/src/lib/components/ui/card/card-description.svelte create mode 100644 web/src/lib/components/ui/card/card-header.svelte create mode 100644 web/src/lib/components/ui/card/card-title.svelte create mode 100644 web/src/lib/components/ui/card/card.svelte create mode 100644 web/src/lib/components/ui/card/index.ts create mode 100644 web/src/lib/components/ui/input/index.ts create mode 100644 web/src/lib/components/ui/input/input.svelte create mode 100644 web/src/lib/components/ui/label/index.ts create mode 100644 web/src/lib/components/ui/label/label.svelte create mode 100644 web/src/lib/components/ui/scroll-area/index.ts create mode 100644 web/src/lib/components/ui/scroll-area/scroll-area.svelte create mode 100644 web/src/lib/components/ui/separator/index.ts create mode 100644 web/src/lib/components/ui/separator/separator.svelte create mode 100644 web/src/lib/components/ui/table/index.ts create mode 100644 web/src/lib/components/ui/table/table-body.svelte create mode 100644 web/src/lib/components/ui/table/table-cell.svelte create mode 100644 web/src/lib/components/ui/table/table-head.svelte create mode 100644 web/src/lib/components/ui/table/table-header.svelte create mode 100644 web/src/lib/components/ui/table/table-row.svelte create mode 100644 web/src/lib/components/ui/table/table.svelte create mode 100644 web/src/lib/index.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+layout.ts create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/layout.css create mode 100644 web/src/routes/modules/+page.svelte create mode 100644 web/src/routes/monitoring/+page.svelte create mode 100644 web/src/routes/peers/+page.svelte create mode 100644 web/src/routes/revisions/+page.svelte create mode 100644 web/src/routes/schedule/+page.svelte create mode 100644 web/src/routes/settings/+page.svelte create mode 100644 web/static/robots.txt create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.cursor/plans/evobgp_архитектура_0e73ef02.plan.md b/.cursor/plans/evobgp_архитектура_0e73ef02.plan.md index e0d9a9b..7a61f03 100644 --- a/.cursor/plans/evobgp_архитектура_0e73ef02.plan.md +++ b/.cursor/plans/evobgp_архитектура_0e73ef02.plan.md @@ -4,28 +4,28 @@ overview: Оба профиля по умолчанию на PostgreSQL (оди todos: - id: schema-db content: "Схема БД: PostgreSQL (основной); опционально SQLite (microVPS single-container); модули, ревизии, jobs" - status: pending + status: completed - id: bird-generator content: Формат bird.conf, include-фрагменты, фильтры, birdc configure - status: pending + status: completed - id: rest-jobs content: REST (refresh, apply, preview, rollback, bundle API) и async jobs - status: pending + status: completed - id: node-agent content: evobgp-agent + bird2 в Docker на мастере; evobgp-node + тот же паттерн на реплике (отдельная ВМ) - status: pending + status: completed - id: observability content: Метрики, алерты префиксов и BGP-сессий - status: pending + status: completed - id: docker-ms content: Compose profiles reference + microVPS; сервисы bird2 + evobgp-agent; сеть BGP; лимиты; логи; prune - status: pending + status: completed - id: go-modules content: Monorepo по §1 (дерево репозитория); internal/*, cmd/evobgp-all и cmd/* для reference - status: pending + status: completed - id: replica-bundle content: Подписанный бандл ревизии, API, evobgp-node - status: pending + status: completed - id: risk-hardening content: Двухфазный deploy, LKG, pin BIRD, политика миграций, обязательная подпись бандла на ноде status: completed @@ -37,7 +37,7 @@ todos: status: completed - id: web-ui content: "Web UI (Svelte + shadcn-svelte) в отдельном контейнере: списки, расписание, мониторинг" - status: pending + status: completed isProject: false --- diff --git a/cmd/evobgp-agent/main.go b/cmd/evobgp-agent/main.go index 38dd16d..f3c9c8a 100644 --- a/cmd/evobgp-agent/main.go +++ b/cmd/evobgp-agent/main.go @@ -1,3 +1,67 @@ package main -func main() {} +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "evobgp/internal/birdfmt" +) + +func main() { + bird := flag.String("bird", "", "path to bird binary (default: bird from PATH)") + birdc := flag.String("birdc", "", "path to birdc binary (default: birdc from PATH)") + socket := flag.String("socket", "", "optional birdc control socket (-s)") + timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [flags] \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Commands:\n") + fmt.Fprintf(os.Stderr, " parse-check run bird -c -p (syntax check)\n") + fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n") + flag.PrintDefaults() + } + flag.Parse() + args := flag.Args() + if len(args) < 1 { + flag.Usage() + os.Exit(2) + } + + ctl := &birdfmt.BirdCtl{Socket: *socket} + if *bird != "" { + ctl.Bird = *bird + } + if *birdc != "" { + ctl.Birdc = *birdc + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + switch args[0] { + case "parse-check": + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "parse-check requires exactly one argument: path to bird.conf") + os.Exit(2) + } + if err := ctl.ParseCheck(ctx, args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case "configure": + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "configure takes no extra arguments") + os.Exit(2) + } + if err := ctl.Configure(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) + flag.Usage() + os.Exit(2) + } +} diff --git a/cmd/evobgp-all/main.go b/cmd/evobgp-all/main.go index 38dd16d..6427be9 100644 --- a/cmd/evobgp-all/main.go +++ b/cmd/evobgp-all/main.go @@ -1,3 +1,89 @@ package main -func main() {} +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "evobgp/internal/birdfmt" + "evobgp/internal/config" + "evobgp/internal/deploy" + "evobgp/internal/httpapi" + "evobgp/internal/ingest" + "evobgp/internal/observability" + "evobgp/internal/platform" + "evobgp/internal/render" + "evobgp/internal/scheduler" +) + +// microVPS entrypoint: one process — HTTP API (same as evobgp-api) plus in-process stubs for scheduler/ingest/render/deploy. +func main() { + cfg := config.Load() + opts := httpapi.Options{ + APIKeys: os.Getenv("EVOBGP_API_KEYS"), + InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1", + SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0", + BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")), + CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")), + } + srv, err := httpapi.New(opts) + if err != nil { + log.Fatal(err) + } + observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go scheduler.Run(ctx) + go ingest.Run(ctx) + go render.Run(ctx) + go deploy.Run(ctx) + + startBirdMetricsPoller() + + httpSrv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: srv.Handler(), + } + go func() { + svc := platform.ServiceName("evobgp-all") + log.Printf("%s (microVPS): HTTP API on %s", svc, cfg.HTTPAddr) + log.Printf("bundle public key (base64): %s", srv.BundlePublicKeyBase64()) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP shutdown: %v", err) + } + log.Printf("%s stopped", platform.ServiceName("evobgp-all")) +} + +func startBirdMetricsPoller() { + sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")) + if sock == "" { + return + } + interval := 30 * time.Second + if d, err := time.ParseDuration(strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_INTERVAL"))); err == nil && d > 0 { + interval = d + } + bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")) + observability.StartBirdProtocolsPoller(sock, bin, interval, + func(ctx context.Context, socket, birdcBin string) (string, error) { + return birdfmt.ShowProtocols(ctx, socket, birdcBin) + }, + birdfmt.CountEstablishedBGPSessions, + ) + log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval) +} diff --git a/cmd/evobgp-api/main.go b/cmd/evobgp-api/main.go index 38dd16d..2816155 100644 --- a/cmd/evobgp-api/main.go +++ b/cmd/evobgp-api/main.go @@ -1,3 +1,86 @@ package main -func main() {} +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "evobgp/internal/birdfmt" + "evobgp/internal/config" + "evobgp/internal/httpapi" + "evobgp/internal/observability" + "evobgp/internal/platform" +) + +func main() { + cfg := config.Load() + seedDemo := os.Getenv("EVOBGP_SEED_DEMO") != "0" + opts := httpapi.Options{ + APIKeys: os.Getenv("EVOBGP_API_KEYS"), + InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1", + SeedDemo: seedDemo, + BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")), + CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")), + } + srv, err := httpapi.New(opts) + if err != nil { + log.Fatal(err) + } + + observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))) + startBirdMetricsPoller() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + httpSrv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: srv.Handler(), + } + go func() { + svc := platform.ServiceName("evobgp-api") + log.Printf("%s listening on %s", svc, cfg.HTTPAddr) + log.Printf("bundle signing public key (base64, set on evobgp-node): %s", srv.BundlePublicKeyBase64()) + if opts.SeedDemo { + 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)") + } + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP shutdown: %v", err) + } + log.Printf("%s stopped", platform.ServiceName("evobgp-api")) +} + +func startBirdMetricsPoller() { + sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")) + if sock == "" { + return + } + interval := 30 * time.Second + if d, err := time.ParseDuration(strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_INTERVAL"))); err == nil && d > 0 { + interval = d + } + bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")) + observability.StartBirdProtocolsPoller(sock, bin, interval, + func(ctx context.Context, socket, birdcBin string) (string, error) { + return birdfmt.ShowProtocols(ctx, socket, birdcBin) + }, + birdfmt.CountEstablishedBGPSessions, + ) + log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval) +} diff --git a/cmd/evobgp-deploy/main.go b/cmd/evobgp-deploy/main.go index 38dd16d..7539075 100644 --- a/cmd/evobgp-deploy/main.go +++ b/cmd/evobgp-deploy/main.go @@ -1,3 +1,24 @@ package main -func main() {} +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "evobgp/internal/config" + "evobgp/internal/deploy" +) + +func main() { + cfg := config.Load() + name := cfg.ServiceName + if name == "" { + name = "evobgp-deploy" + } + log.Printf("%s starting (reference profile worker)", name) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + deploy.Run(ctx) +} diff --git a/cmd/evobgp-ingest/main.go b/cmd/evobgp-ingest/main.go index 38dd16d..4d424ff 100644 --- a/cmd/evobgp-ingest/main.go +++ b/cmd/evobgp-ingest/main.go @@ -1,3 +1,24 @@ package main -func main() {} +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "evobgp/internal/config" + "evobgp/internal/ingest" +) + +func main() { + cfg := config.Load() + name := cfg.ServiceName + if name == "" { + name = "evobgp-ingest" + } + log.Printf("%s starting (reference profile worker)", name) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ingest.Run(ctx) +} diff --git a/cmd/evobgp-node/main.go b/cmd/evobgp-node/main.go index 38dd16d..3269583 100644 --- a/cmd/evobgp-node/main.go +++ b/cmd/evobgp-node/main.go @@ -1,3 +1,240 @@ package main -func main() {} +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "evobgp/internal/birdfmt" + "evobgp/internal/bundle" + "evobgp/internal/signing" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "pull-bundle": + os.Exit(cmdPullBundle(os.Args[2:])) + case "verify-bundle": + os.Exit(cmdVerifyBundle(os.Args[2:])) + case "apply-bundle": + os.Exit(cmdApplyBundle(os.Args[2:])) + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: + %s pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path] + %s verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX) + %s apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX) + [-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION] + +apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure. +`, os.Args[0], os.Args[0], os.Args[0]) +} + +func cmdPullBundle(args []string) int { + fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError) + base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080") + token := fs.String("token", "", "Bearer token (node role)") + speaker := fs.String("speaker-id", "", "bgp_speaker id") + revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)") + out := fs.String("o", "bundle.tar.gz", "output file") + _ = fs.Parse(args) + + if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" { + fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required") + return 2 + } + rev := strings.TrimSpace(*revision) + if rev == "" { + var err error + rev, err = fetchLatestRevision(*base, *token, *speaker) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + body, err := fetchBundle(*base, *token, *speaker, rev) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if err := os.WriteFile(*out, body, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev) + return 0 +} + +func fetchLatestRevision(base, token, speaker string) (string, error) { + u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest" + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer 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))) + } + var out struct { + RevisionID string `json:"revision_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.RevisionID == "" { + return "", fmt.Errorf("empty revision_id in response") + } + return out.RevisionID, nil +} + +func fetchBundle(base, token, speaker, revision string) ([]byte, error) { + u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer 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))) + } + return io.ReadAll(resp.Body) +} + +func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) { + switch { + case strings.TrimSpace(pubB64) != "": + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64)) + if err != nil { + return nil, err + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize) + } + return ed25519.PublicKey(raw), nil + case strings.TrimSpace(pubHex) != "": + return bundle.ParsePublicKeyHex(pubHex) + default: + return nil, fmt.Errorf("public key required") + } +} + +func cmdVerifyBundle(args []string) int { + fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError) + path := fs.String("f", "", "path to bundle.tar.gz") + pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)") + pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)") + _ = fs.Parse(args) + + if *path == "" { + fmt.Fprintln(os.Stderr, "verify-bundle: -f required") + return 2 + } + pub, err := loadPubKey(*pubB64, *pubHex) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + raw, err := os.ReadFile(*path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + v, err := signing.VerifyGzippedTar(raw, pub) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files)) + return 0 +} + +func cmdApplyBundle(args []string) int { + fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError) + path := fs.String("f", "", "path to bundle.tar.gz") + dir := fs.String("extract-dir", "", "directory to extract into") + pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)") + pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)") + bird := fs.String("bird", "", "bird binary (default PATH)") + birdc := fs.String("birdc", "", "birdc binary (default PATH)") + socket := fs.String("socket", "", "birdc -s socket path") + timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc") + _ = fs.Parse(args) + + if *path == "" || *dir == "" { + fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required") + return 2 + } + pub, err := loadPubKey(*pubB64, *pubHex) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + raw, err := os.ReadFile(*path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + v, err := signing.VerifyGzippedTar(raw, pub) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + root := filepath.Clean(*dir) + if err := os.MkdirAll(root, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if err := bundle.WriteExtractedFiles(root, v); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + mainRel := v.FindMainBirdConf() + if mainRel == "" { + fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest") + return 1 + } + mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/"))) + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket} + if err := ctl.ParseCheck(ctx, mainPath); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if err := ctl.Configure(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath) + return 0 +} diff --git a/cmd/evobgp-render/main.go b/cmd/evobgp-render/main.go index 38dd16d..99ae92c 100644 --- a/cmd/evobgp-render/main.go +++ b/cmd/evobgp-render/main.go @@ -1,3 +1,24 @@ package main -func main() {} +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "evobgp/internal/config" + "evobgp/internal/render" +) + +func main() { + cfg := config.Load() + name := cfg.ServiceName + if name == "" { + name = "evobgp-render" + } + log.Printf("%s starting (reference profile worker)", name) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + render.Run(ctx) +} diff --git a/cmd/evobgp-scheduler/main.go b/cmd/evobgp-scheduler/main.go index 38dd16d..071dc94 100644 --- a/cmd/evobgp-scheduler/main.go +++ b/cmd/evobgp-scheduler/main.go @@ -1,3 +1,24 @@ package main -func main() {} +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "evobgp/internal/config" + "evobgp/internal/scheduler" +) + +func main() { + cfg := config.Load() + name := cfg.ServiceName + if name == "" { + name = "evobgp-scheduler" + } + log.Printf("%s starting (reference profile worker)", name) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + scheduler.Run(ctx) +} diff --git a/deploy/bird/bird.conf b/deploy/bird/bird.conf new file mode 100644 index 0000000..25c4bb2 --- /dev/null +++ b/deploy/bird/bird.conf @@ -0,0 +1,10 @@ +# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";). +router id 192.0.2.1; + +protocol device { +} + +protocol direct { + ipv4; + ipv6; +} diff --git a/deploy/compose/bgp-stack.compose.yaml b/deploy/compose/bgp-stack.compose.yaml new file mode 100644 index 0000000..7c79f5c --- /dev/null +++ b/deploy/compose/bgp-stack.compose.yaml @@ -0,0 +1,41 @@ +# Устаревший минимальный стек только bird2 + agent. Полные профили reference / microvps: +# docker compose --profile reference up -d --build -f deploy/compose/docker-compose.yaml +# docker compose --profile microvps up -d --build -f deploy/compose/docker-compose.yaml +# +# Для входящего BGP (TCP/179) на Linux часто нужен network_mode: host или macvlan — см. план §4.1. +name: evobgp-bgp-stack + +services: + bird2: + build: + context: ../.. + dockerfile: deploy/docker/bird2/Dockerfile + restart: unless-stopped + cap_add: + - NET_ADMIN + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + # network_mode: "host" # раскомментируйте при анонсе BGP с хоста + + evobgp-agent: + build: + context: ../.. + dockerfile: deploy/docker/evobgp-agent/Dockerfile + restart: unless-stopped + depends_on: + - bird2 + cap_add: + - NET_ADMIN + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + entrypoint: ["/bin/sleep"] + command: ["infinity"] + # Примеры (exec в контейнере sidecar): + # docker compose -f ... exec evobgp-agent /usr/local/bin/evobgp-agent parse-check /etc/bird/bird.conf + # docker compose -f ... exec evobgp-agent /usr/local/bin/evobgp-agent configure + +volumes: + bird_etc: + bird_run: diff --git a/deploy/compose/docker-compose.yaml b/deploy/compose/docker-compose.yaml new file mode 100644 index 0000000..8566967 --- /dev/null +++ b/deploy/compose/docker-compose.yaml @@ -0,0 +1,193 @@ +# EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS). +# +# Запуск: +# docker compose --profile reference up -d --build +# docker compose --profile microvps up -d --build +# +# Профиль reference: Web UI на http://localhost:3000 (nginx → /v1 и /metrics на evobgp-api:8080). +# +# Очистка неиспользуемых данных Docker (PowerShell; осторожно с -v): +# docker system prune -f +# docker volume prune -f +# +# Входящий BGP (TCP/179): на Linux для публичного пира часто нужен network_mode: host у bird2 +# или macvlan/ipvlan — см. архитектурный план §4.1. Здесь опубликован порт 179 для отладки. +name: evobgp + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + postgres: + profiles: ["reference", "microvps"] + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: evobgp + POSTGRES_PASSWORD: evobgp + POSTGRES_DB: evobgp + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U evobgp -d evobgp"] + interval: 10s + timeout: 5s + retries: 5 + logging: *default-logging + deploy: + resources: + limits: + cpus: "1.0" + memory: 384M + + evobgp-api: + profiles: ["reference"] + build: + context: ../.. + dockerfile: deploy/docker/gobinary/Dockerfile + args: + BIN: evobgp-api + INSTALL_BIRDC: "1" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + bird2: + condition: service_started + ports: + - "8080:8080" + environment: + EVOBGP_HTTP_ADDR: ":8080" + EVOBGP_SEED_DEMO: "1" + EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl + EVOBGP_BIRDC_INTERVAL: 30s + # Для прямого доступа к API с dev-сервера Vite (порт 5173); в Docker UI трафик идёт через evobgp-web proxy. + EVOBGP_CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000" + volumes: + - bird_run:/run/bird:ro + logging: *default-logging + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + + evobgp-web: + profiles: ["reference"] + build: + context: ../.. + dockerfile: deploy/docker/evobgp-web/Dockerfile + restart: unless-stopped + depends_on: + - evobgp-api + ports: + - "3000:80" + logging: *default-logging + deploy: + resources: + limits: + cpus: "0.5" + memory: 128M + + evobgp-all: + profiles: ["microvps"] + build: + context: ../.. + dockerfile: deploy/docker/gobinary/Dockerfile + args: + BIN: evobgp-all + INSTALL_BIRDC: "1" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + bird2: + condition: service_started + ports: + - "8080:8080" + environment: + EVOBGP_HTTP_ADDR: ":8080" + EVOBGP_SEED_DEMO: "1" + EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl + EVOBGP_BIRDC_INTERVAL: 30s + volumes: + - bird_run:/run/bird:ro + logging: *default-logging + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + + bird2: + profiles: ["reference", "microvps"] + build: + context: ../.. + dockerfile: deploy/docker/bird2/Dockerfile + restart: unless-stopped + 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 + ports: + - "179:179/tcp" + logging: *default-logging + deploy: + resources: + limits: + memory: 256M + + evobgp-agent: + profiles: ["reference", "microvps"] + build: + context: ../.. + dockerfile: deploy/docker/evobgp-agent/Dockerfile + restart: unless-stopped + depends_on: + - bird2 + cap_add: + - NET_ADMIN + volumes: + - bird_etc:/etc/bird + - bird_run:/run/bird + entrypoint: ["/bin/sh", "-c"] + command: ["sleep infinity"] + logging: *default-logging + deploy: + resources: + limits: + memory: 128M + + prometheus: + profiles: ["reference"] + image: prom/prometheus:v2.54.1 + restart: unless-stopped + depends_on: + - evobgp-api + ports: + - "9090:9090" + volumes: + - ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=15d + - --web.enable-lifecycle + logging: *default-logging + deploy: + resources: + limits: + memory: 512M + +volumes: + pgdata: + bird_etc: + bird_run: diff --git a/deploy/docker/bird2/Dockerfile b/deploy/docker/bird2/Dockerfile new file mode 100644 index 0000000..bdf7895 --- /dev/null +++ b/deploy/docker/bird2/Dockerfile @@ -0,0 +1,9 @@ +# BIRD 2 alongside evobgp-agent; share /etc/bird with the agent container (plan §4.1). +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends bird2 \ + && rm -rf /var/lib/apt/lists/* +COPY deploy/bird/bird.conf /etc/bird/bird.conf +RUN mkdir -p /etc/bird/bird.d +EXPOSE 179 +# Foreground for Docker; configs on shared volume override this file at runtime. +CMD ["bird", "-f", "-c", "/etc/bird/bird.conf"] diff --git a/deploy/docker/evobgp-agent/Dockerfile b/deploy/docker/evobgp-agent/Dockerfile new file mode 100644 index 0000000..d698b15 --- /dev/null +++ b/deploy/docker/evobgp-agent/Dockerfile @@ -0,0 +1,13 @@ +# evobgp-agent CLI (parse-check, birdc configure); install bird2 so `bird -p` matches bird2 image (plan §13). +FROM golang:1.22-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +COPY . . +RUN go build -trimpath -ldflags="-s -w" -o /out/evobgp-agent ./cmd/evobgp-agent + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends bird2 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/evobgp-agent /usr/local/bin/evobgp-agent +WORKDIR /etc/bird +ENTRYPOINT ["/usr/local/bin/evobgp-agent"] diff --git a/deploy/docker/evobgp-web/Dockerfile b/deploy/docker/evobgp-web/Dockerfile new file mode 100644 index 0000000..b4fab16 --- /dev/null +++ b/deploy/docker/evobgp-web/Dockerfile @@ -0,0 +1,11 @@ +# Статическая панель EvoBGP (SvelteKit) + nginx как reverse-proxy к evobgp-api (/v1, /metrics). +FROM node:22-alpine AS build +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY deploy/docker/evobgp-web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /web/build /usr/share/nginx/html diff --git a/deploy/docker/evobgp-web/nginx.conf b/deploy/docker/evobgp-web/nginx.conf new file mode 100644 index 0000000..989a7fd --- /dev/null +++ b/deploy/docker/evobgp-web/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location /v1/ { + proxy_pass http://evobgp-api:8080/v1/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /metrics { + proxy_pass http://evobgp-api:8080/metrics; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/docker/gobinary/Dockerfile b/deploy/docker/gobinary/Dockerfile new file mode 100644 index 0000000..b0bf54e --- /dev/null +++ b/deploy/docker/gobinary/Dockerfile @@ -0,0 +1,18 @@ +# Универсальная сборка бинаря из cmd/* (ARG BIN=evobgp-api | evobgp-all). +# INSTALL_BIRDC=1 ставит bird2 в рантайм-образ для EVOBGP_BIRDC_SOCKET (poll метрик birdc). +FROM golang:1.22-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG BIN=evobgp-api +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp ./cmd/${BIN} + +FROM debian:bookworm-slim +ARG INSTALL_BIRDC=0 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && if [ "$INSTALL_BIRDC" = "1" ]; then apt-get install -y --no-install-recommends bird2; fi \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/evobgp /usr/local/bin/evobgp +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/evobgp"] diff --git a/deploy/prometheus/alerts.yml b/deploy/prometheus/alerts.yml new file mode 100644 index 0000000..11f294d --- /dev/null +++ b/deploy/prometheus/alerts.yml @@ -0,0 +1,24 @@ +# Правила для Prometheus (профиль compose `reference`). Подключите Alertmanager при необходимости. +groups: + - name: evobgp-controlplane + rules: + - alert: EvoBGPMaterializedPrefixesZero + expr: evobgp_materialized_prefixes_max == 0 + for: 2h + labels: + severity: warning + annotations: + summary: "В сторе control-plane нет ни одной ревизии с ненулевым materialized_prefix_count." + description: "Проверьте render/ingest и наличие ревизий." + + - alert: EvoBGPBirdEstablishedMissing + expr: | + evobgp_bird_protocols_scrape_success == 1 + and evobgp_bgp_peers_configured_total > 0 + and evobgp_bird_bgp_sessions_established == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "Настроены пиры в сторе, но birdc не видит Established BGP-сессий." + description: "Проверьте bird2, сеть (TCP/179), пароли и что EVOBGP_BIRDC_SOCKET доступен API-контейнеру." diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..92f9f8e --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: evobgp-api + metrics_path: /metrics + static_configs: + - targets: ["evobgp-api:8080"] diff --git a/go.mod b/go.mod index c661833..a62c458 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module evobgp go 1.22 + +require ( + github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.20.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..65c4bf2 --- /dev/null +++ b/go.sum @@ -0,0 +1,68 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:Ty41ER440msb0O0wD5nx4736lXe6Yvz9gof6rHUU8= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/birdfmt/bgp.go b/internal/birdfmt/bgp.go new file mode 100644 index 0000000..6f1b5bc --- /dev/null +++ b/internal/birdfmt/bgp.go @@ -0,0 +1,114 @@ +package birdfmt + +import ( + "fmt" + "strings" +) + +// BGPPeerIPv4Options describes a single BGP session (BIRD 2, IPv4 AF). +type BGPPeerIPv4Options struct { + ProtocolName string // e.g. evobgp_peer_uplink + LocalIP string // e.g. 192.0.2.1 + LocalASN uint32 + NeighborIP string + NeighborASN uint32 + // ExportFilter is a filter name, or empty for "export all". + ExportFilter string + // ImportFilter is a filter name, or empty for "import all". + ImportFilter string +} + +// RenderProtocolBGPIPv4 renders a protocol bgp { … ipv4 { … } } block. +func RenderProtocolBGPIPv4(opts BGPPeerIPv4Options) (string, error) { + if strings.TrimSpace(opts.ProtocolName) == "" { + return "", fmt.Errorf("birdfmt: protocol name is required") + } + if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" { + return "", fmt.Errorf("birdfmt: local and neighbor addresses are required") + } + if opts.LocalASN == 0 || opts.NeighborASN == 0 { + return "", fmt.Errorf("birdfmt: AS numbers must be non-zero") + } + imp := "all" + if strings.TrimSpace(opts.ImportFilter) != "" { + imp = "filter " + strings.TrimSpace(opts.ImportFilter) + } + exp := "all" + if strings.TrimSpace(opts.ExportFilter) != "" { + exp = "filter " + strings.TrimSpace(opts.ExportFilter) + } + + var b strings.Builder + b.WriteString("protocol bgp ") + b.WriteString(strings.TrimSpace(opts.ProtocolName)) + b.WriteString(" {\n") + b.WriteString(" local ") + b.WriteString(strings.TrimSpace(opts.LocalIP)) + fmt.Fprintf(&b, " as %d;\n", opts.LocalASN) + b.WriteString(" neighbor ") + b.WriteString(strings.TrimSpace(opts.NeighborIP)) + fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN) + b.WriteString(" ipv4 {\n") + b.WriteString(" import ") + b.WriteString(imp) + b.WriteString(";\n") + b.WriteString(" export ") + b.WriteString(exp) + b.WriteString(";\n") + b.WriteString(" };\n") + b.WriteString("}\n") + return b.String(), nil +} + +// BGPPeerIPv6Options describes a BGP session for the IPv6 AF. +type BGPPeerIPv6Options struct { + ProtocolName string + LocalIP string + LocalASN uint32 + NeighborIP string + NeighborASN uint32 + ExportFilter string + ImportFilter string +} + +// RenderProtocolBGPIPv6 renders a protocol bgp block with ipv6 { import/export }. +func RenderProtocolBGPIPv6(opts BGPPeerIPv6Options) (string, error) { + if strings.TrimSpace(opts.ProtocolName) == "" { + return "", fmt.Errorf("birdfmt: protocol name is required") + } + if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" { + return "", fmt.Errorf("birdfmt: local and neighbor addresses are required") + } + if opts.LocalASN == 0 || opts.NeighborASN == 0 { + return "", fmt.Errorf("birdfmt: AS numbers must be non-zero") + } + imp := "all" + if strings.TrimSpace(opts.ImportFilter) != "" { + imp = "filter " + strings.TrimSpace(opts.ImportFilter) + } + exp := "all" + if strings.TrimSpace(opts.ExportFilter) != "" { + exp = "filter " + strings.TrimSpace(opts.ExportFilter) + } + + var b strings.Builder + b.WriteString("protocol bgp ") + b.WriteString(strings.TrimSpace(opts.ProtocolName)) + b.WriteString(" {\n") + b.WriteString(" local ") + b.WriteString(strings.TrimSpace(opts.LocalIP)) + fmt.Fprintf(&b, " as %d;\n", opts.LocalASN) + b.WriteString(" neighbor ") + b.WriteString(strings.TrimSpace(opts.NeighborIP)) + fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN) + b.WriteString(" ipv6 {\n") + b.WriteString(" import ") + b.WriteString(imp) + b.WriteString(";\n") + b.WriteString(" export ") + b.WriteString(exp) + b.WriteString(";\n") + b.WriteString(" };\n") + b.WriteString("}\n") + return b.String(), nil +} diff --git a/internal/birdfmt/bgp_test.go b/internal/birdfmt/bgp_test.go new file mode 100644 index 0000000..20e346f --- /dev/null +++ b/internal/birdfmt/bgp_test.go @@ -0,0 +1,29 @@ +package birdfmt + +import ( + "strings" + "testing" +) + +func TestRenderProtocolBGPIPv4_Validation(t *testing.T) { + _, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRenderProtocolBGPIPv4_ExportAll(t *testing.T) { + got, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{ + ProtocolName: "p", + LocalIP: "192.0.2.1", + LocalASN: 1, + NeighborIP: "192.0.2.2", + NeighborASN: 2, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "export all;") { + t.Fatal(got) + } +} diff --git a/internal/birdfmt/birdctl.go b/internal/birdfmt/birdctl.go new file mode 100644 index 0000000..a1c179b --- /dev/null +++ b/internal/birdfmt/birdctl.go @@ -0,0 +1,71 @@ +package birdfmt + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// BirdCtl runs bird(8) and birdc(8) for parse checks and configure reload. +type BirdCtl struct { + // Bird is the bird binary path (default "bird"). + Bird string + // Birdc is the birdc binary path (default "birdc"). + Birdc string + // Socket is optional birdc control socket (-s); empty uses birdc default. + Socket string +} + +func (c *BirdCtl) birdBin() string { + if strings.TrimSpace(c.Bird) != "" { + return c.Bird + } + return "bird" +} + +func (c *BirdCtl) birdcBin() string { + if strings.TrimSpace(c.Birdc) != "" { + return c.Birdc + } + return "birdc" +} + +// ParseCheck runs `bird -c -p` to validate syntax without starting the daemon. +func (c *BirdCtl) ParseCheck(ctx context.Context, mainConfigPath string) error { + mainConfigPath = strings.TrimSpace(mainConfigPath) + if mainConfigPath == "" { + return fmt.Errorf("birdfmt: main config path is required for parse check") + } + cmd := exec.CommandContext(ctx, c.birdBin(), "-c", mainConfigPath, "-p") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("bird -p: %w: %s", err, msg) + } + return fmt.Errorf("bird -p: %w", err) + } + return nil +} + +// Configure runs `birdc configure` to load the current config from disk (BIRD 2). +func (c *BirdCtl) Configure(ctx context.Context) error { + args := []string{"configure"} + if s := strings.TrimSpace(c.Socket); s != "" { + args = append([]string{"-s", s}, args...) + } + cmd := exec.CommandContext(ctx, c.birdcBin(), args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("birdc configure: %w: %s", err, msg) + } + return fmt.Errorf("birdc configure: %w", err) + } + return nil +} diff --git a/internal/birdfmt/doc.go b/internal/birdfmt/doc.go new file mode 100644 index 0000000..9fb4bd7 --- /dev/null +++ b/internal/birdfmt/doc.go @@ -0,0 +1,19 @@ +// Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include +// fragments under bird.d/, export filters, static route protocols, and minimal BGP peer +// blocks. Runtime helpers parse-check configs (bird -p) and reload (birdc configure). +// +// # Layout +// +// Operator keeps a stable bird.conf (or generated skeleton) next to EvoBGP fragments: +// +// bird.conf — router id, protocol device, protocol direct, include lines +// bird.d/evobgp_*.conf — generated prefixes, filters, peers (names from constants) +// +// Include order should list filter definitions before protocols that reference them +// (see StandardIncludeFragments). +// +// # Apply workflow +// +// 1. Write new fragment files to a staging directory, run bird -c -p. +// 2. Atomically swap staging → live config dir, then birdc configure (see BirdCtl). +package birdfmt diff --git a/internal/birdfmt/filter.go b/internal/birdfmt/filter.go new file mode 100644 index 0000000..1681950 --- /dev/null +++ b/internal/birdfmt/filter.go @@ -0,0 +1,79 @@ +package birdfmt + +import ( + "fmt" + "net/netip" + "sort" + "strings" +) + +// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix +// is in prefixes (exact CIDR match via set membership), and rejects others. +func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) { + if strings.TrimSpace(filterName) == "" { + return "", fmt.Errorf("birdfmt: filter name is required") + } + uniq := make(map[string]netip.Prefix) + for _, p := range prefixes { + if !p.Addr().Is4() { + continue + } + m := p.Masked() + uniq[m.String()] = m + } + keys := make([]string, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("filter ") + b.WriteString(strings.TrimSpace(filterName)) + b.WriteString(" {\n") + if len(keys) == 0 { + b.WriteString(" reject;\n") + } else { + b.WriteString(" if net ~ [ ") + b.WriteString(strings.Join(keys, ", ")) + b.WriteString(" ] then accept;\n") + b.WriteString(" reject;\n") + } + b.WriteString("}\n") + return b.String(), nil +} + +// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject). +func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) { + if strings.TrimSpace(filterName) == "" { + return "", fmt.Errorf("birdfmt: filter name is required") + } + uniq := make(map[string]netip.Prefix) + for _, p := range prefixes { + if !p.Addr().Is6() { + continue + } + m := p.Masked() + uniq[m.String()] = m + } + keys := make([]string, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("filter ") + b.WriteString(strings.TrimSpace(filterName)) + b.WriteString(" {\n") + if len(keys) == 0 { + b.WriteString(" reject;\n") + } else { + b.WriteString(" if net ~ [ ") + b.WriteString(strings.Join(keys, ", ")) + b.WriteString(" ] then accept;\n") + b.WriteString(" reject;\n") + } + b.WriteString("}\n") + return b.String(), nil +} diff --git a/internal/birdfmt/filter_test.go b/internal/birdfmt/filter_test.go new file mode 100644 index 0000000..0f9d81c --- /dev/null +++ b/internal/birdfmt/filter_test.go @@ -0,0 +1,47 @@ +package birdfmt + +import ( + "net/netip" + "os" + "strings" + "testing" +) + +func TestRenderExportFilterIPv4_Empty(t *testing.T) { + got, err := RenderExportFilterIPv4("evobgp_x", nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "filter evobgp_x") { + t.Fatal(got) + } + if !strings.Contains(got, "reject;") { + t.Fatal(got) + } +} + +func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) { + v6 := netip.MustParsePrefix("2001:db8::/32") + got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(got, "2001:db8") { + t.Fatal("v6 prefix should be skipped in v4 filter") + } +} + +func TestRenderExportFilterIPv6(t *testing.T) { + p := netip.MustParsePrefix("2001:db8::/32") + got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p}) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile("testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(got) != strings.TrimSpace(string(want)) { + t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want)) + } +} diff --git a/internal/birdfmt/fragment.go b/internal/birdfmt/fragment.go new file mode 100644 index 0000000..b34a1d6 --- /dev/null +++ b/internal/birdfmt/fragment.go @@ -0,0 +1,47 @@ +package birdfmt + +import ( + "fmt" + "strings" +) + +// ManagedBanner returns a comment block marking EvoBGP-generated files (optional first line in bird.d/*.conf). +func ManagedBanner(revisionHint string) string { + rev := strings.TrimSpace(revisionHint) + var b strings.Builder + b.WriteString("# EvoBGP generated — do not edit by hand.\n") + if rev != "" { + b.WriteString("# Revision: ") + b.WriteString(rev) + b.WriteByte('\n') + } + return b.String() +} + +// JoinFragments concatenates non-empty text blocks with a blank line between them. +func JoinFragments(parts ...string) string { + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n\n") + "\n" +} + +// ValidateIncludePath rejects paths that could break out of the config directory. +func ValidateIncludePath(p string) error { + p = strings.TrimSpace(p) + if p == "" { + return fmt.Errorf("birdfmt: include path is empty") + } + if strings.Contains(p, "..") { + return fmt.Errorf("birdfmt: include path must not contain '..': %q", p) + } + return nil +} diff --git a/internal/birdfmt/fragment_test.go b/internal/birdfmt/fragment_test.go new file mode 100644 index 0000000..47aa7aa --- /dev/null +++ b/internal/birdfmt/fragment_test.go @@ -0,0 +1,22 @@ +package birdfmt + +import "testing" + +func TestValidateIncludePath(t *testing.T) { + if err := ValidateIncludePath("bird.d/x.conf"); err != nil { + t.Fatal(err) + } + if err := ValidateIncludePath("../etc/passwd"); err == nil { + t.Fatal("expected error for ..") + } +} + +func TestJoinFragments(t *testing.T) { + got := JoinFragments("a", "", "b") + if got != "a\n\nb\n" { + t.Fatalf("%q", got) + } + if JoinFragments() != "" { + t.Fatal("empty join should be empty") + } +} diff --git a/internal/birdfmt/layout.go b/internal/birdfmt/layout.go new file mode 100644 index 0000000..81c1ac1 --- /dev/null +++ b/internal/birdfmt/layout.go @@ -0,0 +1,95 @@ +package birdfmt + +import ( + "fmt" + "strings" +) + +// Directory and fragment file names for EvoBGP-generated includes (relative to bird.conf). +const ( + DirBirdD = "bird.d" + + FragmentPrefixesV4 = "evobgp_prefixes_v4.conf" + FragmentPrefixesV6 = "evobgp_prefixes_v6.conf" + FragmentFiltersV4 = "evobgp_filters_v4.conf" + FragmentFiltersV6 = "evobgp_filters_v6.conf" + FragmentPeers = "evobgp_peers.conf" +) + +// FragmentIncludePath returns a POSIX include path such as bird.d/evobgp_prefixes_v4.conf. +func FragmentIncludePath(fragmentBaseName string) string { + if fragmentBaseName == "" { + return DirBirdD + "/" + } + return DirBirdD + "/" + fragmentBaseName +} + +// StandardIncludeFragments is the recommended order: filters before peers that reference them. +func StandardIncludeFragments() []string { + return []string{ + FragmentIncludePath(FragmentFiltersV4), + FragmentIncludePath(FragmentFiltersV6), + FragmentIncludePath(FragmentPrefixesV4), + FragmentIncludePath(FragmentPrefixesV6), + FragmentIncludePath(FragmentPeers), + } +} + +// MainBirdConfOptions describes the top-level bird.conf skeleton EvoBGP expects beside bird.d/. +type MainBirdConfOptions struct { + // RouterID is the BIRD router id (IPv4 dotted quad recommended). + RouterID string + // Includes are paths as in include "…" (e.g. bird.d/evobgp_prefixes_v4.conf). + Includes []string + // Preamble is optional comment lines (each line prefixed with #), no trailing newline required. + Preamble string +} + +// RenderMainBirdConf returns a BIRD 2 main config: device, direct, include lines. +// RouterID must be non-empty. +func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) { + if strings.TrimSpace(opts.RouterID) == "" { + return "", fmt.Errorf("birdfmt: router id is required") + } + var b strings.Builder + pre := strings.TrimSpace(opts.Preamble) + if pre != "" { + for _, line := range strings.Split(pre, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + b.WriteByte('\n') + continue + } + if !strings.HasPrefix(line, "#") { + b.WriteString("# ") + } + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteByte('\n') + } + b.WriteString("router id ") + b.WriteString(strings.TrimSpace(opts.RouterID)) + b.WriteString(";\n\n") + for _, inc := range opts.Includes { + inc = strings.TrimSpace(inc) + if inc == "" { + continue + } + b.WriteString("include \"") + b.WriteString(inc) + b.WriteString("\";\n") + } + if len(opts.Includes) > 0 { + b.WriteByte('\n') + } + b.WriteString(`protocol device { +} + +protocol direct { + ipv4; + ipv6; +} +`) + return b.String(), nil +} diff --git a/internal/birdfmt/layout_test.go b/internal/birdfmt/layout_test.go new file mode 100644 index 0000000..6bf2d44 --- /dev/null +++ b/internal/birdfmt/layout_test.go @@ -0,0 +1,55 @@ +package birdfmt + +import ( + _ "embed" + "strings" + "testing" +) + +//go:embed testdata/golden/main_bird_skeleton.golden +var goldenMainBirdSkeleton string + +func TestRenderMainBirdConf_Golden(t *testing.T) { + got, err := RenderMainBirdConf(MainBirdConfOptions{ + RouterID: "192.0.2.1", + Includes: StandardIncludeFragments(), + }) + if err != nil { + t.Fatal(err) + } + want := strings.TrimSuffix(goldenMainBirdSkeleton, "\n") + got = strings.TrimSuffix(got, "\n") + if got != want { + t.Fatalf("golden mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderMainBirdConf_Preamble(t *testing.T) { + got, err := RenderMainBirdConf(MainBirdConfOptions{ + RouterID: "192.0.2.1", + Includes: nil, + Preamble: "operator note\n# already commented", + }) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got, "# operator note\n") { + t.Fatalf("expected preamble prefix, got:\n%s", got) + } + if !strings.Contains(got, "# # already commented") { + t.Fatal(got) + } +} + +func TestRenderMainBirdConf_Errors(t *testing.T) { + _, err := RenderMainBirdConf(MainBirdConfOptions{}) + if err == nil { + t.Fatal("expected error for empty router id") + } +} + +func TestFragmentIncludePath(t *testing.T) { + if p := FragmentIncludePath(FragmentPrefixesV4); p != "bird.d/evobgp_prefixes_v4.conf" { + t.Fatal(p) + } +} diff --git a/internal/birdfmt/protocols.go b/internal/birdfmt/protocols.go new file mode 100644 index 0000000..5c57a87 --- /dev/null +++ b/internal/birdfmt/protocols.go @@ -0,0 +1,52 @@ +package birdfmt + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// ShowProtocols runs `birdc [-s socket] show protocols all` and returns stdout (BIRD 2). +func ShowProtocols(ctx context.Context, socket, birdcBin string) (string, error) { + if birdcBin == "" { + birdcBin = "birdc" + } + args := []string{"show", "protocols", "all"} + if s := strings.TrimSpace(socket); s != "" { + args = append([]string{"-s", s}, args...) + } + cmd := exec.CommandContext(ctx, birdcBin, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return "", fmt.Errorf("birdc show protocols: %w: %s", err, msg) + } + return "", fmt.Errorf("birdc show protocols: %w", err) + } + return stdout.String(), nil +} + +// CountEstablishedBGPSessions counts BGP protocol rows whose line contains "Established" +// (heuristic for `birdc show protocols` / `show protocols all` output). +func CountEstablishedBGPSessions(showProtocolsOutput string) int { + lines := strings.Split(showProtocolsOutput, "\n") + n := 0 + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "name") || strings.HasPrefix(strings.ToLower(line), "table") { + continue + } + if !strings.Contains(strings.ToLower(line), "bgp") { + continue + } + if strings.Contains(strings.ToLower(line), "established") { + n++ + } + } + return n +} diff --git a/internal/birdfmt/protocols_test.go b/internal/birdfmt/protocols_test.go new file mode 100644 index 0000000..08a44cd --- /dev/null +++ b/internal/birdfmt/protocols_test.go @@ -0,0 +1,16 @@ +package birdfmt + +import "testing" + +func TestCountEstablishedBGPSessions(t *testing.T) { + sample := ` +BIRD 2.14 ready. +Name Proto Table State Since Info +device1 Device --- up 10:00:00 +uplink4 BGP --- up 10:00:05 Established +uplink6 BGP --- start 10:00:06 Active +` + if got := CountEstablishedBGPSessions(sample); got != 1 { + t.Fatalf("got %d want 1", got) + } +} diff --git a/internal/birdfmt/scenarios_test.go b/internal/birdfmt/scenarios_test.go index daeda7a..923045e 100644 --- a/internal/birdfmt/scenarios_test.go +++ b/internal/birdfmt/scenarios_test.go @@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) { "large_prefix_list", "minimal", "mixed_static_bgp", + "standard_layout", "static_ipv4", "with_include", } diff --git a/internal/birdfmt/standard_layout_test.go b/internal/birdfmt/standard_layout_test.go new file mode 100644 index 0000000..8e18971 --- /dev/null +++ b/internal/birdfmt/standard_layout_test.go @@ -0,0 +1,72 @@ +package birdfmt + +import ( + "net/netip" + "os" + "strings" + "testing" +) + +// Ensures generator output matches the standard_layout scenario files (CI bird -p). +func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) { + p4 := netip.MustParsePrefix("203.0.113.0/24") + + f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4}) + if err != nil { + t.Fatal(err) + } + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4) + + f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil) + if err != nil { + t.Fatal(err) + } + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf", f6) + + staticV4 := RenderStaticIPv4Protocol("evobgp_prefixes_v4", []netip.Prefix{p4}) + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf", staticV4) + + staticV6 := RenderStaticIPv6Protocol("evobgp_prefixes_v6", nil) + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf", staticV6) + + peer, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{ + ProtocolName: "evobgp_peer_ci", + LocalIP: "192.0.2.1", + LocalASN: 65001, + NeighborIP: "192.0.2.2", + NeighborASN: 65002, + ExportFilter: "evobgp_export_v4", + }) + if err != nil { + t.Fatal(err) + } + assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf", peer) + + main, err := RenderMainBirdConf(MainBirdConfOptions{ + RouterID: "192.0.2.1", + Includes: StandardIncludeFragments(), + Preamble: "tags: layout, include, filter, peer\nStandard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).", + }) + if err != nil { + t.Fatal(err) + } + // Fixture has two header comment lines; RenderMainBirdConf adds # per line. + wantMain, err := os.ReadFile("testdata/scenarios/standard_layout/bird.conf") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(main) != strings.TrimSpace(string(wantMain)) { + t.Fatalf("main bird.conf mismatch\n--- got ---\n%s\n--- want ---\n%s", main, wantMain) + } +} + +func assertFileEquals(t *testing.T, path, content string) { + t.Helper() + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(content) != strings.TrimSpace(string(want)) { + t.Fatalf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, content, string(want)) + } +} diff --git a/internal/birdfmt/static.go b/internal/birdfmt/static.go index be16ba0..e55816d 100644 --- a/internal/birdfmt/static.go +++ b/internal/birdfmt/static.go @@ -37,3 +37,34 @@ func RenderStaticIPv4Protocol(protocolName string, prefixes []netip.Prefix) stri b.WriteString("}\n") return b.String() } + +// RenderStaticIPv6Protocol renders a BIRD 2 `protocol static` block for IPv6 prefixes. +func RenderStaticIPv6Protocol(protocolName string, prefixes []netip.Prefix) string { + if protocolName == "" { + protocolName = "evobgp_static_v6" + } + uniq := make(map[string]netip.Prefix) + for _, p := range prefixes { + if !p.Addr().Is6() { + continue + } + uniq[p.String()] = p.Masked() + } + keys := make([]string, 0, len(uniq)) + for k := range uniq { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("protocol static ") + b.WriteString(protocolName) + b.WriteString(" {\n ipv6;\n") + for _, k := range keys { + b.WriteString(" route ") + b.WriteString(k) + b.WriteString(" unreachable;\n") + } + b.WriteString("}\n") + return b.String() +} diff --git a/internal/birdfmt/static_test.go b/internal/birdfmt/static_test.go index 7f7590a..841e41b 100644 --- a/internal/birdfmt/static_test.go +++ b/internal/birdfmt/static_test.go @@ -32,3 +32,14 @@ func TestRenderStaticIPv4Protocol_Empty(t *testing.T) { t.Fatal(got) } } + +func TestRenderStaticIPv6Protocol_Dedup(t *testing.T) { + p := netip.MustParsePrefix("2001:db8::/32") + got := RenderStaticIPv6Protocol("evobgp_v6", []netip.Prefix{p, p}) + if !strings.Contains(got, "2001:db8::/32") { + t.Fatal(got) + } + if !strings.Contains(got, "ipv6") { + t.Fatal(got) + } +} diff --git a/internal/birdfmt/testdata/golden/main_bird_skeleton.golden b/internal/birdfmt/testdata/golden/main_bird_skeleton.golden new file mode 100644 index 0000000..243e238 --- /dev/null +++ b/internal/birdfmt/testdata/golden/main_bird_skeleton.golden @@ -0,0 +1,15 @@ +router id 192.0.2.1; + +include "bird.d/evobgp_filters_v4.conf"; +include "bird.d/evobgp_filters_v6.conf"; +include "bird.d/evobgp_prefixes_v4.conf"; +include "bird.d/evobgp_prefixes_v6.conf"; +include "bird.d/evobgp_peers.conf"; + +protocol device { +} + +protocol direct { + ipv4; + ipv6; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf new file mode 100644 index 0000000..801e927 --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.conf @@ -0,0 +1,18 @@ +# tags: layout, include, filter, peer +# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments). + +router id 192.0.2.1; + +include "bird.d/evobgp_filters_v4.conf"; +include "bird.d/evobgp_filters_v6.conf"; +include "bird.d/evobgp_prefixes_v4.conf"; +include "bird.d/evobgp_prefixes_v6.conf"; +include "bird.d/evobgp_peers.conf"; + +protocol device { +} + +protocol direct { + ipv4; + ipv6; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf new file mode 100644 index 0000000..803e7eb --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf @@ -0,0 +1,4 @@ +filter evobgp_export_v4 { + if net ~ [ 203.0.113.0/24 ] then accept; + reject; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf new file mode 100644 index 0000000..997db05 --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf @@ -0,0 +1,3 @@ +filter evobgp_export_v6 { + reject; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf new file mode 100644 index 0000000..366c775 --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf @@ -0,0 +1,8 @@ +protocol bgp evobgp_peer_ci { + local 192.0.2.1 as 65001; + neighbor 192.0.2.2 as 65002; + ipv4 { + import all; + export filter evobgp_export_v4; + }; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf new file mode 100644 index 0000000..f1603be --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf @@ -0,0 +1,4 @@ +protocol static evobgp_prefixes_v4 { + ipv4; + route 203.0.113.0/24 unreachable; +} diff --git a/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf new file mode 100644 index 0000000..e8beada --- /dev/null +++ b/internal/birdfmt/testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf @@ -0,0 +1,3 @@ +protocol static evobgp_prefixes_v6 { + ipv6; +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go new file mode 100644 index 0000000..f3b8db4 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,51 @@ +package bundle + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" +) + +func TestBuildVerifyRoundtrip(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + frag := map[string]string{ + "bird.conf": "router id 192.0.2.1;\n\nprotocol device {\n}\n\nprotocol direct {\n ipv4;\n ipv6;\n}\n", + } + raw, err := BuildGzippedTar("rev-1", "sp-1", frag, priv) + if err != nil { + t.Fatal(err) + } + pub := priv.Public().(ed25519.PublicKey) + v, err := VerifyGzippedTar(raw, pub) + if err != nil { + t.Fatal(err) + } + if v.Manifest.RevisionID != "rev-1" { + t.Fatalf("revision %q", v.Manifest.RevisionID) + } + if len(v.Files) != 1 { + t.Fatalf("files %d", len(v.Files)) + } +} + +func TestVerifyWrongKey(t *testing.T) { + _, privA, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _, privB, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + raw, err := BuildGzippedTar("rev-x", "sp-x", map[string]string{"a.conf": "x"}, privA) + if err != nil { + t.Fatal(err) + } + pubB := privB.Public().(ed25519.PublicKey) + if _, err := VerifyGzippedTar(raw, pubB); err == nil { + t.Fatal("expected signature error with wrong public key") + } +} diff --git a/internal/bundle/extract.go b/internal/bundle/extract.go new file mode 100644 index 0000000..4a3a33f --- /dev/null +++ b/internal/bundle/extract.go @@ -0,0 +1,46 @@ +package bundle + +import ( + "fmt" + "os" + pathpkg "path" + "path/filepath" + "strings" +) + +// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed). +func WriteExtractedFiles(root string, v *VerifiedContents) error { + root = filepath.Clean(root) + for _, fe := range v.Manifest.Files { + data, ok := v.Files[fe.Path] + if !ok { + return fmt.Errorf("bundle: missing file %q", fe.Path) + } + rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/")) + if rel == "." || strings.HasPrefix(rel, "..") { + return fmt.Errorf("bundle: unsafe path %q", fe.Path) + } + dest := filepath.Join(root, filepath.FromSlash(rel)) + relToRoot, err := filepath.Rel(root, dest) + if err != nil || strings.HasPrefix(relToRoot, "..") { + return fmt.Errorf("bundle: path escapes root: %q", fe.Path) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, data, 0o644); err != nil { + return err + } + } + return nil +} + +// FindMainBirdConf returns the first path ending with bird.conf from extracted manifest order. +func (v *VerifiedContents) FindMainBirdConf() string { + for _, fe := range v.Manifest.Files { + if strings.HasSuffix(fe.Path, "bird.conf") { + return fe.Path + } + } + return "" +} diff --git a/internal/bundle/pack.go b/internal/bundle/pack.go new file mode 100644 index 0000000..90d2e70 --- /dev/null +++ b/internal/bundle/pack.go @@ -0,0 +1,131 @@ +package bundle + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "path" + "sort" + "strings" + "time" +) + +// 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"` +} + +// FileEntry is one file inside the bundle archive. +type FileEntry struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +const sigFileName = "bundle.sig" +const manifestName = "manifest.json" + +// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files. +func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) { + if len(priv) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("bundle: invalid ed25519 private key size") + } + pub := priv.Public().(ed25519.PublicKey) + + norm := make(map[string]string, len(fragments)) + for p, content := range fragments { + p = path.Clean(strings.TrimPrefix(p, "/")) + if p == "." || strings.HasPrefix(p, "..") { + return nil, fmt.Errorf("bundle: invalid path %q", p) + } + norm[p] = content + } + + var files []FileEntry + var payload [][]byte + keys := sortedStringKeys(norm) + for _, p := range keys { + data := []byte(norm[p]) + h := sha256.Sum256(data) + files = append(files, FileEntry{Path: p, SHA256: fmt.Sprintf("%x", h[:])}) + payload = append(payload, data) + } + + m := Manifest{ + RevisionID: revisionID, + SpeakerID: speakerID, + CreatedAt: time.Now().UTC(), + Files: files, + Algorithm: "ed25519", + PublicKeyB64: base64.StdEncoding.EncodeToString(pub), + } + manifestJSON, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + sig := ed25519.Sign(priv, manifestJSON) + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + add := func(name string, body []byte) error { + hdr := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(body)), + ModTime: time.Now().UTC(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err := tw.Write(body) + return err + } + + if err := add(manifestName, manifestJSON); err != nil { + _ = tw.Close() + _ = gw.Close() + return nil, err + } + if err := add(sigFileName, sig); err != nil { + _ = tw.Close() + _ = gw.Close() + return nil, err + } + i := 0 + for _, p := range keys { + if err := add(p, payload[i]); err != nil { + _ = tw.Close() + _ = gw.Close() + return nil, err + } + i++ + } + if err := tw.Close(); err != nil { + _ = gw.Close() + return nil, err + } + if err := gw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func sortedStringKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/bundle/verify.go b/internal/bundle/verify.go new file mode 100644 index 0000000..af5692c --- /dev/null +++ b/internal/bundle/verify.go @@ -0,0 +1,110 @@ +package bundle + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "path" + "strings" +) + +// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node. +type VerifiedContents struct { + Manifest Manifest + Files map[string][]byte +} + +// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file. +func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) { + gr, err := gzip.NewReader(bytes.NewReader(bundle)) + if err != nil { + return nil, err + } + defer gr.Close() + + var manifestRaw []byte + var sig []byte + files := make(map[string][]byte) + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + name := path.Clean(hdr.Name) + if strings.HasPrefix(name, "..") { + return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name) + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, err + } + switch name { + case manifestName: + manifestRaw = data + case sigFileName: + sig = data + default: + files[name] = data + } + } + if len(manifestRaw) == 0 || len(sig) == 0 { + return nil, fmt.Errorf("bundle: missing manifest or signature") + } + if !ed25519.Verify(pub, manifestRaw, sig) { + return nil, fmt.Errorf("bundle: ed25519 signature mismatch") + } + var m Manifest + if err := json.Unmarshal(manifestRaw, &m); err != nil { + return nil, err + } + for _, fe := range m.Files { + body, ok := files[fe.Path] + if !ok { + return nil, fmt.Errorf("bundle: missing file %q", fe.Path) + } + h := sha256.Sum256(body) + if fmt.Sprintf("%x", h[:]) != fe.SHA256 { + return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path) + } + } + return &VerifiedContents{Manifest: m, Files: files}, nil +} + +// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes). +func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) { + s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")) + raw, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("bundle: hex decode: %w", err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("bundle: want %d-byte ed25519 public key", ed25519.PublicKeySize) + } + return ed25519.PublicKey(raw), nil +} + +// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional). +func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) { + if m.PublicKeyB64 == "" { + return nil, fmt.Errorf("bundle: manifest missing public_key_base64") + } + raw, err := base64.StdEncoding.DecodeString(m.PublicKeyB64) + if err != nil { + return nil, err + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("bundle: bad public key length") + } + return ed25519.PublicKey(raw), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cdc0392 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,35 @@ +// Package config loads control-plane settings from the environment (see architecture plan §1). +package config + +import ( + "os" + "strings" +) + +// Env holds shared process configuration for cmd/* entrypoints. +type Env struct { + HTTPAddr string + ServiceName string + GitSHA string + // DatabaseURL is the PostgreSQL DSN when SQL-backed repositories are wired (empty in dev). + DatabaseURL string + // BrokerURL is NATS/Redis when the reference profile uses a message broker (empty in microVPS). + BrokerURL string +} + +// Load reads EVOBGP_* environment variables with safe defaults. +func Load() Env { + e := Env{ + HTTPAddr: ":8080", + } + if v := strings.TrimSpace(os.Getenv("EVOBGP_HTTP_ADDR")); v != "" { + e.HTTPAddr = v + } + if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" { + e.ServiceName = v + } + e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")) + e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")) + e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL")) + return e +} diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..26638a8 --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,9 @@ +package db + +import "evobgp/migrations" + +// PostgresMigrations and SQLiteMigrations expose embedded SQL files for migrate runners (plan §1, §11). +var ( + PostgresMigrations = migrations.Postgres + SQLiteMigrations = migrations.SQLite +) diff --git a/internal/deploy/run.go b/internal/deploy/run.go new file mode 100644 index 0000000..63306ea --- /dev/null +++ b/internal/deploy/run.go @@ -0,0 +1,23 @@ +// Package deploy delivers generated BIRD fragments to evobgp-agent / publishes signed bundles for evobgp-node. +package deploy + +import ( + "context" + "log" + "time" +) + +// Run blocks until ctx is cancelled. Reference deployment: worker after render, talks to agent API or shared volume. +func Run(ctx context.Context) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + log.Printf("evobgp-deploy: started (stub; deploy jobs push configs and update bundle pointers)") + for { + select { + case <-ctx.Done(): + log.Printf("evobgp-deploy: stopped") + return + case <-t.C: + } + } +} diff --git a/internal/domain/module.go b/internal/domain/module.go new file mode 100644 index 0000000..359574e --- /dev/null +++ b/internal/domain/module.go @@ -0,0 +1,10 @@ +// Package domain holds pure types and invariants without I/O (plan §1). +package domain + +// Module kinds for prefix sources (plan §6, §7.3). +const ( + ModuleASPrefixes = "AS_PREFIXES" + ModuleCDNCIDRs = "CDN_CIDRS" + ModuleDomains = "DOMAINS" + ModuleIPRanges = "IP_RANGES" +) diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..e395b9f --- /dev/null +++ b/internal/httpapi/auth.go @@ -0,0 +1,135 @@ +package httpapi + +import ( + "context" + "net/http" + "strings" +) + +type ctxKey int + +const authCtxKey ctxKey = 1 + +// Auth holds resolved API identity for a request. +type Auth struct { + TenantID string + Role string // viewer, editor, operator, node + Token string +} + +func authFromContext(ctx context.Context) (Auth, bool) { + a, ok := ctx.Value(authCtxKey).(Auth) + return a, ok +} + +type apiKeyRecord struct { + token string + tenantID string + role string +} + +func parseAPIKeysSpec(spec string) []apiKeyRecord { + spec = strings.TrimSpace(spec) + if spec == "" { + return nil + } + var out []apiKeyRecord + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + fields := strings.Split(part, "|") + if len(fields) != 3 { + continue + } + out = append(out, apiKeyRecord{ + token: strings.TrimSpace(fields[0]), + tenantID: strings.TrimSpace(fields[1]), + role: strings.TrimSpace(fields[2]), + }) + } + return out +} + +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) { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token") + 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 matched == nil { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key") + return + } + a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw} + r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) + next.ServeHTTP(w, r) + }) +} + +func (s *Server) devAuth() (Auth, bool) { + tid, _, _, _, _ := s.store.DemoIDs() + if tid == "" { + return Auth{}, false + } + return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true +} + +func roleLevel(role string) int { + switch strings.ToLower(role) { + case "viewer": + return 1 + case "editor": + return 2 + case "operator": + return 3 + default: + return 0 + } +} + +// requireAtLeast rejects node role and enforces viewer/editor/operator ladder. +func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool { + if strings.ToLower(a.Role) == "node" { + writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource") + return false + } + if roleLevel(a.Role) < roleLevel(need) { + writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role") + return false + } + return true +} + +func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool { + if strings.ToLower(a.Role) != "node" { + writeProblem(w, http.StatusForbidden, "Forbidden", "node role required") + return false + } + return true +} diff --git a/internal/httpapi/cors.go b/internal/httpapi/cors.go new file mode 100644 index 0000000..ccf8266 --- /dev/null +++ b/internal/httpapi/cors.go @@ -0,0 +1,48 @@ +package httpapi + +import ( + "net/http" + "strings" +) + +func parseCORSOrigins(spec string) []string { + spec = strings.TrimSpace(spec) + if spec == "" { + return nil + } + var out []string + for _, p := range strings.Split(spec, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func (s *Server) withCORS(h http.Handler) http.Handler { + if len(s.corsOrigins) == 0 { + return h + } + allowed := make(map[string]struct{}, len(s.corsOrigins)) + for _, o := range s.corsOrigins { + allowed[o] = struct{}{} + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + if _, ok := allowed[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Add("Vary", "Origin") + } + } + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, Accept, X-Tenant-Id") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + h.ServeHTTP(w, r) + }) +} diff --git a/internal/httpapi/problem.go b/internal/httpapi/problem.go new file mode 100644 index 0000000..0f917eb --- /dev/null +++ b/internal/httpapi/problem.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "encoding/json" + "net/http" +) + +// 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"` +} + +func writeProblem(w http.ResponseWriter, status int, title, detail string) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(Problem{ + Type: "about:blank", + Title: title, + Status: status, + Detail: detail, + }) +} + +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 writeNoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go new file mode 100644 index 0000000..61f1438 --- /dev/null +++ b/internal/httpapi/routes.go @@ -0,0 +1,673 @@ +package httpapi + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" + + "evobgp/internal/bundle" + "evobgp/internal/jobs" + "evobgp/internal/observability" + "evobgp/internal/store" +) + +// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated). +func (s *Server) Handler() http.Handler { + v1 := http.NewServeMux() + s.registerV1(v1) + wrappedV1 := http.StripPrefix("/v1", v1) + + s.mux.Handle("GET /metrics", observability.MetricsHandler()) + s.mux.HandleFunc("GET /v1/health", s.handleHealth) + s.mux.HandleFunc("GET /v1/ready", s.handleReady) + s.mux.HandleFunc("GET /v1/version", s.handleVersion) + s.mux.Handle("/v1/", s.authMiddleware(wrappedV1)) + return s.withCORS(observability.HTTPMiddleware(s.mux)) +} + +// BundlePublicKeyBase64 returns the Ed25519 public key for verifying bundles (share with evobgp-node). +func (s *Server) BundlePublicKeyBase64() string { + pub := s.bundlePriv.Public().(ed25519.PublicKey) + return base64.StdEncoding.EncodeToString(pub) +} + +func (s *Server) registerRoutes() { + // routes attached in Handler() +} + +func (s *Server) registerV1(m *http.ServeMux) { + m.HandleFunc("GET /modules", s.handleListModules) + m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) + m.HandleFunc("GET /peers", s.handleListPeers) + m.HandleFunc("GET /speakers", s.handleListSpeakers) + m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh) + m.HandleFunc("GET /revisions", s.handleListRevisions) + m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision) + m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview) + m.HandleFunc("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff) + m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback) + m.HandleFunc("POST /apply", s.handleApply) + m.HandleFunc("POST /speakers/{id}/apply", s.handleSpeakerApply) + m.HandleFunc("POST /bird/reload", s.handleBirdReload) + m.HandleFunc("GET /jobs", s.handleListJobs) + m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob) + 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) + m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": map[string]string{"memory_store": "ok"}}) +} + +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")) + if sha == "" { + sha = "unknown" + } + writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha}) +} + +func moduleJSON(mod *store.Module) map[string]any { + m := map[string]any{ + "id": mod.ID, + "type": mod.Type, + "name": mod.Name, + "enabled": mod.Enabled, + "priority": mod.Priority, + "refresh_interval_sec": mod.RefreshIntervalSec, + "cron_expr": mod.CronExpr, + } + if mod.DefaultCommunityID != nil { + m["default_community_id"] = *mod.DefaultCommunityID + } else { + m["default_community_id"] = nil + } + if mod.DohProfileID != nil { + m["doh_profile_id"] = *mod.DohProfileID + } else { + m["doh_profile_id"] = nil + } + return m +} + +func peerJSON(p *store.BGPPeer) map[string]any { + m := map[string]any{ + "id": p.ID, + "name": p.Name, + "neighbor": p.Neighbor, + "session_state": p.SessionState, + } + if p.SpeakerID != nil { + m["bgp_speaker_id"] = *p.SpeakerID + } else { + m["bgp_speaker_id"] = nil + } + return m +} + +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 +} + +func (s *Server) handleListModules(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 + } + mods := s.store.ListModules(a.TenantID) + items := make([]map[string]any, 0, len(mods)) + for _, mod := range mods { + items = append(items, moduleJSON(mod)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "next_cursor": nil, "has_more": false, + }) +} + +func (s *Server) handleGetModule(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 + } + mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id")) + if err != nil { + if err == store.ErrNotFound || err == store.ErrTenantScope { + writeProblem(w, http.StatusNotFound, "Not Found", "module not found") + return + } + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + writeJSON(w, http.StatusOK, moduleJSON(mod)) +} + +func (s *Server) handleListPeers(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 + } + peers := s.store.ListPeers(a.TenantID) + items := make([]map[string]any, 0, len(peers)) + for _, p := range peers { + items = append(items, peerJSON(p)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "next_cursor": nil, "has_more": false, + }) +} + +func (s *Server) handleListSpeakers(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 + } + speakers := s.store.ListSpeakersForTenant(a.TenantID) + items := make([]map[string]any, 0, len(speakers)) + for _, sp := range speakers { + items = append(items, speakerJSON(sp)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "next_cursor": nil, "has_more": false, + }) +} + +func (s *Server) handleModuleRefresh(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, "editor") { + return + } + moduleID := r.PathValue("module_id") + mod, err := s.store.GetModule(a.TenantID, moduleID) + if err != nil { + if err == store.ErrNotFound || err == store.ErrTenantScope { + writeProblem(w, http.StatusNotFound, "Not Found", "module not found") + return + } + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + if mod.Type == "IP_RANGES" { + writeNoContent(w) + return + } + idem := r.Header.Get("Idempotency-Key") + var idemPtr *string + if strings.TrimSpace(idem) != "" { + idem = strings.TrimSpace(idem) + idemPtr = &idem + } + mid := mod.ID + j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID}) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Location", "/v1/jobs/"+j.ID) + snap := j.Snapshot() + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]}) +} + +func (s *Server) handleListRevisions(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 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit == 0 { + limit = 50 + } + cursor := r.URL.Query().Get("cursor") + moduleID := r.URL.Query().Get("module_id") + items, next, more := s.store.ListRevisions(a.TenantID, moduleID, cursor, limit) + out := make([]map[string]any, 0, len(items)) + for _, rev := range items { + out = append(out, revisionJSON(rev)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": out, "next_cursor": strPtrOrNull(next), "has_more": more, + }) +} + +func revisionJSON(rev *store.Revision) map[string]any { + m := map[string]any{ + "id": rev.ID, + "content_hash": rev.ContentHash, + "created_at": rev.CreatedAt.UTC().Format(time.RFC3339Nano), + "materialized_prefix_count": rev.MaterializedPrefixCount, + } + if rev.ModuleID != "" { + m["module_id"] = rev.ModuleID + } else { + m["module_id"] = nil + } + if rev.ParentRevisionID != nil { + m["parent_revision_id"] = *rev.ParentRevisionID + } else { + m["parent_revision_id"] = nil + } + return m +} + +func strPtrOrNull(s string) any { + if s == "" { + return nil + } + return s +} + +func (s *Server) handleGetRevision(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 + } + rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id")) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + writeJSON(w, http.StatusOK, revisionJSON(rev)) +} + +func (s *Server) handleRevisionPreview(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 + } + rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id")) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + acc := r.Header.Get("Accept") + if strings.Contains(acc, "text/plain") && !strings.Contains(acc, "application/json") { + var b strings.Builder + for _, k := range sortedFragmentKeys(rev.PreviewFragments) { + b.WriteString("# --- ") + b.WriteString(k) + b.WriteString(" ---\n") + b.WriteString(rev.PreviewFragments[k]) + b.WriteByte('\n') + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(b.String())) + return + } + obj := make(map[string]any, len(rev.PreviewFragments)+1) + for k, v := range rev.PreviewFragments { + obj[k] = v + } + writeJSON(w, http.StatusOK, obj) +} + +func (s *Server) handleRevisionDiff(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 + } + d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b")) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + writeJSON(w, http.StatusOK, d) +} + +func (s *Server) handleRevisionRollback(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, "editor") { + return + } + revID := r.PathValue("revision_id") + if _, err := s.store.GetRevision(a.TenantID, revID); err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + var idemPtr *string + if idem != "" { + idemPtr = &idem + } + j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindRevisionRollback, idemPtr, nil, map[string]any{ + "source_revision_id": revID, + }) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Location", "/v1/jobs/"+j.ID) + snap := j.Snapshot() + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]}) +} + +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if strings.ToLower(a.Role) != "operator" { + writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required") + return + } + var body struct { + RevisionID string `json:"revision_id"` + Strategy string `json:"strategy"` + DryRun bool `json:"dry_run"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + revID := strings.TrimSpace(body.RevisionID) + if revID == "" { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required") + return + } + if _, err := s.store.GetRevision(a.TenantID, revID); err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + if body.DryRun { + writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "revision_id": revID}) + return + } + idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + var idemPtr *string + if idem != "" { + idemPtr = &idem + } + j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{ + "revision_id": revID, + "strategy": body.Strategy, + }) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Location", "/v1/jobs/"+j.ID) + snap := j.Snapshot() + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]}) +} + +func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if strings.ToLower(a.Role) != "operator" { + writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required") + return + } + spkID := r.PathValue("id") + if _, err := s.store.GetSpeaker(a.TenantID, spkID); err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found") + return + } + var body struct { + RevisionID string `json:"revision_id"` + DryRun bool `json:"dry_run"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + revID := strings.TrimSpace(body.RevisionID) + if revID == "" { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required") + return + } + if _, err := s.store.GetRevision(a.TenantID, revID); err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + if body.DryRun { + writeJSON(w, http.StatusOK, map[string]any{"dry_run": true}) + return + } + idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + var idemPtr *string + if idem != "" { + idemPtr = &idem + } + j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{ + "revision_id": revID, + "speaker_id": spkID, + }) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Location", "/v1/jobs/"+j.ID) + snap := j.Snapshot() + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]}) +} + +func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if strings.ToLower(a.Role) != "operator" { + writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required") + return + } + idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + var idemPtr *string + if idem != "" { + idemPtr = &idem + } + j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Location", "/v1/jobs/"+j.ID) + writeJSON(w, http.StatusAccepted, map[string]any{"job_id": j.ID, "status": "queued"}) +} + +func (s *Server) handleListJobs(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 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + status := r.URL.Query().Get("status") + kind := r.URL.Query().Get("kind") + cursor := r.URL.Query().Get("cursor") + list, next, more := s.jobs.List(a.TenantID, status, kind, cursor, limit) + items := make([]map[string]any, 0, len(list)) + for _, j := range list { + items = append(items, j.Snapshot()) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "next_cursor": strPtrOrNull(next), "has_more": more, + }) +} + +func (s *Server) handleGetJob(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 + } + writeJSON(w, http.StatusOK, j.Snapshot()) +} + +func (s *Server) handleCancelJob(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, "editor") { + return + } + j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id")) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "job not found") + return + } + writeJSON(w, http.StatusAccepted, j.Snapshot()) +} + +func (s *Server) handleNodeLatestRevision(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireNode(w, a) { + return + } + sid := r.PathValue("speaker_id") + sp, err := s.store.GetSpeakerAnyTenant(sid) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found") + return + } + if sp.TenantID != a.TenantID { + writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope") + return + } + rid, at, err := s.store.LatestPublishedRevision(sid) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "no published revision") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "revision_id": rid, "published_at": at.UTC().Format(time.RFC3339Nano), + }) +} + +func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireNode(w, a) { + return + } + sid := r.PathValue("speaker_id") + rid := r.PathValue("revision_id") + sp, err := s.store.GetSpeakerAnyTenant(sid) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found") + return + } + if sp.TenantID != a.TenantID { + writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope") + return + } + rev, err := s.store.GetRevision(a.TenantID, rid) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) + return + } + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", `attachment; filename="bundle.tar.gz"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tgz) +} + +func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireNode(w, a) { + return + } + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "accepted", + "message": "enrollment stub; operator approval required in production", + }) +} + +func sortedFragmentKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..84763a5 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,78 @@ +package httpapi + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" + + "evobgp/internal/jobs" + "evobgp/internal/observability" + "evobgp/internal/store" +) + +// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle). +type Server struct { + store *store.Memory + jobs *jobs.Registry + bundlePriv ed25519.PrivateKey + apiKeys []apiKeyRecord + insecureDev bool + corsOrigins []string + mux *http.ServeMux +} + +// Options configures the API server. +type Options struct { + // APIKeys is comma-separated "token|tenantUUID|role" (role: viewer, editor, operator, node). + APIKeys string + // InsecureDev with SeedDemo allows Bearer "dev" as operator for the demo tenant (local only). + InsecureDev bool + SeedDemo bool + // BundleSeedHex is 64 hex chars (32 bytes) for deterministic Ed25519 bundle signing; if empty, random. + BundleSeedHex string + // CORSAllowedOrigins is comma-separated list of allowed browser Origins (e.g. http://localhost:4173). + CORSAllowedOrigins string +} + +// New constructs Server and wiring for async jobs. +func New(opts Options) (*Server, error) { + mem := store.NewMemory() + if opts.SeedDemo { + mem.SeedDemo() + } + wk := &jobs.Worker{Store: mem} + reg := jobs.NewRegistry(wk.Process) + + var priv ed25519.PrivateKey + if strings.TrimSpace(opts.BundleSeedHex) != "" { + seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex)) + if err != nil { + return nil, err + } + if len(seed) != ed25519.SeedSize { + return nil, errors.New("httpapi: BundleSeedHex must decode to 32 bytes") + } + priv = ed25519.NewKeyFromSeed(seed) + } else { + _, priv, _ = ed25519.GenerateKey(rand.Reader) + } + + s := &Server{ + store: mem, + jobs: reg, + bundlePriv: priv, + apiKeys: parseAPIKeysSpec(opts.APIKeys), + insecureDev: opts.InsecureDev && opts.SeedDemo, + corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins), + } + observability.RegisterStoreMetrics(mem) + s.mux = http.NewServeMux() + s.registerRoutes() + return s, nil +} + +// Store exposes the in-memory store (for operators / tests). +func (s *Server) Store() *store.Memory { return s.store } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 0000000..4c9b2d0 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,228 @@ +package httpapi + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "evobgp/internal/jobs" + "evobgp/internal/signing" +) + +const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101" + +func TestAPIRefreshApplyJobsBundle(t *testing.T) { + srv, err := New(Options{ + InsecureDev: true, + SeedDemo: true, + BundleSeedHex: testBundleSeed, + }) + if err != nil { + t.Fatal(err) + } + tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs() + srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + client := ts.Client() + base := ts.URL + + t.Run("prometheus metrics", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + raw, _ := io.ReadAll(resp.Body) + s := string(raw) + for _, needle := range []string{ + "evobgp_materialized_prefixes_max", + "evobgp_bgp_peers_configured_total", + "evobgp_http_requests_total", // incremented by this scrape request + } { + if !strings.Contains(s, needle) { + t.Fatalf("metrics body missing %q", needle) + } + } + }) + + t.Run("refresh IP_RANGES no op", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + }) + + t.Run("refresh CDN queues job", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modCDN+"/refresh", nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + var body struct { + JobID string `json:"job_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + waitJob(t, client, base, "opkey", body.JobID) + }) + + t.Run("preview revision", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/preview", nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + }) + + t.Run("list modules peers speakers", func(t *testing.T) { + for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} { + req, _ := http.NewRequest(http.MethodGet, base+path, nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d: %s", path, resp.StatusCode, b) + } + var body struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(b, &body); err != nil { + t.Fatalf("%s json: %v", path, err) + } + if len(body.Items) < 1 { + t.Fatalf("%s expected items", path) + } + } + }) + + t.Run("rollback queues job", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + var body struct { + JobID string `json:"job_id"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + waitJob(t, client, base, "opkey", body.JobID) + }) + + t.Run("apply all speakers", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, base+"/v1/apply", strings.NewReader(`{"revision_id":"`+rev+`"}`)) + req.Header.Set("Authorization", "Bearer opkey") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + var body struct { + JobID string `json:"job_id"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + waitJob(t, client, base, "opkey", body.JobID) + }) + + pubB64 := srv.BundlePublicKeyBase64() + pubBytes, err := base64.StdEncoding.DecodeString(pubB64) + if err != nil { + t.Fatal(err) + } + + t.Run("node bundle roundtrip verify", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, base+"/v1/speakers/"+speaker+"/bundle/"+rev, nil) + req.Header.Set("Authorization", "Bearer nodekey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + _, err = signing.VerifyGzippedTar(raw, ed25519.PublicKey(pubBytes)) + if err != nil { + t.Fatal(err) + } + }) +} + +func waitJob(t *testing.T, client *http.Client, base, token, jobID string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + req, _ := http.NewRequest(http.MethodGet, base+"/v1/jobs/"+jobID, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var body struct { + Status string `json:"status"` + } + _ = json.Unmarshal(b, &body) + if body.Status == jobs.StatusSucceeded || body.Status == jobs.StatusFailed { + if body.Status != jobs.StatusSucceeded { + t.Fatalf("job %s status %s", jobID, body.Status) + } + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("job %s did not complete", jobID) +} diff --git a/internal/ingest/run.go b/internal/ingest/run.go new file mode 100644 index 0000000..eaf6f62 --- /dev/null +++ b/internal/ingest/run.go @@ -0,0 +1,23 @@ +// Package ingest fetches CDN lists, resolves DoH domains, and writes normalized prefixes into the database. +package ingest + +import ( + "context" + "log" + "time" +) + +// Run blocks until ctx is cancelled. Reference deployment: separate process consuming the job queue. +func Run(ctx context.Context) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + log.Printf("evobgp-ingest: started (stub; ingest workers dequeue from broker or job_audit)") + for { + select { + case <-ctx.Done(): + log.Printf("evobgp-ingest: stopped") + return + case <-t.C: + } + } +} diff --git a/internal/jobs/errors.go b/internal/jobs/errors.go new file mode 100644 index 0000000..1d42dd1 --- /dev/null +++ b/internal/jobs/errors.go @@ -0,0 +1,5 @@ +package jobs + +import "errors" + +var ErrNotFound = errors.New("jobs: not found") diff --git a/internal/jobs/job.go b/internal/jobs/job.go new file mode 100644 index 0000000..28f98ec --- /dev/null +++ b/internal/jobs/job.go @@ -0,0 +1,285 @@ +package jobs + +import ( + "fmt" + "sort" + "sync" + "time" + + "github.com/google/uuid" +) + +// Status values align with OpenAPI JobStatus and DB constraint job_audit_status_chk. +const ( + StatusQueued = "queued" + StatusRunning = "running" + StatusSucceeded = "succeeded" + StatusFailed = "failed" + StatusCancelled = "cancelled" +) + +// Job is the API-facing job model (maps to job_audit). +type Job struct { + ID string + TenantID string + Kind string + Status string + IdempotencyKey *string + ModuleID *string + CreatedAt time.Time + StartedAt *time.Time + FinishedAt *time.Time + Error *string + ProgressPct *int16 + Meta map[string]any + cancelRequested bool + mu sync.Mutex +} + +func (j *Job) MarkRunning() { + j.mu.Lock() + defer j.mu.Unlock() + if j.Status != StatusQueued { + return + } + now := time.Now().UTC() + j.StartedAt = &now + j.Status = StatusRunning +} + +func (j *Job) Succeed() { + j.mu.Lock() + defer j.mu.Unlock() + now := time.Now().UTC() + j.FinishedAt = &now + j.Status = StatusSucceeded +} + +func (j *Job) Fail(msg string) { + j.mu.Lock() + defer j.mu.Unlock() + now := time.Now().UTC() + j.FinishedAt = &now + j.Status = StatusFailed + j.Error = &msg +} + +func (j *Job) MarkCancelled() { + j.mu.Lock() + defer j.mu.Unlock() + if j.Status == StatusSucceeded || j.Status == StatusFailed || j.Status == StatusCancelled { + return + } + now := time.Now().UTC() + j.FinishedAt = &now + j.Status = StatusCancelled +} + +func (j *Job) IsCancelRequested() bool { + j.mu.Lock() + defer j.mu.Unlock() + return j.cancelRequested +} + +func (j *Job) RequestCancel() bool { + j.mu.Lock() + defer j.mu.Unlock() + j.cancelRequested = true + if j.Status == StatusQueued { + now := time.Now().UTC() + j.FinishedAt = &now + j.Status = StatusCancelled + return true + } + return false +} + +// Snapshot returns a consistent view for JSON serialization (safe under concurrent worker updates). +func (j *Job) mergeMeta(kv map[string]any) { + j.mu.Lock() + defer j.mu.Unlock() + if j.Meta == nil { + j.Meta = map[string]any{} + } + for k, v := range kv { + j.Meta[k] = v + } +} + +// statusLocked is used by the worker defer for metrics (any stable terminal or in-flight status). +func (j *Job) statusLocked() string { + j.mu.Lock() + defer j.mu.Unlock() + return j.Status +} + +func (j *Job) Snapshot() map[string]any { + j.mu.Lock() + defer j.mu.Unlock() + metaCopy := make(map[string]any, len(j.Meta)) + for k, v := range j.Meta { + metaCopy[k] = v + } + m := map[string]any{ + "job_id": j.ID, "kind": j.Kind, "status": j.Status, + "created_at": j.CreatedAt.UTC().Format(time.RFC3339Nano), + "meta": metaCopy, + } + if j.IdempotencyKey != nil { + m["idempotency_key"] = *j.IdempotencyKey + } else { + m["idempotency_key"] = nil + } + if j.StartedAt != nil { + m["started_at"] = j.StartedAt.UTC().Format(time.RFC3339Nano) + } else { + m["started_at"] = nil + } + if j.FinishedAt != nil { + m["finished_at"] = j.FinishedAt.UTC().Format(time.RFC3339Nano) + } else { + m["finished_at"] = nil + } + if j.Error != nil { + m["error"] = *j.Error + } else { + m["error"] = nil + } + return m +} + +// Registry tracks jobs in memory (microVPS-style single process; swap for PG + SKIP LOCKED later). +type Registry struct { + mu sync.RWMutex + byID map[string]*Job + byIdempo map[idempoKey]*Job + workerStart func(j *Job) +} + +type idempoKey struct { + tenant string + key string +} + +func NewRegistry(workerStart func(j *Job)) *Registry { + return &Registry{ + byID: make(map[string]*Job), + byIdempo: make(map[idempoKey]*Job), + workerStart: workerStart, + } +} + +// Enqueue creates a job or returns an existing one for the same idempotency key. +func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if idempotencyKey != nil && *idempotencyKey != "" { + k := idempoKey{tenant: tenantID, key: *idempotencyKey} + if existing, ok := r.byIdempo[k]; ok { + return existing, false, nil + } + } + + j := &Job{ + ID: uuid.NewString(), + TenantID: tenantID, + Kind: kind, + Status: StatusQueued, + IdempotencyKey: idempotencyKey, + ModuleID: moduleID, + CreatedAt: time.Now().UTC(), + Meta: cloneMeta(meta), + } + if idempotencyKey != nil && *idempotencyKey != "" { + r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j + } + r.byID[j.ID] = j + + if r.workerStart != nil { + go r.workerStart(j) + } + return j, true, nil +} + +func cloneMeta(m map[string]any) map[string]any { + if m == nil { + return map[string]any{} + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func (r *Registry) Get(tenantID, jobID string) (*Job, error) { + r.mu.RLock() + defer r.mu.RUnlock() + j, ok := r.byID[jobID] + if !ok || j.TenantID != tenantID { + return nil, ErrNotFound + } + return j, nil +} + +func (r *Registry) List(tenantID, statusFilter, kindFilter, cursor string, limit int) ([]*Job, string, bool) { + if limit <= 0 { + limit = 50 + } + r.mu.RLock() + var all []*Job + for _, j := range r.byID { + if j.TenantID != tenantID { + continue + } + if statusFilter != "" && j.Status != statusFilter { + continue + } + if kindFilter != "" && j.Kind != kindFilter { + continue + } + all = append(all, j) + } + r.mu.RUnlock() + + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + off := 0 + if cursor != "" { + _ = parseCursor(cursor, &off) + } + end := off + limit + next := "" + hasMore := false + if end > len(all) { + end = len(all) + } else { + hasMore = true + next = formatCursor(end) + } + if off >= len(all) { + return nil, "", false + } + return all[off:end], next, hasMore +} + +func parseCursor(s string, off *int) error { + _, err := fmt.Sscanf(s, "%d", off) + return err +} + +func formatCursor(off int) string { + return fmt.Sprintf("%d", off) +} + +// RequestCancel marks a job for cancellation (best-effort). +func (r *Registry) RequestCancel(tenantID, jobID string) (*Job, error) { + j, err := r.Get(tenantID, jobID) + if err != nil { + return nil, err + } + j.RequestCancel() + return j, nil +} diff --git a/internal/jobs/worker.go b/internal/jobs/worker.go new file mode 100644 index 0000000..cdb498f --- /dev/null +++ b/internal/jobs/worker.go @@ -0,0 +1,98 @@ +package jobs + +import ( + "evobgp/internal/observability" + "evobgp/internal/store" +) + +const ( + KindModuleRefresh = "module_refresh" + KindDeployApply = "deploy_apply" + KindRevisionRollback = "revision_rollback" + KindBirdReload = "bird_reload" +) + +// Worker executes queued jobs against an in-memory store (stub for full render/deploy pipeline). +type Worker struct { + Store *store.Memory +} + +// Process is registered as Registry.workerStart. +func (w *Worker) Process(j *Job) { + defer func() { + observability.RecordJobTerminal(j.Kind, j.statusLocked()) + }() + + if w == nil || w.Store == nil { + j.MarkRunning() + j.Fail("worker not configured") + return + } + j.MarkRunning() + if j.IsCancelRequested() { + j.MarkCancelled() + return + } + + switch j.Kind { + case KindModuleRefresh: + j.Succeed() + case KindDeployApply: + w.runDeployApply(j) + case KindRevisionRollback: + w.runRollback(j) + case KindBirdReload: + j.Succeed() + default: + j.Fail("unknown job kind") + } +} + +func (w *Worker) runDeployApply(j *Job) { + rev, _ := j.Meta["revision_id"].(string) + spk, hasSpeaker := j.Meta["speaker_id"].(string) + if rev == "" { + j.Fail("missing revision_id in job meta") + return + } + applyOne := func(speakerID string) error { + if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, rev); err != nil { + return err + } + // Replica / node pulls use LatestPublishedRevision; keep pointer in sync with successful deploy. + if err := w.Store.PublishRevisionForSpeaker(speakerID, rev); err != nil { + return err + } + return nil + } + if hasSpeaker && spk != "" { + if err := applyOne(spk); err != nil { + j.Fail(err.Error()) + return + } + j.Succeed() + return + } + for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) { + if err := applyOne(sp.ID); err != nil { + j.Fail(err.Error()) + return + } + } + j.Succeed() +} + +func (w *Worker) runRollback(j *Job) { + src, _ := j.Meta["source_revision_id"].(string) + if src == "" { + j.Fail("missing source_revision_id in job meta") + return + } + newID, err := w.Store.CreateRollbackRevision(j.TenantID, src) + if err != nil { + j.Fail(err.Error()) + return + } + j.mergeMeta(map[string]any{"new_revision_id": newID}) + j.Succeed() +} diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..28979ee --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,224 @@ +// Package observability registers Prometheus metrics for the control plane (prefix aggregates, BGP peers, jobs, HTTP). +package observability + +import ( + "context" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "evobgp/internal/store" +) + +const namespace = "evobgp" + +var ( + metricsMem atomic.Pointer[store.Memory] + registerCollectorOnce sync.Once +) + +var ( + httpRequests = promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "http_requests_total", + Help: "HTTP requests handled by the API mux (excludes /metrics).", + }, + []string{"method", "code"}, + ) + + jobsFinished = promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "jobs_finished_total", + Help: "Async jobs that reached a terminal state.", + }, + []string{"kind", "status"}, + ) + + birdBGPEstablished = promauto.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "bird_bgp_sessions_established", + Help: "BGP sessions in Established state from birdc show protocols (0 if scrape disabled or failed).", + }) + + birdProtocolsScrapeSuccess = promauto.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "bird_protocols_scrape_success", + Help: "1 if the last birdc protocols scrape succeeded, else 0.", + }) + + buildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "build_info", + Help: "Build metadata (value always 1).", + }, []string{"version", "git_sha"}) +) + +// RecordJobTerminal increments jobs_finished_total for terminal statuses. +func RecordJobTerminal(kind, status string) { + switch status { + case "succeeded", "failed", "cancelled": + jobsFinished.WithLabelValues(kind, status).Inc() + default: + return + } +} + +// SetBuildInfo sets evobgp_build_info gauge (idempotent labels). +func SetBuildInfo(version, gitSHA string) { + if version == "" { + version = "unknown" + } + if gitSHA == "" { + gitSHA = "unknown" + } + buildInfo.WithLabelValues(version, gitSHA).Set(1) +} + +type memoryStoreCollector struct { + prefixMaxDesc *prometheus.Desc + prefixSumDesc *prometheus.Desc + peersDesc *prometheus.Desc + peerStateDesc *prometheus.Desc +} + +func newMemoryStoreCollector() *memoryStoreCollector { + return &memoryStoreCollector{ + prefixMaxDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "materialized_prefixes_max"), + "Maximum materialized_prefix_count among all revisions in the store.", + nil, nil, + ), + prefixSumDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "materialized_prefixes_sum"), + "Sum of materialized_prefix_count over revisions (development aggregate).", + nil, nil, + ), + peersDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "bgp_peers_configured_total"), + "BGP peers configured in the control-plane store.", + nil, nil, + ), + peerStateDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "bgp_peer_sessions"), + "Configured BGP peers in the store by session_state (intent / last known, not live BIRD).", + []string{"state"}, nil, + ), + } +} + +func (c *memoryStoreCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.prefixMaxDesc + ch <- c.prefixSumDesc + ch <- c.peersDesc + ch <- c.peerStateDesc +} + +func (c *memoryStoreCollector) Collect(ch chan<- prometheus.Metric) { + mem := metricsMem.Load() + if mem == nil { + return + } + maxN, sumN := mem.MaterializedPrefixStats() + ch <- prometheus.MustNewConstMetric(c.prefixMaxDesc, prometheus.GaugeValue, float64(maxN)) + ch <- prometheus.MustNewConstMetric(c.prefixSumDesc, prometheus.GaugeValue, float64(sumN)) + ch <- prometheus.MustNewConstMetric(c.peersDesc, prometheus.GaugeValue, float64(mem.PeerCount())) + for state, n := range mem.PeerSessionCountsByState() { + if state == "" { + state = "unknown" + } + ch <- prometheus.MustNewConstMetric(c.peerStateDesc, prometheus.GaugeValue, float64(n), state) + } +} + +// RegisterStoreMetrics points Prometheus collectors at the given store (last call wins; safe for tests). +func RegisterStoreMetrics(mem *store.Memory) { + if mem == nil { + return + } + metricsMem.Store(mem) + registerCollectorOnce.Do(func() { + prometheus.DefaultRegisterer.MustRegister(newMemoryStoreCollector()) + }) +} + +// SetBirdSessionMetrics updates gauges from an optional birdc scrape. +func SetBirdSessionMetrics(established int, scrapeOK bool) { + birdBGPEstablished.Set(float64(established)) + if scrapeOK { + birdProtocolsScrapeSuccess.Set(1) + } else { + birdProtocolsScrapeSuccess.Set(0) + } +} + +// MetricsHandler returns the Prometheus scrape handler. +func MetricsHandler() http.Handler { + return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}) +} + +// HTTPMiddleware records method and status code for all wrapped requests. +func HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + httpRequests.WithLabelValues(r.Method, strconv.Itoa(sw.status)).Inc() + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty. +func StartBirdProtocolsPoller(socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) { + socket = trimSpace(socket) + if socket == "" || interval <= 0 || showFn == nil || countFn == nil { + return + } + scrape := func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + out, err := showFn(ctx, socket, birdcPath) + cancel() + if err != nil { + SetBirdSessionMetrics(0, false) + return + } + SetBirdSessionMetrics(countFn(out), true) + } + go func() { + scrape() + t := time.NewTicker(interval) + defer t.Stop() + for range t.C { + scrape() + } + }() +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 { + last := s[len(s)-1] + if last != ' ' && last != '\t' { + break + } + s = s[:len(s)-1] + } + return s +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..26bf867 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,16 @@ +// Package platform groups cross-cutting runtime concerns (logging hooks, build/service identity). +// Metrics and HTTP middleware live in internal/observability for now. +package platform + +import ( + "os" + "strings" +) + +// ServiceName returns EVOBGP_SERVICE or defaultName when unset. +func ServiceName(defaultName string) string { + if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" { + return v + } + return defaultName +} diff --git a/internal/render/run.go b/internal/render/run.go new file mode 100644 index 0000000..c7e26a4 --- /dev/null +++ b/internal/render/run.go @@ -0,0 +1,23 @@ +// Package render materializes prefix sets, creates config_revision rows, and builds BIRD text via internal/birdfmt. +package render + +import ( + "context" + "log" + "time" +) + +// Run blocks until ctx is cancelled. Reference deployment: worker process after ingest completes. +func Run(ctx context.Context) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + log.Printf("evobgp-render: started (stub; render jobs produce revisions and artifacts)") + for { + select { + case <-ctx.Done(): + log.Printf("evobgp-render: stopped") + return + case <-t.C: + } + } +} diff --git a/internal/repository/doc.go b/internal/repository/doc.go new file mode 100644 index 0000000..eb1c2c3 --- /dev/null +++ b/internal/repository/doc.go @@ -0,0 +1,6 @@ +// Package repository will host SQL-backed data access (plan §1: tenant, module, peer, revision, jobs). +// +// The in-memory implementation used by the current API lives in internal/store. +// Migrations: see migrations/ at repository root. + +package repository diff --git a/internal/scheduler/run.go b/internal/scheduler/run.go new file mode 100644 index 0000000..7215518 --- /dev/null +++ b/internal/scheduler/run.go @@ -0,0 +1,25 @@ +// Package scheduler drives module/CDN refresh intervals and enqueues work (broker or job_audit). +// This process entrypoint is a stub until PostgreSQL and NATS/Redis are wired. +package scheduler + +import ( + "context" + "log" + "time" +) + +// Run blocks until ctx is cancelled. In production it connects to DB + broker and schedules refresh events. +func Run(ctx context.Context) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + log.Printf("evobgp-scheduler: started (stub; connect EVOBGP_DATABASE_URL / EVOBGP_BROKER_URL for full stack)") + for { + select { + case <-ctx.Done(): + log.Printf("evobgp-scheduler: stopped") + return + case <-t.C: + // Placeholder: evaluate module cron / refresh_interval and publish jobs. + } + } +} diff --git a/internal/signing/verify.go b/internal/signing/verify.go new file mode 100644 index 0000000..be20104 --- /dev/null +++ b/internal/signing/verify.go @@ -0,0 +1,13 @@ +// Package signing is the trust boundary for replica bundles: verify Ed25519 signatures separately from TLS (plan §10, §13). +package signing + +import ( + "crypto/ed25519" + + "evobgp/internal/bundle" +) + +// VerifyGzippedTar checks the manifest signature and file SHA-256 entries (delegates to internal/bundle). +func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*bundle.VerifiedContents, error) { + return bundle.VerifyGzippedTar(raw, pub) +} diff --git a/internal/signing/verify_test.go b/internal/signing/verify_test.go new file mode 100644 index 0000000..aa8449d --- /dev/null +++ b/internal/signing/verify_test.go @@ -0,0 +1,28 @@ +package signing + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + + "evobgp/internal/bundle" +) + +func TestVerifyGzippedTarDelegates(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + raw, err := bundle.BuildGzippedTar("r1", "s1", map[string]string{"bird.conf": "router id 192.0.2.1;\n"}, priv) + if err != nil { + t.Fatal(err) + } + pub := priv.Public().(ed25519.PublicKey) + v, err := VerifyGzippedTar(raw, pub) + if err != nil { + t.Fatal(err) + } + if v.Manifest.RevisionID != "r1" { + t.Fatalf("revision %q", v.Manifest.RevisionID) + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go new file mode 100644 index 0000000..a8e7a5b --- /dev/null +++ b/internal/store/memory.go @@ -0,0 +1,494 @@ +package store + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +var ( + ErrNotFound = errors.New("store: not found") + ErrTenantScope = errors.New("store: tenant mismatch") + ErrInvalidInput = errors.New("store: invalid input") +) + +// Memory is a development-oriented in-memory backend for control-plane API handlers. +type Memory struct { + mu sync.RWMutex + + tenants map[string]*Tenant + modules map[string]*Module + revisions map[string]*Revision + speakers map[string]*Speaker + + // speakerID -> latest published revision for evobgp-node pulls + publishedRevision map[string]publishedInfo + + peers map[string]*BGPPeer + + // DemoIDs valid after SeedDemo() + demoTenantID string + demoModuleCDN string + demoModuleIP string + demoRevisionID string + demoSpeakerID string +} + +type publishedInfo struct { + RevisionID string + PublishedAt time.Time +} + +type Tenant struct { + ID string + Name string + Slug string +} + +type Module struct { + ID string + TenantID string + Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES + Name string + Enabled bool + RefreshIntervalSec int // 0 = unset + CronExpr string // optional cron for scheduler (display / future use) + Priority int + DefaultCommunityID *string + DohProfileID *string +} + +type Revision struct { + ID string + TenantID string + ModuleID string + ContentHash string + ParentRevisionID *string + CreatedAt time.Time + // MaterializedPrefixCount is the size of the prefix set for this revision (control-plane / render output). + MaterializedPrefixCount int + // PreviewFragments maps logical paths (e.g. bird.conf) to generated text. + PreviewFragments map[string]string +} + +// BGPPeer is a minimal stand-in until SQL-backed bgp_peer is wired into the API. +type BGPPeer struct { + ID string + TenantID string + SpeakerID *string + Name string + Neighbor string + SessionState string // e.g. Established, Idle, Connect (intent or cached ops view) +} + +type Speaker struct { + ID string + TenantID string + Role string + Endpoint string + LastAppliedRevisionID *string +} + +func NewMemory() *Memory { + return &Memory{ + tenants: make(map[string]*Tenant), + modules: make(map[string]*Module), + revisions: make(map[string]*Revision), + speakers: make(map[string]*Speaker), + publishedRevision: make(map[string]publishedInfo), + peers: make(map[string]*BGPPeer), + } +} + +// SeedDemo installs a minimal tenant, modules, revision, and speaker for local testing. +func (m *Memory) SeedDemo() { + m.mu.Lock() + defer m.mu.Unlock() + + tid := uuid.NewString() + m.tenants[tid] = &Tenant{ID: tid, Name: "Demo", Slug: "demo"} + + mCDN := uuid.NewString() + m.modules[mCDN] = &Module{ + ID: mCDN, + TenantID: tid, + Type: "CDN_CIDRS", + Name: "demo-cdn", + Enabled: true, + RefreshIntervalSec: 3600, + CronExpr: "", + Priority: 10, + } + + mIP := uuid.NewString() + m.modules[mIP] = &Module{ + ID: mIP, + TenantID: tid, + Type: "IP_RANGES", + Name: "demo-static", + Enabled: true, + RefreshIntervalSec: 0, + CronExpr: "", + Priority: 20, + } + + parent := uuid.NewString() + m.revisions[parent] = &Revision{ + ID: parent, + TenantID: tid, + ModuleID: "", + ContentHash: "sha256:parent", + ParentRevisionID: nil, + CreatedAt: time.Now().UTC().Add(-time.Hour), + MaterializedPrefixCount: 0, + PreviewFragments: map[string]string{"bird.conf": "# parent revision\n"}, + } + + rid := uuid.NewString() + m.revisions[rid] = &Revision{ + ID: rid, + TenantID: tid, + ModuleID: "", + ContentHash: "sha256:demo-rev-1", + ParentRevisionID: &parent, + CreatedAt: time.Now().UTC(), + MaterializedPrefixCount: 128, + PreviewFragments: map[string]string{ + // Valid minimal BIRD 2 skeleton (see internal/birdfmt/testdata/scenarios/minimal/bird.conf). + "bird.conf": `# EvoBGP demo bundle +router id 192.0.2.1; + +protocol device { +} + +protocol direct { + ipv4; + ipv6; +} +`, + "bird.d/evobgp_demo.conf": "# static demo fragment\n", + }, + } + + sid := uuid.NewString() + m.speakers[sid] = &Speaker{ + ID: sid, + TenantID: tid, + Role: "replica", + Endpoint: "10.0.0.2:179", + LastAppliedRevisionID: nil, + } + m.publishedRevision[sid] = publishedInfo{RevisionID: rid, PublishedAt: time.Now().UTC()} + + p1 := uuid.NewString() + m.peers[p1] = &BGPPeer{ + ID: p1, + TenantID: tid, + SpeakerID: &sid, + Name: "demo-upstream-4", + Neighbor: "198.51.100.2", + SessionState: "Established", + } + p2 := uuid.NewString() + m.peers[p2] = &BGPPeer{ + ID: p2, + TenantID: tid, + SpeakerID: &sid, + Name: "demo-upstream-6", + Neighbor: "2001:db8::2", + SessionState: "Idle", + } + + m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID = tid, mCDN, mIP, rid, sid +} + +// MaterializedPrefixStats returns max and sum of MaterializedPrefixCount across revisions. +func (m *Memory) MaterializedPrefixStats() (max int, sum int) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, r := range m.revisions { + sum += r.MaterializedPrefixCount + if r.MaterializedPrefixCount > max { + max = r.MaterializedPrefixCount + } + } + return max, sum +} + +// PeerCount returns the number of configured BGP peers. +func (m *Memory) PeerCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.peers) +} + +// PeerSessionCountsByState returns peer counts grouped by SessionState. +func (m *Memory) PeerSessionCountsByState() map[string]int { + m.mu.RLock() + defer m.mu.RUnlock() + out := make(map[string]int) + for _, p := range m.peers { + st := strings.TrimSpace(p.SessionState) + if st == "" { + st = "unknown" + } + out[st]++ + } + return out +} + +// DemoIDs returns IDs from SeedDemo; empty strings if SeedDemo was not called. +func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID +} + +// ListModules returns modules for a tenant (sorted by priority, then name). +func (m *Memory) ListModules(tenantID string) []*Module { + m.mu.RLock() + defer m.mu.RUnlock() + var out []*Module + for _, mod := range m.modules { + if mod.TenantID == tenantID { + out = append(out, mod) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Priority != out[j].Priority { + return out[i].Priority < out[j].Priority + } + return out[i].Name < out[j].Name + }) + return out +} + +// ListPeers returns BGP peers for a tenant (sorted by name). +func (m *Memory) ListPeers(tenantID string) []*BGPPeer { + m.mu.RLock() + defer m.mu.RUnlock() + var out []*BGPPeer + for _, p := range m.peers { + if p.TenantID == tenantID { + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) { + m.mu.RLock() + defer m.mu.RUnlock() + mod, ok := m.modules[moduleID] + if !ok { + return nil, ErrNotFound + } + if mod.TenantID != tenantID { + return nil, ErrTenantScope + } + return mod, nil +} + +func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) { + m.mu.RLock() + defer m.mu.RUnlock() + rev, ok := m.revisions[revisionID] + if !ok { + return nil, ErrNotFound + } + if rev.TenantID != tenantID { + return nil, ErrTenantScope + } + return rev, nil +} + +func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) { + m.mu.RLock() + defer m.mu.RUnlock() + sp, ok := m.speakers[speakerID] + if !ok { + return nil, ErrNotFound + } + if sp.TenantID != tenantID { + return nil, ErrTenantScope + } + return sp, nil +} + +// ListSpeakersForTenant returns speakers for a tenant (IDs sorted) for deploy-all and similar operations. +func (m *Memory) ListSpeakersForTenant(tenantID string) []*Speaker { + m.mu.RLock() + defer m.mu.RUnlock() + var ids []string + for id, sp := range m.speakers { + if sp.TenantID == tenantID { + ids = append(ids, id) + } + } + sort.Strings(ids) + out := make([]*Speaker, 0, len(ids)) + for _, id := range ids { + out = append(out, m.speakers[id]) + } + return out +} + +// GetSpeakerAnyTenant resolves a speaker without tenant check (node API uses speaker id in path). +func (m *Memory) GetSpeakerAnyTenant(speakerID string) (*Speaker, error) { + m.mu.RLock() + defer m.mu.RUnlock() + sp, ok := m.speakers[speakerID] + if !ok { + return nil, ErrNotFound + } + return sp, nil +} + +func (m *Memory) LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error) { + m.mu.RLock() + defer m.mu.RUnlock() + info, ok := m.publishedRevision[speakerID] + if !ok { + return "", time.Time{}, ErrNotFound + } + return info.RevisionID, info.PublishedAt, nil +} + +// CreateRollbackRevision adds a new revision that reuses content from sourceRevisionID (same hash & preview copy). +func (m *Memory) CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + src, ok := m.revisions[sourceRevisionID] + if !ok { + return "", ErrNotFound + } + if src.TenantID != tenantID { + return "", ErrTenantScope + } + newID = uuid.NewString() + frag := make(map[string]string, len(src.PreviewFragments)) + for k, v := range src.PreviewFragments { + frag[k] = v + } + parent := sourceRevisionID + m.revisions[newID] = &Revision{ + ID: newID, + TenantID: tenantID, + ModuleID: src.ModuleID, + ContentHash: src.ContentHash + ":rollback", + ParentRevisionID: &parent, + CreatedAt: time.Now().UTC(), + MaterializedPrefixCount: src.MaterializedPrefixCount, + PreviewFragments: frag, + } + return newID, nil +} + +// SetLastAppliedRevision updates speaker state after deploy job (demo). +func (m *Memory) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + sp, ok := m.speakers[speakerID] + if !ok { + return ErrNotFound + } + if sp.TenantID != tenantID { + return ErrTenantScope + } + if _, ok := m.revisions[revisionID]; !ok { + return fmt.Errorf("%w: revision", ErrNotFound) + } + sp.LastAppliedRevisionID = &revisionID + return nil +} + +// PublishRevisionForSpeaker marks latest bundle pointer for a replica speaker. +func (m *Memory) PublishRevisionForSpeaker(speakerID, revisionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.speakers[speakerID]; !ok { + return ErrNotFound + } + if _, ok := m.revisions[revisionID]; !ok { + return fmt.Errorf("%w: revision", ErrNotFound) + } + m.publishedRevision[speakerID] = publishedInfo{RevisionID: revisionID, PublishedAt: time.Now().UTC()} + return nil +} + +// RevisionDiff returns a trivial JSON-friendly diff between prefix sets (empty for memory demo). +func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) { + _, err := m.getRevisionLocked(tenantID, aID) + if err != nil { + return nil, err + } + _, err = m.getRevisionLocked(tenantID, bID) + if err != nil { + return nil, err + } + return map[string]any{ + "revision_a": aID, + "revision_b": bID, + "prefixes": map[string]any{ + "added": []string{}, + "removed": []string{}, + "unchanged_count": 0, + }, + }, nil +} + +func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) { + rev, ok := m.revisions[revisionID] + if !ok { + return nil, ErrNotFound + } + if rev.TenantID != tenantID { + return nil, ErrTenantScope + } + return rev, nil +} + +// ListRevisions returns recent revisions for tenant (newest first), cursor is opaque offset string. +func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool) { + if limit <= 0 { + limit = 50 + } + m.mu.RLock() + defer m.mu.RUnlock() + var all []*Revision + for _, r := range m.revisions { + if r.TenantID != tenantID { + continue + } + if moduleID != "" && r.ModuleID != moduleID { + continue + } + all = append(all, r) + } + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + end := off + limit + if end > len(all) { + end = len(all) + } + page := all[off:end] + if end < len(all) { + nextCursor = fmt.Sprintf("%d", end) + hasMore = true + } + return page, nextCursor, hasMore +} diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..4629bff --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,13 @@ +package migrations + +import "embed" + +// Postgres contains versioned SQL for the primary PostgreSQL path (plan §11). +// +//go:embed postgres/*.sql +var Postgres embed.FS + +// SQLite contains the parallel schema for optional microVPS_sqlite (plan §2, §11). +// +//go:embed sqlite/*.sql +var SQLite embed.FS diff --git a/migrations/postgres/000001_init.down.sql b/migrations/postgres/000001_init.down.sql new file mode 100644 index 0000000..ea4cc8a --- /dev/null +++ b/migrations/postgres/000001_init.down.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS module_cdn_fetch_log; +DROP TABLE IF EXISTS global_settings; +DROP TABLE IF EXISTS job_audit; +DROP TABLE IF EXISTS revision_materialized_prefix; +DROP TABLE IF EXISTS bgp_peer; +DROP TABLE IF EXISTS bgp_speaker; +DROP TABLE IF EXISTS config_revision; +DROP TABLE IF EXISTS module_ip_range_entry; +DROP TABLE IF EXISTS module_as_entry; +DROP TABLE IF EXISTS module_domain_entry; +DROP TABLE IF EXISTS module_cdn_source; +DROP TABLE IF EXISTS module; +DROP TABLE IF EXISTS bgp_community; +DROP TABLE IF EXISTS doh_profile; +DROP TABLE IF EXISTS tenant; diff --git a/migrations/postgres/000001_init.up.sql b/migrations/postgres/000001_init.up.sql new file mode 100644 index 0000000..ee30e0a --- /dev/null +++ b/migrations/postgres/000001_init.up.sql @@ -0,0 +1,220 @@ +-- EvoBGP initial schema (PostgreSQL). See plan §5. +-- UUIDs: application may use UUID v7/ULID as text-compatible UUID. + +CREATE TABLE tenant ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT tenant_slug_chk CHECK (length(trim(slug)) > 0), + CONSTRAINT tenant_slug_uniq UNIQUE (slug) +); + +CREATE TABLE doh_profile ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + timeout_ms INTEGER, + secret_ref TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT doh_profile_url_chk CHECK (length(trim(url)) > 0) +); + +CREATE INDEX idx_doh_profile_tenant ON doh_profile (tenant_id); + +CREATE TABLE bgp_community ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + value_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bgp_community_kind_chk CHECK (length(trim(kind)) > 0) +); + +CREATE INDEX idx_bgp_community_tenant ON bgp_community (tenant_id); + +CREATE TABLE module ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + type TEXT NOT NULL, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + priority INTEGER NOT NULL DEFAULT 0, + doh_profile_id UUID REFERENCES doh_profile (id) ON DELETE SET NULL, + refresh_interval_sec INTEGER, + cron_expr TEXT, + default_community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT module_type_chk CHECK (type IN ( + 'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS', 'IP_RANGES' + )), + CONSTRAINT module_name_chk CHECK (length(trim(name)) > 0) +); + +CREATE INDEX idx_module_tenant ON module (tenant_id); +CREATE INDEX idx_module_tenant_type ON module (tenant_id, type); +CREATE INDEX idx_module_tenant_enabled ON module (tenant_id, enabled) WHERE deleted_at IS NULL; + +CREATE TABLE module_cdn_source ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE, + source_kind TEXT NOT NULL, + url TEXT NOT NULL, + etag TEXT, + refresh_interval_sec INTEGER, + community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT module_cdn_source_url_chk CHECK (length(trim(url)) > 0) +); + +CREATE INDEX idx_module_cdn_source_module ON module_cdn_source (module_id); + +CREATE TABLE module_domain_entry ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE, + fqdn TEXT NOT NULL, + community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + resolve_meta JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT module_domain_fqdn_chk CHECK (length(trim(fqdn)) > 0) +); + +CREATE INDEX idx_module_domain_entry_module ON module_domain_entry (module_id); + +CREATE TABLE module_as_entry ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE, + asn BIGINT, + prefix CIDR, + community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT module_as_entry_asn_or_prefix_chk CHECK (asn IS NOT NULL OR prefix IS NOT NULL) +); + +CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id); + +CREATE TABLE module_ip_range_entry ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE, + prefix CIDR NOT NULL, + community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_module_ip_range_entry_module ON module_ip_range_entry (module_id); + +-- Full config snapshot for a tenant (optionally tied to one module for filtering in API). +CREATE TABLE config_revision ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + module_id UUID REFERENCES module (id) ON DELETE SET NULL, + content_hash TEXT NOT NULL, + parent_revision_id UUID REFERENCES config_revision (id) ON DELETE SET NULL, + artifact_ref TEXT, + meta_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT config_revision_hash_chk CHECK (length(trim(content_hash)) > 0) +); + +CREATE INDEX idx_config_revision_tenant ON config_revision (tenant_id, created_at DESC); +CREATE INDEX idx_config_revision_module ON config_revision (module_id) WHERE module_id IS NOT NULL; + +CREATE TABLE bgp_speaker ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + role TEXT NOT NULL, + endpoint TEXT, + last_applied_revision_id UUID REFERENCES config_revision (id) ON DELETE SET NULL, + meta_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bgp_speaker_role_chk CHECK (role IN ('master', 'replica')) +); + +CREATE INDEX idx_bgp_speaker_tenant ON bgp_speaker (tenant_id); + +CREATE TABLE bgp_peer ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + bgp_speaker_id UUID REFERENCES bgp_speaker (id) ON DELETE SET NULL, + neighbor INET NOT NULL, + remote_asn BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + policies_json JSONB NOT NULL DEFAULT '{}', + meta_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_bgp_peer_tenant ON bgp_peer (tenant_id); +CREATE INDEX idx_bgp_peer_speaker ON bgp_peer (bgp_speaker_id); + +CREATE TABLE revision_materialized_prefix ( + id BIGSERIAL PRIMARY KEY, + revision_id UUID NOT NULL REFERENCES config_revision (id) ON DELETE CASCADE, + prefix CIDR NOT NULL, + community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT '', + meta_json JSONB NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_rev_mat_prefix_revision ON revision_materialized_prefix (revision_id); +CREATE INDEX idx_rev_mat_prefix_value ON revision_materialized_prefix (prefix); + +CREATE TABLE job_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + kind TEXT NOT NULL, + status TEXT NOT NULL, + idempotency_key TEXT, + module_id UUID REFERENCES module (id) ON DELETE SET NULL, + error_message TEXT, + progress_pct SMALLINT, + meta_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + CONSTRAINT job_audit_status_chk CHECK (status IN ( + 'queued', 'running', 'succeeded', 'failed', 'cancelled' + )), + CONSTRAINT job_audit_kind_chk CHECK (length(trim(kind)) > 0) +); + +CREATE INDEX idx_job_audit_tenant_created ON job_audit (tenant_id, created_at DESC); +CREATE INDEX idx_job_audit_tenant_status ON job_audit (tenant_id, status); +CREATE UNIQUE INDEX idx_job_audit_idempotency + ON job_audit (tenant_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE TABLE global_settings ( + tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + key TEXT NOT NULL, + value_json JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, key), + CONSTRAINT global_settings_key_chk CHECK (length(trim(key)) > 0) +); + +-- Optional CDN fetch audit (plan §5.4). +CREATE TABLE module_cdn_fetch_log ( + id BIGSERIAL PRIMARY KEY, + module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE, + source_id UUID REFERENCES module_cdn_source (id) ON DELETE SET NULL, + http_status INTEGER, + bytes BIGINT, + error TEXT, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_module_cdn_fetch_log_module ON module_cdn_fetch_log (module_id, fetched_at DESC); diff --git a/migrations/sqlite/000001_init.down.sql b/migrations/sqlite/000001_init.down.sql new file mode 100644 index 0000000..2a7ee90 --- /dev/null +++ b/migrations/sqlite/000001_init.down.sql @@ -0,0 +1,17 @@ +PRAGMA foreign_keys = ON; + +DROP TABLE IF EXISTS module_cdn_fetch_log; +DROP TABLE IF EXISTS global_settings; +DROP TABLE IF EXISTS job_audit; +DROP TABLE IF EXISTS revision_materialized_prefix; +DROP TABLE IF EXISTS bgp_peer; +DROP TABLE IF EXISTS bgp_speaker; +DROP TABLE IF EXISTS config_revision; +DROP TABLE IF EXISTS module_ip_range_entry; +DROP TABLE IF EXISTS module_as_entry; +DROP TABLE IF EXISTS module_domain_entry; +DROP TABLE IF EXISTS module_cdn_source; +DROP TABLE IF EXISTS module; +DROP TABLE IF EXISTS bgp_community; +DROP TABLE IF EXISTS doh_profile; +DROP TABLE IF EXISTS tenant; diff --git a/migrations/sqlite/000001_init.up.sql b/migrations/sqlite/000001_init.up.sql new file mode 100644 index 0000000..aab9c4b --- /dev/null +++ b/migrations/sqlite/000001_init.up.sql @@ -0,0 +1,214 @@ +-- EvoBGP initial schema (SQLite, optional microVPS_sqlite / single-container). +-- UUIDs: supply from application (ULID/UUIDv7). JSON columns stored as TEXT. + +PRAGMA foreign_keys = ON; + +CREATE TABLE tenant ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + 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')), + CHECK (length(trim(slug)) > 0) +); + +CREATE TABLE doh_profile ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + timeout_ms INTEGER, + secret_ref TEXT, + 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')), + CHECK (length(trim(url)) > 0) +); + +CREATE INDEX idx_doh_profile_tenant ON doh_profile (tenant_id); + +CREATE TABLE bgp_community ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + value_json TEXT NOT NULL DEFAULT '{}', + 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')), + CHECK (length(trim(kind)) > 0) +); + +CREATE INDEX idx_bgp_community_tenant ON bgp_community (tenant_id); + +CREATE TABLE module ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + type TEXT NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 0, + doh_profile_id TEXT REFERENCES doh_profile (id) ON DELETE SET NULL, + refresh_interval_sec INTEGER, + cron_expr TEXT, + default_community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL, + deleted_at TEXT, + 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')), + CHECK (type IN ('AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS', 'IP_RANGES')), + CHECK (length(trim(name)) > 0) +); + +CREATE INDEX idx_module_tenant ON module (tenant_id); +CREATE INDEX idx_module_tenant_type ON module (tenant_id, type); +CREATE INDEX idx_module_tenant_enabled ON module (tenant_id, enabled) WHERE deleted_at IS NULL; + +CREATE TABLE module_cdn_source ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + source_kind TEXT NOT NULL, + url TEXT NOT NULL, + etag TEXT, + refresh_interval_sec INTEGER, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET 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')), + CHECK (length(trim(url)) > 0) +); + +CREATE INDEX idx_module_cdn_source_module ON module_cdn_source (module_id); + +CREATE TABLE module_domain_entry ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + fqdn TEXT NOT NULL, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL, + resolve_meta TEXT NOT NULL DEFAULT '{}', + 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')), + CHECK (length(trim(fqdn)) > 0) +); + +CREATE INDEX idx_module_domain_entry_module ON module_domain_entry (module_id); + +CREATE TABLE module_as_entry ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + asn INTEGER, + prefix TEXT, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET 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')), + CHECK (asn IS NOT NULL OR prefix IS NOT NULL) +); + +CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id); + +CREATE TABLE module_ip_range_entry ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + prefix TEXT NOT NULL, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET 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')) +); + +CREATE INDEX idx_module_ip_range_entry_module ON module_ip_range_entry (module_id); + +CREATE TABLE config_revision ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + module_id TEXT REFERENCES module (id) ON DELETE SET NULL, + content_hash TEXT NOT NULL, + parent_revision_id TEXT REFERENCES config_revision (id) ON DELETE SET NULL, + artifact_ref TEXT, + meta_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (length(trim(content_hash)) > 0) +); + +CREATE INDEX idx_config_revision_tenant ON config_revision (tenant_id, created_at DESC); +CREATE INDEX idx_config_revision_module ON config_revision (module_id) WHERE module_id IS NOT NULL; + +CREATE TABLE bgp_speaker ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + role TEXT NOT NULL, + endpoint TEXT, + last_applied_revision_id TEXT REFERENCES config_revision (id) ON DELETE SET NULL, + meta_json TEXT NOT NULL DEFAULT '{}', + 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')), + CHECK (role IN ('master', 'replica')) +); + +CREATE INDEX idx_bgp_speaker_tenant ON bgp_speaker (tenant_id); + +CREATE TABLE bgp_peer ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + bgp_speaker_id TEXT REFERENCES bgp_speaker (id) ON DELETE SET NULL, + neighbor TEXT NOT NULL, + remote_asn INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + policies_json TEXT NOT NULL DEFAULT '{}', + meta_json TEXT NOT NULL DEFAULT '{}', + 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')) +); + +CREATE INDEX idx_bgp_peer_tenant ON bgp_peer (tenant_id); +CREATE INDEX idx_bgp_peer_speaker ON bgp_peer (bgp_speaker_id); + +CREATE TABLE revision_materialized_prefix ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id TEXT NOT NULL REFERENCES config_revision (id) ON DELETE CASCADE, + prefix TEXT NOT NULL, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT '', + meta_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX idx_rev_mat_prefix_revision ON revision_materialized_prefix (revision_id); +CREATE INDEX idx_rev_mat_prefix_value ON revision_materialized_prefix (prefix); + +CREATE TABLE job_audit ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + kind TEXT NOT NULL, + status TEXT NOT NULL, + idempotency_key TEXT, + module_id TEXT REFERENCES module (id) ON DELETE SET NULL, + error_message TEXT, + progress_pct INTEGER, + meta_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + started_at TEXT, + finished_at TEXT, + CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + CHECK (length(trim(kind)) > 0) +); + +CREATE INDEX idx_job_audit_tenant_created ON job_audit (tenant_id, created_at DESC); +CREATE INDEX idx_job_audit_tenant_status ON job_audit (tenant_id, status); +CREATE UNIQUE INDEX idx_job_audit_idempotency ON job_audit (tenant_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE TABLE global_settings ( + tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE, + key TEXT NOT NULL, + value_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (tenant_id, key), + CHECK (length(trim(key)) > 0) +); + +CREATE TABLE module_cdn_fetch_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + source_id TEXT REFERENCES module_cdn_source (id) ON DELETE SET NULL, + http_status INTEGER, + bytes INTEGER, + error TEXT, + fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX idx_module_cdn_fetch_log_module ON module_cdn_fetch_log (module_id, fetched_at DESC); diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..6255599 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,19 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": [ + "prettier-plugin-svelte", + "prettier-plugin-tailwindcss" + ], + "tailwindStylesheet": "./src/routes/layout.css", + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 0000000..cdab7df --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "svelte.svelte-vscode", + "bradlc.vscode-tailwindcss", + "esbenp.prettier-vscode" + ] +} diff --git a/web/.vscode/settings.json b/web/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/web/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..f77bfa2 --- /dev/null +++ b/web/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.14.0 create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:static" prettier --no-download-check --install npm web +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..cdc83c7 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2402 @@ +{ + "name": "web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.1", + "dependencies": { + "@lucide/svelte": "^1.7.0", + "clsx": "^2.1.1", + "svelte-sonner": "^1.1.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.18", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.7.0.tgz", + "integrity": "sha512-YytBKOUBGox7yWcykZnYxOkn5WpR5G1qYXLYXV/j1B79SOTTEKzB+s5yF5Rq9l9OkweDStNH2b4yTqfvhEhV8g==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.56.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.56.1.tgz", + "integrity": "sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", + "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-sonner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz", + "integrity": "sha512-3lYM6ZIqWe+p9vwwWHGWP/ZdvHiUtzURsud2quIxivrX4rvpXh6i+geBGn0m3JS6KwW6W8VgbOl3xQMcDuh6gg==", + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..346fdc0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.18", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@lucide/svelte": "^1.7.0", + "clsx": "^2.1.1", + "svelte-sonner": "^1.1.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tw-animate-css": "^1.4.0" + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/AppShell.svelte b/web/src/lib/AppShell.svelte new file mode 100644 index 0000000..49f0c39 --- /dev/null +++ b/web/src/lib/AppShell.svelte @@ -0,0 +1,60 @@ + + +
+ +
+ {@render children?.()} +
+
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 0000000..f53f4b6 --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,47 @@ +import { browser } from '$app/environment'; + +export const TOKEN_STORAGE_KEY = 'evobgp_api_token'; + +export type Problem = { + title?: string; + status?: number; + detail?: string; +}; + +function mergeHeaders(init?: RequestInit): Headers { + const h = new Headers(init?.headers); + if (!h.has('Accept')) { + h.set('Accept', 'application/json'); + } + if (browser) { + const t = localStorage.getItem(TOKEN_STORAGE_KEY); + if (t && !h.has('Authorization')) { + h.set('Authorization', `Bearer ${t}`); + } + } + return h; +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + if (!browser) { + throw new Error('API is only available in the browser'); + } + return fetch(path, { ...init, headers: mergeHeaders(init) }); +} + +export async function apiJSON(path: string, init?: RequestInit): Promise { + const res = await apiFetch(path, init); + const text = await res.text(); + if (!res.ok) { + let detail = text; + try { + const j = JSON.parse(text) as Problem; + if (j?.detail) detail = j.detail; + } catch { + /* plain text */ + } + throw new Error(detail || `HTTP ${res.status}`); + } + if (!text) return undefined as T; + return JSON.parse(text) as T; +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts new file mode 100644 index 0000000..555f829 --- /dev/null +++ b/web/src/lib/api/types.ts @@ -0,0 +1,77 @@ +export type ModuleRow = { + id: string; + type: string; + name: string; + enabled: boolean; + priority: number; + refresh_interval_sec: number; + cron_expr: string; + default_community_id: string | null; + doh_profile_id: string | null; +}; + +export type ModulesResponse = { + items: ModuleRow[]; + next_cursor: string | null; + has_more: boolean; +}; + +export type RevisionRow = { + id: string; + content_hash: string; + created_at: string; + parent_revision_id: string | null; + materialized_prefix_count: number; + module_id: string | null; +}; + +export type RevisionsResponse = { + items: RevisionRow[]; + next_cursor: string | null; + has_more: boolean; +}; + +export type PeerRow = { + id: string; + name: string; + neighbor: string; + session_state: string; + bgp_speaker_id: string | null; +}; + +export type PeersResponse = { + items: PeerRow[]; + next_cursor: string | null; + has_more: boolean; +}; + +export type SpeakerRow = { + id: string; + role: string; + endpoint: string; + last_applied_revision_id: string | null; +}; + +export type SpeakersResponse = { + items: SpeakerRow[]; + next_cursor: string | null; + has_more: boolean; +}; + +export type JobRow = { + job_id: string; + kind: string; + status: string; + idempotency_key?: string | null; + created_at?: string; + started_at?: string | null; + finished_at?: string | null; + error?: string | null; + meta?: Record; +}; + +export type JobsResponse = { + items: JobRow[]; + next_cursor: string | null; + has_more: boolean; +}; diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/web/src/lib/components/ui/badge/badge.svelte b/web/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..cb378f7 --- /dev/null +++ b/web/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,38 @@ + + + + +{@render children?.()} diff --git a/web/src/lib/components/ui/badge/index.ts b/web/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..5033951 --- /dev/null +++ b/web/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +import Root, { badgeVariants, type BadgeVariant } from './badge.svelte'; +export { Root, Root as Badge, badgeVariants, type BadgeVariant }; diff --git a/web/src/lib/components/ui/button/button.svelte b/web/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..9586b4d --- /dev/null +++ b/web/src/lib/components/ui/button/button.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/lib/components/ui/button/index.ts b/web/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..bbdc224 --- /dev/null +++ b/web/src/lib/components/ui/button/index.ts @@ -0,0 +1,8 @@ +import Root, { buttonVariants, type ButtonSize, type ButtonVariant } from './button.svelte'; +export { + Root, + Root as Button, + buttonVariants, + type ButtonSize, + type ButtonVariant +}; diff --git a/web/src/lib/components/ui/card/card-content.svelte b/web/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..2dbed82 --- /dev/null +++ b/web/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,12 @@ + + +
{@render children?.()}
diff --git a/web/src/lib/components/ui/card/card-description.svelte b/web/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..4527cde --- /dev/null +++ b/web/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,12 @@ + + +

{@render children?.()}

diff --git a/web/src/lib/components/ui/card/card-header.svelte b/web/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..9094f13 --- /dev/null +++ b/web/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,12 @@ + + +
{@render children?.()}
diff --git a/web/src/lib/components/ui/card/card-title.svelte b/web/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..0deb47b --- /dev/null +++ b/web/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,14 @@ + + +

+ {@render children?.()} +

diff --git a/web/src/lib/components/ui/card/card.svelte b/web/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..0429da8 --- /dev/null +++ b/web/src/lib/components/ui/card/card.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children?.()} +
diff --git a/web/src/lib/components/ui/card/index.ts b/web/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..318b5bb --- /dev/null +++ b/web/src/lib/components/ui/card/index.ts @@ -0,0 +1,14 @@ +import Root from './card.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; +import Description from './card-description.svelte'; +import Content from './card-content.svelte'; + +export { + Root, + Root as Card, + Header as CardHeader, + Title as CardTitle, + Description as CardDescription, + Content as CardContent +}; diff --git a/web/src/lib/components/ui/input/index.ts b/web/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f55e99c --- /dev/null +++ b/web/src/lib/components/ui/input/index.ts @@ -0,0 +1,2 @@ +import Root from './input.svelte'; +export { Root, Root as Input }; diff --git a/web/src/lib/components/ui/input/input.svelte b/web/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..ca430ed --- /dev/null +++ b/web/src/lib/components/ui/input/input.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/lib/components/ui/label/index.ts b/web/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..2543553 --- /dev/null +++ b/web/src/lib/components/ui/label/index.ts @@ -0,0 +1,2 @@ +import Root from './label.svelte'; +export { Root, Root as Label }; diff --git a/web/src/lib/components/ui/label/label.svelte b/web/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..407823c --- /dev/null +++ b/web/src/lib/components/ui/label/label.svelte @@ -0,0 +1,15 @@ + + + diff --git a/web/src/lib/components/ui/scroll-area/index.ts b/web/src/lib/components/ui/scroll-area/index.ts new file mode 100644 index 0000000..bec8204 --- /dev/null +++ b/web/src/lib/components/ui/scroll-area/index.ts @@ -0,0 +1,2 @@ +import Root from './scroll-area.svelte'; +export { Root, Root as ScrollArea }; diff --git a/web/src/lib/components/ui/scroll-area/scroll-area.svelte b/web/src/lib/components/ui/scroll-area/scroll-area.svelte new file mode 100644 index 0000000..20825c1 --- /dev/null +++ b/web/src/lib/components/ui/scroll-area/scroll-area.svelte @@ -0,0 +1,12 @@ + + +
{@render children?.()}
diff --git a/web/src/lib/components/ui/separator/index.ts b/web/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..18cdeac --- /dev/null +++ b/web/src/lib/components/ui/separator/index.ts @@ -0,0 +1,2 @@ +import Root from './separator.svelte'; +export { Root, Root as Separator }; diff --git a/web/src/lib/components/ui/separator/separator.svelte b/web/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..e42befd --- /dev/null +++ b/web/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/web/src/lib/components/ui/table/index.ts b/web/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..d80b74d --- /dev/null +++ b/web/src/lib/components/ui/table/index.ts @@ -0,0 +1,16 @@ +import Root from './table.svelte'; +import Header from './table-header.svelte'; +import Body from './table-body.svelte'; +import Row from './table-row.svelte'; +import Head from './table-head.svelte'; +import Cell from './table-cell.svelte'; + +export { + Root, + Root as Table, + Header as TableHeader, + Body as TableBody, + Row as TableRow, + Head as TableHead, + Cell as TableCell +}; diff --git a/web/src/lib/components/ui/table/table-body.svelte b/web/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..6d8168d --- /dev/null +++ b/web/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,12 @@ + + +{@render children?.()} diff --git a/web/src/lib/components/ui/table/table-cell.svelte b/web/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..c6283bd --- /dev/null +++ b/web/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + +[role=checkbox]]:translate-y-[2px]', className)} + {colspan} + {rowspan} + {...rest}>{@render children?.()} diff --git a/web/src/lib/components/ui/table/table-head.svelte b/web/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..d9eaec8 --- /dev/null +++ b/web/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,18 @@ + + +[role=checkbox]]:translate-y-[2px]', + className + )} + {...rest}>{@render children?.()} diff --git a/web/src/lib/components/ui/table/table-header.svelte b/web/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..0814de1 --- /dev/null +++ b/web/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,12 @@ + + +{@render children?.()} diff --git a/web/src/lib/components/ui/table/table-row.svelte b/web/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..6dc7c3d --- /dev/null +++ b/web/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,15 @@ + + +{@render children?.()} diff --git a/web/src/lib/components/ui/table/table.svelte b/web/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..0d629ca --- /dev/null +++ b/web/src/lib/components/ui/table/table.svelte @@ -0,0 +1,14 @@ + + +
+ {@render children?.()}
+
diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..97525cc --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..2c1e9bd --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,15 @@ + + + + + EvoBGP + + +{@render children()} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 0000000..89da957 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = true; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..bc28a43 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,97 @@ + + +
+
+

Обзор

+

+ Краткая сводка по API. Укажите токен в разделе «Настройки», если запросы к защищённым путям + возвращают 401. +

+
+ +
+ + + Health + GET /v1/health + + + {#if health === 'ok'} + ok + {:else if health === 'err'} + недоступно + {:else} + проверка… + {/if} + + + + + + Модули + GET /v1/modules + + +

{modulesN}

+
+
+ + + + Ревизии + GET /v1/revisions + + +

{revN}

+
+
+ + + + Пиры + GET /v1/peers + + +

{peersN}

+
+
+
+
diff --git a/web/src/routes/layout.css b/web/src/routes/layout.css new file mode 100644 index 0000000..40e9739 --- /dev/null +++ b/web/src/routes/layout.css @@ -0,0 +1,120 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/web/src/routes/modules/+page.svelte b/web/src/routes/modules/+page.svelte new file mode 100644 index 0000000..0f5cd5a --- /dev/null +++ b/web/src/routes/modules/+page.svelte @@ -0,0 +1,84 @@ + + +
+
+

Модули префиксов

+

Список экземпляров модулей tenant (из API).

+
+ + + + Модули + GET /v1/modules + + + {#if err} +

{err}

+ {:else} + + + + + Имя + Тип + Приоритет + Статус + + + + {#each rows as m} + + {m.name} + + {m.type} + + {m.priority} + + {#if m.enabled} + вкл + {:else} + выкл + {/if} + + + {:else} + + Нет данных + + {/each} + +
+
+ {/if} +
+
+
diff --git a/web/src/routes/monitoring/+page.svelte b/web/src/routes/monitoring/+page.svelte new file mode 100644 index 0000000..75d6e72 --- /dev/null +++ b/web/src/routes/monitoring/+page.svelte @@ -0,0 +1,148 @@ + + +
+
+

Мониторинг

+

+ Публичные системные эндпоинты и срез Prometheus-метрик (первые строки). Полный scrape обычно делает + Prometheus. +

+
+ +
+ + + Health + GET /v1/health + + + {#if healthOk === true} + OK + {:else if healthOk === false} + ошибка + {:else} + + {/if} +
{healthBody || '—'}
+
+
+ + + + Ready + GET /v1/ready + + + {#if ready} +
{JSON.stringify(
+							ready,
+							null,
+							2
+						)}
+ {:else} +

Недоступно

+ {/if} +
+
+ + + + Версия + GET /v1/version + + + {#if version} +
+
+
api_version
+
{version.api_version ?? '—'}
+
+
+
git_sha
+
{version.git_sha ?? '—'}
+
+
+ {:else} +

Недоступно

+ {/if} +
+
+ + + + Метрики + GET /metrics (Prometheus) + + + + {#if metricsErr} +

{metricsErr}

+ {/if} + +
{metricsSnippet ||
+							'Нажмите «Загрузить срез»'}
+
+
+
+
+
diff --git a/web/src/routes/peers/+page.svelte b/web/src/routes/peers/+page.svelte new file mode 100644 index 0000000..c35cae0 --- /dev/null +++ b/web/src/routes/peers/+page.svelte @@ -0,0 +1,118 @@ + + +
+
+

Пиры и спикеры

+

BGP-пиры и экземпляры BIRD.

+
+ + {#if err} +

{err}

+ {/if} + + + + Спикеры + GET /v1/speakers + + + + + + + Роль + Endpoint + Последняя ревизия + + + + {#each speakers as s} + + {s.role} + {s.endpoint} + {s.last_applied_revision_id ?? '—'} + + {:else} + + Нет данных + + {/each} + +
+
+
+
+ + + + Пиры + GET /v1/peers + + + + + + + Имя + Neighbor + Сессия + + + + {#each peers as p} + + {p.name} + {p.neighbor} + + {p.session_state} + + + {:else} + + Нет данных + + {/each} + +
+
+
+
+
diff --git a/web/src/routes/revisions/+page.svelte b/web/src/routes/revisions/+page.svelte new file mode 100644 index 0000000..8ca56ff --- /dev/null +++ b/web/src/routes/revisions/+page.svelte @@ -0,0 +1,80 @@ + + +
+
+

Ревизии

+

История конфигурации BIRD.

+
+ + + + Ревизии + GET /v1/revisions + + + {#if err} +

{err}

+ {:else} + + + + + ID + Хэш + Префиксы + Создана + + + + {#each rows as r} + + {r.id} + {r.content_hash} + {r.materialized_prefix_count ?? '—'} + {r.created_at} + + {:else} + + Нет данных + + {/each} + +
+
+ {/if} +
+
+
diff --git a/web/src/routes/schedule/+page.svelte b/web/src/routes/schedule/+page.svelte new file mode 100644 index 0000000..45debd5 --- /dev/null +++ b/web/src/routes/schedule/+page.svelte @@ -0,0 +1,161 @@ + + +
+
+

Расписание и задачи

+

+ Интервалы обновления модулей (поля из API) и ручной refresh → ingest. Ниже — последние задачи из + job_audit. +

+
+ + + + Модули и refresh + + POST /v1/modules/{id}/refresh (роль editor+). CDN/домены/AS — очередь; IP_RANGES — 204. + + + + + + + + Модуль + Тип + Интервал + Cron + Действие + + + + {#each modules as m} + + {m.name} + + {m.type} + + {intervalLabel(m.refresh_interval_sec)} + {m.cron_expr || '—'} + + + + + {:else} + + Нет модулей + + {/each} + +
+
+
+
+ + + + Последние задачи + GET /v1/jobs + + + + + + + Вид + Статус + Создана + + + + {#each jobs as j} + + {j.kind} + + {j.status} + + {j.created_at ?? '—'} + + {:else} + + Нет задач + + {/each} + +
+
+
+
+
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte new file mode 100644 index 0000000..bc7159f --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,51 @@ + + +
+
+

Настройки

+

+ Bearer-токен для заголовка Authorization. Для + локального демо с + EVOBGP_DEV_INSECURE=1 + можно использовать токен dev. +

+
+ + + + API-ключ + Хранится только в localStorage + + +
+ + +
+ +
+
+
diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..6469714 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,24 @@ +import adapter from '@sveltejs/adapter-static'; +import { relative, sep } from 'node:path'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // defaults to rune mode for the project, except for `node_modules`. Can be removed in svelte 6. + runes: ({ filename }) => { + const relativePath = relative(import.meta.dirname, filename); + const pathSegments = relativePath.toLowerCase().split(sep); + const isExternalLibrary = pathSegments.includes('node_modules'); + + return isExternalLibrary ? undefined : true; + } + }, + kit: { + adapter: adapter({ + fallback: 'index.html', + strict: false + }) + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..37d23cb --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true }, + '/metrics': { target: 'http://127.0.0.1:8080', changeOrigin: true } + } + } +});