Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec 1e04e91dd8 feat(network): add delete functionality for speakers in network speakers card
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m6s
CI / release (push) Successful in 4m12s
Implemented a delete feature for speakers within the NetworkSpeakersCard component. This includes a confirmation dialog for deletion and integration with the delete speaker mutation. Updated the table columns to include an action button for deleting speakers, enhancing the user experience for managing network speakers.
2026-07-31 03:19:54 +07:00
Denozordec 5fbe9c9b56 feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
2026-07-30 22:31:59 +07:00
Denozordec fd3a217cbe feat(api): enhance community retrieval with flexible ID handling
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m5s
Updated the GetCommunity function to accept both UUIDs and community titles for improved flexibility in community retrieval. Added error handling for invalid ID formats and adjusted related functions to ensure consistent behavior across memory and PostgreSQL storage. This change enhances the API's usability by allowing more intuitive community lookups.
2026-07-23 11:30:46 +07:00
Denozordec ff6efec4c5 feat(api): add endpoint to list community prefixes with pagination
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 1m4s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m17s
Introduced a new GET endpoint `/v1/communities/{id}/prefixes` to retrieve unique prefixes associated with a community, including pagination support via cursor and limit parameters. Updated OpenAPI documentation to reflect this addition. Implemented backend logic in both PostgreSQL and in-memory storage to handle the new functionality, ensuring proper authorization checks and response formatting.
2026-07-23 11:10:53 +07:00
DenozordecandCursor 738d2e2256 feat(httpapi): add local audit log with portal dual-write
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 32s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m56s
Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:24:54 +07:00
51 changed files with 3385 additions and 48 deletions
@@ -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))
}}
/>
</>
)
}
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import {
getSpeakerFilterFieldValue,
@@ -15,6 +17,7 @@ import {
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import { useDeleteSpeakerMutation } from '@/queries/network'
import type { SpeakerRow } from '@/types/api'
/**
@@ -22,6 +25,10 @@ import type { SpeakerRow } from '@/types/api'
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
function speakerDeleteLabel(s: SpeakerRow): string {
return s.agent_domain ?? s.endpoint ?? s.id
}
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
@@ -75,7 +82,9 @@ export function NetworkSpeakersCard({
error,
onRetry,
}: NetworkSpeakersCardProps) {
const deleteMutation = useDeleteSpeakerMutation()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
const addButton = useMemo(
@@ -88,6 +97,33 @@ export function NetworkSpeakersCard({
[],
)
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
() => [
...speakerColumns,
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
aria-label="Удалить спикера"
title="Удалить"
onClick={() => setDeleteTarget(row.original)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[],
)
return (
<>
<ResourcePage
@@ -100,7 +136,7 @@ export function NetworkSpeakersCard({
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={speakerColumns}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
@@ -116,6 +152,27 @@ export function NetworkSpeakersCard({
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить спикера?"
description={
deleteTarget
? `${speakerDeleteLabel(deleteTarget)} будет удалён. Привязки пиров к этому спикеру сбросятся.`
: undefined
}
confirmLabel="Удалить"
confirmLoadingLabel="Удаление…"
destructive
confirmLoading={deleteMutation.isPending}
onConfirm={() => {
if (!deleteTarget) return
void deleteMutation.mutateAsync(deleteTarget.id).then(() => setDeleteTarget(null))
}}
/>
</>
)
}
+45
View File
@@ -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({
+14 -1
View File
@@ -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
+30 -5
View File
@@ -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}
+39 -7
View File
@@ -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">
+26
View File
@@ -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
+1
View File
@@ -42,6 +42,7 @@ func main() {
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
}
srv, err := httpapi.New(opts)
if err != nil {
+1
View File
@@ -37,6 +37,7 @@ func main() {
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
}
srv, err := httpapi.New(opts)
if err != nil {
+1
View File
@@ -12,6 +12,7 @@
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
| `AUTH_AUDIT_INGEST_SECRET` / `EVOBGP_AUTH_AUDIT_INGEST_SECRET` | Shared secret для push CRUD audit в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`) |
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
Источник tenant (по приоритету):
+10
View File
@@ -116,6 +116,16 @@
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
## CRUD audit (`/v1/audit`)
Локальный журнал изменений CRUD (modules, peers, settings, API keys, …). Миграция `000030_audit_log`. Чтение — `bgp:monitoring:read` (viewer+).
| Метод | Путь | Роль | Назначение |
|-------|------|------|------------|
| `GET` | `/v1/audit` | viewer+ | Пагинированный audit (`cursor`, `limit`, опционально `action`, `severity`) |
При `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` каждая запись дополнительно отправляется в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`).
## Соглашения из OpenAPI
- Ошибки в стиле **RFC 9457** (`application/problem+json`): `type`, `title`, `status`, `detail`, и т.д.
+7
View File
@@ -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 и интерфейса
+286
View File
@@ -59,6 +59,8 @@ tags:
description: Сессия текущего API-ключа (tenant и роль).
- name: Monitoring
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
- name: Audit
description: Журнал CRUD-изменений tenant (локально + опциональный push в auth-portal). Чтение — bgp:monitoring:read.
- name: Maintenance
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
- name: RuntimeLogs
@@ -1028,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
@@ -1297,6 +1305,70 @@ components:
has_more:
type: boolean
AuditSeverity:
type: string
enum: [info, warning, critical]
AuditLogEntry:
type: object
required:
[id, tenant_id, event_id, source_app, action, severity, summary, created_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
tenant_id:
$ref: "#/components/schemas/ResourceId"
event_id:
type: string
description: Stable id for portal ingest deduplication (prefix bgp-).
source_app:
type: string
enum: [bgp]
action:
type: string
description: Machine action key (e.g. bgp.module.create).
severity:
$ref: "#/components/schemas/AuditSeverity"
actor_user_id:
type: ["string", "null"]
actor_email:
type: ["string", "null"]
actor_name:
type: ["string", "null"]
actor_api_key_prefix:
type: ["string", "null"]
target_type:
type: ["string", "null"]
enum: [app_resource, null]
target_id:
type: ["string", "null"]
summary:
type: string
details:
type: ["object", "null"]
additionalProperties: true
ip:
type: ["string", "null"]
created_at:
type: string
format: date-time
portal_pushed_at:
type: ["string", "null"]
format: date-time
AuditLogList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/AuditLogEntry"
next_cursor:
type: string
has_more:
type: boolean
RuntimeLogAutoPolicy:
type: object
properties:
@@ -1649,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]
@@ -2856,6 +2973,57 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/communities/{id}/prefixes:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/CommunityId"
- $ref: "#/components/parameters/Cursor"
- name: limit
in: query
schema:
type: integer
default: 500
maximum: 5000
get:
tags: [Communities]
summary: Префиксы community (latest revision per module)
description: |
Уникальные materialized-префиксы с данным community_id
из последней ревизии каждого модуля tenant.
Поле `prefixes` — плоский список для клиентов вроде EvoFirewall.
operationId: listCommunityPrefixes
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [items, has_more]
properties:
items:
type: array
items:
type: object
required: [prefix]
properties:
prefix:
type: string
source:
type: string
prefixes:
type: array
items:
type: string
next_cursor:
type: ["string", "null"]
has_more:
type: boolean
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/communities/{id}:
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -2983,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"
@@ -4520,6 +4772,40 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/audit:
get:
tags: [Audit]
summary: Журнал CRUD audit tenant
description: |
Локальный журнал изменений (modules, peers, settings, API keys и т.д.).
При настроенных `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` события также
отправляются в auth-portal ingest (`source_app=bgp`).
operationId: listAuditLog
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
- name: action
in: query
schema:
type: string
description: Filter by action prefix/key (exact match).
- name: severity
in: query
schema:
$ref: "#/components/schemas/AuditSeverity"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/AuditLogList"
"400":
$ref: "#/components/responses/BadRequest"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+118
View File
@@ -0,0 +1,118 @@
// Package audit pushes local audit events to auth-portal ingest API.
package audit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"evobgp/internal/httpclient"
"evobgp/internal/store"
)
const ingestPath = "/api/v1/ingest/audit"
// PortalPusher sends audit rows to auth-portal (best-effort, async-friendly).
type PortalPusher struct {
BaseURL string
Secret string
HTTPClient *http.Client
MarkPushed func(id string) error
}
// PushEvent posts one audit entry to portal ingest.
func (p *PortalPusher) PushEvent(ctx context.Context, entry *store.AuditEntry) error {
if p == nil || entry == nil {
return nil
}
base := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
secret := strings.TrimSpace(p.Secret)
if base == "" || secret == "" {
return nil
}
hc := p.HTTPClient
if hc == nil {
hc = httpclient.New(15 * time.Second)
}
body := map[string]any{
"events": []map[string]any{p.eventPayload(entry)},
}
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("audit: marshal ingest: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+ingestPath, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("audit: portal ingest: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("audit: portal ingest %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
if p.MarkPushed != nil {
if err := p.MarkPushed(entry.ID); err != nil {
log.Printf("audit: mark portal pushed id=%s: %v", entry.ID, err)
}
}
return nil
}
func (p *PortalPusher) eventPayload(entry *store.AuditEntry) map[string]any {
ev := map[string]any{
"event_id": entry.EventID,
"source_app": store.AuditSourceAppBGP,
"action": entry.Action,
"severity": entry.Severity,
"summary": entry.Summary,
"created_at": entry.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if entry.ActorUserID != "" {
ev["actor_user_id"] = entry.ActorUserID
} else {
ev["actor_user_id"] = nil
}
if entry.ActorEmail != "" {
ev["actor_email"] = entry.ActorEmail
} else {
ev["actor_email"] = nil
}
if entry.ActorName != "" {
ev["actor_name"] = entry.ActorName
} else {
ev["actor_name"] = nil
}
if entry.TargetType != "" {
ev["target_type"] = entry.TargetType
} else {
ev["target_type"] = nil
}
if entry.TargetID != "" {
ev["target_id"] = entry.TargetID
} else {
ev["target_id"] = nil
}
if entry.Details != nil {
ev["details"] = entry.Details
} else {
ev["details"] = nil
}
if entry.IP != "" {
ev["ip"] = entry.IP
} else {
ev["ip"] = nil
}
return ev
}
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"evobgp/internal/store"
)
func TestPortalPusherPushEvent(t *testing.T) {
var got struct {
Events []map[string]any `json:"events"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != ingestPath {
t.Fatalf("path=%s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-secret" {
t.Fatalf("auth=%q", r.Header.Get("Authorization"))
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"accepted": 1, "duplicates": 0})
}))
defer srv.Close()
marked := false
p := &PortalPusher{
BaseURL: srv.URL,
Secret: "test-secret",
MarkPushed: func(id string) error {
marked = id == "local-id"
return nil
},
}
entry := &store.AuditEntry{
ID: "local-id",
EventID: "bgp-test-event",
Action: "bgp.module.create",
Severity: store.AuditSeverityInfo,
Summary: "Created module",
SourceApp: store.AuditSourceAppBGP,
CreatedAt: time.Now().UTC(),
TargetType: store.AuditTargetAppResource,
TargetID: "mod-1",
}
if err := p.PushEvent(context.Background(), entry); err != nil {
t.Fatal(err)
}
if len(got.Events) != 1 {
t.Fatalf("events=%d", len(got.Events))
}
if got.Events[0]["source_app"] != "bgp" {
t.Fatalf("source_app=%v", got.Events[0]["source_app"])
}
if !marked {
t.Fatal("expected mark pushed")
}
}
+121
View File
@@ -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
}
+47
View File
@@ -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)
}
}
+50 -10
View File
@@ -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
}
}
}
+25 -2
View File
@@ -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")
}
}
+1
View File
@@ -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;
};
}
+171
View File
@@ -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))
}
+113
View File
@@ -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)
}
}
+4
View File
@@ -83,6 +83,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
s.registerCRUDRoutes(m)
s.registerAuditRoutes(m)
s.registerPostgresMonitoringRoutes(m)
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
@@ -298,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)
+6 -1
View File
@@ -131,6 +131,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
}
out := apiKeyJSON(&created.APIKey)
out["token"] = created.Token
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+created.Name, created.ID, map[string]any{"api_key_id": created.ID, "role": created.Role})
writeJSON(w, http.StatusCreated, out)
}
@@ -187,6 +188,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.update", "Updated API key "+k.Name, k.ID, map[string]any{"api_key_id": k.ID, "role": k.Role})
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
@@ -195,7 +197,8 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
keyID := r.PathValue("id")
if err := s.store.RevokeAPIKey(a.TenantID, keyID); err != nil {
writeStoreErr(w, err)
return
}
@@ -203,6 +206,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.revoke", "Revoked API key", keyID, map[string]any{"api_key_id": keyID})
w.WriteHeader(http.StatusNoContent)
}
@@ -222,5 +226,6 @@ func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
}
out := apiKeyJSON(&rotated.APIKey)
out["token"] = rotated.Token
s.recordCRUDAudit(r, a, "bgp.api_key.rotate", "Rotated API key "+rotated.Name, rotated.ID, map[string]any{"api_key_id": rotated.ID})
writeJSON(w, http.StatusOK, out)
}
+171
View File
@@ -0,0 +1,171 @@
package httpapi
import (
"context"
"log"
"net"
"net/http"
"strings"
"time"
"evobgp/internal/audit"
"evobgp/internal/store"
)
func (s *Server) registerAuditRoutes(m *http.ServeMux) {
m.HandleFunc("GET /audit", s.handleListAudit)
}
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 200)
filter := store.AuditListFilter{
Action: strings.TrimSpace(r.URL.Query().Get("action")),
Severity: strings.TrimSpace(r.URL.Query().Get("severity")),
}
if filter.Severity != "" && !store.ValidAuditSeverity(filter.Severity) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid severity")
return
}
items, next, hasMore, err := s.store.ListAudit(a.TenantID, cursor, limit, filter)
if err != nil {
writeInternalError(w, "audit_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, row := range items {
out = append(out, auditEntryJSON(row))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
func auditEntryJSON(row *store.AuditEntry) map[string]any {
if row == nil {
return map[string]any{}
}
m := map[string]any{
"id": row.ID,
"tenant_id": row.TenantID,
"event_id": row.EventID,
"source_app": row.SourceApp,
"action": row.Action,
"severity": row.Severity,
"actor_user_id": strPtrOrNull(row.ActorUserID),
"actor_email": strPtrOrNull(row.ActorEmail),
"actor_name": strPtrOrNull(row.ActorName),
"actor_api_key_prefix": strPtrOrNull(row.ActorAPIKeyPrefix),
"target_type": strPtrOrNull(row.TargetType),
"target_id": strPtrOrNull(row.TargetID),
"summary": row.Summary,
"details": row.Details,
"ip": strPtrOrNull(row.IP),
"created_at": row.CreatedAt.UTC().Format(time.RFC3339Nano),
"portal_pushed_at": nil,
}
if row.PortalPushedAt != nil {
m["portal_pushed_at"] = row.PortalPushedAt.UTC().Format(time.RFC3339Nano)
}
if m["details"] == nil {
m["details"] = nil
}
return m
}
func (s *Server) recordCRUDAudit(r *http.Request, a Auth, action, summary, targetID string, details map[string]any) {
if s == nil || s.store == nil {
return
}
in := store.AuditAppendInput{
TenantID: a.TenantID,
Action: action,
Severity: store.AuditSeverityInfo,
TargetType: store.AuditTargetAppResource,
TargetID: targetID,
Summary: summary,
Details: details,
IP: clientIP(r),
}
fillAuditActor(&in, a)
entry, err := s.store.AppendAudit(in)
if err != nil {
log.Printf("httpapi: audit append action=%s: %v", action, err)
return
}
s.pushAuditToPortal(entry)
}
func fillAuditActor(in *store.AuditAppendInput, a Auth) {
if in == nil {
return
}
if a.Kind == AuthKindJWT {
in.ActorUserID = strings.TrimSpace(a.UserID)
in.ActorEmail = strings.TrimSpace(a.Email)
if in.ActorEmail != "" {
in.ActorName = in.ActorEmail
}
return
}
prefix := actorPrefix(a)
in.ActorAPIKeyPrefix = prefix
if prefix != "" {
in.ActorName = "apikey:" + prefix
}
}
func (s *Server) pushAuditToPortal(entry *store.AuditEntry) {
if s == nil || s.auditPusher == nil || entry == nil {
return
}
pusher := s.auditPusher
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := pusher.PushEvent(ctx, entry); err != nil {
log.Printf("httpapi: audit portal push event_id=%s: %v", entry.EventID, err)
}
}()
}
func clientIP(r *http.Request) string {
if r == nil {
return ""
}
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
return xrip
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
// initAuditPusher wires portal push when URL and secret are configured.
func (s *Server) initAuditPusher(portalURL, ingestSecret string) {
base := strings.TrimSpace(portalURL)
secret := strings.TrimSpace(ingestSecret)
if base == "" || secret == "" {
return
}
s.auditPusher = &audit.PortalPusher{
BaseURL: base,
Secret: secret,
MarkPushed: func(id string) error {
if s.store == nil {
return nil
}
return s.store.MarkAuditPortalPushed(id)
},
}
}
+52
View File
@@ -0,0 +1,52 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"evobgp/internal/store"
)
func TestHandleListAudit(t *testing.T) {
mem := store.NewMemory()
mem.SeedDemo()
tenant, _, _, _, _ := mem.DemoIDs()
srv, err := New(Options{SeedDemo: false, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
srv.store = mem
_, err = mem.AppendAudit(store.AuditAppendInput{
TenantID: tenant,
Action: "bgp.module.create",
Summary: "Created module demo",
TargetID: "mod-x",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/v1/audit", nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Items) != 1 {
t.Fatalf("items=%d", len(body.Items))
}
if body.Items[0]["action"] != "bgp.module.create" {
t.Fatalf("action=%v", body.Items[0]["action"])
}
}
@@ -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)
}
}
+110 -7
View File
@@ -8,6 +8,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -56,6 +57,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("GET /communities", s.handleListComm)
m.HandleFunc("POST /communities", s.handlePostComm)
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)
@@ -64,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)
@@ -113,6 +116,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.create", "Created module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "type": mod.Type, "name": mod.Name})
writeJSON(w, http.StatusCreated, moduleJSON(mod))
}
@@ -174,6 +178,7 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.update", "Updated module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "name": mod.Name})
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
@@ -193,6 +198,7 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.delete", "Deleted module", moduleID, map[string]any{"module_id": moduleID})
w.WriteHeader(http.StatusNoContent)
}
@@ -227,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
}
@@ -369,6 +378,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.create", "Created CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID, "url": x.URL})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
}
@@ -399,6 +409,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.update", "Updated CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
}
@@ -409,10 +420,12 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
sourceID := r.PathValue("source_id")
if err := s.store.DeleteCDNSource(a.TenantID, mid, sourceID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.delete", "Deleted CDN source", sourceID, map[string]any{"module_id": mid, "source_id": sourceID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -471,6 +484,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.create", "Created AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
writeJSON(w, http.StatusCreated, asEntryJSON(x))
}
@@ -491,6 +505,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.update", "Updated AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
writeJSON(w, http.StatusOK, asEntryJSON(x))
}
@@ -501,10 +516,12 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteASEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.delete", "Deleted AS entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -548,6 +565,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.create", "Created domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
}
@@ -568,6 +586,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.update", "Updated domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
writeJSON(w, http.StatusOK, domainEntryJSON(x))
}
@@ -578,10 +597,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteDomainEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.delete", "Deleted domain entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -625,6 +646,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.create", "Created IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
}
@@ -645,6 +667,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.update", "Updated IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
writeJSON(w, http.StatusOK, ipRangeJSON(x))
}
@@ -655,10 +678,12 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.delete", "Deleted IP range entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -850,6 +875,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.create", "Created DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
writeJSON(w, http.StatusCreated, dohJSON(x))
}
@@ -868,6 +894,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.update", "Updated DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
writeJSON(w, http.StatusOK, dohJSON(x))
}
@@ -876,10 +903,12 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
profileID := r.PathValue("id")
if err := s.store.DeleteDohProfile(a.TenantID, profileID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.delete", "Deleted DoH profile", profileID, map[string]any{"profile_id": profileID})
w.WriteHeader(http.StatusNoContent)
}
@@ -921,6 +950,39 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, commJSON(x))
}
func (s *Server) handleListCommPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 500
}
cursor := r.URL.Query().Get("cursor")
rows, next, more, err := s.store.ListCommunityPrefixes(a.TenantID, r.PathValue("id"), cursor, limit)
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(rows))
prefixes := make([]string, 0, len(rows))
for _, pr := range rows {
m := map[string]any{"prefix": pr.Prefix}
if pr.Source != "" {
m["source"] = pr.Source
}
items = append(items, m)
prefixes = append(prefixes, pr.Prefix)
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"prefixes": prefixes,
"next_cursor": strPtrOrNull(next),
"has_more": more,
})
}
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
@@ -936,6 +998,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.create", "Created community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
writeJSON(w, http.StatusCreated, commJSON(x))
}
@@ -954,6 +1017,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.update", "Updated community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
writeJSON(w, http.StatusOK, commJSON(x))
}
@@ -962,10 +1026,12 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
commID := r.PathValue("id")
if err := s.store.DeleteCommunity(a.TenantID, commID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.delete", "Deleted community", commID, map[string]any{"community_id": commID})
w.WriteHeader(http.StatusNoContent)
}
@@ -988,6 +1054,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.create", "Created BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
s.enqueuePeerReconcile(a.TenantID, "peer_create")
writeJSON(w, http.StatusCreated, peerJSON(x))
}
@@ -1031,6 +1098,7 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.update", "Updated BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
writeJSON(w, http.StatusOK, peerJSON(x))
}
@@ -1051,6 +1119,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.delete", "Deleted BGP peer", peerID, map[string]any{"peer_id": peerID})
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -1074,6 +1143,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.create", "Created speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
resp := speakerJSONFromStore(s.store, x)
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
resp["agent_secret"] = meta.AgentSecret
@@ -1109,6 +1179,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.update", "Updated speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
}
@@ -1117,10 +1188,12 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
speakerID := r.PathValue("speaker_id")
if err := s.store.DeleteSpeaker(a.TenantID, speakerID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.delete", "Deleted speaker", speakerID, map[string]any{"speaker_id": speakerID})
w.WriteHeader(http.StatusNoContent)
}
@@ -1187,9 +1260,39 @@ 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
}
keys := make([]string, 0, len(body))
for k := range body {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func parseRevisionRetentionMinutes(v any) (int, bool) {
const minMinutes = 15
const maxMinutes = 30 * 24 * 60
+12 -5
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"evobgp/internal/audit"
"evobgp/internal/jobs"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
@@ -38,11 +39,13 @@ type Server struct {
mux *http.ServeMux
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
auditIngestSecret string
auditPusher *audit.PortalPusher
}
// Options configures the API server.
@@ -63,6 +66,8 @@ type Options struct {
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
// AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional).
AuditIngestSecret string
}
// New constructs Server and wiring for async jobs.
@@ -123,10 +128,12 @@ func New(opts Options) (*Server, error) {
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret),
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.initAuditPusher(s.authPortalURL, s.auditIngestSecret)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
+49
View File
@@ -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
+108 -3
View File
@@ -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
@@ -1252,6 +1272,91 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
return &c, nil
}
func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]store.PrefixRow, string, bool, error) {
comm, err := p.GetCommunity(tenantID, communityID)
if err != nil {
return nil, "", false, err
}
resolvedID := comm.ID
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
useSnap := prefixSnapshotTableExists(ctx, p.pool)
var rows pgx.Rows
if useSnap {
rows, err = p.pool.Query(ctx, `
WITH latest AS (
SELECT DISTINCT ON (module_id) id, prefix_snapshot_id
FROM config_revision
WHERE tenant_id = $1::uuid AND module_id IS NOT NULL
ORDER BY module_id, created_at DESC
),
combined AS (
SELECT rmp.prefix::text AS prefix, COALESCE(rmp.source, '') AS source
FROM revision_materialized_prefix rmp
JOIN latest l ON l.id = rmp.revision_id
WHERE l.prefix_snapshot_id IS NULL AND rmp.community_id = $2::uuid
UNION
SELECT psr.prefix::text, COALESCE(psr.source, '')
FROM prefix_snapshot_row psr
JOIN latest l ON l.prefix_snapshot_id = psr.snapshot_id
WHERE l.prefix_snapshot_id IS NOT NULL AND psr.community_id = $2::uuid
)
SELECT prefix, source FROM combined
ORDER BY prefix
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
} else {
rows, err = p.pool.Query(ctx, `
WITH latest AS (
SELECT DISTINCT ON (module_id) id
FROM config_revision
WHERE tenant_id = $1::uuid AND module_id IS NOT NULL
ORDER BY module_id, created_at DESC
)
SELECT DISTINCT rmp.prefix::text, COALESCE(rmp.source, '')
FROM revision_materialized_prefix rmp
JOIN latest l ON l.id = rmp.revision_id
WHERE rmp.community_id = $2::uuid
ORDER BY 1
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
}
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var all []store.PrefixRow
for rows.Next() {
var pr store.PrefixRow
if err := rows.Scan(&pr.Prefix, &pr.Source); err != nil {
continue
}
pr.CommunityID = &resolvedID
all = append(all, pr)
}
more := len(all) > limit
if more {
all = all[:limit]
}
next := ""
if more {
next = fmt.Sprintf("%d", off+limit)
}
if len(all) == 0 {
return nil, "", false, nil
}
return all, next, more, nil
}
func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) {
if in == nil {
return nil, store.ErrInvalidInput
+186
View File
@@ -0,0 +1,186 @@
package repository
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"evobgp/internal/store"
)
// AppendAudit inserts a tenant-scoped audit row.
func (p *Postgres) AppendAudit(in store.AuditAppendInput) (*store.AuditEntry, error) {
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
return nil, store.ErrInvalidInput
}
sev := strings.TrimSpace(in.Severity)
if sev == "" {
sev = store.AuditSeverityInfo
}
if !store.ValidAuditSeverity(sev) {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
id := uuid.NewString()
eventID := "bgp-" + uuid.NewString()
var detailJSON []byte
if in.Details != nil {
detailJSON, _ = json.Marshal(in.Details)
}
var createdAt time.Time
err := p.pool.QueryRow(ctx, `
INSERT INTO audit_log
(id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at)
VALUES ($1, $2, $3, 'bgp', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now())
RETURNING created_at`,
id, strings.TrimSpace(in.TenantID), eventID, strings.TrimSpace(in.Action), sev,
nullIfEmpty(in.ActorUserID), nullIfEmpty(in.ActorEmail), nullIfEmpty(in.ActorName),
nullIfEmpty(in.ActorAPIKeyPrefix), nullIfEmpty(in.TargetType), nullIfEmpty(in.TargetID),
strings.TrimSpace(in.Summary), nullJSONBytes(detailJSON), nullIfEmpty(in.IP),
).Scan(&createdAt)
if err != nil {
return nil, err
}
return &store.AuditEntry{
ID: id,
TenantID: strings.TrimSpace(in.TenantID),
EventID: eventID,
SourceApp: store.AuditSourceAppBGP,
Action: strings.TrimSpace(in.Action),
Severity: sev,
ActorUserID: strings.TrimSpace(in.ActorUserID),
ActorEmail: strings.TrimSpace(in.ActorEmail),
ActorName: strings.TrimSpace(in.ActorName),
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
TargetType: strings.TrimSpace(in.TargetType),
TargetID: strings.TrimSpace(in.TargetID),
Summary: strings.TrimSpace(in.Summary),
Details: in.Details,
IP: strings.TrimSpace(in.IP),
CreatedAt: createdAt.UTC(),
}, nil
}
// ListAudit returns paginated audit rows for a tenant.
func (p *Postgres) ListAudit(tenantID, cursor string, limit int, filter store.AuditListFilter) ([]*store.AuditEntry, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
args := []any{tenantID}
where := "tenant_id = $1"
argN := 2
if a := strings.TrimSpace(filter.Action); a != "" {
where += " AND action = $" + strconv.Itoa(argN)
args = append(args, a)
argN++
}
if s := strings.TrimSpace(filter.Severity); s != "" {
where += " AND severity = $" + strconv.Itoa(argN)
args = append(args, s)
argN++
}
args = append(args, limit+1, off)
q := `
SELECT id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at, portal_pushed_at
FROM audit_log
WHERE ` + where + `
ORDER BY created_at DESC, id DESC
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
rows, err := p.pool.Query(ctx, q, args...)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.AuditEntry
for rows.Next() {
row, err := scanAuditEntry(rows.Scan)
if err != nil {
return nil, "", false, err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, "", false, err
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, nil
}
// MarkAuditPortalPushed sets portal_pushed_at for a row.
func (p *Postgres) MarkAuditPortalPushed(id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `UPDATE audit_log SET portal_pushed_at = now() WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func scanAuditEntry(scan func(dest ...any) error) (*store.AuditEntry, error) {
var row store.AuditEntry
var actorUserID, actorEmail, actorName, actorPrefix, targetType, targetID, ip *string
var detailRaw []byte
var portalPushed *time.Time
if err := scan(
&row.ID, &row.TenantID, &row.EventID, &row.SourceApp, &row.Action, &row.Severity,
&actorUserID, &actorEmail, &actorName, &actorPrefix,
&targetType, &targetID, &row.Summary, &detailRaw, &ip, &row.CreatedAt, &portalPushed,
); err != nil {
return nil, err
}
row.CreatedAt = row.CreatedAt.UTC()
if actorUserID != nil {
row.ActorUserID = *actorUserID
}
if actorEmail != nil {
row.ActorEmail = *actorEmail
}
if actorName != nil {
row.ActorName = *actorName
}
if actorPrefix != nil {
row.ActorAPIKeyPrefix = *actorPrefix
}
if targetType != nil {
row.TargetType = *targetType
}
if targetID != nil {
row.TargetID = *targetID
}
if ip != nil {
row.IP = *ip
}
if len(detailRaw) > 0 {
_ = json.Unmarshal(detailRaw, &row.Details)
}
if portalPushed != nil {
t := portalPushed.UTC()
row.PortalPushedAt = &t
}
return &row, nil
}
@@ -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
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"strings"
"time"
)
const (
AuditSourceAppBGP = "bgp"
AuditTargetAppResource = "app_resource"
AuditSeverityInfo = "info"
AuditSeverityWarning = "warning"
AuditSeverityCritical = "critical"
)
// AuditEntry is a persisted CRUD / settings audit row (local + portal ingest).
type AuditEntry struct {
ID string
TenantID string
EventID string
SourceApp string
Action string
Severity string
ActorUserID string
ActorEmail string
ActorName string
ActorAPIKeyPrefix string
TargetType string
TargetID string
Summary string
Details map[string]any
IP string
CreatedAt time.Time
PortalPushedAt *time.Time
}
// AuditAppendInput is input for AppendAudit.
type AuditAppendInput struct {
TenantID string
Action string
Severity string
ActorUserID string
ActorEmail string
ActorName string
ActorAPIKeyPrefix string
TargetType string
TargetID string
Summary string
Details map[string]any
IP string
}
// AuditListFilter optional query filters for ListAudit.
type AuditListFilter struct {
Action string
Severity string
}
// ValidAuditSeverity reports whether s is an allowed severity.
func ValidAuditSeverity(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case AuditSeverityInfo, AuditSeverityWarning, AuditSeverityCritical:
return true
default:
return false
}
}
+14
View File
@@ -60,6 +60,8 @@ type Backend interface {
CreateCommunity(tenantID string, in *Community) (*Community, error)
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
DeleteCommunity(tenantID, id string) error
// ListCommunityPrefixes returns unique prefixes tagged with community from latest revision per module.
ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) (prefixes []PrefixRow, nextCursor string, hasMore bool, err error)
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
ListPeers(tenantID string) []*BGPPeer
@@ -68,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)
@@ -132,6 +141,11 @@ type Backend interface {
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
// CRUD audit log (tenant-scoped; optional portal ingest push from httpapi).
AppendAudit(in AuditAppendInput) (*AuditEntry, error)
ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error)
MarkAuditPortalPushed(id string) error
// Firewall blocklist clients and policy rules.
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
+4
View File
@@ -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
@@ -50,6 +52,7 @@ type Memory struct {
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
auditLog []*AuditEntry
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -142,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),
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) AppendAudit(in AuditAppendInput) (*AuditEntry, error) {
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
return nil, ErrInvalidInput
}
sev := strings.TrimSpace(in.Severity)
if sev == "" {
sev = AuditSeverityInfo
}
if !ValidAuditSeverity(sev) {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
row := &AuditEntry{
ID: uuid.NewString(),
TenantID: strings.TrimSpace(in.TenantID),
EventID: "bgp-" + uuid.NewString(),
SourceApp: AuditSourceAppBGP,
Action: strings.TrimSpace(in.Action),
Severity: sev,
ActorUserID: strings.TrimSpace(in.ActorUserID),
ActorEmail: strings.TrimSpace(in.ActorEmail),
ActorName: strings.TrimSpace(in.ActorName),
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
TargetType: strings.TrimSpace(in.TargetType),
TargetID: strings.TrimSpace(in.TargetID),
Summary: strings.TrimSpace(in.Summary),
Details: in.Details,
IP: strings.TrimSpace(in.IP),
CreatedAt: now,
}
m.mu.Lock()
defer m.mu.Unlock()
m.auditLog = append(m.auditLog, row)
return cloneAuditEntry(row), nil
}
func (m *Memory) ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var filtered []*AuditEntry
for _, row := range m.auditLog {
if row.TenantID != tenantID {
continue
}
if a := strings.TrimSpace(filter.Action); a != "" && row.Action != a {
continue
}
if s := strings.TrimSpace(filter.Severity); s != "" && row.Severity != s {
continue
}
filtered = append(filtered, row)
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
return filtered[i].ID > filtered[j].ID
}
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(filtered) {
end = len(filtered)
} else if end < len(filtered) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(filtered) {
return nil, "", false, nil
}
out := make([]*AuditEntry, end-off)
for i := off; i < end; i++ {
out[i-off] = cloneAuditEntry(filtered[i])
}
return out, next, hasMore, nil
}
func (m *Memory) MarkAuditPortalPushed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, row := range m.auditLog {
if row.ID == id {
now := time.Now().UTC()
row.PortalPushedAt = &now
return nil
}
}
return ErrNotFound
}
func cloneAuditEntry(row *AuditEntry) *AuditEntry {
if row == nil {
return nil
}
cp := *row
if row.Details != nil {
cp.Details = make(map[string]any, len(row.Details))
for k, v := range row.Details {
cp.Details[k] = v
}
}
return &cp
}
+70
View File
@@ -0,0 +1,70 @@
package store
import "testing"
func TestMemoryAppendAndListAudit(t *testing.T) {
m := NewMemory()
tenantA := "tenant-a"
tenantB := "tenant-b"
entry, err := m.AppendAudit(AuditAppendInput{
TenantID: tenantA,
Action: "bgp.module.create",
Summary: "Created module test",
TargetID: "mod-1",
})
if err != nil {
t.Fatal(err)
}
if entry == nil || entry.EventID == "" || entry.SourceApp != AuditSourceAppBGP {
t.Fatalf("unexpected entry: %+v", entry)
}
if _, err := m.AppendAudit(AuditAppendInput{
TenantID: tenantB,
Action: "bgp.peer.delete",
Summary: "Deleted peer",
}); err != nil {
t.Fatal(err)
}
items, _, hasMore, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || hasMore {
t.Fatalf("items=%d hasMore=%v", len(items), hasMore)
}
if items[0].Action != "bgp.module.create" {
t.Fatalf("action=%s", items[0].Action)
}
filtered, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{Action: "bgp.peer.delete"})
if err != nil {
t.Fatal(err)
}
if len(filtered) != 0 {
t.Fatalf("expected empty filter result, got %d", len(filtered))
}
if err := m.MarkAuditPortalPushed(entry.ID); err != nil {
t.Fatal(err)
}
items2, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
if err != nil {
t.Fatal(err)
}
if items2[0].PortalPushedAt == nil {
t.Fatal("expected portal_pushed_at")
}
}
func TestMemoryAppendAuditValidation(t *testing.T) {
m := NewMemory()
if _, err := m.AppendAudit(AuditAppendInput{}); err != ErrInvalidInput {
t.Fatalf("err=%v", err)
}
if _, err := m.AppendAudit(AuditAppendInput{TenantID: "t", Action: "x", Summary: "s", Severity: "bad"}); err != ErrInvalidInput {
t.Fatalf("err=%v", err)
}
}
+89 -4
View File
@@ -1,6 +1,8 @@
package store
import (
"sort"
"strconv"
"strings"
"time"
@@ -583,14 +585,97 @@ 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) {
commRow, err := m.GetCommunity(tenantID, communityID)
if err != nil {
return nil, "", false, err
}
resolvedID := commRow.ID
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
m.mu.RLock()
defer m.mu.RUnlock()
latestByModule := map[string]*Revision{}
for _, rev := range m.revisions {
if rev.TenantID != tenantID || strings.TrimSpace(rev.ModuleID) == "" {
continue
}
cur := latestByModule[rev.ModuleID]
if cur == nil || rev.CreatedAt.After(cur.CreatedAt) {
latestByModule[rev.ModuleID] = rev
}
}
seen := map[string]struct{}{}
var all []PrefixRow
for _, rev := range latestByModule {
for _, pr := range m.revPrefixes[rev.ID] {
if pr.CommunityID == nil || *pr.CommunityID != resolvedID {
continue
}
pfx := strings.TrimSpace(pr.Prefix)
if pfx == "" {
continue
}
if _, ok := seen[pfx]; ok {
continue
}
seen[pfx] = struct{}{}
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 })
if off > len(all) {
return nil, "", false, nil
}
end := off + limit
more := false
next := ""
if end < len(all) {
more = true
next = strconv.Itoa(end)
all = all[off:end]
} else {
all = all[off:]
}
if len(all) == 0 {
return nil, "", false, nil
}
return all, next, more, nil
}
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
+213
View File
@@ -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
}
+44
View File
@@ -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_audit_log_tenant_action;
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
DROP INDEX IF EXISTS idx_audit_log_event_id;
DROP TABLE IF EXISTS audit_log;
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
event_id TEXT NOT NULL,
source_app TEXT NOT NULL DEFAULT 'bgp',
action TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
actor_user_id TEXT,
actor_email TEXT,
actor_name TEXT,
actor_api_key_prefix TEXT,
target_type TEXT,
target_id TEXT,
summary TEXT NOT NULL,
details_json JSONB,
ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
portal_pushed_at TIMESTAMPTZ,
CONSTRAINT audit_log_severity_chk CHECK (severity IN ('info', 'warning', 'critical')),
CONSTRAINT audit_log_source_app_chk CHECK (source_app = 'bgp'),
CONSTRAINT audit_log_summary_chk CHECK (length(trim(summary)) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
ON audit_log (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
ON audit_log (tenant_id, action, created_at DESC);
@@ -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_audit_log_tenant_action;
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
DROP INDEX IF EXISTS idx_audit_log_event_id;
DROP TABLE IF EXISTS audit_log;
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
event_id TEXT NOT NULL,
source_app TEXT NOT NULL DEFAULT 'bgp',
action TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
actor_user_id TEXT,
actor_email TEXT,
actor_name TEXT,
actor_api_key_prefix TEXT,
target_type TEXT,
target_id TEXT,
summary TEXT NOT NULL,
details_json TEXT,
ip TEXT,
created_at TEXT NOT NULL,
portal_pushed_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
ON audit_log (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
ON audit_log (tenant_id, action, created_at DESC);
@@ -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 = '';