Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbe9c9b56 |
@@ -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,
|
BgpSpeakerCreate,
|
||||||
BgpSpeakerPatch,
|
BgpSpeakerPatch,
|
||||||
BirdStatus,
|
BirdStatus,
|
||||||
|
PeerDiscoveryApprove,
|
||||||
|
PeerDiscoveriesResponse,
|
||||||
|
PeerDiscoveryRow,
|
||||||
PeerRow,
|
PeerRow,
|
||||||
PeersResponse,
|
PeersResponse,
|
||||||
SpeakerRow,
|
SpeakerRow,
|
||||||
@@ -19,6 +22,7 @@ export const NETWORK_AUTO_REFRESH_MS = 30_000
|
|||||||
export const networkKeys = {
|
export const networkKeys = {
|
||||||
all: ['network'] as const,
|
all: ['network'] as const,
|
||||||
peers: () => [...networkKeys.all, 'peers'] as const,
|
peers: () => [...networkKeys.all, 'peers'] as const,
|
||||||
|
discovered: () => [...networkKeys.all, 'discovered'] as const,
|
||||||
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
||||||
bird: () => [...networkKeys.all, 'bird'] 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() {
|
export function networkSpeakersQueryOptions() {
|
||||||
return queryOptions<SpeakersResponse>({
|
return queryOptions<SpeakersResponse>({
|
||||||
queryKey: networkKeys.speakers(),
|
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() {
|
export function useCreateSpeakerMutation() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const BIRD_SETTING_KEYS = [
|
|||||||
'bird_local_asn',
|
'bird_local_asn',
|
||||||
'bird_bgp_source_ipv4',
|
'bird_bgp_source_ipv4',
|
||||||
'bird_bgp_source_ipv6',
|
'bird_bgp_source_ipv6',
|
||||||
|
'peer_discovery_enabled',
|
||||||
|
'peer_discovery_ranges_v4',
|
||||||
|
'peer_discovery_ranges_v6',
|
||||||
|
'peer_discovery_require_external',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] 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',
|
'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 = {
|
export const settingsKeys = {
|
||||||
all: ['settings'] as const,
|
all: ['settings'] as const,
|
||||||
@@ -53,6 +61,11 @@ export function settingsQueryOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
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 (NUMERIC_SETTING_KEYS.has(key)) {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
|
|||||||
@@ -5,29 +5,32 @@ import { TabsContent } from '@evobgp/ui/components/tabs'
|
|||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { NetworkDiscoveredPeersCard } from '@/components/network/network-discovered-peers-card'
|
||||||
import { NetworkKpi } from '@/components/network/network-kpi'
|
import { NetworkKpi } from '@/components/network/network-kpi'
|
||||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import {
|
import {
|
||||||
networkBirdQueryOptions,
|
networkBirdQueryOptions,
|
||||||
|
networkDiscoveredPeersQueryOptions,
|
||||||
networkPeersQueryOptions,
|
networkPeersQueryOptions,
|
||||||
networkSpeakersQueryOptions,
|
networkSpeakersQueryOptions,
|
||||||
} from '@/queries/network'
|
} from '@/queries/network'
|
||||||
|
|
||||||
type NetworkTab = 'peers' | 'speakers'
|
type NetworkTab = 'peers' | 'discovered' | 'speakers'
|
||||||
|
|
||||||
function parseNetworkTab(value: unknown): NetworkTab {
|
function parseNetworkTab(value: unknown): NetworkTab {
|
||||||
if (value === 'speakers') return 'speakers'
|
if (value === 'speakers') return 'speakers'
|
||||||
|
if (value === 'discovered') return 'discovered'
|
||||||
// legacy: overview | control-plane → peers
|
// legacy: overview | control-plane → peers
|
||||||
return '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/stats-12
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @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')({
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
component: NetworkComponent,
|
component: NetworkComponent,
|
||||||
@@ -40,16 +43,26 @@ function NetworkComponent() {
|
|||||||
const search = useSearch({ from: '/_auth/network' })
|
const search = useSearch({ from: '/_auth/network' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const discoveredQ = useQuery({
|
||||||
|
...networkDiscoveredPeersQueryOptions(),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
})
|
||||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), 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 peers = peersQ.data?.items ?? []
|
||||||
|
const discovered = discoveredQ.data?.items ?? []
|
||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void peersQ.refetch()
|
void peersQ.refetch()
|
||||||
|
void discoveredQ.refetch()
|
||||||
void speakersQ.refetch()
|
void speakersQ.refetch()
|
||||||
void birdQ.refetch()
|
void birdQ.refetch()
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,7 @@ function NetworkComponent() {
|
|||||||
<div className="flex flex-col gap-4 md:gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description="BGP-пиры, спикеры и статус BIRD"
|
description="BGP-пиры, автообнаружение и спикеры"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||||
@@ -81,6 +94,7 @@ function NetworkComponent() {
|
|||||||
}
|
}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'peers', label: 'Пиры', count: peers.length },
|
{ id: 'peers', label: 'Пиры', count: peers.length },
|
||||||
|
{ id: 'discovered', label: 'На одобрение', count: discovered.length },
|
||||||
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -95,6 +109,17 @@ function NetworkComponent() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="speakers" className="mt-4">
|
||||||
<NetworkSpeakersCard
|
<NetworkSpeakersCard
|
||||||
items={speakers}
|
items={speakers}
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|||||||
bird_local_asn: 'Локальный ASN',
|
bird_local_asn: 'Локальный ASN',
|
||||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
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() {
|
function TenantSettingsComponent() {
|
||||||
@@ -143,17 +160,32 @@ function TenantSettingsComponent() {
|
|||||||
<SettingsSettingField
|
<SettingsSettingField
|
||||||
key={key}
|
key={key}
|
||||||
title={BIRD_LABELS[key]}
|
title={BIRD_LABELS[key]}
|
||||||
description={key}
|
description={BIRD_HINTS[key] ?? key}
|
||||||
labelFor={key}
|
labelFor={key}
|
||||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
>
|
>
|
||||||
<Input
|
{key === 'peer_discovery_enabled' ||
|
||||||
id={key}
|
key === 'peer_discovery_require_external' ? (
|
||||||
value={birdForm[key] ?? ''}
|
<SelectField
|
||||||
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
id={key}
|
||||||
placeholder={BIRD_LABELS[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>
|
</SettingsSettingField>
|
||||||
))}
|
))}
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
|
|||||||
@@ -221,10 +221,36 @@ export type BgpPeerCreate = {
|
|||||||
}
|
}
|
||||||
export type BgpPeerPatch = Partial<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 ----
|
// ---- Speakers ----
|
||||||
export type BgpSessionLive = {
|
export type BgpSessionLive = {
|
||||||
name: string
|
name: string
|
||||||
neighbor?: string
|
neighbor?: string
|
||||||
|
neighbor_as?: number
|
||||||
|
neighbor_id?: string
|
||||||
state: string
|
state: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -105,6 +105,13 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
### Настройки (`/v1/settings`)
|
### Настройки (`/v1/settings`)
|
||||||
- KV c ключами BIRD и дополнительными feature flags.
|
- 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`.
|
- Ключевые параметры 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).
|
- **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 и интерфейса
|
### Web UI: настройки tenant и интерфейса
|
||||||
|
|||||||
@@ -1030,6 +1030,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
neighbor:
|
neighbor:
|
||||||
type: string
|
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:
|
state:
|
||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -1715,6 +1721,51 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
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:
|
BgpPeerCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [neighbor, remote_asn]
|
required: [neighbor, remote_asn]
|
||||||
@@ -3100,6 +3151,90 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$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}:
|
/v1/peers/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/TenantId"
|
- $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
|
package birdfmt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
||||||
type BGPSession struct {
|
type BGPSession struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Neighbor string `json:"neighbor,omitempty"`
|
Neighbor string `json:"neighbor,omitempty"`
|
||||||
State string `json:"state"`
|
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 {
|
func ParseBGPSessions(output string) []BGPSession {
|
||||||
var out []BGPSession
|
var out []BGPSession
|
||||||
var cur *BGPSession
|
var cur *BGPSession
|
||||||
@@ -43,12 +54,41 @@ func ParseBGPSessions(output string) []BGPSession {
|
|||||||
if cur == nil {
|
if cur == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
|
parseBGPSessionDetailLine(cur, trim)
|
||||||
if idx := strings.Index(trim, prefix); idx >= 0 {
|
|
||||||
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
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
|
evobgp_p_abc123 BGP master4 up 10:00:05 Established
|
||||||
Neighbor address: 198.51.100.2
|
Neighbor address: 198.51.100.2
|
||||||
Neighbor AS: 65001
|
Neighbor AS: 65001
|
||||||
|
Neighbor ID: 192.0.2.50
|
||||||
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
||||||
Neighbor address: 2001:db8::2
|
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)
|
sessions := ParseBGPSessions(sample)
|
||||||
if len(sessions) != 2 {
|
if len(sessions) != 3 {
|
||||||
t.Fatalf("got %d sessions want 2", len(sessions))
|
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" {
|
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
|
||||||
t.Fatalf("session0: %+v", sessions[0])
|
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" {
|
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
|
||||||
t.Fatalf("session1: %+v", sessions[1])
|
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_ipv4_peer",
|
||||||
"bgp_ipv6_peer",
|
"bgp_ipv6_peer",
|
||||||
"domains_resolved",
|
"domains_resolved",
|
||||||
|
"dynamic_discovery",
|
||||||
"empty_static",
|
"empty_static",
|
||||||
"filter_export",
|
"filter_export",
|
||||||
"large_prefix_list",
|
"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)
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
||||||
|
if fresh {
|
||||||
|
s.syncPeerDiscoveriesFromLive(a.TenantID, liveViews)
|
||||||
|
}
|
||||||
items := make([]map[string]any, 0, len(page))
|
items := make([]map[string]any, 0, len(page))
|
||||||
for _, p := range page {
|
for _, p := range page {
|
||||||
row := peerJSON(p)
|
row := peerJSON(p)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
||||||
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
||||||
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
||||||
|
s.registerPeerDiscoveryRoutes(m)
|
||||||
|
|
||||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
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)
|
writeStoreErr(w, err)
|
||||||
return
|
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)})
|
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"})
|
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 {
|
func settingsAuditKeys(body map[string]any) []string {
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -940,6 +940,50 @@ func uint32FromSettingsMap(m map[string]any, key string) uint32 {
|
|||||||
return 0
|
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 {
|
func intFromSettingsMap(m map[string]any, key string) int {
|
||||||
v, ok := m[key]
|
v, ok := m[key]
|
||||||
if !ok || v == nil {
|
if !ok || v == nil {
|
||||||
@@ -1004,6 +1048,11 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
|
|||||||
peers := st.ListPeers(tenantID)
|
peers := st.ListPeers(tenantID)
|
||||||
var parts []string
|
var parts []string
|
||||||
parts = append(parts, birdfmt.ManagedBanner("peers"))
|
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 {
|
for _, p := range peers {
|
||||||
if p == nil || !p.Enabled {
|
if p == nil || !p.Enabled {
|
||||||
continue
|
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)
|
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
|
||||||
DeletePeer(tenantID, id string) 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
|
ListSpeakersForTenant(tenantID string) []*Speaker
|
||||||
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
|
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
|
||||||
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Memory struct {
|
|||||||
|
|
||||||
peers map[string]*BGPPeer
|
peers map[string]*BGPPeer
|
||||||
|
|
||||||
|
peerDiscoveries map[string]*BGPPeerDiscovery
|
||||||
|
|
||||||
dohProfiles map[string]*DohProfile
|
dohProfiles map[string]*DohProfile
|
||||||
communities map[string]*Community
|
communities map[string]*Community
|
||||||
cdnSources map[string]*CDNSource
|
cdnSources map[string]*CDNSource
|
||||||
@@ -143,6 +145,7 @@ func NewMemory() *Memory {
|
|||||||
speakers: make(map[string]*Speaker),
|
speakers: make(map[string]*Speaker),
|
||||||
publishedRevision: make(map[string]publishedInfo),
|
publishedRevision: make(map[string]publishedInfo),
|
||||||
peers: make(map[string]*BGPPeer),
|
peers: make(map[string]*BGPPeer),
|
||||||
|
peerDiscoveries: make(map[string]*BGPPeerDiscovery),
|
||||||
dohProfiles: make(map[string]*DohProfile),
|
dohProfiles: make(map[string]*DohProfile),
|
||||||
communities: make(map[string]*Community),
|
communities: make(map[string]*Community),
|
||||||
cdnSources: make(map[string]*CDNSource),
|
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