Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe |
@@ -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)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListCommunityPrefixesByIDAndLabel(t *testing.T) {
|
||||
srv, err := New(Options{
|
||||
InsecureDev: true,
|
||||
SeedDemo: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
|
||||
reqList, _ := http.NewRequest(http.MethodGet, base+"/v1/communities?limit=10", nil)
|
||||
reqList.Header.Set("Authorization", "Bearer vwkey")
|
||||
respList, err := client.Do(reqList)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respList.Body.Close() }()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
}
|
||||
var listBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Community string `json:"community"`
|
||||
Title string `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(respList.Body).Decode(&listBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listBody.Items) == 0 {
|
||||
t.Fatal("expected seeded community")
|
||||
}
|
||||
comm := listBody.Items[0]
|
||||
|
||||
assertPrefixesOK := func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
|
||||
}
|
||||
var body struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Items == nil {
|
||||
t.Fatalf("%s: expected items array (got nil)", path)
|
||||
}
|
||||
if body.Prefixes == nil {
|
||||
t.Fatalf("%s: expected prefixes array (got nil)", path)
|
||||
}
|
||||
}
|
||||
|
||||
// UUID id
|
||||
assertPrefixesOK(t, "/v1/communities/"+comm.ID+"/prefixes?limit=100")
|
||||
// Community string (legacy / autocomplete label without title)
|
||||
assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(comm.Community)+"/prefixes?limit=100")
|
||||
if comm.Title != "" {
|
||||
// Full Base UI {value,label} display string
|
||||
label := comm.Community + " · " + comm.Title
|
||||
assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(label)+"/prefixes?limit=100")
|
||||
}
|
||||
|
||||
req404, _ := http.NewRequest(http.MethodGet, base+"/v1/communities/missing-community/prefixes", nil)
|
||||
req404.Header.Set("Authorization", "Bearer vwkey")
|
||||
resp404, err := client.Do(req404)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp404.Body.Close() }()
|
||||
if resp404.StatusCode != http.StatusNotFound {
|
||||
b, _ := io.ReadAll(resp404.Body)
|
||||
t.Fatalf("expected 404, got %d: %s", resp404.StatusCode, b)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
|
||||
m.HandleFunc("GET /communities", s.handleListComm)
|
||||
m.HandleFunc("POST /communities", s.handlePostComm)
|
||||
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
||||
m.HandleFunc("GET /communities/{id}/prefixes", s.handleListCommPrefixes)
|
||||
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
||||
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
|
||||
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
|
||||
|
||||
@@ -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)
|
||||
@@ -232,6 +233,9 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
|
||||
case "23505":
|
||||
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
|
||||
return true
|
||||
case "22P02":
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid id format")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1256,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
|
||||
|
||||
@@ -1237,12 +1237,32 @@ func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
||||
func (p *Postgres) GetCommunity(tenantID, idOrKey string) (*store.Community, error) {
|
||||
ctx := context.Background()
|
||||
key := strings.TrimSpace(idOrKey)
|
||||
if key == "" {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
var c store.Community
|
||||
c.TenantID = tenantID
|
||||
err := p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
||||
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||
// Prefer UUID id; fall back to community / title so clients that store the
|
||||
// autocomplete label (Base UI {value,label} → label) still resolve.
|
||||
var err error
|
||||
if _, perr := uuid.Parse(key); perr == nil {
|
||||
err = p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, key, tenantID).Scan(
|
||||
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||
} else {
|
||||
err = p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, community, title, value_json::text FROM bgp_community
|
||||
WHERE tenant_id=$1 AND (
|
||||
community = $2
|
||||
OR title = $2
|
||||
OR (NULLIF(trim(title), '') IS NOT NULL AND (community || ' · ' || title) = $2)
|
||||
)
|
||||
ORDER BY community
|
||||
LIMIT 1`, tenantID, key).Scan(
|
||||
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
@@ -1253,9 +1273,11 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
||||
}
|
||||
|
||||
func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]store.PrefixRow, string, bool, error) {
|
||||
if _, err := p.GetCommunity(tenantID, communityID); err != nil {
|
||||
comm, err := p.GetCommunity(tenantID, communityID)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
resolvedID := comm.ID
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
@@ -1271,7 +1293,6 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l
|
||||
ctx := context.Background()
|
||||
useSnap := prefixSnapshotTableExists(ctx, p.pool)
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if useSnap {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
WITH latest AS (
|
||||
@@ -1293,7 +1314,7 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l
|
||||
)
|
||||
SELECT prefix, source FROM combined
|
||||
ORDER BY prefix
|
||||
LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off)
|
||||
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
|
||||
} else {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
WITH latest AS (
|
||||
@@ -1307,20 +1328,19 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l
|
||||
JOIN latest l ON l.id = rmp.revision_id
|
||||
WHERE rmp.community_id = $2::uuid
|
||||
ORDER BY 1
|
||||
LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off)
|
||||
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var all []store.PrefixRow
|
||||
comm := communityID
|
||||
for rows.Next() {
|
||||
var pr store.PrefixRow
|
||||
if err := rows.Scan(&pr.Prefix, &pr.Source); err != nil {
|
||||
continue
|
||||
}
|
||||
pr.CommunityID = &comm
|
||||
pr.CommunityID = &resolvedID
|
||||
all = append(all, pr)
|
||||
}
|
||||
more := len(all) > limit
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -585,20 +585,36 @@ func (m *Memory) ListCommunities(tenantID string) ([]*Community, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) {
|
||||
func (m *Memory) GetCommunity(tenantID, idOrKey string) (*Community, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.communities[id]
|
||||
if !ok || c.TenantID != tenantID {
|
||||
key := strings.TrimSpace(idOrKey)
|
||||
if key == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
if c, ok := m.communities[key]; ok && c.TenantID == tenantID {
|
||||
return c, nil
|
||||
}
|
||||
for _, c := range m.communities {
|
||||
if c.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if c.Community == key || c.Title == key {
|
||||
return c, nil
|
||||
}
|
||||
if strings.TrimSpace(c.Title) != "" && c.Community+" · "+c.Title == key {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]PrefixRow, string, bool, error) {
|
||||
if _, err := m.GetCommunity(tenantID, communityID); err != nil {
|
||||
commRow, err := m.GetCommunity(tenantID, communityID)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
resolvedID := commRow.ID
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
@@ -626,10 +642,9 @@ func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, lim
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
var all []PrefixRow
|
||||
comm := communityID
|
||||
for _, rev := range latestByModule {
|
||||
for _, pr := range m.revPrefixes[rev.ID] {
|
||||
if pr.CommunityID == nil || *pr.CommunityID != communityID {
|
||||
if pr.CommunityID == nil || *pr.CommunityID != resolvedID {
|
||||
continue
|
||||
}
|
||||
pfx := strings.TrimSpace(pr.Prefix)
|
||||
@@ -640,7 +655,7 @@ func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, lim
|
||||
continue
|
||||
}
|
||||
seen[pfx] = struct{}{}
|
||||
all = append(all, PrefixRow{Prefix: pfx, CommunityID: &comm, Source: pr.Source})
|
||||
all = append(all, PrefixRow{Prefix: pfx, CommunityID: &resolvedID, Source: pr.Source})
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Prefix < all[j].Prefix })
|
||||
|
||||
@@ -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