diff --git a/apps/web/src/components/network/network-discovered-peers-card.tsx b/apps/web/src/components/network/network-discovered-peers-card.tsx new file mode 100644 index 0000000..f299d04 --- /dev/null +++ b/apps/web/src/components/network/network-discovered-peers-card.tsx @@ -0,0 +1,274 @@ +import { useMemo, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { CheckIcon, SearchIcon, XIcon } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { Input } from '@evobgp/ui/components/input' +import { Label } from '@evobgp/ui/components/label' + +import { ConfirmDialog } from '@/components/confirm-dialog' +import { DataGridPrimaryCell } from '@/components/data-grid-cell' +import { FormDrawer } from '@/components/form-drawer' +import { LoadingButton } from '@/components/loading-button' +import { SelectField } from '@/components/select-field' +import { StatusBadge } from '@/components/status-badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { + createFilter, + type Filter, + type FilterFieldConfig, +} from '@/components/reui/filters' +import { ResourcePage } from '@/components/reui-kit' +import { bgpSessionStateRu } from '@/lib/ui-labels' +import { + useApproveDiscoveredPeerMutation, + useRejectDiscoveredPeerMutation, +} from '@/queries/network' +import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api' + +/** + * Pending BGP peer discoveries — approve / reject. + * @see https://reui.io/preview/base/data-grid-filtering-2 + * @see https://reui.io/preview/base/components/c-empty-1 + */ + +function speakerLabel(s: SpeakerRow): string { + if (s.role === 'master') { + const host = s.agent_domain ?? s.endpoint + return host ? `CP · ${host}` : 'CP (master)' + } + return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…` +} + +function createDefaultFilters(): Filter[] { + return [createFilter('neighbor', 'contains', [''])] +} + +const filterFields: FilterFieldConfig[] = [ + { + key: 'neighbor', + label: 'Сосед', + icon: , + type: 'text', + className: 'w-48', + placeholder: 'IP или Neighbor ID…', + }, +] + +interface NetworkDiscoveredPeersCardProps { + items: PeerDiscoveryRow[] + speakers: SpeakerRow[] + isLoading: boolean + isError: boolean + error: unknown + onRetry: () => void +} + +export function NetworkDiscoveredPeersCard({ + items, + speakers, + isLoading, + isError, + error, + onRetry, +}: NetworkDiscoveredPeersCardProps) { + const approveMutation = useApproveDiscoveredPeerMutation() + const rejectMutation = useRejectDiscoveredPeerMutation() + const [filters, setFilters] = useState(createDefaultFilters) + const [approveTarget, setApproveTarget] = useState(null) + const [rejectTarget, setRejectTarget] = useState(null) + const [name, setName] = useState('') + const [speakerId, setSpeakerId] = useState(null) + + const speakerItems = useMemo( + () => [ + { value: '', label: 'Авто (с ноды обнаружения)' }, + ...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })), + ], + [speakers], + ) + + const columns = useMemo[]>( + () => [ + { + id: 'neighbor_id', + accessorFn: (row) => row.neighbor_id || row.neighbor, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Neighbor ID' }, + }, + { + accessorKey: 'remote_asn', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.remote_asn || '—'} + ), + meta: { headerTitle: 'ASN' }, + }, + { + accessorKey: 'session_state', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Состояние' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => Действия, + cell: ({ row }) => ( +
+ + +
+ ), + }, + ], + [], + ) + + function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown { + if (field === 'neighbor') { + return `${item.neighbor_id ?? ''} ${item.neighbor}` + } + return undefined + } + + async function confirmApprove() { + if (!approveTarget) return + await approveMutation.mutateAsync({ + id: approveTarget.id, + body: { + name: name.trim() || undefined, + bgp_speaker_id: speakerId || null, + }, + }) + setApproveTarget(null) + } + + return ( + <> + setFilters(createDefaultFilters())} + getFilterFieldValue={getFilterFieldValue} + columns={columns} + data={items} + getRowId={(row) => row.id} + isLoading={isLoading} + isError={isError} + error={error instanceof Error ? error : null} + onRetry={onRetry} + emptyState={{ + title: 'Нет ожидающих пиров', + description: + 'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.', + }} + /> + + { + if (!open) setApproveTarget(null) + }} + title="Одобрить пира" + description={ + approveTarget + ? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}` + : undefined + } + className="sm:max-w-sm" + footer={ + <> + + void confirmApprove()} + > + Одобрить + + + } + > +
+ + setName(e.target.value)} + placeholder="client-edge-1" + /> +
+ setSpeakerId(v || null)} + placeholder="Авто" + /> +
+ + { + if (!open) setRejectTarget(null) + }} + title="Отклонить пира?" + description={ + rejectTarget + ? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.` + : undefined + } + confirmLabel="Отклонить" + destructive + confirmLoading={rejectMutation.isPending} + onConfirm={() => { + if (!rejectTarget) return + void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null)) + }} + /> + + ) +} diff --git a/apps/web/src/queries/network.ts b/apps/web/src/queries/network.ts index 360b9d7..75206bb 100644 --- a/apps/web/src/queries/network.ts +++ b/apps/web/src/queries/network.ts @@ -8,6 +8,9 @@ import type { BgpSpeakerCreate, BgpSpeakerPatch, BirdStatus, + PeerDiscoveryApprove, + PeerDiscoveriesResponse, + PeerDiscoveryRow, PeerRow, PeersResponse, SpeakerRow, @@ -19,6 +22,7 @@ export const NETWORK_AUTO_REFRESH_MS = 30_000 export const networkKeys = { all: ['network'] as const, peers: () => [...networkKeys.all, 'peers'] as const, + discovered: () => [...networkKeys.all, 'discovered'] as const, speakers: () => [...networkKeys.all, 'speakers'] as const, bird: () => [...networkKeys.all, 'bird'] as const, } @@ -31,6 +35,14 @@ export function networkPeersQueryOptions() { }) } +export function networkDiscoveredPeersQueryOptions() { + return queryOptions({ + queryKey: networkKeys.discovered(), + queryFn: () => apiJSON('/v1/peers/discovered?status=pending'), + staleTime: 10_000, + }) +} + export function networkSpeakersQueryOptions() { return queryOptions({ queryKey: networkKeys.speakers(), @@ -91,6 +103,39 @@ export function useDeletePeerMutation() { }) } +export function useApproveDiscoveredPeerMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body?: PeerDiscoveryApprove }) => + apiMutate<{ peer: PeerRow; discovery: PeerDiscoveryRow }>( + `/v1/peers/discovered/${id}/approve`, + 'POST', + body ?? {}, + { idempotent: false }, + ), + onSuccess: () => { + toast.success('Пир одобрен') + invalidateNetwork(qc) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить пира'), + }) +} + +export function useRejectDiscoveredPeerMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/v1/peers/discovered/${id}/reject`, 'POST', {}, { + idempotent: false, + }), + onSuccess: () => { + toast.success('Пир отклонён') + invalidateNetwork(qc) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отклонить пира'), + }) +} + export function useCreateSpeakerMutation() { const qc = useQueryClient() return useMutation({ diff --git a/apps/web/src/queries/settings.ts b/apps/web/src/queries/settings.ts index 52f1dd1..1d9cb0c 100644 --- a/apps/web/src/queries/settings.ts +++ b/apps/web/src/queries/settings.ts @@ -10,6 +10,10 @@ export const BIRD_SETTING_KEYS = [ 'bird_local_asn', 'bird_bgp_source_ipv4', 'bird_bgp_source_ipv6', + 'peer_discovery_enabled', + 'peer_discovery_ranges_v4', + 'peer_discovery_ranges_v6', + 'peer_discovery_require_external', ] as const export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const @@ -38,7 +42,11 @@ export const NUMERIC_SETTING_KEYS = new Set([ 'runtime_logs_max_file_mb', ]) -export const BOOLEAN_SETTING_KEYS = new Set(['runtime_logs_auto_enabled']) +export const BOOLEAN_SETTING_KEYS = new Set([ + 'runtime_logs_auto_enabled', + 'peer_discovery_enabled', + 'peer_discovery_require_external', +]) export const settingsKeys = { all: ['settings'] as const, @@ -53,6 +61,11 @@ export function settingsQueryOptions() { } export function parseKnownValue(key: KnownSettingKey, value: unknown): string { + if (BOOLEAN_SETTING_KEYS.has(key)) { + if (value === true || value === 1 || value === 'true' || value === '1') return 'true' + if (value === false || value === 0 || value === 'false' || value === '0') return 'false' + return '' + } if (NUMERIC_SETTING_KEYS.has(key)) { if (typeof value === 'number' && Number.isFinite(value)) return String(value) if (typeof value === 'string') return value diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 6f10465..a01687a 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -5,29 +5,32 @@ import { TabsContent } from '@evobgp/ui/components/tabs' import { RefreshCw } from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' +import { NetworkDiscoveredPeersCard } from '@/components/network/network-discovered-peers-card' import { NetworkKpi } from '@/components/network/network-kpi' import { NetworkPeersCard } from '@/components/network/network-peers-card' import { NetworkSpeakersCard } from '@/components/network/network-speakers-card' import { PageHeader } from '@/components/page-header' import { networkBirdQueryOptions, + networkDiscoveredPeersQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions, } from '@/queries/network' -type NetworkTab = 'peers' | 'speakers' +type NetworkTab = 'peers' | 'discovered' | 'speakers' function parseNetworkTab(value: unknown): NetworkTab { if (value === 'speakers') return 'speakers' + if (value === 'discovered') return 'discovered' // legacy: overview | control-plane → peers return 'peers' } /** - * Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists. + * Network ops page — KPI (stats-12) + peers/discovered/speakers ResourcePage lists. * @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/data-grid-filtering-2 - * @see https://reui.io/preview/base/empty-state-12 + * @see https://reui.io/preview/base/components/c-empty-1 */ export const Route = createFileRoute('/_auth/network')({ component: NetworkComponent, @@ -40,16 +43,26 @@ function NetworkComponent() { const search = useSearch({ from: '/_auth/network' }) const navigate = Route.useNavigate() const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) + const discoveredQ = useQuery({ + ...networkDiscoveredPeersQueryOptions(), + refetchInterval: 15_000, + }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) - const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching + const refreshing = + peersQ.isFetching || + discoveredQ.isFetching || + speakersQ.isFetching || + birdQ.isFetching const peers = peersQ.data?.items ?? [] + const discovered = discoveredQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? [] const overviewLoading = peersQ.isLoading || speakersQ.isLoading function refetchAll() { void peersQ.refetch() + void discoveredQ.refetch() void speakersQ.refetch() void birdQ.refetch() } @@ -58,7 +71,7 @@ function NetworkComponent() {
@@ -81,6 +94,7 @@ function NetworkComponent() { } tabs={[ { id: 'peers', label: 'Пиры', count: peers.length }, + { id: 'discovered', label: 'На одобрение', count: discovered.length }, { id: 'speakers', label: 'Спикеры', count: speakers.length }, ]} > @@ -95,6 +109,17 @@ function NetworkComponent() { /> + + void discoveredQ.refetch()} + /> + + = { bird_local_asn: 'Локальный ASN', bird_bgp_source_ipv4: 'BGP source IPv4', bird_bgp_source_ipv6: 'BGP source IPv6', + peer_discovery_enabled: 'Автообнаружение пиров', + peer_discovery_ranges_v4: 'Discovery CIDR IPv4', + peer_discovery_ranges_v6: 'Discovery CIDR IPv6', + peer_discovery_require_external: 'Только external ASN', +} + +const BIRD_BOOL_ITEMS = [ + { value: 'true', label: 'Вкл' }, + { value: 'false', label: 'Выкл' }, +] as const + +const BIRD_HINTS: Partial> = { + 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 = '';