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()