From 54a0b5b966cadcb0545d98ecc4cd9835a699b101 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 20:53:11 +0700 Subject: [PATCH] feat: add lookup functionality for IP/domain verification and enhance dashboard links Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality. --- .../dashboard/dashboard-quick-links.tsx | 10 +- apps/web/src/components/layout/app-shell.tsx | 2 + .../components/lookup/lookup-matches-grid.tsx | 129 ++++++++ .../components/lookup/lookup-search-form.tsx | 76 +++++ .../components/lookup/lookup-summary-kpi.tsx | 50 +++ apps/web/src/queries/lookup.ts | 21 ++ apps/web/src/routes/_auth/lookup.tsx | 86 ++++++ apps/web/src/types/api.ts | 29 ++ apps/web/tsconfig.tsbuildinfo | 2 +- docs/api.md | 9 + docs/openapi.yaml | 133 ++++++++ docs/ui-design-contract.md | 1 + internal/httpapi/routes.go | 1 + internal/httpapi/routes_lookup.go | 37 +++ internal/httpapi/routes_lookup_test.go | 79 +++++ internal/lookup/lookup.go | 290 ++++++++++++++++++ internal/lookup/lookup_test.go | 206 +++++++++++++ 17 files changed, 1159 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/lookup/lookup-matches-grid.tsx create mode 100644 apps/web/src/components/lookup/lookup-search-form.tsx create mode 100644 apps/web/src/components/lookup/lookup-summary-kpi.tsx create mode 100644 apps/web/src/queries/lookup.ts create mode 100644 apps/web/src/routes/_auth/lookup.tsx create mode 100644 internal/httpapi/routes_lookup.go create mode 100644 internal/httpapi/routes_lookup_test.go create mode 100644 internal/lookup/lookup.go create mode 100644 internal/lookup/lookup_test.go diff --git a/apps/web/src/components/dashboard/dashboard-quick-links.tsx b/apps/web/src/components/dashboard/dashboard-quick-links.tsx index 56f7bf1..9dc5d64 100644 --- a/apps/web/src/components/dashboard/dashboard-quick-links.tsx +++ b/apps/web/src/components/dashboard/dashboard-quick-links.tsx @@ -1,8 +1,16 @@ -import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react' +import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react' import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit' const ACTIONS: QuickActionItem[] = [ + { + id: 'lookup', + title: 'Проверка IP/домена', + description: 'Membership в списках и community (entry + snapshot).', + to: '/lookup', + icon: , + iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground', + }, { id: 'new-module', title: 'Создать модуль', diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 6ed4522..a2f350f 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -10,6 +10,7 @@ import { KeyRound, ServerCog, Shield, + Search, } from 'lucide-react' import { @@ -74,6 +75,7 @@ const NAV_GROUPS: NavGroup[] = [ label: 'Маршрутизация', items: [ { to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' }, + { to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' }, { to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } }, { to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' }, ], diff --git a/apps/web/src/components/lookup/lookup-matches-grid.tsx b/apps/web/src/components/lookup/lookup-matches-grid.tsx new file mode 100644 index 0000000..e8c0754 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-matches-grid.tsx @@ -0,0 +1,129 @@ +import { ColumnDef } from '@tanstack/react-table' +import { useMemo } from 'react' +import { useNavigate } from '@tanstack/react-router' + +import { CategoryBadge } from '@/components/category-badge' +import { DataGridPrimaryCell } from '@/components/data-grid-cell' +import { DataGridCard, DataGridSection } from '@/components/data-grid-shell' +import { Badge } from '@/components/reui/badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { useClientDataGrid } from '@/hooks/use-client-data-grid' +import type { LookupMatch } from '@/types/api' + +/** + * Lookup matches grid — data-grid-filtering-2 pattern. + * @see https://reui.io/preview/base/data-grid-filtering-2 + * @see https://reui.io/docs/components/base/badge + */ +export function LookupMatchesGrid({ + items, + isLoading = false, +}: { + items: LookupMatch[] + isLoading?: boolean +}) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'layer', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.layer} + + ), + meta: { headerTitle: 'Слой' }, + }, + { + accessorKey: 'module_name', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Модуль' }, + }, + { + accessorKey: 'matched_value', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Совпадение' }, + }, + { + id: 'community', + accessorFn: (row) => row.community_title || row.community || '', + header: ({ column }) => , + cell: ({ row }) => { + const title = row.original.community_title?.trim() + const value = row.original.community?.trim() + if (!title && !value) { + return + } + return ( + + ) + }, + meta: { headerTitle: 'Community' }, + }, + { + id: 'source', + enableSorting: false, + header: 'Источник', + cell: ({ row }) => + row.original.source ? ( + {row.original.source} + ) : ( + + ), + meta: { headerTitle: 'Источник' }, + }, + ], + [], + ) + + const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ + data: items, + columns, + getSearchText: (row) => + `${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`, + getRowId: (row) => + `${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`, + }) + + return ( + + + void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } }) + } + /> + + ) +} diff --git a/apps/web/src/components/lookup/lookup-search-form.tsx b/apps/web/src/components/lookup/lookup-search-form.tsx new file mode 100644 index 0000000..63385a1 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-search-form.tsx @@ -0,0 +1,76 @@ +import { useState, type FormEvent } from 'react' +import { Search } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { Field, FieldLabel } from '@evobgp/ui/components/field' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@evobgp/ui/components/input-group' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' + +/** + * Lookup search form — Frame + InputGroup (form-7 pattern). + * @see https://reui.io/preview/base/form-7 + * @see https://reui.io/docs/components/base/frame + */ +export function LookupSearchForm({ + initialQuery = '', + isPending = false, + onSubmit, +}: { + initialQuery?: string + isPending?: boolean + onSubmit: (q: string) => void +}) { + const [value, setValue] = useState(initialQuery) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const q = value.trim() + if (!q) return + onSubmit(q) + } + + return ( + + + Проверка списка + + IP или FQDN — поиск в entries и материализованных snapshots с community. + + + +
+ + IP или домен + + + + + setValue(e.target.value)} + placeholder="8.8.8.8 или example.com" + autoComplete="off" + autoFocus + /> + + + +
+
+ + ) +} diff --git a/apps/web/src/components/lookup/lookup-summary-kpi.tsx b/apps/web/src/components/lookup/lookup-summary-kpi.tsx new file mode 100644 index 0000000..b2839ed --- /dev/null +++ b/apps/web/src/components/lookup/lookup-summary-kpi.tsx @@ -0,0 +1,50 @@ +import { Layers, ListChecks, Radar } from 'lucide-react' + +import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' +import type { LookupResponse } from '@/types/api' + +/** + * Lookup summary KPI — stats-12 via KpiStatGrid. + * @see https://reui.io/preview/base/stats-12 + */ +export function LookupSummaryKpi({ data }: { data: LookupResponse }) { + const entryCount = data.matches.filter((m) => m.layer === 'entry').length + const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length + + const items: KpiStatItem[] = [ + { + id: 'matched', + label: 'Результат', + value: data.matched ? 'Найдено' : 'Не найдено', + hint: data.normalized, + icon: , + iconClassName: data.matched + ? 'bg-success text-success-foreground [&_svg]:text-success-foreground' + : 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground', + variant: data.matched ? 'default' : 'warning', + }, + { + id: 'entry', + label: 'Слой entry', + value: entryCount, + hint: 'сырые списки', + icon: , + iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground', + }, + { + id: 'snapshot', + label: 'Слой snapshot', + value: snapshotCount, + hint: 'материализация', + icon: , + iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground', + }, + ] + + return ( + + ) +} diff --git a/apps/web/src/queries/lookup.ts b/apps/web/src/queries/lookup.ts new file mode 100644 index 0000000..16b49cc --- /dev/null +++ b/apps/web/src/queries/lookup.ts @@ -0,0 +1,21 @@ +import { queryOptions } from '@tanstack/react-query' + +import { apiJSON } from '@/lib/api-client' +import type { LookupResponse } from '@/types/api' + +export const lookupKeys = { + all: ['lookup'] as const, + query: (q: string) => [...lookupKeys.all, q] as const, +} + +/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */ +export function lookupQueryOptions(q: string) { + const trimmed = q.trim() + return queryOptions({ + queryKey: lookupKeys.query(trimmed), + queryFn: () => + apiJSON(`/v1/lookup?q=${encodeURIComponent(trimmed)}`), + enabled: trimmed.length > 0, + staleTime: 15_000, + }) +} diff --git a/apps/web/src/routes/_auth/lookup.tsx b/apps/web/src/routes/_auth/lookup.tsx new file mode 100644 index 0000000..6ac6657 --- /dev/null +++ b/apps/web/src/routes/_auth/lookup.tsx @@ -0,0 +1,86 @@ +import { createFileRoute, useSearch } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { Search } from 'lucide-react' + +import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid' +import { LookupSearchForm } from '@/components/lookup/lookup-search-form' +import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi' +import { PageHeader } from '@/components/page-header' +import { EmptyState } from '@/components/empty-state' +import { QueryState } from '@/components/query-state' +import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons' +import { lookupQueryOptions } from '@/queries/lookup' + +/** + * Quick membership lookup page. + * Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2 + * @see https://reui.io/preview/base/stats-12 + * @see https://reui.io/preview/base/form-7 + * @see https://reui.io/preview/base/data-grid-filtering-2 + * @see https://reui.io/preview/base/empty-state-2 + */ +export const Route = createFileRoute('/_auth/lookup')({ + component: LookupComponent, + validateSearch: (search: Record) => ({ + q: typeof search.q === 'string' ? search.q : '', + }), +}) + +function LookupComponent() { + const { q } = useSearch({ from: '/_auth/lookup' }) + const navigate = Route.useNavigate() + const lookupQ = useQuery(lookupQueryOptions(q)) + + return ( +
+ + + void navigate({ search: { q: next } })} + /> + + {!q.trim() ? ( + } + title="Введите IP или домен" + description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам." + /> + ) : ( + void lookupQ.refetch()} + skeleton={ +
+ + +
+ } + > + {(data) => ( +
+ + {data.matched ? ( + + ) : ( + } + title="Не найдено в списках" + description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`} + /> + )} +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 193e38b..d0f908f 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -151,6 +151,35 @@ export type BgpCommunityCreate = { export type BgpCommunityPatch = Partial export type CommunitiesResponse = Page +// ---- Lookup (GET /v1/lookup) ---- +/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */ +export type LookupQueryKind = 'ip' | 'domain' +export type LookupLayer = 'entry' | 'snapshot' +export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix' + +export type LookupMatch = { + layer: LookupLayer + module_id: string + module_name: string + module_type: ModuleType + match_kind: LookupMatchKind + matched_value: string + entry_id?: string + source?: string + community_id?: string | null + community?: string + community_title?: string +} + +export type LookupResponse = { + query: string + query_kind: LookupQueryKind + normalized: string + matched: boolean + match_count: number + matches: LookupMatch[] +} + // ---- Peers ---- export type PeerSessionOnSpeaker = { speaker_id: string diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index ac905f4..ea812b4 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 716f971..5080db7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,6 +33,15 @@ - `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}` - `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries` - `POST /v1/modules/{module_id}/refresh` +- `GET /v1/router-lists/catalog` — агрегированный каталог модулей/entries/communities + +### Lookup + +- `GET /v1/lookup?q=` — быстрая проверка IP или FQDN в списках (viewer+). + - Слой `entry`: `IP_RANGES` (`CIDR.Contains`) / `DOMAINS` (нормализованный FQDN). + - Слой `snapshot`: материализованные `module_prefix_snapshot` (для IP — Contains по всем модулям; для домена — `source=domain` у matched DOMAINS-модулей). + - В каждом матче — community (`community_id` / значение / title). + - Live DoH не выполняется. Контракт: OpenAPI `lookupMembership`. ### DoH profiles diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c870070..4897344 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -27,6 +27,8 @@ tags: description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире. - name: Modules description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+. + - name: Lookup + description: Быстрая проверка membership IP/FQDN в списках (entries + module prefix snapshots) и community. Чтение - viewer+. - name: DoH profiles description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается. - name: Communities @@ -221,6 +223,12 @@ components: application/problem+json: schema: $ref: "#/components/schemas/Problem" + BadRequest: + description: Некорректный запрос (пустой или невалидный параметр). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" Forbidden: description: Недостаточно прав для операции. content: @@ -751,6 +759,90 @@ components: description: Человекочитаемое название для UI и фильтров. additionalProperties: true + LookupQueryKind: + type: string + enum: [ip, domain] + description: Определённый тип запроса после нормализации. + + LookupLayer: + type: string + enum: [entry, snapshot] + description: | + `entry` — сырые IP_RANGES / DOMAINS entries; + `snapshot` — материализованные префиксы `module_prefix_snapshot`. + + LookupMatchKind: + type: string + enum: [ip_range, domain, prefix] + description: Вид совпадения (entry CIDR, entry FQDN или snapshot prefix). + + LookupMatch: + type: object + required: + - layer + - module_id + - module_name + - module_type + - match_kind + - matched_value + properties: + layer: + $ref: "#/components/schemas/LookupLayer" + module_id: + $ref: "#/components/schemas/ResourceId" + module_name: + type: string + module_type: + $ref: "#/components/schemas/ModuleType" + match_kind: + $ref: "#/components/schemas/LookupMatchKind" + matched_value: + type: string + description: CIDR, FQDN или prefix, с которым совпал запрос. + entry_id: + type: string + description: ID entry (только для layer=entry). + source: + type: string + description: Источник строки snapshot (ip_range, domain, as, cdn, …). + community_id: + type: ["string", "null"] + community: + type: string + description: Техническое значение BGP community. + community_title: + type: string + description: Человекочитаемое название community. + + LookupResponse: + type: object + required: + - query + - query_kind + - normalized + - matched + - match_count + - matches + properties: + query: + type: string + description: Исходная строка запроса. + query_kind: + $ref: "#/components/schemas/LookupQueryKind" + normalized: + type: string + description: Нормализованный IP или FQDN. + matched: + type: boolean + description: true, если есть хотя бы одно совпадение. + match_count: + type: integer + minimum: 0 + matches: + type: array + items: + $ref: "#/components/schemas/LookupMatch" + BgpPeer: type: object required: @@ -1780,6 +1872,47 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/lookup: + get: + tags: [Lookup] + summary: Проверка IP или домена в списках + description: | + Быстрая membership-проверка по tenant: + + - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot` + (все module prefix snapshots, `Prefix.Contains`); + - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot` + (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть). + + Community на матче: `entry.community_id || module.default_community_id` (entry) + или `PrefixRow.community_id` (snapshot), с join к справочнику communities. + + Live DoH resolve не выполняется — только уже материализованный snapshot. + operationId: lookupMembership + parameters: + - $ref: "#/components/parameters/TenantId" + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 253 + description: IP-адрес или FQDN для проверки. + responses: + "200": + description: Результат проверки (в т.ч. matched=false при отсутствии совпадений). + content: + application/json: + schema: + $ref: "#/components/schemas/LookupResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/router-lists/catalog: get: tags: [Modules] diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index 3223602..ef3c9d3 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -25,6 +25,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad | Auth | `auth-13` | https://reui.io/preview/base/auth-13 | | Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 | | Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 | +| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 | ## Kit API (`reui-kit/`) diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 3021e7a..34b9c1b 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -54,6 +54,7 @@ func (s *Server) registerRoutes() { func (s *Server) registerV1(m *http.ServeMux) { m.HandleFunc("GET /modules", s.handleListModules) + m.HandleFunc("GET /lookup", s.handleLookup) m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog) m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) m.HandleFunc("GET /peers", s.handleListPeers) diff --git a/internal/httpapi/routes_lookup.go b/internal/httpapi/routes_lookup.go new file mode 100644 index 0000000..2ea2d90 --- /dev/null +++ b/internal/httpapi/routes_lookup.go @@ -0,0 +1,37 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "evobgp/internal/lookup" + "evobgp/internal/store" +) + +// handleLookup implements GET /v1/lookup?q= (operationId: lookupMembership). +func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireAtLeast(w, a, "viewer") { + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required") + return + } + res, err := lookup.Lookup(s.store, a.TenantID, q) + if err != nil { + if errors.Is(err, store.ErrInvalidInput) { + writeProblem(w, http.StatusBadRequest, "Bad Request", err.Error()) + return + } + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/internal/httpapi/routes_lookup_test.go b/internal/httpapi/routes_lookup_test.go new file mode 100644 index 0000000..c1071a6 --- /dev/null +++ b/internal/httpapi/routes_lookup_test.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "evobgp/internal/store" +) + +func TestLookupMembershipHTTP(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, modIP, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor") + + comms, err := srv.Store().ListCommunities(tenant) + if err != nil || len(comms) == 0 { + t.Fatal("demo community") + } + cid := comms[0].ID + if _, err := srv.Store().CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{ + Prefix: "198.51.100.0/24", + CommunityID: &cid, + }); err != nil { + t.Fatal(err) + } + if err := srv.Store().SetModulePrefixSnapshot(tenant, modIP, "t", []store.PrefixRow{ + {Prefix: "198.51.100.0/24", CommunityID: &cid, Source: "ip_range"}, + }); err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q="+url.QueryEscape("198.51.100.7"), nil) + req.Header.Set("Authorization", "Bearer edkey") + resp, err := ts.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("status %d: %s", resp.StatusCode, b) + } + var body struct { + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + QueryKind string `json:"query_kind"` + Matches []struct { + Layer string `json:"layer"` + } `json:"matches"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Matched || body.QueryKind != "ip" || body.MatchCount < 2 { + t.Fatalf("unexpected body: %+v", body) + } + + reqBad, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q=", nil) + reqBad.Header.Set("Authorization", "Bearer edkey") + respBad, err := ts.Client().Do(reqBad) + if err != nil { + t.Fatal(err) + } + defer func() { _ = respBad.Body.Close() }() + if respBad.StatusCode != http.StatusBadRequest { + t.Fatalf("empty q: status %d", respBad.StatusCode) + } +} diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go new file mode 100644 index 0000000..3424a4b --- /dev/null +++ b/internal/lookup/lookup.go @@ -0,0 +1,290 @@ +// Package lookup implements dual-layer membership checks for IP addresses and FQDNs +// against module entries and materialized prefix snapshots. +package lookup + +import ( + "fmt" + "net/netip" + "strings" + "unicode" + + "evobgp/internal/store" +) + +// QueryKind is the normalized kind of a lookup query. +type QueryKind string + +const ( + KindIP QueryKind = "ip" + KindDomain QueryKind = "domain" +) + +// Layer identifies which data source produced a match. +type Layer string + +const ( + LayerEntry Layer = "entry" + LayerSnapshot Layer = "snapshot" +) + +// MatchKind is the concrete match type within a layer. +type MatchKind string + +const ( + MatchIPRange MatchKind = "ip_range" + MatchDomain MatchKind = "domain" + MatchPrefix MatchKind = "prefix" +) + +// Match is one membership hit (entry or snapshot) with resolved community fields. +type Match struct { + Layer Layer `json:"layer"` + ModuleID string `json:"module_id"` + ModuleName string `json:"module_name"` + ModuleType string `json:"module_type"` + MatchKind MatchKind `json:"match_kind"` + MatchedValue string `json:"matched_value"` + EntryID string `json:"entry_id,omitempty"` + Source string `json:"source,omitempty"` + CommunityID *string `json:"community_id,omitempty"` + Community string `json:"community,omitempty"` + CommunityTitle string `json:"community_title,omitempty"` +} + +// Result is the full lookup response payload. +type Result struct { + Query string `json:"query"` + QueryKind QueryKind `json:"query_kind"` + Normalized string `json:"normalized"` + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + Matches []Match `json:"matches"` +} + +// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots). +func Lookup(st store.Backend, tenantID, q string) (*Result, error) { + raw := strings.TrimSpace(q) + if raw == "" { + return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput) + } + + comms, err := st.ListCommunities(tenantID) + if err != nil { + return nil, err + } + commByID := make(map[string]*store.Community, len(comms)) + for _, c := range comms { + if c != nil { + commByID[c.ID] = c + } + } + + out := &Result{ + Query: raw, + Matches: make([]Match, 0), + } + + if addr, err := netip.ParseAddr(raw); err == nil { + out.QueryKind = KindIP + out.Normalized = addr.String() + if err := lookupIP(st, tenantID, addr, out, commByID); err != nil { + return nil, err + } + } else { + fqdn, ok := normalizeFQDN(raw) + if !ok { + return nil, fmt.Errorf("%w: query must be an IP address or FQDN", store.ErrInvalidInput) + } + out.QueryKind = KindDomain + out.Normalized = fqdn + if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil { + return nil, err + } + } + + out.MatchCount = len(out.Matches) + out.Matched = out.MatchCount > 0 + return out, nil +} + +func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, commByID map[string]*store.Community) error { + for _, mod := range st.ListModules(tenantID) { + if mod == nil { + continue + } + if mod.Type == "IP_RANGES" { + entries, err := st.ListIPRangeEntries(tenantID, mod.ID) + if err != nil { + return err + } + for _, e := range entries { + if e == nil { + continue + } + pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix)) + if err != nil { + continue + } + if !pfx.Contains(addr) { + continue + } + out.Matches = append(out.Matches, decorateMatch(Match{ + Layer: LayerEntry, + ModuleID: mod.ID, + ModuleName: mod.Name, + ModuleType: mod.Type, + MatchKind: MatchIPRange, + MatchedValue: e.Prefix, + EntryID: e.ID, + CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID), + }, commByID)) + } + } + + snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID) + if err != nil { + return err + } + if !ok || snap == nil { + continue + } + for _, row := range snap.Prefixes { + pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix)) + if err != nil { + continue + } + if !pfx.Contains(addr) { + continue + } + out.Matches = append(out.Matches, decorateMatch(Match{ + Layer: LayerSnapshot, + ModuleID: mod.ID, + ModuleName: mod.Name, + ModuleType: mod.Type, + MatchKind: MatchPrefix, + MatchedValue: row.Prefix, + Source: row.Source, + CommunityID: row.CommunityID, + }, commByID)) + } + } + return nil +} + +func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error { + matchedModuleIDs := make(map[string]*store.Module) + + for _, mod := range st.ListModules(tenantID) { + if mod == nil || mod.Type != "DOMAINS" { + continue + } + entries, err := st.ListDomainEntries(tenantID, mod.ID) + if err != nil { + return err + } + for _, e := range entries { + if e == nil { + continue + } + norm, ok := normalizeFQDN(e.FQDN) + if !ok || norm != fqdn { + continue + } + matchedModuleIDs[mod.ID] = mod + out.Matches = append(out.Matches, decorateMatch(Match{ + Layer: LayerEntry, + ModuleID: mod.ID, + ModuleName: mod.Name, + ModuleType: mod.Type, + MatchKind: MatchDomain, + MatchedValue: e.FQDN, + EntryID: e.ID, + CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID), + }, commByID)) + } + } + + for mid, mod := range matchedModuleIDs { + snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mid) + if err != nil { + return err + } + if !ok || snap == nil { + continue + } + for _, row := range snap.Prefixes { + if !strings.EqualFold(strings.TrimSpace(row.Source), "domain") { + continue + } + out.Matches = append(out.Matches, decorateMatch(Match{ + Layer: LayerSnapshot, + ModuleID: mod.ID, + ModuleName: mod.Name, + ModuleType: mod.Type, + MatchKind: MatchPrefix, + MatchedValue: row.Prefix, + Source: row.Source, + CommunityID: row.CommunityID, + }, commByID)) + } + } + return nil +} + +func resolveCommunityID(entryID, defaultID *string) *string { + if entryID != nil && strings.TrimSpace(*entryID) != "" { + return entryID + } + if defaultID != nil && strings.TrimSpace(*defaultID) != "" { + return defaultID + } + return nil +} + +func decorateMatch(m Match, commByID map[string]*store.Community) Match { + if m.CommunityID == nil { + return m + } + c, ok := commByID[*m.CommunityID] + if !ok || c == nil { + return m + } + m.Community = c.Community + m.CommunityTitle = c.Title + return m +} + +// normalizeFQDN lowercases, trims trailing dots, and validates a simple hostname shape. +func normalizeFQDN(s string) (string, bool) { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, ".") + s = strings.ToLower(s) + if s == "" || len(s) > 253 { + return "", false + } + if strings.ContainsAny(s, " /\\\t\n") { + return "", false + } + if _, err := netip.ParseAddr(s); err == nil { + return "", false + } + labels := strings.Split(s, ".") + if len(labels) < 2 { + return "", false + } + for _, label := range labels { + if label == "" || len(label) > 63 { + return "", false + } + if label[0] == '-' || label[len(label)-1] == '-' { + return "", false + } + for _, r := range label { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + continue + } + return "", false + } + } + return s, true +} diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go new file mode 100644 index 0000000..c88f1a5 --- /dev/null +++ b/internal/lookup/lookup_test.go @@ -0,0 +1,206 @@ +package lookup + +import ( + "errors" + "testing" + + "evobgp/internal/store" +) + +func TestLookupIPEntryAndSnapshot(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, modIP, _, _ := m.DemoIDs() + + cid := "" + comms, err := m.ListCommunities(tenant) + if err != nil || len(comms) == 0 { + t.Fatal("expected demo community") + } + cid = comms[0].ID + + def := cid + if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &def}); err != nil { + t.Fatal(err) + } + + entryComm := cid + e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{ + Prefix: "203.0.113.0/24", + CommunityID: &entryComm, + }) + if err != nil { + t.Fatal(err) + } + + if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash1", []store.PrefixRow{ + {Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"}, + }); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "203.0.113.10") + if err != nil { + t.Fatal(err) + } + if res.QueryKind != KindIP || res.Normalized != "203.0.113.10" { + t.Fatalf("kind/normalized: %+v", res) + } + if !res.Matched || res.MatchCount < 2 { + t.Fatalf("expected entry+snapshot matches, got %+v", res) + } + + var entryHit, snapHit bool + for _, hit := range res.Matches { + if hit.Layer == LayerEntry && hit.EntryID == e.ID { + entryHit = true + if hit.Community != "demo-comm" || hit.CommunityTitle != "Demo" { + t.Fatalf("entry community: %+v", hit) + } + } + if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" { + snapHit = true + } + } + if !entryHit || !snapHit { + t.Fatalf("missing layers entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches) + } +} + +func TestLookupIPCommunityFallback(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, modIP, _, _ := m.DemoIDs() + comms, _ := m.ListCommunities(tenant) + cid := comms[0].ID + if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil { + t.Fatal(err) + } + if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "10.1.2.3") + if err != nil { + t.Fatal(err) + } + if !res.Matched { + t.Fatal("expected match") + } + found := false + for _, hit := range res.Matches { + if hit.Layer == LayerEntry { + found = true + if hit.CommunityID == nil || *hit.CommunityID != cid { + t.Fatalf("expected default community, got %+v", hit) + } + if hit.Community != "demo-comm" { + t.Fatalf("community value: %+v", hit) + } + } + } + if !found { + t.Fatal("no entry match") + } +} + +func TestLookupDomainEntryAndSnapshot(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + mod, err := m.CreateModule(tenant, &store.Module{ + Type: "DOMAINS", + Name: "demo-domains", + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + comms, _ := m.ListCommunities(tenant) + cid := comms[0].ID + + e, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{ + FQDN: "Example.COM.", + CommunityID: &cid, + }) + if err != nil { + t.Fatal(err) + } + if err := m.SetModulePrefixSnapshot(tenant, mod.ID, "hash-d", []store.PrefixRow{ + {Prefix: "198.51.100.1/32", CommunityID: &cid, Source: "domain"}, + {Prefix: "203.0.113.9/32", CommunityID: &cid, Source: "other"}, + }); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "example.com") + if err != nil { + t.Fatal(err) + } + if res.QueryKind != KindDomain || res.Normalized != "example.com" { + t.Fatalf("kind/normalized: %+v", res) + } + if !res.Matched { + t.Fatal("expected match") + } + + var entryHit, snapHit, otherSnap bool + for _, hit := range res.Matches { + if hit.Layer == LayerEntry && hit.EntryID == e.ID { + entryHit = true + } + if hit.Layer == LayerSnapshot && hit.MatchedValue == "198.51.100.1/32" { + snapHit = true + } + if hit.MatchedValue == "203.0.113.9/32" { + otherSnap = true + } + } + if !entryHit || !snapHit { + t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches) + } + if otherSnap { + t.Fatal("non-domain snapshot source should be excluded") + } +} + +func TestLookupNoMatch(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + res, err := Lookup(m, tenant, "192.0.2.1") + if err != nil { + t.Fatal(err) + } + if res.Matched || res.MatchCount != 0 || len(res.Matches) != 0 { + t.Fatalf("expected empty: %+v", res) + } +} + +func TestLookupInvalid(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + _, err := Lookup(m, tenant, "") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("empty: %v", err) + } + _, err = Lookup(m, tenant, "not a host") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("spaces: %v", err) + } + _, err = Lookup(m, tenant, "localhost") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("single label: %v", err) + } +} + +func TestNormalizeFQDN(t *testing.T) { + got, ok := normalizeFQDN(" Example.COM. ") + if !ok || got != "example.com" { + t.Fatalf("got %q ok=%v", got, ok) + } +}