From f45b2bed9036bf40724c0d57d41cc6dca1ff8402 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 31 Jul 2026 13:20:18 +0700 Subject: [PATCH] feat(lookup): enhance lookup functionality to support CIDR queries Updated the lookup system to allow for CIDR queries in addition to IP and domain searches. This includes modifications to the API, frontend components, and documentation to reflect the new capabilities. The LookupSearchForm and LookupComponent were adjusted to accommodate CIDR input, and relevant tests were added to ensure functionality. Co-authored-by: Cursor --- .../src/components/lookup/lookup-add-step.tsx | 199 ++++++++ .../components/lookup/lookup-search-form.tsx | 6 +- .../src/components/lookup/lookup-wizard.tsx | 380 ++++++++++++++ apps/web/src/components/reui/stepper.tsx | 470 ++++++++++++++++++ apps/web/src/lib/auth.ts | 25 + apps/web/src/routes/_auth/lookup.tsx | 67 +-- apps/web/src/types/api.ts | 2 +- apps/web/tsconfig.tsbuildinfo | 2 +- docs/openapi.yaml | 10 +- internal/httpapi/routes_lookup.go | 2 +- internal/lookup/lookup.go | 92 +++- internal/lookup/lookup_test.go | 54 ++ 12 files changed, 1240 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/lookup/lookup-add-step.tsx create mode 100644 apps/web/src/components/lookup/lookup-wizard.tsx create mode 100644 apps/web/src/components/reui/stepper.tsx diff --git a/apps/web/src/components/lookup/lookup-add-step.tsx b/apps/web/src/components/lookup/lookup-add-step.tsx new file mode 100644 index 0000000..cf90bc2 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-add-step.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' +import { Field, FieldLabel } from '@evobgp/ui/components/field' + +import { CommunitySelect } from '@/components/modules/community-select' +import { LoadingButton } from '@/components/loading-button' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { SelectMenu } from '@/components/select-field' +import { ApiError, apiMutate } from '@/lib/api-client' +import type { + BgpCommunity, + LookupQueryKind, + LookupResponse, + ModuleRow, +} from '@/types/api' + +/** + * Lookup wizard step 3 — choose module + community and create entry. + * @see https://reui.io/preview/base/wizard-2 + * @see https://reui.io/docs/components/base/frame + */ + +function hostPrefixFromIp(ip: string): string { + return ip.includes(':') ? `${ip}/128` : `${ip}/32` +} + +function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] { + return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES' +} + +export function LookupAddStep({ + data, + modules, + communities, + onCancel, + onAdded, +}: { + data: LookupResponse + modules: ModuleRow[] + communities: BgpCommunity[] + onCancel: () => void + onAdded: () => void | Promise +}) { + const wantedType = moduleTypeForKind(data.query_kind) + const eligible = useMemo( + () => modules.filter((m) => m.type === wantedType), + [modules, wantedType], + ) + + const [moduleId, setModuleId] = useState('') + const [communityId, setCommunityId] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (eligible.length === 0) { + setModuleId('') + return + } + setModuleId((prev) => + prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id, + ) + }, [eligible]) + + useEffect(() => { + const mod = eligible.find((m) => m.id === moduleId) + if (!mod) { + setCommunityId(null) + return + } + setCommunityId(mod.default_community_id ?? null) + }, [moduleId, eligible]) + + const moduleItems = useMemo( + () => + eligible.map((m) => ({ + value: m.id, + label: m.name, + })), + [eligible], + ) + + async function handleAdd() { + if (!moduleId) { + toast.error('Выберите модуль') + return + } + if (data.query_kind !== 'domain' && !communityId) { + toast.error('Укажите community') + return + } + + setSaving(true) + try { + if (data.query_kind === 'domain') { + await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', { + fqdn: data.normalized, + community_id: communityId, + }) + toast.success('Домен добавлен') + } else { + const prefix = + data.query_kind === 'cidr' + ? data.normalized + : hostPrefixFromIp(data.normalized) + await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', { + prefix, + community_id: communityId, + }) + toast.success('Префикс добавлен') + } + await onAdded() + } catch (e) { + toast.error(e instanceof ApiError ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + if (eligible.length === 0) { + return ( + + Нет подходящего модуля + + Создайте модуль типа {wantedType}, затем повторите добавление.{' '} + + + + ) + } + + const valueLabel = + data.query_kind === 'domain' + ? data.normalized + : data.query_kind === 'cidr' + ? data.normalized + : hostPrefixFromIp(data.normalized) + + return ( + + + Добавить в списки + + «{valueLabel}» отсутствует в списках. Выберите модуль и community. + + + + + Модуль ({wantedType}) + { + if (v) setModuleId(v) + }} + /> + + + + + + void handleAdd()} + disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)} + > + Добавить + + + + ) +} diff --git a/apps/web/src/components/lookup/lookup-search-form.tsx b/apps/web/src/components/lookup/lookup-search-form.tsx index 63385a1..df0029b 100644 --- a/apps/web/src/components/lookup/lookup-search-form.tsx +++ b/apps/web/src/components/lookup/lookup-search-form.tsx @@ -44,13 +44,13 @@ export function LookupSearchForm({ Проверка списка - IP или FQDN — поиск в entries и материализованных snapshots с community. + IP, CIDR или FQDN — поиск в entries и материализованных snapshots с community.
- IP или домен + IP, CIDR или домен @@ -60,7 +60,7 @@ export function LookupSearchForm({ name="q" value={value} onChange={(e) => setValue(e.target.value)} - placeholder="8.8.8.8 или example.com" + placeholder="8.8.8.8, 203.0.113.0/24 или example.com" autoComplete="off" autoFocus /> diff --git a/apps/web/src/components/lookup/lookup-wizard.tsx b/apps/web/src/components/lookup/lookup-wizard.tsx new file mode 100644 index 0000000..2fcca81 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-wizard.tsx @@ -0,0 +1,380 @@ +import { useEffect, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Plus, Search, ShieldAlert } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' + +import { LookupAddStep } from '@/components/lookup/lookup-add-step' +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 { EmptyState } from '@/components/empty-state' +import { QueryState } from '@/components/query-state' +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' +import { + Stepper, + StepperContent, + StepperDescription, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from '@/components/reui/stepper' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons' +import { sessionCanWriteModules } from '@/lib/auth' +import { authSessionQueryOptions } from '@/queries/auth' +import { directoriesCommunitiesQueryOptions } from '@/queries/directories' +import { lookupKeys, lookupQueryOptions } from '@/queries/lookup' +import { modulesListQueryOptions } from '@/queries/modules' + +/** + * Lookup membership wizard — check → result → optional add. + * DNA: wizard-2 · surface frame · stepper. + * @see https://reui.io/preview/base/wizard-2 + * @see https://reui.io/docs/components/base/stepper + * @see https://reui.io/preview/base/stats-12 + * @see https://reui.io/preview/base/data-grid-filtering-2 + */ + +const STEP_QUERY = 1 +const STEP_RESULT = 2 +const STEP_ADD = 3 +const STEP_DONE = 4 + +export function LookupWizard({ + q, + onSubmitQuery, +}: { + q: string + onSubmitQuery: (next: string) => void +}) { + const queryClient = useQueryClient() + const trimmed = q.trim() + const lookupQ = useQuery(lookupQueryOptions(trimmed)) + const sessionQ = useQuery(authSessionQueryOptions()) + + const canWrite = sessionCanWriteModules(sessionQ.data) + const [step, setStep] = useState(trimmed ? STEP_RESULT : STEP_QUERY) + const [offerAdd, setOfferAdd] = useState(false) + const loadCatalog = step === STEP_ADD || step === STEP_DONE + + const modulesQ = useQuery({ + ...modulesListQueryOptions(), + enabled: loadCatalog, + }) + const communitiesQ = useQuery({ + ...directoriesCommunitiesQueryOptions(), + enabled: loadCatalog, + }) + + useEffect(() => { + setOfferAdd(false) + setStep(trimmed ? STEP_RESULT : STEP_QUERY) + }, [trimmed]) + + function resetToQuery() { + setOfferAdd(false) + setStep(STEP_QUERY) + onSubmitQuery('') + } + + function goToAdd() { + setOfferAdd(true) + setStep(STEP_ADD) + } + + async function handleAdded() { + setStep(STEP_DONE) + await queryClient.invalidateQueries({ queryKey: lookupKeys.query(trimmed) }) + await lookupQ.refetch() + } + + const data = lookupQ.data + const notFound = data != null && !data.matched + + return ( +
+ + + Мастер проверки + + Проверка IP / CIDR / домена в списках, затем при необходимости — добавление. + + + + , + }} + > + + STEP_QUERY}> + + {STEP_QUERY} +
+ Запрос + + IP, CIDR или FQDN + +
+
+ +
+ STEP_RESULT} + disabled={!trimmed} + > + + {STEP_RESULT} +
+ Результат + + Есть в списках? + +
+
+ +
+ STEP_ADD} + disabled={!offerAdd && step !== STEP_ADD && step !== STEP_DONE} + > + + {STEP_ADD} +
+ Добавление + + Модуль и community + +
+
+ +
+ + + {STEP_DONE} +
+ Готово + + Подтверждение + +
+
+
+
+ + + + + {!trimmed ? ( +
+ } + title="Введите IP, CIDR или домен" + description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots." + /> +
+ ) : null} +
+ + + {!trimmed ? ( + } + title="Сначала выполните проверку" + description="Вернитесь к шагу «Запрос» и укажите значение." + /> + ) : ( + void lookupQ.refetch()} + skeleton={ +
+ + +
+ } + > + {(result) => ( +
+ + + + {result.matched ? ( + <> + + + Уже есть в списках + + «{result.normalized}» найден в entries и/или snapshots + ({result.match_count} совпад.). + + + + + + + + ) : canWrite ? ( + + + Не найдено в списках + + «{result.normalized}» отсутствует. Добавить запись? + + + + + + + ) : ( + + + Не найдено + + «{result.normalized}» отсутствует в списках. У вас нет + права добавлять записи (нужно bgp:modules:write). + + + + + + )} +
+ )} +
+ )} +
+ + + {data && notFound ? ( + { + setOfferAdd(false) + setStep(STEP_RESULT) + }} + onAdded={handleAdded} + /> + ) : ( + + Добавление недоступно + + Сначала выполните проверку для значения, которого ещё нет в списках. + + + )} + + + +
+ + + Запись добавлена + + Повторная проверка обновлена. Можно посмотреть совпадения или + начать новый запрос. + + + + + + + {lookupQ.data ? ( + <> + + {lookupQ.data.matched ? ( + + ) : null} + + ) : null} +
+
+
+
+
+ +
+ ) +} diff --git a/apps/web/src/components/reui/stepper.tsx b/apps/web/src/components/reui/stepper.tsx new file mode 100644 index 0000000..07468b5 --- /dev/null +++ b/apps/web/src/components/reui/stepper.tsx @@ -0,0 +1,470 @@ +import type { HTMLAttributes, ReactElement } from "react" +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" + +import { cn } from "@evobgp/ui/lib/utils" + +// Types +type StepperOrientation = "horizontal" | "vertical" +type StepState = "active" | "completed" | "inactive" | "loading" +type StepIndicators = { + active?: React.ReactNode + completed?: React.ReactNode + inactive?: React.ReactNode + loading?: React.ReactNode +} + +interface StepperContextValue { + activeStep: number + setActiveStep: (step: number) => void + stepsCount: number + orientation: StepperOrientation + registerTrigger: (node: HTMLButtonElement | null) => void + triggerNodes: HTMLButtonElement[] + focusNext: (currentIdx: number) => void + focusPrev: (currentIdx: number) => void + focusFirst: () => void + focusLast: () => void + indicators: StepIndicators +} + +interface StepItemContextValue { + step: number + state: StepState + isDisabled: boolean + isLoading: boolean +} + +const StepperContext = createContext(undefined) +const StepItemContext = createContext( + undefined +) + +function useStepper() { + const ctx = useContext(StepperContext) + if (!ctx) throw new Error("useStepper must be used within a Stepper") + return ctx +} + +function useStepItem() { + const ctx = useContext(StepItemContext) + if (!ctx) throw new Error("useStepItem must be used within a StepperItem") + return ctx +} + +interface StepperProps extends HTMLAttributes { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: StepperOrientation + indicators?: StepIndicators +} + +function Stepper({ + defaultValue = 1, + value, + onValueChange, + orientation = "horizontal", + className, + children, + indicators = {}, + ...props +}: StepperProps) { + const [activeStep, setActiveStep] = useState(defaultValue) + const [triggerNodes, setTriggerNodes] = useState([]) + + // Register/unregister triggers + const registerTrigger = useCallback((node: HTMLButtonElement | null) => { + setTriggerNodes((prev) => { + if (node && !prev.includes(node)) { + return [...prev, node] + } else if (!node && prev.includes(node!)) { + return prev.filter((n) => n !== node) + } else { + return prev + } + }) + }, []) + + const handleSetActiveStep = useCallback( + (step: number) => { + if (value === undefined) { + setActiveStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + // Keyboard navigation logic + const focusTrigger = (idx: number) => { + if (triggerNodes[idx]) triggerNodes[idx].focus() + } + const focusNext = (currentIdx: number) => + focusTrigger((currentIdx + 1) % triggerNodes.length) + const focusPrev = (currentIdx: number) => + focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length) + const focusFirst = () => focusTrigger(0) + const focusLast = () => focusTrigger(triggerNodes.length - 1) + + // Context value + const contextValue = useMemo( + () => ({ + activeStep: currentStep, + setActiveStep: handleSetActiveStep, + stepsCount: Children.toArray(children).filter( + (child): child is ReactElement => + isValidElement(child) && + (child.type as { displayName?: string }).displayName === "StepperItem" + ).length, + orientation, + registerTrigger, + focusNext, + focusPrev, + focusFirst, + focusLast, + triggerNodes, + indicators, + }), + [ + currentStep, + handleSetActiveStep, + children, + orientation, + registerTrigger, + triggerNodes, + ] + ) + + return ( + +
+ {children} +
+
+ ) +} + +interface StepperItemProps extends React.HTMLAttributes { + step: number + completed?: boolean + disabled?: boolean + loading?: boolean +} + +function StepperItem({ + step, + completed = false, + disabled = false, + loading = false, + className, + children, + ...props +}: StepperItemProps) { + const { activeStep } = useStepper() + + const state: StepState = + completed || step < activeStep + ? "completed" + : activeStep === step + ? "active" + : "inactive" + + const isLoading = loading && step === activeStep + + return ( + +
+ {children} +
+
+ ) +} + +type StepperTriggerProps = useRender.ComponentProps<"button"> + +function StepperTrigger({ + className, + children, + tabIndex, + render, + ...props +}: StepperTriggerProps) { + const { state, isLoading } = useStepItem() + const stepperCtx = useStepper() + const { + setActiveStep, + activeStep, + registerTrigger, + triggerNodes, + focusNext, + focusPrev, + focusFirst, + focusLast, + } = stepperCtx + const { step, isDisabled } = useStepItem() + const isSelected = activeStep === step + const id = `stepper-tab-${step}` + const panelId = `stepper-panel-${step}` + + // Register this trigger for keyboard navigation + const btnRef = useRef(null) + useEffect(() => { + if (btnRef.current) { + registerTrigger(btnRef.current) + } + }, [btnRef.current]) + + // Find our index among triggers for navigation + const myIdx = useMemo( + () => + triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), + [triggerNodes, btnRef.current] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault() + if (myIdx !== -1 && focusNext) focusNext(myIdx) + break + case "ArrowLeft": + case "ArrowUp": + e.preventDefault() + if (myIdx !== -1 && focusPrev) focusPrev(myIdx) + break + case "Home": + e.preventDefault() + if (focusFirst) focusFirst() + break + case "End": + e.preventDefault() + if (focusLast) focusLast() + break + case "Enter": + case " ": + e.preventDefault() + setActiveStep(step) + break + } + } + + const defaultProps = { + role: "tab", + id, + "aria-selected": isSelected, + "aria-controls": panelId, + tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1, + "data-slot": "stepper-trigger", + "data-state": state, + "data-loading": isLoading, + className: cn( + "focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60", + "gap-2.5 rounded-full", + className + ), + onClick: () => setActiveStep(step), + onKeyDown: handleKeyDown, + disabled: isDisabled, + children, + } + + return useRender({ + defaultTagName: "button", + render, + ref: btnRef, + props: mergeProps<"button">(defaultProps, props), + }) +} + +function StepperIndicator({ + children, + className, +}: React.ComponentProps<"div">) { + const { state, isLoading } = useStepItem() + const { indicators } = useStepper() + + return ( +
+
+ {indicators && + ((isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive)) + ? (isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive) + : children} +
+
+ ) +} + +function StepperSeparator({ className }: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ ) +} + +function StepperTitle({ children, className }: React.ComponentProps<"h3">) { + const { state } = useStepItem() + + return ( +

+ {children} +

+ ) +} + +function StepperDescription({ + children, + className, +}: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ {children} +
+ ) +} + +function StepperNav({ children, className }: React.ComponentProps<"nav">) { + const { activeStep, orientation } = useStepper() + + return ( + + ) +} + +function StepperPanel({ children, className }: React.ComponentProps<"div">) { + const { activeStep } = useStepper() + + return ( +
+ {children} +
+ ) +} + +interface StepperContentProps extends React.ComponentProps<"div"> { + value: number + forceMount?: boolean +} + +function StepperContent({ + value, + forceMount, + children, + className, +}: StepperContentProps) { + const { activeStep } = useStepper() + const isActive = value === activeStep + + if (!forceMount && !isActive) { + return null + } + + return ( + + ) +} + +export { + useStepper, + useStepItem, + Stepper, + StepperItem, + StepperTrigger, + StepperIndicator, + StepperSeparator, + StepperTitle, + StepperDescription, + StepperPanel, + StepperContent, + StepperNav, + type StepperProps, + type StepperItemProps, + type StepperTriggerProps, + type StepperContentProps, +} \ No newline at end of file diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index a612927..6c44222 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -283,6 +283,31 @@ export function sessionCanManageApiKeys(session: { return session.role === 'operator' } +/** + * Whether session may create/update module entries (`bgp:modules:write`). + * Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+. + */ +export function sessionCanWriteModules(session: { + role?: string + kind?: string + is_admin?: boolean + permissions?: readonly string[] +} | null | undefined): boolean { + if (!session) return false + const jwtPath = + session.kind === 'jwt' || + session.is_admin === true || + (session.permissions?.length ?? 0) > 0 + if (jwtPath) { + return ( + session.is_admin === true || + hasPermission(session.permissions ?? [], 'bgp:modules:write') + ) + } + const role = (session.role ?? '').toLowerCase() + return role === 'editor' || role === 'operator' +} + /** Nav path → minimum permission to show the item. Sync with app-shell NAV. */ export function permissionForPath(pathname: string): string | null { if (pathname === '/' || pathname.startsWith('/dashboard')) { diff --git a/apps/web/src/routes/_auth/lookup.tsx b/apps/web/src/routes/_auth/lookup.tsx index 6ac6657..5d518d5 100644 --- a/apps/web/src/routes/_auth/lookup.tsx +++ b/apps/web/src/routes/_auth/lookup.tsx @@ -1,23 +1,16 @@ 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 { LookupWizard } from '@/components/lookup/lookup-wizard' 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 + * Quick membership lookup wizard. + * Surface: frame · wizard-2 · stepper · KPI: stats-12 · grid: data-grid-filtering-2 + * @see https://reui.io/preview/base/wizard-2 + * @see https://reui.io/docs/components/base/stepper * @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 + * @see https://reui.io/preview/base/empty-state-12 */ export const Route = createFileRoute('/_auth/lookup')({ component: LookupComponent, @@ -29,58 +22,18 @@ export const Route = createFileRoute('/_auth/lookup')({ function LookupComponent() { const { q } = useSearch({ from: '/_auth/lookup' }) const navigate = Route.useNavigate() - const lookupQ = useQuery(lookupQueryOptions(q)) return (
- void navigate({ search: { q: next } })} + 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 89c2841..d0f44d9 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -163,7 +163,7 @@ 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 LookupQueryKind = 'ip' | 'domain' | 'cidr' export type LookupLayer = 'entry' | 'snapshot' export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix' diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 41e9ea2..58422b7 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./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/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.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/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/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.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-discovered-peers-card.tsx","./src/components/network/network-kpi.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/frame-data-grid.tsx","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.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-app-switcher.ts","./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.test.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.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/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.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/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.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.gen.ts","./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/vite-env.d.ts","./src/components/app-switcher.tsx","./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/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.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/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/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/lookup/lookup-wizard.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-discovered-peers-card.tsx","./src/components/network/network-kpi.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/stepper.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/frame-data-grid.tsx","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.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-app-switcher.ts","./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.test.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.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/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.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/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.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.gen.ts","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1cb0445..881f418 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -789,7 +789,7 @@ components: LookupQueryKind: type: string - enum: [ip, domain] + enum: [ip, domain, cidr] description: Определённый тип запроса после нормализации. LookupLayer: @@ -864,7 +864,7 @@ components: $ref: "#/components/schemas/LookupQueryKind" normalized: type: string - description: Нормализованный IP или FQDN. + description: Нормализованный IP, masked CIDR или FQDN. matched: type: boolean description: true, если есть хотя бы одно совпадение. @@ -2028,12 +2028,14 @@ paths: /v1/lookup: get: tags: [Lookup] - summary: Проверка IP или домена в списках + summary: Проверка IP, CIDR или домена в списках description: | Быстрая membership-проверка по tenant: - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot` (все module prefix snapshots, `Prefix.Contains`); + - **CIDR** — слой `entry` (`IP_RANGES`, равенство masked-префикса или покрытие + запрошенной сети) и слой `snapshot` (то же правило); - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot` (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть); затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка @@ -2054,7 +2056,7 @@ paths: type: string minLength: 1 maxLength: 253 - description: IP-адрес или FQDN для проверки. + description: IP-адрес, CIDR или FQDN для проверки. responses: "200": description: Результат проверки (в т.ч. matched=false при отсутствии совпадений). diff --git a/internal/httpapi/routes_lookup.go b/internal/httpapi/routes_lookup.go index 7d7acb8..b3ecfce 100644 --- a/internal/httpapi/routes_lookup.go +++ b/internal/httpapi/routes_lookup.go @@ -27,7 +27,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) { res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q) if err != nil { if errors.Is(err, store.ErrInvalidInput) { - writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN") + writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address, CIDR, or FQDN") return } writeStoreErr(w, err) diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index 84f8dfb..8ad271b 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -19,6 +19,7 @@ type QueryKind string const ( KindIP QueryKind = "ip" KindDomain QueryKind = "domain" + KindCIDR QueryKind = "cidr" ) // Layer identifies which data source produced a match. @@ -69,7 +70,7 @@ type Result struct { // DomainResolver resolves a hostname to IP addresses (A/AAAA). type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error) -// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots). +// Lookup checks whether q (IP, CIDR, or FQDN) is present in tenant lists (entries + snapshots). // For domains, FQDN membership is checked first, then live DNS resolve and IP membership. func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) { return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver) @@ -109,10 +110,17 @@ func LookupWithResolver( if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil { return nil, err } + } else if pfx, err := netip.ParsePrefix(raw); err == nil { + masked := pfx.Masked() + out.QueryKind = KindCIDR + out.Normalized = masked.String() + if err := lookupCIDR(st, tenantID, masked, 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) + return nil, fmt.Errorf("%w: query must be an IP address, CIDR, or FQDN", store.ErrInvalidInput) } out.QueryKind = KindDomain out.Normalized = fqdn @@ -250,6 +258,86 @@ func lookupIP( return nil } +// prefixCoversQuery reports whether listPfx equals query or fully contains it. +func prefixCoversQuery(listPfx, query netip.Prefix) bool { + listPfx = listPfx.Masked() + query = query.Masked() + if listPfx == query { + return true + } + return listPfx.Contains(query.Addr()) && listPfx.Bits() <= query.Bits() +} + +func lookupCIDR( + st store.Backend, + tenantID string, + query netip.Prefix, + 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 !prefixCoversQuery(pfx, query) { + 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 !prefixCoversQuery(pfx, query) { + 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) diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go index f838140..4269a34 100644 --- a/internal/lookup/lookup_test.go +++ b/internal/lookup/lookup_test.go @@ -220,6 +220,60 @@ func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) { } } +func TestLookupCIDREntryAndSnapshot(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, modIP, _, _ := m.DemoIDs() + comms, _ := m.ListCommunities(tenant) + cid := comms[0].ID + + e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{ + Prefix: "203.0.113.0/24", + CommunityID: &cid, + }) + if err != nil { + t.Fatal(err) + } + if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-cidr", []store.PrefixRow{ + {Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"}, + }); err != nil { + t.Fatal(err) + } + + res, err := Lookup(context.Background(), m, tenant, "203.0.113.0/24") + if err != nil { + t.Fatal(err) + } + if res.QueryKind != KindCIDR || res.Normalized != "203.0.113.0/24" { + t.Fatalf("kind/normalized: %+v", res) + } + if !res.Matched || res.MatchCount < 2 { + t.Fatalf("expected entry+snapshot, got %+v", res) + } + + var entryHit, snapHit bool + for _, hit := range res.Matches { + if hit.Layer == LayerEntry && hit.EntryID == e.ID { + entryHit = true + } + if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" { + snapHit = true + } + } + if !entryHit || !snapHit { + t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches) + } + + // Narrower query covered by wider entry. + res2, err := Lookup(context.Background(), m, tenant, "203.0.113.128/25") + if err != nil { + t.Fatal(err) + } + if res2.QueryKind != KindCIDR || !res2.Matched { + t.Fatalf("expected cover match: %+v", res2) + } +} + func TestLookupNoMatch(t *testing.T) { m := store.NewMemory() m.SeedDemo()