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.
This commit is contained in:
@@ -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: <Search aria-hidden />,
|
||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
},
|
||||
{
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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<ColumnDef<LookupMatch>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'layer',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.layer}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Слой' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'module_name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.module_name}
|
||||
subtitle={row.original.module_type}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'matched_value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.matched_value}
|
||||
subtitle={row.original.match_kind}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Совпадение' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
accessorFn: (row) => row.community_title || row.community || '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
|
||||
cell: ({ row }) => {
|
||||
const title = row.original.community_title?.trim()
|
||||
const value = row.original.community?.trim()
|
||||
if (!title && !value) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
title={title || value || '—'}
|
||||
subtitle={title && value && title !== value ? value : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
enableSorting: false,
|
||||
header: 'Источник',
|
||||
cell: ({ row }) =>
|
||||
row.original.source ? (
|
||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
),
|
||||
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 (
|
||||
<DataGridCard
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
>
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет совпадений"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Фильтр совпадений…"
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
/>
|
||||
</DataGridCard>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Проверка списка</FrameTitle>
|
||||
<FrameDescription>
|
||||
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="lookup-q"
|
||||
name="q"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="8.8.8.8 или example.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||
Проверить
|
||||
</Button>
|
||||
</form>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -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: <Radar aria-hidden />,
|
||||
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: <ListChecks aria-hidden />,
|
||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
},
|
||||
{
|
||||
id: 'snapshot',
|
||||
label: 'Слой snapshot',
|
||||
value: snapshotCount,
|
||||
hint: 'материализация',
|
||||
icon: <Layers aria-hidden />,
|
||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={items}
|
||||
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<LookupResponse>({
|
||||
queryKey: lookupKeys.query(trimmed),
|
||||
queryFn: () =>
|
||||
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
|
||||
enabled: trimmed.length > 0,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>) => ({
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Проверка"
|
||||
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
|
||||
/>
|
||||
|
||||
<LookupSearchForm
|
||||
key={q}
|
||||
initialQuery={q}
|
||||
isPending={lookupQ.isFetching}
|
||||
onSubmit={(next) => void navigate({ search: { q: next } })}
|
||||
/>
|
||||
|
||||
{!q.trim() ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Введите IP или домен"
|
||||
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
|
||||
/>
|
||||
) : (
|
||||
<QueryState
|
||||
data={lookupQ.data}
|
||||
isLoading={lookupQ.isLoading}
|
||||
isError={lookupQ.isError}
|
||||
error={lookupQ.error}
|
||||
onRetry={() => void lookupQ.refetch()}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<SectionCardsSkeleton />
|
||||
<TableSkeleton rows={5} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<LookupSummaryKpi data={data} />
|
||||
{data.matched ? (
|
||||
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Не найдено в списках"
|
||||
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -151,6 +151,35 @@ export type BgpCommunityCreate = {
|
||||
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
||||
export type CommunitiesResponse = Page<BgpCommunity>
|
||||
|
||||
// ---- 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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user