> = {
+ peer_discovery_enabled:
+ 'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
+ peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
+ peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
+ peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
}
function TenantSettingsComponent() {
@@ -143,17 +160,32 @@ function TenantSettingsComponent() {
- setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
- placeholder={BIRD_LABELS[key]}
- />
+ {key === 'peer_discovery_enabled' ||
+ key === 'peer_discovery_require_external' ? (
+
+ setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
+ }
+ placeholder="Выкл"
+ />
+ ) : (
+
+ setBirdForm((s) => ({ ...s, [key]: e.target.value }))
+ }
+ placeholder={BIRD_LABELS[key]}
+ />
+ )}
))}
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index 092b96a..17929f4 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -221,10 +221,36 @@ export type BgpPeerCreate = {
}
export type BgpPeerPatch = Partial
+export type PeerDiscoveryRow = {
+ id: string
+ speaker_id?: string | null
+ neighbor_id?: string
+ neighbor: string
+ remote_asn?: number
+ protocol_name?: string
+ session_state?: string
+ status: 'pending' | 'approved' | 'rejected' | string
+ first_seen_at?: string
+ last_seen_at?: string
+ approved_peer_id?: string | null
+}
+
+export type PeerDiscoveriesResponse = {
+ items: PeerDiscoveryRow[]
+}
+
+export type PeerDiscoveryApprove = {
+ name?: string
+ bgp_speaker_id?: string | null
+ enabled?: boolean
+}
+
// ---- Speakers ----
export type BgpSessionLive = {
name: string
neighbor?: string
+ neighbor_as?: number
+ neighbor_id?: string
state: string
}
diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo
index 53bdbf2..6d274df 100644
--- a/apps/web/tsconfig.tsbuildinfo
+++ b/apps/web/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/docs/manual.md b/docs/manual.md
index 14e39db..d11862e 100644
--- a/docs/manual.md
+++ b/docs/manual.md
@@ -105,6 +105,13 @@ EvoBGP управляет генерацией и применением BGP-к
### Настройки (`/v1/settings`)
- KV c ключами BIRD и дополнительными feature flags.
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
+- **Автообнаружение пиров (peer discovery):**
+ - `peer_discovery_enabled` (bool) — генерирует в `evobgp_peers.conf` dynamic BGP listener (`neighbor range` + `import none` / `export none`).
+ - `peer_discovery_ranges_v4` / `peer_discovery_ranges_v6` — CIDR через пробел/запятую (обязательны при enabled).
+ - `peer_discovery_require_external` (bool, default true) — `neighbor range … external`.
+ - Live-сессии `evobgp_dyn_*` попадают в `GET /v1/peers/discovered`; оператор **одобряет** (`POST …/approve` → обычный `bgp_peer` + `peer_reconcile`) или **отклоняет**.
+ - Идентичность pending: **Neighbor ID** (BGP Identifier), иначе `neighbor+ASN`.
+ - UI: Сеть → вкладка «На одобрение»; настройки — Параметры → BIRD.
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
### Web UI: настройки tenant и интерфейса
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 486e863..2c1d1e5 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1030,6 +1030,12 @@ components:
type: string
neighbor:
type: string
+ neighbor_as:
+ type: integer
+ description: Remote ASN from birdc (`Neighbor AS:`).
+ neighbor_id:
+ type: string
+ description: BGP Identifier / Neighbor ID from birdc (`Neighbor ID:`).
state:
type: string
additionalProperties: true
@@ -1715,6 +1721,51 @@ components:
type: string
additionalProperties: true
+ BgpPeerDiscovery:
+ type: object
+ required: [id, neighbor, status]
+ properties:
+ id:
+ $ref: "#/components/schemas/ResourceId"
+ speaker_id:
+ type: ["string", "null"]
+ neighbor_id:
+ type: string
+ description: BGP Identifier (Neighbor ID / router ID) from birdc.
+ neighbor:
+ type: string
+ description: Neighbor IP address.
+ remote_asn:
+ type: integer
+ protocol_name:
+ type: string
+ description: BIRD protocol name (evobgp_dyn_*).
+ session_state:
+ type: string
+ status:
+ type: string
+ enum: [pending, approved, rejected]
+ first_seen_at:
+ type: string
+ format: date-time
+ last_seen_at:
+ type: string
+ format: date-time
+ approved_peer_id:
+ type: ["string", "null"]
+ additionalProperties: true
+
+ BgpPeerDiscoveryApprove:
+ type: object
+ properties:
+ name:
+ type: string
+ bgp_speaker_id:
+ type: ["string", "null"]
+ enabled:
+ type: boolean
+ additionalProperties: false
+
BgpPeerCreate:
type: object
required: [neighbor, remote_asn]
@@ -3100,6 +3151,90 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
+ /v1/peers/discovered:
+ get:
+ tags: [Peers]
+ summary: Список обнаруженных (неодобренных) пиров
+ description: >
+ Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`.
+ По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
+ operationId: listDiscoveredPeers
+ parameters:
+ - $ref: "#/components/parameters/TenantId"
+ - name: status
+ in: query
+ schema:
+ type: string
+ enum: [pending, approved, rejected, all]
+ default: pending
+ responses:
+ "200":
+ description: Успешно.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [items]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/BgpPeerDiscovery"
+ default:
+ $ref: "#/components/responses/DefaultProblem"
+
+ /v1/peers/discovered/{id}/approve:
+ post:
+ tags: [Peers]
+ summary: Одобрить обнаруженного пира
+ description: >
+ Создаёт обычный `bgp_peer` из discovery-записи и запускает `peer_reconcile`.
+ operationId: approveDiscoveredPeer
+ parameters:
+ - $ref: "#/components/parameters/TenantId"
+ - $ref: "#/components/parameters/PeerId"
+ - $ref: "#/components/parameters/IdempotencyKey"
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BgpPeerDiscoveryApprove"
+ responses:
+ "200":
+ description: Пир создан, discovery → approved.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ peer:
+ $ref: "#/components/schemas/BgpPeer"
+ discovery:
+ $ref: "#/components/schemas/BgpPeerDiscovery"
+ default:
+ $ref: "#/components/responses/DefaultProblem"
+
+ /v1/peers/discovered/{id}/reject:
+ post:
+ tags: [Peers]
+ summary: Отклонить обнаруженного пира
+ description: Помечает discovery как rejected; повторно не всплывает при sync.
+ operationId: rejectDiscoveredPeer
+ parameters:
+ - $ref: "#/components/parameters/TenantId"
+ - $ref: "#/components/parameters/PeerId"
+ - $ref: "#/components/parameters/IdempotencyKey"
+ responses:
+ "200":
+ description: Discovery → rejected.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BgpPeerDiscovery"
+ default:
+ $ref: "#/components/responses/DefaultProblem"
+
/v1/peers/{id}:
parameters:
- $ref: "#/components/parameters/TenantId"
diff --git a/internal/birdfmt/bgp_discovery.go b/internal/birdfmt/bgp_discovery.go
new file mode 100644
index 0000000..0dbac51
--- /dev/null
+++ b/internal/birdfmt/bgp_discovery.go
@@ -0,0 +1,121 @@
+package birdfmt
+
+import (
+ "fmt"
+ "net/netip"
+ "strings"
+)
+
+// Parent protocol names for discovery listeners (not spawned session names).
+const (
+ DiscoveryProtocolV4 = "evobgp_discover_v4"
+ DiscoveryProtocolV6 = "evobgp_discover_v6"
+)
+
+// DynamicBGPDiscoveryOptions configures quarantine dynamic BGP listeners.
+type DynamicBGPDiscoveryOptions struct {
+ RangesV4 []string // CIDR prefixes
+ RangesV6 []string
+ RequireExternal bool // neighbor range … external (default true)
+ DynamicNameDigits int // default 4
+}
+
+// RenderDynamicBGPDiscovery renders passive dynamic BGP quarantine listeners.
+// Spawned sessions use DynamicPeerProtocolPrefix ("evobgp_dyn_").
+// Channel policy is always import none / export none (no announcements until approve).
+func RenderDynamicBGPDiscovery(opts DynamicBGPDiscoveryOptions) (string, error) {
+ digits := opts.DynamicNameDigits
+ if digits <= 0 {
+ digits = 4
+ }
+ var parts []string
+ if len(opts.RangesV4) > 0 {
+ s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV4, BGPTemplateNameV4, "ipv4", opts.RangesV4, opts.RequireExternal, digits)
+ if err != nil {
+ return "", err
+ }
+ parts = append(parts, s)
+ }
+ if len(opts.RangesV6) > 0 {
+ s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV6, BGPTemplateNameV6, "ipv6", opts.RangesV6, opts.RequireExternal, digits)
+ if err != nil {
+ return "", err
+ }
+ parts = append(parts, s)
+ }
+ if len(parts) == 0 {
+ return "", nil
+ }
+ return strings.Join(parts, "\n"), nil
+}
+
+func renderDynamicDiscoveryAF(protoName, templateName, af string, ranges []string, external bool, digits int) (string, error) {
+ var cleaned []string
+ for _, r := range ranges {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ pfx, err := netip.ParsePrefix(r)
+ if err != nil {
+ return "", fmt.Errorf("birdfmt: invalid discovery range %q: %w", r, err)
+ }
+ if af == "ipv4" && !pfx.Addr().Is4() {
+ return "", fmt.Errorf("birdfmt: discovery range %q is not IPv4", r)
+ }
+ if af == "ipv6" && !pfx.Addr().Is6() {
+ return "", fmt.Errorf("birdfmt: discovery range %q is not IPv6", r)
+ }
+ cleaned = append(cleaned, pfx.Masked().String())
+ }
+ if len(cleaned) == 0 {
+ return "", nil
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "protocol bgp %s from %s {\n", protoName, templateName)
+ for _, cidr := range cleaned {
+ b.WriteString(" neighbor range ")
+ b.WriteString(cidr)
+ if external {
+ b.WriteString(" external")
+ }
+ b.WriteString(";\n")
+ }
+ fmt.Fprintf(&b, " dynamic name \"%s\";\n", DynamicPeerProtocolPrefix)
+ fmt.Fprintf(&b, " dynamic name digits %d;\n", digits)
+ b.WriteString(" multihop;\n")
+ b.WriteString(" passive;\n")
+ fmt.Fprintf(&b, " %s {\n", af)
+ b.WriteString(" import none;\n")
+ b.WriteString(" export none;\n")
+ b.WriteString(" };\n")
+ b.WriteString("}\n")
+ return b.String(), nil
+}
+
+// ParseDiscoveryRanges splits a settings string (comma / newline / space separated) into CIDRs.
+func ParseDiscoveryRanges(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ raw = strings.ReplaceAll(raw, ",", " ")
+ raw = strings.ReplaceAll(raw, "\n", " ")
+ raw = strings.ReplaceAll(raw, ";", " ")
+ fields := strings.Fields(raw)
+ out := make([]string, 0, len(fields))
+ seen := map[string]struct{}{}
+ for _, f := range fields {
+ f = strings.TrimSpace(f)
+ if f == "" {
+ continue
+ }
+ if _, ok := seen[f]; ok {
+ continue
+ }
+ seen[f] = struct{}{}
+ out = append(out, f)
+ }
+ return out
+}
diff --git a/internal/birdfmt/bgp_discovery_test.go b/internal/birdfmt/bgp_discovery_test.go
new file mode 100644
index 0000000..f21ea39
--- /dev/null
+++ b/internal/birdfmt/bgp_discovery_test.go
@@ -0,0 +1,47 @@
+package birdfmt
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderDynamicBGPDiscovery_v4(t *testing.T) {
+ out, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
+ RangesV4: []string{"198.51.100.0/24", "203.0.113.0/24"},
+ RequireExternal: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "protocol bgp evobgp_discover_v4 from bgp_template",
+ "neighbor range 198.51.100.0/24 external;",
+ "neighbor range 203.0.113.0/24 external;",
+ `dynamic name "evobgp_dyn_";`,
+ "dynamic name digits 4;",
+ "multihop;",
+ "passive;",
+ "import none;",
+ "export none;",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestRenderDynamicBGPDiscovery_invalid(t *testing.T) {
+ _, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
+ RangesV4: []string{"not-a-cidr"},
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestParseDiscoveryRanges(t *testing.T) {
+ got := ParseDiscoveryRanges("198.51.100.0/24, 203.0.113.0/24\n198.51.100.0/24")
+ if len(got) != 2 {
+ t.Fatalf("got %v", got)
+ }
+}
diff --git a/internal/birdfmt/bgp_sessions.go b/internal/birdfmt/bgp_sessions.go
index 8e46a91..f8c8897 100644
--- a/internal/birdfmt/bgp_sessions.go
+++ b/internal/birdfmt/bgp_sessions.go
@@ -1,17 +1,28 @@
package birdfmt
import (
+ "strconv"
"strings"
)
// BGPSession is one BGP protocol block from `birdc show protocols all`.
type BGPSession struct {
- Name string `json:"name"`
- Neighbor string `json:"neighbor,omitempty"`
- State string `json:"state"`
+ Name string `json:"name"`
+ Neighbor string `json:"neighbor,omitempty"`
+ NeighborAS int64 `json:"neighbor_as,omitempty"`
+ NeighborID string `json:"neighbor_id,omitempty"`
+ State string `json:"state"`
}
-// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
+// DynamicPeerProtocolPrefix is the BIRD protocol name prefix for discovery-spawned sessions.
+const DynamicPeerProtocolPrefix = "evobgp_dyn_"
+
+// IsDynamicDiscoverySession reports whether the protocol was spawned by the discovery listener.
+func IsDynamicDiscoverySession(name string) bool {
+ return strings.HasPrefix(strings.TrimSpace(name), DynamicPeerProtocolPrefix)
+}
+
+// ParseBGPSessions extracts BGP protocol name, state, neighbor, Neighbor AS, and Neighbor ID from birdc output.
func ParseBGPSessions(output string) []BGPSession {
var out []BGPSession
var cur *BGPSession
@@ -43,12 +54,41 @@ func ParseBGPSessions(output string) []BGPSession {
if cur == nil {
continue
}
- for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
- if idx := strings.Index(trim, prefix); idx >= 0 {
- cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
- break
- }
- }
+ parseBGPSessionDetailLine(cur, trim)
}
return out
}
+
+func parseBGPSessionDetailLine(cur *BGPSession, trim string) {
+ for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
+ if idx := strings.Index(trim, prefix); idx >= 0 {
+ // Avoid matching "Neighbor AS:" / "Neighbor ID:" via bare "Neighbor:"
+ if prefix == "Neighbor:" {
+ rest := strings.TrimSpace(trim[idx+len(prefix):])
+ if strings.HasPrefix(strings.ToLower(rest), "as:") || strings.HasPrefix(strings.ToLower(rest), "id:") {
+ continue
+ }
+ if strings.Contains(strings.ToLower(trim), "neighbor as:") || strings.Contains(strings.ToLower(trim), "neighbor id:") {
+ continue
+ }
+ }
+ cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
+ return
+ }
+ }
+ for _, prefix := range []string{"Neighbor AS:", "Neighbor As:", "Neighbor as:"} {
+ if idx := strings.Index(trim, prefix); idx >= 0 {
+ raw := strings.TrimSpace(trim[idx+len(prefix):])
+ if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
+ cur.NeighborAS = n
+ }
+ return
+ }
+ }
+ for _, prefix := range []string{"Neighbor ID:", "Neighbor Id:", "Neighbor id:", "BGP Identifier:", "BGP identifier:"} {
+ if idx := strings.Index(trim, prefix); idx >= 0 {
+ cur.NeighborID = strings.TrimSpace(trim[idx+len(prefix):])
+ return
+ }
+ }
+}
diff --git a/internal/birdfmt/bgp_sessions_test.go b/internal/birdfmt/bgp_sessions_test.go
index d954c9c..5a57ab9 100644
--- a/internal/birdfmt/bgp_sessions_test.go
+++ b/internal/birdfmt/bgp_sessions_test.go
@@ -10,17 +10,40 @@ device1 Device --- up 10:00:00
evobgp_p_abc123 BGP master4 up 10:00:05 Established
Neighbor address: 198.51.100.2
Neighbor AS: 65001
+ Neighbor ID: 192.0.2.50
evobgp_p_def456 BGP master4 up 10:00:06 Active
Neighbor address: 2001:db8::2
+evobgp_dyn_0001 BGP master4 up 10:00:07 Established
+ Neighbor address: 203.0.113.10
+ Neighbor AS: 65099
+ Neighbor ID: 203.0.113.10
`
sessions := ParseBGPSessions(sample)
- if len(sessions) != 2 {
- t.Fatalf("got %d sessions want 2", len(sessions))
+ if len(sessions) != 3 {
+ t.Fatalf("got %d sessions want 3", len(sessions))
}
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
t.Fatalf("session0: %+v", sessions[0])
}
+ if sessions[0].NeighborAS != 65001 || sessions[0].NeighborID != "192.0.2.50" {
+ t.Fatalf("session0 ids: as=%d id=%q", sessions[0].NeighborAS, sessions[0].NeighborID)
+ }
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
t.Fatalf("session1: %+v", sessions[1])
}
+ if !IsDynamicDiscoverySession(sessions[2].Name) {
+ t.Fatalf("session2 should be dynamic: %+v", sessions[2])
+ }
+ if sessions[2].NeighborAS != 65099 || sessions[2].NeighborID != "203.0.113.10" {
+ t.Fatalf("session2 ids: %+v", sessions[2])
+ }
+}
+
+func TestIsDynamicDiscoverySession(t *testing.T) {
+ if !IsDynamicDiscoverySession("evobgp_dyn_0001") {
+ t.Fatal("expected true")
+ }
+ if IsDynamicDiscoverySession("evobgp_p_abc") {
+ t.Fatal("expected false")
+ }
}
diff --git a/internal/birdfmt/scenarios_test.go b/internal/birdfmt/scenarios_test.go
index 923045e..2a1cfed 100644
--- a/internal/birdfmt/scenarios_test.go
+++ b/internal/birdfmt/scenarios_test.go
@@ -18,6 +18,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
"bgp_ipv4_peer",
"bgp_ipv6_peer",
"domains_resolved",
+ "dynamic_discovery",
"empty_static",
"filter_export",
"large_prefix_list",
diff --git a/internal/birdfmt/testdata/scenarios/dynamic_discovery/bird.conf b/internal/birdfmt/testdata/scenarios/dynamic_discovery/bird.conf
new file mode 100644
index 0000000..84a74c1
--- /dev/null
+++ b/internal/birdfmt/testdata/scenarios/dynamic_discovery/bird.conf
@@ -0,0 +1,53 @@
+# tags: dynamic, discovery, bgp
+# Dynamic BGP discovery quarantine listener (neighbor range + import/export none).
+
+router id 192.0.2.1;
+
+protocol device {
+}
+
+protocol direct {
+ ipv4;
+ ipv6;
+}
+
+filter evobgp_export_v4 {
+ if net ~ [ 203.0.113.0/24 ] then accept;
+ reject;
+}
+
+filter evobgp_export_v6 {
+ reject;
+}
+
+template bgp bgp_template {
+ local as 65001;
+ ipv4 {
+ import none;
+ export filter evobgp_export_v4;
+ };
+ hold time 90;
+ keepalive time 30;
+}
+
+template bgp bgp_template_v6 {
+ local as 65001;
+ ipv6 {
+ import none;
+ export filter evobgp_export_v6;
+ };
+ hold time 90;
+ keepalive time 30;
+}
+
+protocol bgp evobgp_discover_v4 from bgp_template {
+ neighbor range 198.51.100.0/24 external;
+ dynamic name "evobgp_dyn_";
+ dynamic name digits 4;
+ multihop;
+ passive;
+ ipv4 {
+ import none;
+ export none;
+ };
+}
diff --git a/internal/httpapi/peers_discovery.go b/internal/httpapi/peers_discovery.go
new file mode 100644
index 0000000..618c60a
--- /dev/null
+++ b/internal/httpapi/peers_discovery.go
@@ -0,0 +1,171 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "evobgp/internal/birdfmt"
+ "evobgp/internal/store"
+)
+
+func (s *Server) registerPeerDiscoveryRoutes(m *http.ServeMux) {
+ m.HandleFunc("GET /peers/discovered", s.handleListPeerDiscoveries)
+ m.HandleFunc("POST /peers/discovered/{id}/approve", s.handleApprovePeerDiscovery)
+ m.HandleFunc("POST /peers/discovered/{id}/reject", s.handleRejectPeerDiscovery)
+}
+
+func peerDiscoveryJSON(d *store.BGPPeerDiscovery) map[string]any {
+ if d == nil {
+ return nil
+ }
+ m := map[string]any{
+ "id": d.ID,
+ "neighbor_id": d.NeighborID,
+ "neighbor": d.Neighbor,
+ "remote_asn": d.RemoteASN,
+ "protocol_name": d.ProtocolName,
+ "session_state": d.SessionState,
+ "status": d.Status,
+ "first_seen_at": d.FirstSeenAt.UTC().Format(time.RFC3339Nano),
+ "last_seen_at": d.LastSeenAt.UTC().Format(time.RFC3339Nano),
+ }
+ if d.SpeakerID != "" {
+ m["speaker_id"] = d.SpeakerID
+ } else {
+ m["speaker_id"] = nil
+ }
+ if d.ApprovedPeerID != "" {
+ m["approved_peer_id"] = d.ApprovedPeerID
+ } else {
+ m["approved_peer_id"] = nil
+ }
+ return m
+}
+
+// syncPeerDiscoveriesFromLive upserts pending discoveries from dynamic BGP sessions.
+func (s *Server) syncPeerDiscoveriesFromLive(tenantID string, views []speakerBGPLive) {
+ if s.store == nil || tenantID == "" {
+ return
+ }
+ knownNeighbors := map[string]struct{}{}
+ for _, p := range s.store.ListPeers(tenantID) {
+ if p == nil {
+ continue
+ }
+ if n, ok := store.NormalizePeerNeighborString(p.Neighbor); ok {
+ knownNeighbors[n] = struct{}{}
+ }
+ }
+ now := time.Now().UTC()
+ for _, v := range views {
+ for _, sess := range v.Sessions {
+ if !birdfmt.IsDynamicDiscoverySession(sess.Name) {
+ continue
+ }
+ neighbor, ok := store.NormalizePeerNeighborString(sess.Neighbor)
+ if !ok {
+ continue
+ }
+ if _, known := knownNeighbors[neighbor]; known {
+ continue
+ }
+ _, _ = s.store.UpsertPeerDiscovery(tenantID, &store.PeerDiscoveryUpsert{
+ SpeakerID: v.SpeakerID,
+ NeighborID: strings.TrimSpace(sess.NeighborID),
+ Neighbor: neighbor,
+ RemoteASN: sess.NeighborAS,
+ ProtocolName: sess.Name,
+ SessionState: sess.State,
+ SeenAt: now,
+ })
+ }
+ }
+}
+
+func (s *Server) handleListPeerDiscoveries(w http.ResponseWriter, r *http.Request) {
+ a, ok := authFromContext(r.Context())
+ if !ok {
+ writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
+ return
+ }
+ if !s.requirePerm(w, a, "bgp:network:read") {
+ return
+ }
+ status := strings.TrimSpace(r.URL.Query().Get("status"))
+ if status == "" {
+ status = store.PeerDiscoveryPending
+ }
+ // Refresh live discovery when listing pending.
+ if status == store.PeerDiscoveryPending || status == "all" {
+ views := s.collectSpeakerBGPLive(r.Context(), a.TenantID, true)
+ s.syncPeerDiscoveriesFromLive(a.TenantID, views)
+ }
+ listStatus := status
+ if status == "all" {
+ listStatus = ""
+ }
+ items, err := s.store.ListPeerDiscoveries(a.TenantID, listStatus)
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ out := make([]map[string]any, 0, len(items))
+ for _, d := range items {
+ out = append(out, peerDiscoveryJSON(d))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"items": out})
+}
+
+func (s *Server) handleApprovePeerDiscovery(w http.ResponseWriter, r *http.Request) {
+ a, ok := authFromContext(r.Context())
+ if !ok || !s.requirePerm(w, a, "bgp:network:write") {
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ BGPSpeakerID *string `json:"bgp_speaker_id"`
+ Enabled *bool `json:"enabled"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ peer, disc, err := s.store.ApprovePeerDiscovery(a.TenantID, r.PathValue("id"), &store.PeerDiscoveryApproveInput{
+ Name: body.Name,
+ SpeakerID: body.BGPSpeakerID,
+ Enabled: body.Enabled,
+ })
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ s.enqueuePeerReconcile(a.TenantID, "peer_discovery_approve")
+ s.recordCRUDAudit(r, a, "bgp.peer.discovery.approve", "Approved discovered peer "+peer.Neighbor, peer.ID, map[string]any{
+ "peer_id": peer.ID,
+ "discovery_id": disc.ID,
+ "neighbor": peer.Neighbor,
+ "neighbor_id": disc.NeighborID,
+ "remote_asn": peer.RemoteASN,
+ })
+ writeJSON(w, http.StatusOK, map[string]any{
+ "peer": peerJSON(peer),
+ "discovery": peerDiscoveryJSON(disc),
+ })
+}
+
+func (s *Server) handleRejectPeerDiscovery(w http.ResponseWriter, r *http.Request) {
+ a, ok := authFromContext(r.Context())
+ if !ok || !s.requirePerm(w, a, "bgp:network:write") {
+ return
+ }
+ disc, err := s.store.RejectPeerDiscovery(a.TenantID, r.PathValue("id"))
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ s.recordCRUDAudit(r, a, "bgp.peer.discovery.reject", "Rejected discovered peer "+disc.Neighbor, disc.ID, map[string]any{
+ "discovery_id": disc.ID,
+ "neighbor": disc.Neighbor,
+ "neighbor_id": disc.NeighborID,
+ })
+ writeJSON(w, http.StatusOK, peerDiscoveryJSON(disc))
+}
diff --git a/internal/httpapi/peers_discovery_test.go b/internal/httpapi/peers_discovery_test.go
new file mode 100644
index 0000000..9c612c4
--- /dev/null
+++ b/internal/httpapi/peers_discovery_test.go
@@ -0,0 +1,113 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "evobgp/internal/store"
+)
+
+func TestPeerDiscoveryApproveReject(t *testing.T) {
+ srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer srv.Close()
+ tenant, _, _, _, speaker := srv.Store().DemoIDs()
+ mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|operator")
+ h := srv.Handler()
+
+ disc, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
+ SpeakerID: speaker,
+ NeighborID: "203.0.113.10",
+ Neighbor: "203.0.113.10",
+ RemoteASN: 65099,
+ ProtocolName: "evobgp_dyn_0001",
+ SessionState: "Established",
+ SeenAt: time.Now().UTC(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ listReq := httptest.NewRequest(http.MethodGet, "/v1/peers/discovered?status=pending", nil)
+ listReq.Header.Set("Authorization", "Bearer edkey")
+ listRec := httptest.NewRecorder()
+ h.ServeHTTP(listRec, listReq)
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
+ }
+
+ approveBody := `{"name":"client-a"}`
+ approveReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc.ID+"/approve", strings.NewReader(approveBody))
+ approveReq.Header.Set("Authorization", "Bearer edkey")
+ approveReq.Header.Set("Content-Type", "application/json")
+ approveRec := httptest.NewRecorder()
+ h.ServeHTTP(approveRec, approveReq)
+ if approveRec.Code != http.StatusOK {
+ t.Fatalf("approve status %d body %s", approveRec.Code, approveRec.Body.String())
+ }
+ var approveOut struct {
+ Peer struct {
+ ID string `json:"id"`
+ Neighbor string `json:"neighbor"`
+ Name string `json:"name"`
+ } `json:"peer"`
+ }
+ if err := json.Unmarshal(approveRec.Body.Bytes(), &approveOut); err != nil {
+ t.Fatal(err)
+ }
+ if approveOut.Peer.Neighbor != "203.0.113.10" || approveOut.Peer.Name != "client-a" {
+ t.Fatalf("unexpected peer: %+v", approveOut.Peer)
+ }
+
+ peers := srv.Store().ListPeers(tenant)
+ found := false
+ for _, p := range peers {
+ if p.ID == approveOut.Peer.ID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatal("approved peer not in ListPeers")
+ }
+
+ disc2, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
+ SpeakerID: speaker,
+ NeighborID: "198.51.100.99",
+ Neighbor: "198.51.100.99",
+ RemoteASN: 65100,
+ ProtocolName: "evobgp_dyn_0002",
+ SessionState: "Active",
+ SeenAt: time.Now().UTC(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rejReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc2.ID+"/reject", nil)
+ rejReq.Header.Set("Authorization", "Bearer edkey")
+ rejRec := httptest.NewRecorder()
+ h.ServeHTTP(rejRec, rejReq)
+ if rejRec.Code != http.StatusOK {
+ t.Fatalf("reject status %d body %s", rejRec.Code, rejRec.Body.String())
+ }
+ again, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
+ NeighborID: "198.51.100.99",
+ Neighbor: "198.51.100.99",
+ RemoteASN: 65100,
+ ProtocolName: "evobgp_dyn_0002",
+ SessionState: "Established",
+ SeenAt: time.Now().UTC(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Status != store.PeerDiscoveryRejected {
+ t.Fatalf("expected rejected, got %s", again.Status)
+ }
+}
diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go
index 23ec82e..3c33ad4 100644
--- a/internal/httpapi/routes.go
+++ b/internal/httpapi/routes.go
@@ -299,6 +299,9 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
+ if fresh {
+ s.syncPeerDiscoveriesFromLive(a.TenantID, liveViews)
+ }
items := make([]map[string]any, 0, len(page))
for _, p := range page {
row := peerJSON(p)
diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go
index 1ac4e5a..1b96c73 100644
--- a/internal/httpapi/routes_crud.go
+++ b/internal/httpapi/routes_crud.go
@@ -66,6 +66,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
+ s.registerPeerDiscoveryRoutes(m)
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
@@ -1259,10 +1260,27 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
+ if peerDiscoverySettingsChanged(body) {
+ s.enqueuePeerReconcile(a.TenantID, "peer_discovery_settings")
+ }
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+func peerDiscoverySettingsChanged(body map[string]any) bool {
+ for _, k := range []string{
+ "peer_discovery_enabled",
+ "peer_discovery_ranges_v4",
+ "peer_discovery_ranges_v6",
+ "peer_discovery_require_external",
+ } {
+ if _, ok := body[k]; ok {
+ return true
+ }
+ }
+ return false
+}
+
func settingsAuditKeys(body map[string]any) []string {
if len(body) == 0 {
return nil
diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go
index ba4d788..69957a4 100644
--- a/internal/pipeline/refresh.go
+++ b/internal/pipeline/refresh.go
@@ -940,6 +940,50 @@ func uint32FromSettingsMap(m map[string]any, key string) uint32 {
return 0
}
+func boolFromSettingsMap(m map[string]any, key string, defaultVal bool) bool {
+ v, ok := m[key]
+ if !ok || v == nil {
+ return defaultVal
+ }
+ switch x := v.(type) {
+ case bool:
+ return x
+ case float64:
+ return x != 0
+ case int:
+ return x != 0
+ case string:
+ s := strings.ToLower(strings.TrimSpace(x))
+ if s == "true" || s == "1" || s == "yes" {
+ return true
+ }
+ if s == "false" || s == "0" || s == "no" {
+ return false
+ }
+ }
+ return defaultVal
+}
+
+func renderPeerDiscoveryBirdFragment(st store.Backend, tenantID string) (string, error) {
+ settings, err := st.ListGlobalSettings(tenantID)
+ if err != nil || settings == nil {
+ return "", nil
+ }
+ if !boolFromSettingsMap(settings, "peer_discovery_enabled", false) {
+ return "", nil
+ }
+ rangesV4 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v4"))
+ rangesV6 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v6"))
+ if len(rangesV4) == 0 && len(rangesV6) == 0 {
+ return "", nil
+ }
+ return birdfmt.RenderDynamicBGPDiscovery(birdfmt.DynamicBGPDiscoveryOptions{
+ RangesV4: rangesV4,
+ RangesV6: rangesV6,
+ RequireExternal: boolFromSettingsMap(settings, "peer_discovery_require_external", true),
+ })
+}
+
func intFromSettingsMap(m map[string]any, key string) int {
v, ok := m[key]
if !ok || v == nil {
@@ -1004,6 +1048,11 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
peers := st.ListPeers(tenantID)
var parts []string
parts = append(parts, birdfmt.ManagedBanner("peers"))
+ if disc, err := renderPeerDiscoveryBirdFragment(st, tenantID); err != nil {
+ return "", err
+ } else if disc != "" {
+ parts = append(parts, disc)
+ }
for _, p := range peers {
if p == nil || !p.Enabled {
continue
diff --git a/internal/repository/postgres_peer_discovery.go b/internal/repository/postgres_peer_discovery.go
new file mode 100644
index 0000000..6cffd96
--- /dev/null
+++ b/internal/repository/postgres_peer_discovery.go
@@ -0,0 +1,274 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "evobgp/internal/store"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+func (p *Postgres) ListPeerDiscoveries(tenantID, status string) ([]*store.BGPPeerDiscovery, error) {
+ ctx := context.Background()
+ status = strings.TrimSpace(strings.ToLower(status))
+ q := `
+ SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
+ neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
+ status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
+ FROM bgp_peer_discovery WHERE tenant_id=$1`
+ args := []any{tenantID}
+ if status != "" {
+ q += ` AND status=$2`
+ args = append(args, status)
+ }
+ q += ` ORDER BY last_seen_at DESC`
+ rows, err := p.pool.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*store.BGPPeerDiscovery
+ for rows.Next() {
+ d, err := scanPeerDiscovery(rows)
+ if err != nil {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out, nil
+}
+
+func (p *Postgres) GetPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
+ ctx := context.Background()
+ row := p.pool.QueryRow(ctx, `
+ SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
+ neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
+ status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
+ FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2`, id, tenantID)
+ d, err := scanPeerDiscovery(row)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, store.ErrNotFound
+ }
+ return nil, err
+ }
+ return d, nil
+}
+
+type peerDiscoveryScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanPeerDiscovery(row peerDiscoveryScanner) (*store.BGPPeerDiscovery, error) {
+ var d store.BGPPeerDiscovery
+ var first, last time.Time
+ err := row.Scan(
+ &d.ID, &d.TenantID, &d.SpeakerID, &d.NeighborID,
+ &d.Neighbor, &d.RemoteASN, &d.ProtocolName, &d.SessionState,
+ &d.Status, &first, &last, &d.ApprovedPeerID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ d.FirstSeenAt = first.UTC()
+ d.LastSeenAt = last.UTC()
+ return &d, nil
+}
+
+func (p *Postgres) UpsertPeerDiscovery(tenantID string, in *store.PeerDiscoveryUpsert) (*store.BGPPeerDiscovery, error) {
+ if in == nil {
+ return nil, store.ErrInvalidInput
+ }
+ neighbor, ok := store.NormalizePeerNeighborString(in.Neighbor)
+ if !ok {
+ return nil, store.ErrInvalidInput
+ }
+ neighborID := strings.TrimSpace(in.NeighborID)
+ seenAt := in.SeenAt
+ if seenAt.IsZero() {
+ seenAt = time.Now().UTC()
+ }
+ ctx := context.Background()
+
+ var existingID string
+ if neighborID != "" {
+ _ = p.pool.QueryRow(ctx, `
+ SELECT id::text FROM bgp_peer_discovery
+ WHERE tenant_id=$1 AND neighbor_id=$2 LIMIT 1`, tenantID, neighborID).Scan(&existingID)
+ }
+ if existingID == "" {
+ _ = p.pool.QueryRow(ctx, `
+ SELECT id::text FROM bgp_peer_discovery
+ WHERE tenant_id=$1 AND neighbor=$2::inet AND remote_asn=$3 AND neighbor_id='' LIMIT 1`,
+ tenantID, neighbor, in.RemoteASN).Scan(&existingID)
+ }
+ if existingID != "" {
+ cur, err := p.GetPeerDiscovery(tenantID, existingID)
+ if err != nil {
+ return nil, err
+ }
+ if cur.Status == store.PeerDiscoveryRejected {
+ return cur, nil
+ }
+ var sp any
+ if s := strings.TrimSpace(in.SpeakerID); s != "" {
+ sp = s
+ }
+ _, err = p.pool.Exec(ctx, `
+ UPDATE bgp_peer_discovery SET
+ neighbor=$3::inet, remote_asn=$4,
+ neighbor_id=CASE WHEN $5 <> '' THEN $5 ELSE neighbor_id END,
+ protocol_name=$6, session_state=$7, last_seen_at=$8,
+ speaker_id=COALESCE($9::uuid, speaker_id),
+ updated_at=now()
+ WHERE id=$1 AND tenant_id=$2 AND status <> 'rejected'`,
+ existingID, tenantID, neighbor, in.RemoteASN, neighborID,
+ strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt, sp)
+ if err != nil {
+ return nil, err
+ }
+ return p.GetPeerDiscovery(tenantID, existingID)
+ }
+
+ id := uuid.NewString()
+ var sp any
+ if s := strings.TrimSpace(in.SpeakerID); s != "" {
+ sp = s
+ }
+ _, err := p.pool.Exec(ctx, `
+ INSERT INTO bgp_peer_discovery (
+ id, tenant_id, speaker_id, neighbor_id, neighbor, remote_asn,
+ protocol_name, session_state, status, first_seen_at, last_seen_at
+ ) VALUES ($1,$2,$3::uuid,$4,$5::inet,$6,$7,$8,'pending',$9,$9)`,
+ id, tenantID, sp, neighborID, neighbor, in.RemoteASN,
+ strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt)
+ if err != nil {
+ return nil, err
+ }
+ return p.GetPeerDiscovery(tenantID, id)
+}
+
+func (p *Postgres) ApprovePeerDiscovery(tenantID, id string, in *store.PeerDiscoveryApproveInput) (*store.BGPPeer, *store.BGPPeerDiscovery, error) {
+ ctx := context.Background()
+ tx, err := p.pool.Begin(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ d, err := p.getPeerDiscoveryTx(ctx, tx, tenantID, id)
+ if err != nil {
+ return nil, nil, err
+ }
+ if d.Status != store.PeerDiscoveryPending {
+ return nil, nil, store.ErrInvalidInput
+ }
+ if d.RemoteASN == 0 {
+ return nil, nil, store.ErrInvalidInput
+ }
+ neighbor, ok := store.NormalizePeerNeighborString(d.Neighbor)
+ if !ok {
+ return nil, nil, store.ErrInvalidInput
+ }
+
+ name := ""
+ enabled := true
+ var speakerID *string
+ if in != nil {
+ name = strings.TrimSpace(in.Name)
+ if in.Enabled != nil {
+ enabled = *in.Enabled
+ }
+ if in.SpeakerID != nil {
+ v := strings.TrimSpace(*in.SpeakerID)
+ if v != "" {
+ speakerID = &v
+ }
+ }
+ }
+ if name == "" {
+ if d.NeighborID != "" {
+ name = "discovered-" + d.NeighborID
+ } else {
+ name = "discovered-" + neighbor
+ }
+ }
+ if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
+ sp := d.SpeakerID
+ speakerID = &sp
+ }
+
+ peerID := uuid.NewString()
+ meta, _ := json.Marshal(map[string]any{"name": name, "session_state": d.SessionState})
+ var sp any
+ if speakerID != nil {
+ sp = *speakerID
+ }
+ _, err = tx.Exec(ctx, `
+ INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
+ VALUES ($1,$2,$3::uuid,$4::inet,$5,$6,'{}'::jsonb,$7::jsonb)`,
+ peerID, tenantID, sp, neighbor, d.RemoteASN, enabled, string(meta))
+ if err != nil {
+ return nil, nil, err
+ }
+ _, err = tx.Exec(ctx, `
+ UPDATE bgp_peer_discovery SET status='approved', approved_peer_id=$3::uuid, last_seen_at=now(), updated_at=now()
+ WHERE id=$1 AND tenant_id=$2`, id, tenantID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, nil, err
+ }
+ peer, err := p.GetPeer(tenantID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ disc, err := p.GetPeerDiscovery(tenantID, id)
+ if err != nil {
+ return nil, nil, err
+ }
+ return peer, disc, nil
+}
+
+func (p *Postgres) RejectPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
+ ctx := context.Background()
+ cur, err := p.GetPeerDiscovery(tenantID, id)
+ if err != nil {
+ return nil, err
+ }
+ if cur.Status == store.PeerDiscoveryApproved {
+ return nil, store.ErrInvalidInput
+ }
+ tag, err := p.pool.Exec(ctx, `
+ UPDATE bgp_peer_discovery SET status='rejected', last_seen_at=now(), updated_at=now()
+ WHERE id=$1 AND tenant_id=$2`, id, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ if tag.RowsAffected() == 0 {
+ return nil, store.ErrNotFound
+ }
+ return p.GetPeerDiscovery(tenantID, id)
+}
+
+func (p *Postgres) getPeerDiscoveryTx(ctx context.Context, tx pgx.Tx, tenantID, id string) (*store.BGPPeerDiscovery, error) {
+ row := tx.QueryRow(ctx, `
+ SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
+ neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
+ status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
+ FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2 FOR UPDATE`, id, tenantID)
+ d, err := scanPeerDiscovery(row)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, store.ErrNotFound
+ }
+ return nil, err
+ }
+ return d, nil
+}
diff --git a/internal/store/backend.go b/internal/store/backend.go
index 559c89b..f3bb656 100644
--- a/internal/store/backend.go
+++ b/internal/store/backend.go
@@ -70,6 +70,13 @@ type Backend interface {
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
DeletePeer(tenantID, id string) error
+ // Peer discovery (dynamic BGP quarantine → approve/reject).
+ ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error)
+ GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
+ UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error)
+ ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error)
+ RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
+
ListSpeakersForTenant(tenantID string) []*Speaker
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
diff --git a/internal/store/memory.go b/internal/store/memory.go
index 58803a6..6e561de 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -33,6 +33,8 @@ type Memory struct {
peers map[string]*BGPPeer
+ peerDiscoveries map[string]*BGPPeerDiscovery
+
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
@@ -143,6 +145,7 @@ func NewMemory() *Memory {
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
+ peerDiscoveries: make(map[string]*BGPPeerDiscovery),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
diff --git a/internal/store/memory_peer_discovery.go b/internal/store/memory_peer_discovery.go
new file mode 100644
index 0000000..bcbd638
--- /dev/null
+++ b/internal/store/memory_peer_discovery.go
@@ -0,0 +1,213 @@
+package store
+
+import (
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func (m *Memory) ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ status = strings.TrimSpace(strings.ToLower(status))
+ var out []*BGPPeerDiscovery
+ for _, d := range m.peerDiscoveries {
+ if d == nil || d.TenantID != tenantID {
+ continue
+ }
+ if status != "" && !strings.EqualFold(d.Status, status) {
+ continue
+ }
+ out = append(out, copyPeerDiscovery(d))
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].LastSeenAt.After(out[j].LastSeenAt)
+ })
+ return out, nil
+}
+
+func (m *Memory) GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ d, ok := m.peerDiscoveries[id]
+ if !ok || d.TenantID != tenantID {
+ return nil, ErrNotFound
+ }
+ return copyPeerDiscovery(d), nil
+}
+
+func (m *Memory) UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error) {
+ if in == nil {
+ return nil, ErrInvalidInput
+ }
+ neighbor, ok := NormalizePeerNeighborString(in.Neighbor)
+ if !ok {
+ return nil, ErrInvalidInput
+ }
+ neighborID := strings.TrimSpace(in.NeighborID)
+ seenAt := in.SeenAt
+ if seenAt.IsZero() {
+ seenAt = time.Now().UTC()
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.tenants[tenantID]; !ok {
+ return nil, ErrTenantScope
+ }
+
+ existing := m.findPeerDiscoveryLocked(tenantID, neighborID, neighbor, in.RemoteASN)
+ if existing != nil {
+ if existing.Status == PeerDiscoveryRejected {
+ return copyPeerDiscovery(existing), nil
+ }
+ if existing.Status == PeerDiscoveryApproved {
+ existing.SessionState = strings.TrimSpace(in.SessionState)
+ existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
+ existing.LastSeenAt = seenAt
+ if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
+ existing.SpeakerID = sp
+ }
+ return copyPeerDiscovery(existing), nil
+ }
+ existing.Neighbor = neighbor
+ existing.RemoteASN = in.RemoteASN
+ if neighborID != "" {
+ existing.NeighborID = neighborID
+ }
+ existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
+ existing.SessionState = strings.TrimSpace(in.SessionState)
+ existing.LastSeenAt = seenAt
+ if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
+ existing.SpeakerID = sp
+ }
+ return copyPeerDiscovery(existing), nil
+ }
+
+ id := uuid.NewString()
+ d := &BGPPeerDiscovery{
+ ID: id,
+ TenantID: tenantID,
+ SpeakerID: strings.TrimSpace(in.SpeakerID),
+ NeighborID: neighborID,
+ Neighbor: neighbor,
+ RemoteASN: in.RemoteASN,
+ ProtocolName: strings.TrimSpace(in.ProtocolName),
+ SessionState: strings.TrimSpace(in.SessionState),
+ Status: PeerDiscoveryPending,
+ FirstSeenAt: seenAt,
+ LastSeenAt: seenAt,
+ }
+ m.peerDiscoveries[id] = d
+ return copyPeerDiscovery(d), nil
+}
+
+func (m *Memory) ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d, ok := m.peerDiscoveries[id]
+ if !ok || d.TenantID != tenantID {
+ return nil, nil, ErrNotFound
+ }
+ if d.Status != PeerDiscoveryPending {
+ return nil, nil, ErrInvalidInput
+ }
+ if d.RemoteASN == 0 {
+ return nil, nil, ErrInvalidInput
+ }
+ neighbor, okN := NormalizePeerNeighborString(d.Neighbor)
+ if !okN {
+ return nil, nil, ErrInvalidInput
+ }
+
+ name := ""
+ enabled := true
+ var speakerID *string
+ if in != nil {
+ name = strings.TrimSpace(in.Name)
+ if in.Enabled != nil {
+ enabled = *in.Enabled
+ }
+ if in.SpeakerID != nil {
+ v := strings.TrimSpace(*in.SpeakerID)
+ if v != "" {
+ speakerID = &v
+ }
+ }
+ }
+ if name == "" {
+ if d.NeighborID != "" {
+ name = "discovered-" + d.NeighborID
+ } else {
+ name = "discovered-" + neighbor
+ }
+ }
+ if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
+ sp := d.SpeakerID
+ speakerID = &sp
+ }
+
+ peerID := uuid.NewString()
+ peer := &BGPPeer{
+ ID: peerID,
+ TenantID: tenantID,
+ SpeakerID: speakerID,
+ Name: name,
+ Neighbor: neighbor,
+ RemoteASN: d.RemoteASN,
+ Enabled: enabled,
+ SessionState: d.SessionState,
+ PoliciesJSON: "{}",
+ }
+ m.peers[peerID] = peer
+
+ d.Status = PeerDiscoveryApproved
+ d.ApprovedPeerID = peerID
+ d.LastSeenAt = time.Now().UTC()
+
+ peerCopy := *peer
+ return &peerCopy, copyPeerDiscovery(d), nil
+}
+
+func (m *Memory) RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d, ok := m.peerDiscoveries[id]
+ if !ok || d.TenantID != tenantID {
+ return nil, ErrNotFound
+ }
+ if d.Status == PeerDiscoveryApproved {
+ return nil, ErrInvalidInput
+ }
+ d.Status = PeerDiscoveryRejected
+ d.LastSeenAt = time.Now().UTC()
+ return copyPeerDiscovery(d), nil
+}
+
+func (m *Memory) findPeerDiscoveryLocked(tenantID, neighborID, neighbor string, asn int64) *BGPPeerDiscovery {
+ for _, d := range m.peerDiscoveries {
+ if d == nil || d.TenantID != tenantID {
+ continue
+ }
+ if neighborID != "" && d.NeighborID == neighborID {
+ return d
+ }
+ if neighborID == "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
+ return d
+ }
+ if neighborID != "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
+ return d
+ }
+ }
+ return nil
+}
+
+func copyPeerDiscovery(d *BGPPeerDiscovery) *BGPPeerDiscovery {
+ if d == nil {
+ return nil
+ }
+ cp := *d
+ return &cp
+}
diff --git a/internal/store/peer_discovery.go b/internal/store/peer_discovery.go
new file mode 100644
index 0000000..d344ecd
--- /dev/null
+++ b/internal/store/peer_discovery.go
@@ -0,0 +1,44 @@
+package store
+
+import "time"
+
+// PeerDiscoveryStatus values for bgp_peer_discovery.status.
+const (
+ PeerDiscoveryPending = "pending"
+ PeerDiscoveryApproved = "approved"
+ PeerDiscoveryRejected = "rejected"
+)
+
+// BGPPeerDiscovery is a live-detected dynamic BGP session awaiting operator action.
+type BGPPeerDiscovery struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id,omitempty"`
+ SpeakerID string `json:"speaker_id,omitempty"`
+ NeighborID string `json:"neighbor_id,omitempty"`
+ Neighbor string `json:"neighbor"`
+ RemoteASN int64 `json:"remote_asn"`
+ ProtocolName string `json:"protocol_name,omitempty"`
+ SessionState string `json:"session_state,omitempty"`
+ Status string `json:"status"`
+ FirstSeenAt time.Time `json:"first_seen_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ ApprovedPeerID string `json:"approved_peer_id,omitempty"`
+}
+
+// PeerDiscoveryUpsert is input for syncing a live dynamic session into the store.
+type PeerDiscoveryUpsert struct {
+ SpeakerID string
+ NeighborID string
+ Neighbor string
+ RemoteASN int64
+ ProtocolName string
+ SessionState string
+ SeenAt time.Time
+}
+
+// PeerDiscoveryApproveInput optional fields when promoting a discovery to BGPPeer.
+type PeerDiscoveryApproveInput struct {
+ Name string
+ SpeakerID *string
+ Enabled *bool
+}
diff --git a/migrations/postgres/000031_peer_discovery.down.sql b/migrations/postgres/000031_peer_discovery.down.sql
new file mode 100644
index 0000000..ceac714
--- /dev/null
+++ b/migrations/postgres/000031_peer_discovery.down.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn;
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_id;
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_status;
+DROP TABLE IF EXISTS bgp_peer_discovery;
diff --git a/migrations/postgres/000031_peer_discovery.up.sql b/migrations/postgres/000031_peer_discovery.up.sql
new file mode 100644
index 0000000..4f7cb7e
--- /dev/null
+++ b/migrations/postgres/000031_peer_discovery.up.sql
@@ -0,0 +1,30 @@
+-- Peer auto-discovery pending / rejected / approved records.
+
+CREATE TABLE IF NOT EXISTS bgp_peer_discovery (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
+ speaker_id UUID REFERENCES bgp_speaker (id) ON DELETE SET NULL,
+ neighbor_id TEXT NOT NULL DEFAULT '',
+ neighbor INET NOT NULL,
+ remote_asn BIGINT NOT NULL DEFAULT 0,
+ protocol_name TEXT NOT NULL DEFAULT '',
+ session_state TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending',
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ approved_peer_id UUID REFERENCES bgp_peer (id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT bgp_peer_discovery_status_chk CHECK (status IN ('pending', 'approved', 'rejected'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_status
+ ON bgp_peer_discovery (tenant_id, status);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_id
+ ON bgp_peer_discovery (tenant_id, neighbor_id)
+ WHERE neighbor_id <> '';
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn
+ ON bgp_peer_discovery (tenant_id, neighbor, remote_asn)
+ WHERE neighbor_id = '';
diff --git a/migrations/sqlite/000031_peer_discovery.down.sql b/migrations/sqlite/000031_peer_discovery.down.sql
new file mode 100644
index 0000000..ceac714
--- /dev/null
+++ b/migrations/sqlite/000031_peer_discovery.down.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn;
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_id;
+DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_status;
+DROP TABLE IF EXISTS bgp_peer_discovery;
diff --git a/migrations/sqlite/000031_peer_discovery.up.sql b/migrations/sqlite/000031_peer_discovery.up.sql
new file mode 100644
index 0000000..812fbe8
--- /dev/null
+++ b/migrations/sqlite/000031_peer_discovery.up.sql
@@ -0,0 +1,30 @@
+-- Peer auto-discovery pending / rejected / approved records.
+
+CREATE TABLE IF NOT EXISTS bgp_peer_discovery (
+ id TEXT PRIMARY KEY NOT NULL,
+ tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
+ speaker_id TEXT REFERENCES bgp_speaker (id) ON DELETE SET NULL,
+ neighbor_id TEXT NOT NULL DEFAULT '',
+ neighbor TEXT NOT NULL,
+ remote_asn INTEGER NOT NULL DEFAULT 0,
+ protocol_name TEXT NOT NULL DEFAULT '',
+ session_state TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending',
+ first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ approved_peer_id TEXT REFERENCES bgp_peer (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 (status IN ('pending', 'approved', 'rejected'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_status
+ ON bgp_peer_discovery (tenant_id, status);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_id
+ ON bgp_peer_discovery (tenant_id, neighbor_id)
+ WHERE neighbor_id != '';
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn
+ ON bgp_peer_discovery (tenant_id, neighbor, remote_asn)
+ WHERE neighbor_id = '';