feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
This commit is contained in:
@@ -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: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
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<Filter[]>(createDefaultFilters)
|
||||
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [speakerId, setSpeakerId] = useState<string | null>(null)
|
||||
|
||||
const speakerItems = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'Авто (с ноды обнаружения)' },
|
||||
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
||||
],
|
||||
[speakers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'neighbor_id',
|
||||
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.neighbor_id || '—'}
|
||||
subtitle={row.original.neighbor}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Neighbor ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Состояние" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setApproveTarget(row.original)
|
||||
setName('')
|
||||
setSpeakerId(row.original.speaker_id ?? null)
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setRejectTarget(row.original)}
|
||||
>
|
||||
<XIcon />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ResourcePage
|
||||
title="На одобрение"
|
||||
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => 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-диапазоны. Новые сессии появятся здесь.',
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormDrawer
|
||||
open={!!approveTarget}
|
||||
onOpenChange={(open) => {
|
||||
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={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
loading={approveMutation.isPending}
|
||||
onClick={() => void confirmApprove()}
|
||||
>
|
||||
Одобрить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="disc-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="disc-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="client-edge-1"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="disc-speaker"
|
||||
label="Спикер"
|
||||
items={speakerItems}
|
||||
value={speakerId ?? ''}
|
||||
onValueChange={(v) => setSpeakerId(v || null)}
|
||||
placeholder="Авто"
|
||||
/>
|
||||
</FormDrawer>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!rejectTarget}
|
||||
onOpenChange={(open) => {
|
||||
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))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<PeerDiscoveriesResponse>({
|
||||
queryKey: networkKeys.discovered(),
|
||||
queryFn: () => apiJSON<PeerDiscoveriesResponse>('/v1/peers/discovered?status=pending'),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function networkSpeakersQueryOptions() {
|
||||
return queryOptions<SpeakersResponse>({
|
||||
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<PeerDiscoveryRow>(`/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({
|
||||
|
||||
@@ -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<KnownSettingKey>([
|
||||
'runtime_logs_max_file_mb',
|
||||
])
|
||||
|
||||
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
|
||||
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||
'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
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Сеть"
|
||||
description="BGP-пиры, спикеры и статус BIRD"
|
||||
description="BGP-пиры, автообнаружение и спикеры"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||
@@ -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() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="discovered" className="mt-4">
|
||||
<NetworkDiscoveredPeersCard
|
||||
items={discovered}
|
||||
speakers={speakers}
|
||||
isLoading={discoveredQ.isLoading}
|
||||
isError={discoveredQ.isError}
|
||||
error={discoveredQ.error}
|
||||
onRetry={() => void discoveredQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="speakers" className="mt-4">
|
||||
<NetworkSpeakersCard
|
||||
items={speakers}
|
||||
|
||||
@@ -54,6 +54,23 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
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<Record<BirdSettingKey, string>> = {
|
||||
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() {
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={key}
|
||||
description={BIRD_HINTS[key] ?? key}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectField
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
<div className="px-4 py-4">
|
||||
|
||||
@@ -221,10 +221,36 @@ export type BgpPeerCreate = {
|
||||
}
|
||||
export type BgpPeerPatch = Partial<BgpPeerCreate>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 и интерфейса
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 = '';
|
||||
@@ -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;
|
||||
@@ -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 = '';
|
||||
Reference in New Issue
Block a user